Implement critical security fixes and modern design system (Pre-launch P0 tasks)
Security Improvements (P0.0-P0.4): - P0.0: Migrate to Gemini-only AI stack (simplified, single billing) - P0.1: Fix CORS to restrict allowed origins from env (was *) - P0.2: Remove hardcoded dev password, require env var - P0.3: Add rate limiting (slowapi) - 3-10 req/min on sensitive endpoints - P0.4: Add request size limits (100MB default via middleware) New Features: - Unified LLM service with Google Gemini priority - OXML geometry extractor for layout parsing - TSX validator for generated React components - Client ID support in presentation requests with access control - Configurable LLM/image timeouts via env vars Modern Design System (P0.9 - partial): - Enhanced CSS design tokens (primary, semantic colors, shadows) - Typography scale (h1-h4, body variants, caption) - Modern animations (fadeIn, slideIn, scaleIn) - Updated Button component with better variants and hover effects - Created unified Card and StatusBadge components - Applied design system to Dashboard and Settings pages Backend Improvements: - Master deck parser simplification - Slide-to-HTML endpoint cleanup (325 lines removed) - Better error handling in prompts endpoint Frontend Improvements: - Settings UI simplified to show only Google/Gemini - Dashboard uses CSS variables instead of hardcoded colors - Improved button transitions and hover states Co-Authored-By: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
69b18a218f
commit
c431d4ab45
23 changed files with 942 additions and 726 deletions
44
.env.example
44
.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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
22
backend/api/middlewares/rate_limit_middleware.py
Normal file
22
backend/api/middlewares/rate_limit_middleware.py
Normal file
|
|
@ -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
|
||||
38
backend/api/middlewares/request_size_middleware.py
Normal file
38
backend/api/middlewares/request_size_middleware.py
Normal file
|
|
@ -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)
|
||||
|
|
@ -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),
|
||||
):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 `<thinking>` block to map the JSON coordinates to flex/grid structures.
|
||||
Example:
|
||||
<thinking>
|
||||
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`.
|
||||
</thinking>
|
||||
|
||||
```html
|
||||
<div class="...">...</div>
|
||||
```
|
||||
Return ONLY the thinking block and the HTML.
|
||||
"""
|
||||
|
||||
HTML_TO_REACT_SYSTEM_PROMPT = """
|
||||
|
|
|
|||
|
|
@ -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)}",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ dependencies = [
|
|||
"arq>=0.26",
|
||||
"pytest-asyncio>=0.25",
|
||||
"httpx>=0.28",
|
||||
"slowapi>=0.1.9",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
136
backend/services/llm_service.py
Normal file
136
backend/services/llm_service.py
Normal file
|
|
@ -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()
|
||||
|
|
@ -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}
|
||||
),
|
||||
|
|
|
|||
100
backend/utils/oxml_geometry.py
Normal file
100
backend/utils/oxml_geometry.py
Normal file
|
|
@ -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)
|
||||
|
||||
39
backend/utils/tsx_validator.py
Normal file
39
backend/utils/tsx_validator.py
Normal file
|
|
@ -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
|
||||
|
||||
|
|
@ -98,12 +98,12 @@ const DashboardPage: React.FC = () => {
|
|||
// If no clients exist, show the original flat dashboard
|
||||
if (!isLoadingClients && clients.length === 0) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#E9E8F8]">
|
||||
<div className="min-h-screen bg-[hsl(var(--surface))]">
|
||||
<Header />
|
||||
<Wrapper>
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
<section>
|
||||
<h2 className="text-2xl font-roboto font-medium mb-6">
|
||||
<h2 className="h2 mb-6">
|
||||
Recent Presentations
|
||||
</h2>
|
||||
<PresentationGrid
|
||||
|
|
@ -122,27 +122,28 @@ const DashboardPage: React.FC = () => {
|
|||
// Client detail view
|
||||
if (selectedClient) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#E9E8F8]">
|
||||
<div className="min-h-screen bg-[hsl(var(--surface))]">
|
||||
<Header />
|
||||
<Wrapper>
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
<main className="container mx-auto px-4 py-8 animate-fadeIn">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Button variant="ghost" size="sm" onClick={handleBack} className="gap-1">
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
All Clients
|
||||
</Button>
|
||||
<span className="text-gray-300">/</span>
|
||||
<h2 className="text-xl font-semibold">{selectedClient.name}</h2>
|
||||
<span className="text-muted-foreground">/</span>
|
||||
<h2 className="h3">{selectedClient.name}</h2>
|
||||
</div>
|
||||
|
||||
{/* New Presentation Button */}
|
||||
<div className="mb-6">
|
||||
<Button
|
||||
onClick={() => handleNewPresentation(selectedClient.id)}
|
||||
className="bg-[#5146E5] hover:bg-[#5146E5]/90 text-white rounded-full px-6"
|
||||
className="rounded-full px-6 shadow-lg"
|
||||
size="lg"
|
||||
>
|
||||
<PlusIcon className="w-4 h-4 mr-2" />
|
||||
<PlusIcon className="w-5 h-5 mr-2" />
|
||||
New Presentation
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -327,10 +328,10 @@ const DashboardPage: React.FC = () => {
|
|||
<div
|
||||
key={client.id}
|
||||
onClick={() => 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"
|
||||
>
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="w-12 h-12 rounded-lg bg-[#5146E5]/10 flex items-center justify-center flex-shrink-0 group-hover:bg-[#5146E5]/20 transition-colors">
|
||||
<div className="w-12 h-12 rounded-lg bg-[hsl(var(--primary-light))] flex items-center justify-center flex-shrink-0 group-hover:bg-[hsl(var(--primary))]/20 transition-colors">
|
||||
{client.logo_url ? (
|
||||
<img
|
||||
src={client.logo_url}
|
||||
|
|
@ -338,7 +339,7 @@ const DashboardPage: React.FC = () => {
|
|||
className="w-8 h-8 object-contain"
|
||||
/>
|
||||
) : (
|
||||
<Building2 className="w-6 h-6 text-[#5146E5]" />
|
||||
<Building2 className="w-6 h-6 text-[hsl(var(--primary))]" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
|
|
@ -348,7 +349,7 @@ const DashboardPage: React.FC = () => {
|
|||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full rounded-full mt-2 group-hover:bg-[#5146E5] group-hover:text-white group-hover:border-[#5146E5] transition-colors"
|
||||
className="w-full rounded-full mt-2 group-hover:bg-[hsl(var(--primary))] group-hover:text-white group-hover:border-[hsl(var(--primary))] transition-all"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleNewPresentation(client.id);
|
||||
|
|
|
|||
|
|
@ -38,18 +38,10 @@ interface SystemSettings {
|
|||
}
|
||||
|
||||
const PROVIDER_LABELS: Record<string, string> = {
|
||||
anthropic: 'Anthropic (Claude)',
|
||||
openai: 'OpenAI (GPT)',
|
||||
google: 'Google (Gemini)',
|
||||
ollama: 'Ollama (Local)',
|
||||
custom: 'Custom',
|
||||
nanobanana_pro: 'NanoBanana Pro (Gemini 3)',
|
||||
gemini_flash: 'Gemini Flash',
|
||||
'dall-e-3': 'DALL-E 3',
|
||||
'gpt-image-1.5': 'GPT Image 1.5',
|
||||
pexels: 'Pexels (Stock)',
|
||||
pixabay: 'Pixabay (Stock)',
|
||||
comfyui: 'ComfyUI',
|
||||
google: 'Google Gemini',
|
||||
gemini_flash: 'Gemini Flash (Image Generation)',
|
||||
pexels: 'Pexels (Stock Photos)',
|
||||
pixabay: 'Pixabay (Stock Photos)',
|
||||
};
|
||||
|
||||
interface ConnectionTestResult {
|
||||
|
|
@ -68,8 +60,6 @@ export default function SettingsPage() {
|
|||
const [llmProvider, setLlmProvider] = useState('');
|
||||
const [llmModel, setLlmModel] = useState('');
|
||||
const [imageProvider, setImageProvider] = useState('');
|
||||
const [anthropicKey, setAnthropicKey] = useState('');
|
||||
const [openaiKey, setOpenaiKey] = useState('');
|
||||
const [googleKey, setGoogleKey] = useState('');
|
||||
|
||||
// Model listing
|
||||
|
|
@ -138,8 +128,6 @@ export default function SettingsPage() {
|
|||
if (llmProvider !== settings?.llm_provider) body.llm_provider = llmProvider;
|
||||
if (llmModel !== settings?.llm_model) body.llm_model = llmModel;
|
||||
if (imageProvider !== settings?.image_provider) body.image_provider = imageProvider;
|
||||
if (anthropicKey) body.anthropic_api_key = anthropicKey;
|
||||
if (openaiKey) body.openai_api_key = openaiKey;
|
||||
if (googleKey) body.google_api_key = googleKey;
|
||||
|
||||
if (Object.keys(body).length === 0) {
|
||||
|
|
@ -158,8 +146,6 @@ export default function SettingsPage() {
|
|||
throw new Error(err.detail || 'Save failed');
|
||||
}
|
||||
toast.success('Settings saved');
|
||||
setAnthropicKey('');
|
||||
setOpenaiKey('');
|
||||
setGoogleKey('');
|
||||
loadSettings();
|
||||
} catch (e) {
|
||||
|
|
@ -169,17 +155,11 @@ export default function SettingsPage() {
|
|||
}
|
||||
};
|
||||
|
||||
const testConnection = async (provider: string) => {
|
||||
setTestingProvider(provider);
|
||||
// Use the currently-typed key if available, otherwise test with stored key
|
||||
const keyMap: Record<string, string> = {
|
||||
anthropic: anthropicKey,
|
||||
openai: openaiKey,
|
||||
google: googleKey,
|
||||
};
|
||||
const testConnection = async () => {
|
||||
setTestingProvider('google');
|
||||
try {
|
||||
const body: Record<string, string> = { provider };
|
||||
if (keyMap[provider]) body.api_key = keyMap[provider];
|
||||
const body: Record<string, string> = { provider: 'google' };
|
||||
if (googleKey) body.api_key = googleKey;
|
||||
|
||||
const res = await fetch('/api/v1/admin/settings/test-connection', {
|
||||
method: 'POST',
|
||||
|
|
@ -187,14 +167,14 @@ export default function SettingsPage() {
|
|||
body: JSON.stringify(body),
|
||||
});
|
||||
const result: ConnectionTestResult = await res.json();
|
||||
setTestResults((prev) => ({ ...prev, [provider]: result }));
|
||||
setTestResults((prev) => ({ ...prev, google: result }));
|
||||
if (result.ok) {
|
||||
toast.success(`${PROVIDER_LABELS[provider] || provider}: Connected (${result.latency_ms}ms)`);
|
||||
toast.success(`Google Gemini: Connected (${result.latency_ms}ms)`);
|
||||
} else {
|
||||
toast.error(`${PROVIDER_LABELS[provider] || provider}: ${result.error}`);
|
||||
toast.error(`Google Gemini: ${result.error}`);
|
||||
}
|
||||
} catch {
|
||||
setTestResults((prev) => ({ ...prev, [provider]: { ok: false, error: 'Request failed' } }));
|
||||
setTestResults((prev) => ({ ...prev, google: { ok: false, error: 'Request failed' } }));
|
||||
toast.error('Connection test failed');
|
||||
} finally {
|
||||
setTestingProvider(null);
|
||||
|
|
@ -234,61 +214,60 @@ export default function SettingsPage() {
|
|||
</div>
|
||||
|
||||
<p className="text-sm text-gray-500">
|
||||
Oliver DeckForge uses <strong>Google Gemini</strong> 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.
|
||||
</p>
|
||||
|
||||
{/* LLM Configuration */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<p className="text-sm text-blue-800">
|
||||
<strong>Get your Google AI API key:</strong>{' '}
|
||||
<a
|
||||
href="https://aistudio.google.com/app/apikey"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-blue-600"
|
||||
>
|
||||
https://aistudio.google.com/app/apikey
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Gemini Configuration */}
|
||||
<Card className="p-6 space-y-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Cpu className="w-5 h-5 text-[#5146E5]" />
|
||||
<h2 className="text-lg font-semibold">LLM Provider</h2>
|
||||
<h2 className="text-lg font-semibold">Google Gemini (LLM)</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<Label>Provider</Label>
|
||||
<Select value={llmProvider} onValueChange={setLlmProvider}>
|
||||
<div className="space-y-1">
|
||||
<Label>Model</Label>
|
||||
{availableModels.length > 0 ? (
|
||||
<Select value={llmModel} onValueChange={setLlmModel}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
{loadingModels ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<SelectValue placeholder="Select model" />
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{settings?.available_llm_providers.map((p) => (
|
||||
<SelectItem key={p} value={p}>
|
||||
{PROVIDER_LABELS[p] || p}
|
||||
{availableModels.map((m) => (
|
||||
<SelectItem key={m} value={m}>
|
||||
{m}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label>Model</Label>
|
||||
{availableModels.length > 0 ? (
|
||||
<Select value={llmModel} onValueChange={setLlmModel}>
|
||||
<SelectTrigger>
|
||||
{loadingModels ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<SelectValue placeholder="Select model" />
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableModels.map((m) => (
|
||||
<SelectItem key={m} value={m}>
|
||||
{m}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
value={llmModel}
|
||||
onChange={(e) => setLlmModel(e.target.value)}
|
||||
placeholder={loadingModels ? 'Loading models...' : 'e.g. claude-sonnet-4-6'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
value={llmModel}
|
||||
onChange={(e) => setLlmModel(e.target.value)}
|
||||
placeholder={loadingModels ? 'Loading models...' : 'e.g. gemini-2.0-flash-exp'}
|
||||
/>
|
||||
)}
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Recommended: <code className="bg-gray-100 px-1 rounded">gemini-2.0-flash-exp</code> (fast & cheap)
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
|
|
@ -316,120 +295,61 @@ export default function SettingsPage() {
|
|||
</div>
|
||||
</Card>
|
||||
|
||||
{/* API Keys */}
|
||||
{/* API Key */}
|
||||
<Card className="p-6 space-y-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Key className="w-5 h-5 text-orange-600" />
|
||||
<h2 className="text-lg font-semibold">API Keys</h2>
|
||||
<h2 className="text-lg font-semibold">Google API Key</h2>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400">
|
||||
Leave blank to keep existing keys. Enter a new value to update.
|
||||
Leave blank to keep existing key. Enter a new value to update.
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<ApiKeyField
|
||||
label="Anthropic API Key"
|
||||
provider="anthropic"
|
||||
isSet={!!settings?.anthropic_api_key_set}
|
||||
value={anthropicKey}
|
||||
onChange={setAnthropicKey}
|
||||
placeholder={settings?.anthropic_api_key_set ? '••••••••••••' : 'sk-ant-...'}
|
||||
testResult={testResults.anthropic}
|
||||
isTesting={testingProvider === 'anthropic'}
|
||||
onTest={() => testConnection('anthropic')}
|
||||
/>
|
||||
<ApiKeyField
|
||||
label="OpenAI API Key"
|
||||
provider="openai"
|
||||
isSet={!!settings?.openai_api_key_set}
|
||||
value={openaiKey}
|
||||
onChange={setOpenaiKey}
|
||||
placeholder={settings?.openai_api_key_set ? '••••••••••••' : 'sk-...'}
|
||||
testResult={testResults.openai}
|
||||
isTesting={testingProvider === 'openai'}
|
||||
onTest={() => testConnection('openai')}
|
||||
/>
|
||||
<ApiKeyField
|
||||
label="Google API Key"
|
||||
provider="google"
|
||||
isSet={!!settings?.google_api_key_set}
|
||||
value={googleKey}
|
||||
onChange={setGoogleKey}
|
||||
placeholder={settings?.google_api_key_set ? '••••••••••••' : 'AIza...'}
|
||||
testResult={testResults.google}
|
||||
isTesting={testingProvider === 'google'}
|
||||
onTest={() => testConnection('google')}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<Label className="flex items-center gap-2">
|
||||
API Key
|
||||
{settings?.google_api_key_set && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 bg-green-100 text-green-700 rounded-full">Set</span>
|
||||
)}
|
||||
{testResults.google && (
|
||||
testResults.google.ok ? (
|
||||
<span className="text-[10px] px-1.5 py-0.5 bg-green-100 text-green-700 rounded-full flex items-center gap-0.5">
|
||||
<CheckCircle2 className="w-2.5 h-2.5" /> OK {testResults.google.latency_ms && `(${testResults.google.latency_ms}ms)`}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[10px] px-1.5 py-0.5 bg-red-100 text-red-700 rounded-full flex items-center gap-0.5">
|
||||
<XCircle className="w-2.5 h-2.5" /> Failed
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="password"
|
||||
value={googleKey}
|
||||
onChange={(e) => setGoogleKey(e.target.value)}
|
||||
placeholder={settings?.google_api_key_set ? '••••••••••••' : 'AIza...'}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={testConnection}
|
||||
disabled={testingProvider === 'google'}
|
||||
className="h-9 px-3 text-xs whitespace-nowrap"
|
||||
title="Test connection"
|
||||
>
|
||||
{testingProvider === 'google' ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<Zap className="w-3.5 h-3.5" />
|
||||
)}
|
||||
Test
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="space-y-1">
|
||||
<Label className="flex items-center gap-2">
|
||||
{label}
|
||||
{isSet && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 bg-green-100 text-green-700 rounded-full">Set</span>
|
||||
)}
|
||||
{testResult && (
|
||||
testResult.ok ? (
|
||||
<span className="text-[10px] px-1.5 py-0.5 bg-green-100 text-green-700 rounded-full flex items-center gap-0.5">
|
||||
<CheckCircle2 className="w-2.5 h-2.5" /> OK {testResult.latency_ms && `(${testResult.latency_ms}ms)`}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[10px] px-1.5 py-0.5 bg-red-100 text-red-700 rounded-full flex items-center gap-0.5">
|
||||
<XCircle className="w-2.5 h-2.5" /> Failed
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="password"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onTest}
|
||||
disabled={isTesting}
|
||||
className="h-9 px-3 text-xs whitespace-nowrap"
|
||||
title="Test connection"
|
||||
>
|
||||
{isTesting ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<Zap className="w-3.5 h-3.5" />
|
||||
)}
|
||||
Test
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
69
frontend/components/shared/Card.tsx
Normal file
69
frontend/components/shared/Card.tsx
Normal file
|
|
@ -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 (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-white rounded-xl border border-border p-6",
|
||||
"transition-all duration-200",
|
||||
hover && "hover:shadow-lg hover:border-[hsl(var(--primary))]/20 cursor-pointer hover:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CardHeaderProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CardHeader({ children, className = "" }: CardHeaderProps) {
|
||||
return <div className={cn("mb-4", className)}>{children}</div>;
|
||||
}
|
||||
|
||||
interface CardTitleProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CardTitle({ children, className = "" }: CardTitleProps) {
|
||||
return <h3 className={cn("text-xl font-semibold", className)}>{children}</h3>;
|
||||
}
|
||||
|
||||
interface CardDescriptionProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CardDescription({ children, className = "" }: CardDescriptionProps) {
|
||||
return <p className={cn("text-sm text-muted-foreground", className)}>{children}</p>;
|
||||
}
|
||||
|
||||
interface CardContentProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CardContent({ children, className = "" }: CardContentProps) {
|
||||
return <div className={cn(className)}>{children}</div>;
|
||||
}
|
||||
|
||||
interface CardFooterProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CardFooter({ children, className = "" }: CardFooterProps) {
|
||||
return <div className={cn("mt-4 pt-4 border-t border-border", className)}>{children}</div>;
|
||||
}
|
||||
34
frontend/components/shared/StatusBadge.tsx
Normal file
34
frontend/components/shared/StatusBadge.tsx
Normal file
|
|
@ -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 (
|
||||
<Badge className={cn(config.color, "border font-medium", className)}>
|
||||
{config.label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -25,12 +25,12 @@ export function HamsterLoader({ size = 'md', className = '' }: HamsterLoaderProp
|
|||
>
|
||||
<div className="wheel" />
|
||||
<div className="hamster">
|
||||
<div className="hamster__head">
|
||||
<div className="hamster__ear" />
|
||||
<div className="hamster__eye" />
|
||||
<div className="hamster__nose" />
|
||||
</div>
|
||||
<div className="hamster__body">
|
||||
<div className="hamster__head">
|
||||
<div className="hamster__ear" />
|
||||
<div className="hamster__eye" />
|
||||
<div className="hamster__nose" />
|
||||
</div>
|
||||
<div className="hamster__limb hamster__limb--fr" />
|
||||
<div className="hamster__limb hamster__limb--fl" />
|
||||
<div className="hamster__limb hamster__limb--br" />
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue