chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
from contextlib import asynccontextmanager
|
||||
import logging
|
||||
import os
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from migrations import migrate_database_on_startup
|
||||
from services.database import create_db_and_tables, dispose_engines
|
||||
from templates.default_templates import import_default_templates_on_startup
|
||||
from utils.get_env import get_app_data_directory_env, get_can_change_keys_env
|
||||
from utils.model_availability import (
|
||||
check_llm_and_image_provider_api_or_model_availability,
|
||||
)
|
||||
from utils.user_config import update_env_with_user_config
|
||||
from utils.simple_auth import (
|
||||
clear_stored_credentials,
|
||||
force_set_credentials,
|
||||
is_auth_configured,
|
||||
setup_initial_credentials,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _configure_application_logging() -> None:
|
||||
"""Honor LOG_LEVEL (default INFO) so template/export diagnostics are visible."""
|
||||
raw = (os.getenv("LOG_LEVEL") or "INFO").strip().upper()
|
||||
level = getattr(logging, raw, logging.INFO)
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.setLevel(level)
|
||||
|
||||
if root_logger.handlers:
|
||||
return
|
||||
|
||||
logger_cursor: logging.Logger | None = logging.getLogger("uvicorn.error")
|
||||
visible_handlers: list[logging.Handler] = []
|
||||
while logger_cursor is not None:
|
||||
visible_handlers.extend(logger_cursor.handlers)
|
||||
if not logger_cursor.propagate:
|
||||
break
|
||||
logger_cursor = logger_cursor.parent
|
||||
|
||||
for handler in visible_handlers:
|
||||
root_logger.addHandler(handler)
|
||||
|
||||
if not root_logger.handlers:
|
||||
logging.basicConfig(level=level)
|
||||
|
||||
|
||||
def _is_truthy(value: str | None) -> bool:
|
||||
if value is None:
|
||||
return False
|
||||
return value.strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _bootstrap_auth_from_env() -> None:
|
||||
"""
|
||||
Bootstrap the single-user login from environment variables.
|
||||
|
||||
Behaviour:
|
||||
- RESET_AUTH=true -> wipe stored credentials (recovery path).
|
||||
- AUTH_USERNAME + AUTH_PASSWORD set:
|
||||
* if no credentials configured -> create them (first-run preseed).
|
||||
* if AUTH_OVERRIDE_FROM_ENV=true -> overwrite existing credentials.
|
||||
- Otherwise do nothing; the login UI will run in setup-mode on first
|
||||
visit and in sign-in-mode afterwards.
|
||||
|
||||
Any errors here are logged and swallowed so a bad env value can never
|
||||
brick the app — the operator can always fall back to the UI/reset flow.
|
||||
"""
|
||||
try:
|
||||
if _is_truthy(os.getenv("RESET_AUTH")):
|
||||
clear_stored_credentials()
|
||||
logger.warning(
|
||||
"RESET_AUTH is set; cleared stored login credentials. "
|
||||
"The next visit will prompt for setup."
|
||||
)
|
||||
|
||||
env_username = os.getenv("AUTH_USERNAME")
|
||||
env_password = os.getenv("AUTH_PASSWORD")
|
||||
if not env_username or not env_password:
|
||||
return
|
||||
|
||||
override = _is_truthy(os.getenv("AUTH_OVERRIDE_FROM_ENV"))
|
||||
if is_auth_configured() and not override:
|
||||
return
|
||||
|
||||
if is_auth_configured() and override:
|
||||
force_set_credentials(env_username, env_password)
|
||||
logger.warning(
|
||||
"AUTH_OVERRIDE_FROM_ENV is set; replaced stored credentials "
|
||||
"with values from AUTH_USERNAME/AUTH_PASSWORD."
|
||||
)
|
||||
else:
|
||||
setup_initial_credentials(env_username, env_password)
|
||||
logger.info(
|
||||
"Initialized login credentials from AUTH_USERNAME/AUTH_PASSWORD."
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - defensive, never fatal.
|
||||
logger.exception("Failed to bootstrap auth from environment: %s", exc)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def app_lifespan(_: FastAPI):
|
||||
"""
|
||||
Lifespan context manager for FastAPI application.
|
||||
Initializes the application data directory, runs Alembic migrations when
|
||||
MIGRATE_DATABASE_ON_STARTUP=true, creates any missing tables, bootstraps
|
||||
the single-user login from env vars (if provided), and checks LLM model
|
||||
availability.
|
||||
"""
|
||||
_configure_application_logging()
|
||||
os.makedirs(get_app_data_directory_env(), exist_ok=True)
|
||||
await migrate_database_on_startup()
|
||||
await create_db_and_tables()
|
||||
await import_default_templates_on_startup()
|
||||
_bootstrap_auth_from_env()
|
||||
if get_can_change_keys_env() != "false":
|
||||
update_env_with_user_config()
|
||||
await check_llm_and_image_provider_api_or_model_availability()
|
||||
yield
|
||||
# Shutdown: release all database connections to prevent stale/leaked pools.
|
||||
await dispose_engines()
|
||||
@@ -0,0 +1,107 @@
|
||||
import os
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import FileResponse
|
||||
|
||||
from api.lifespan import app_lifespan
|
||||
from api.middlewares import SessionAuthMiddleware, UserConfigEnvUpdateMiddleware
|
||||
from api.v1.async_tasks.router import API_V1_ASYNC_TASKS_ROUTER
|
||||
from api.v1.auth.router import API_V1_AUTH_ROUTER
|
||||
from api.v1.mock.router import API_V1_MOCK_ROUTER
|
||||
from api.v1.ppt.router import API_V1_PPT_ROUTER
|
||||
from api.v1.webhook.router import API_V1_WEBHOOK_ROUTER
|
||||
from utils.get_env import (
|
||||
get_app_data_directory_env,
|
||||
get_sentry_dsn_env,
|
||||
get_sentry_send_default_pii_env,
|
||||
get_sentry_traces_sample_rate_env,
|
||||
)
|
||||
from utils.mime_types import init_sandbox_safe_mimetypes
|
||||
from utils.path_helpers import get_resource_path
|
||||
|
||||
|
||||
init_sandbox_safe_mimetypes()
|
||||
|
||||
|
||||
def _maybe_init_sentry() -> None:
|
||||
sentry_dsn = get_sentry_dsn_env()
|
||||
if not sentry_dsn:
|
||||
return
|
||||
|
||||
try:
|
||||
import sentry_sdk
|
||||
except Exception:
|
||||
# Sentry SDK is optional in some runtime targets.
|
||||
return
|
||||
|
||||
traces_sample_rate = get_sentry_traces_sample_rate_env()
|
||||
send_default_pii = get_sentry_send_default_pii_env()
|
||||
try:
|
||||
parsed_sample_rate = (
|
||||
float(traces_sample_rate) if traces_sample_rate is not None else 1.0
|
||||
)
|
||||
except ValueError:
|
||||
parsed_sample_rate = 1.0
|
||||
|
||||
parsed_send_default_pii = (
|
||||
send_default_pii.lower() == "true" if send_default_pii is not None else True
|
||||
)
|
||||
|
||||
sentry_sdk.init(
|
||||
dsn=sentry_dsn,
|
||||
send_default_pii=parsed_send_default_pii,
|
||||
traces_sample_rate=parsed_sample_rate,
|
||||
)
|
||||
|
||||
|
||||
_maybe_init_sentry()
|
||||
|
||||
app = FastAPI(lifespan=app_lifespan)
|
||||
|
||||
# Routers
|
||||
app.include_router(API_V1_PPT_ROUTER)
|
||||
app.include_router(API_V1_WEBHOOK_ROUTER)
|
||||
app.include_router(API_V1_MOCK_ROUTER)
|
||||
app.include_router(API_V1_AUTH_ROUTER)
|
||||
app.include_router(API_V1_ASYNC_TASKS_ROUTER)
|
||||
|
||||
# Mount app_data and static assets (direct FastAPI access; nginx also serves /static in Docker).
|
||||
app_data_dir = get_app_data_directory_env()
|
||||
if app_data_dir:
|
||||
os.makedirs(app_data_dir, exist_ok=True)
|
||||
app.mount("/app_data", StaticFiles(directory=app_data_dir), name="app_data")
|
||||
|
||||
static_dir = get_resource_path("static")
|
||||
if os.path.isdir(static_dir):
|
||||
app.mount("/static", StaticFiles(directory=static_dir), name="static")
|
||||
|
||||
# Middlewares
|
||||
origins = ["*"]
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.add_middleware(UserConfigEnvUpdateMiddleware)
|
||||
app.add_middleware(SessionAuthMiddleware)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def static_icon_fallback_middleware(request: Request, call_next):
|
||||
"""Serve placeholder when icon paths are missing (e.g. renamed Phosphor icons)."""
|
||||
response = await call_next(request)
|
||||
if response.status_code != 404:
|
||||
return response
|
||||
path = request.url.path
|
||||
if not path.startswith("/static/icons/"):
|
||||
return response
|
||||
placeholder = get_resource_path("static/icons/placeholder.svg")
|
||||
if not os.path.isfile(placeholder):
|
||||
return response
|
||||
return FileResponse(placeholder, media_type="image/svg+xml")
|
||||
@@ -0,0 +1,87 @@
|
||||
from fastapi import Request
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from utils.get_env import get_can_change_keys_env, is_disable_auth_enabled
|
||||
from utils.simple_auth import (
|
||||
get_auth_status,
|
||||
get_basic_auth_credentials_from_request,
|
||||
get_session_token_from_request,
|
||||
verify_credentials,
|
||||
)
|
||||
from utils.user_config import update_env_with_user_config
|
||||
|
||||
|
||||
class UserConfigEnvUpdateMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
if get_can_change_keys_env() != "false":
|
||||
update_env_with_user_config()
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
class SessionAuthMiddleware(BaseHTTPMiddleware):
|
||||
_EXEMPT_PREFIXES = (
|
||||
"/api/v1/auth/",
|
||||
)
|
||||
_PUBLIC_APP_DATA_PREFIXES = (
|
||||
"/app_data/images/",
|
||||
"/app_data/fonts/",
|
||||
"/app_data/pptx-to-html/",
|
||||
)
|
||||
_PROTECTED_NON_API_PATHS = {
|
||||
"/docs",
|
||||
"/openapi.json",
|
||||
"/redoc",
|
||||
}
|
||||
|
||||
def _is_exempt(self, path: str) -> bool:
|
||||
return any(path.startswith(prefix) for prefix in self._EXEMPT_PREFIXES)
|
||||
|
||||
def _requires_auth(self, path: str) -> bool:
|
||||
if path.startswith("/api/"):
|
||||
return True
|
||||
# Browser rendering/export may re-fetch slide assets without session/basic headers.
|
||||
if any(path.startswith(prefix) for prefix in self._PUBLIC_APP_DATA_PREFIXES):
|
||||
return False
|
||||
if path.startswith("/app_data/"):
|
||||
return True
|
||||
return path in self._PROTECTED_NON_API_PATHS
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
if is_disable_auth_enabled():
|
||||
return await call_next(request)
|
||||
|
||||
path = request.url.path
|
||||
|
||||
if (
|
||||
request.method == "OPTIONS"
|
||||
or not self._requires_auth(path)
|
||||
or self._is_exempt(path)
|
||||
):
|
||||
return await call_next(request)
|
||||
|
||||
auth_status = get_auth_status(get_session_token_from_request(request))
|
||||
if not auth_status["configured"]:
|
||||
return JSONResponse(
|
||||
status_code=428,
|
||||
content={
|
||||
"detail": "Login setup is required",
|
||||
"setup_required": True,
|
||||
},
|
||||
)
|
||||
|
||||
if not auth_status["authenticated"]:
|
||||
basic_credentials = get_basic_auth_credentials_from_request(request)
|
||||
if basic_credentials and verify_credentials(
|
||||
basic_credentials[0], basic_credentials[1]
|
||||
):
|
||||
request.state.auth_username = basic_credentials[0].strip()
|
||||
return await call_next(request)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={"detail": "Unauthorized"},
|
||||
)
|
||||
|
||||
request.state.auth_username = auth_status.get("username")
|
||||
return await call_next(request)
|
||||
@@ -0,0 +1,78 @@
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlmodel import select
|
||||
|
||||
from models.sql.async_task import AsyncTaskModel
|
||||
from services.database import get_async_session
|
||||
|
||||
|
||||
API_V1_ASYNC_TASKS_ROUTER = APIRouter(
|
||||
prefix="/api/v1/async-tasks",
|
||||
tags=["Async Tasks"],
|
||||
)
|
||||
|
||||
|
||||
@API_V1_ASYNC_TASKS_ROUTER.get(
|
||||
"",
|
||||
response_model=list[AsyncTaskModel],
|
||||
)
|
||||
async def list_async_tasks(
|
||||
task_type: str | None = Query(default=None, alias="type"),
|
||||
status: str | None = Query(default=None),
|
||||
created_at: datetime | None = Query(
|
||||
default=None,
|
||||
description=(
|
||||
"Only include tasks created at or after this timestamp. "
|
||||
"Alias for created_at_from."
|
||||
),
|
||||
),
|
||||
created_at_from: datetime | None = Query(
|
||||
default=None,
|
||||
description="Only include tasks created at or after this timestamp",
|
||||
),
|
||||
created_at_to: datetime | None = Query(
|
||||
default=None,
|
||||
description="Only include tasks created at or before this timestamp",
|
||||
),
|
||||
order_by: Literal["created_at", "updated_at"] = Query(default="created_at"),
|
||||
order: Literal["asc", "desc"] = Query(default="desc"),
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
sql_session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
statement = select(AsyncTaskModel)
|
||||
if task_type is not None:
|
||||
statement = statement.where(AsyncTaskModel.type == task_type)
|
||||
if status is not None:
|
||||
statement = statement.where(AsyncTaskModel.status == status)
|
||||
created_at_start = created_at_from or created_at
|
||||
if created_at_start is not None:
|
||||
statement = statement.where(AsyncTaskModel.created_at >= created_at_start)
|
||||
if created_at_to is not None:
|
||||
statement = statement.where(AsyncTaskModel.created_at <= created_at_to)
|
||||
|
||||
order_column = getattr(AsyncTaskModel, order_by)
|
||||
statement = statement.order_by(
|
||||
order_column.asc() if order == "asc" else order_column.desc()
|
||||
)
|
||||
statement = statement.offset(offset).limit(limit)
|
||||
|
||||
result = await sql_session.execute(statement)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@API_V1_ASYNC_TASKS_ROUTER.get(
|
||||
"/status/{id}",
|
||||
response_model=AsyncTaskModel,
|
||||
)
|
||||
async def check_async_task_status(
|
||||
id: str = Path(description="ID of the async task"),
|
||||
sql_session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
task = await sql_session.get(AsyncTaskModel, id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="No async task found")
|
||||
return task
|
||||
@@ -0,0 +1,107 @@
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from utils.simple_auth import (
|
||||
clear_session_cookie,
|
||||
create_session_token,
|
||||
get_auth_status,
|
||||
get_basic_auth_credentials_from_request,
|
||||
get_session_token_from_request,
|
||||
is_auth_configured,
|
||||
set_session_cookie,
|
||||
setup_initial_credentials,
|
||||
verify_credentials,
|
||||
)
|
||||
from utils.get_env import is_disable_auth_enabled
|
||||
|
||||
API_V1_AUTH_ROUTER = APIRouter(prefix="/api/v1/auth", tags=["Auth"])
|
||||
|
||||
|
||||
class AuthCredentialsRequest(BaseModel):
|
||||
username: str = Field(min_length=3, max_length=128)
|
||||
password: str = Field(min_length=6, max_length=256)
|
||||
|
||||
|
||||
@API_V1_AUTH_ROUTER.get("/status")
|
||||
async def get_status(request: Request):
|
||||
if is_disable_auth_enabled():
|
||||
return {"configured": True, "authenticated": True, "username": "electron"}
|
||||
token = get_session_token_from_request(request)
|
||||
return get_auth_status(token)
|
||||
|
||||
|
||||
@API_V1_AUTH_ROUTER.get("/verify")
|
||||
async def verify_session(request: Request):
|
||||
if is_disable_auth_enabled():
|
||||
return {"authenticated": True, "username": "electron"}
|
||||
|
||||
auth_status = get_auth_status(get_session_token_from_request(request))
|
||||
if not auth_status["configured"]:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
if not auth_status["authenticated"]:
|
||||
basic_credentials = get_basic_auth_credentials_from_request(request)
|
||||
if basic_credentials and verify_credentials(
|
||||
basic_credentials[0], basic_credentials[1]
|
||||
):
|
||||
return {
|
||||
"authenticated": True,
|
||||
"username": basic_credentials[0].strip(),
|
||||
}
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
return {
|
||||
"authenticated": True,
|
||||
"username": auth_status.get("username"),
|
||||
}
|
||||
|
||||
|
||||
@API_V1_AUTH_ROUTER.post("/setup")
|
||||
async def setup_credentials(body: AuthCredentialsRequest, request: Request):
|
||||
if is_auth_configured():
|
||||
raise HTTPException(status_code=409, detail="Credentials already configured")
|
||||
|
||||
try:
|
||||
setup_initial_credentials(body.username, body.password)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
username = body.username.strip()
|
||||
return JSONResponse(
|
||||
{
|
||||
"configured": True,
|
||||
"authenticated": False,
|
||||
"username": username,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@API_V1_AUTH_ROUTER.post("/login")
|
||||
async def login(body: AuthCredentialsRequest, request: Request):
|
||||
if not is_auth_configured():
|
||||
raise HTTPException(status_code=428, detail="Login setup is required")
|
||||
|
||||
if not verify_credentials(body.username, body.password):
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
username = body.username.strip()
|
||||
token = create_session_token(username)
|
||||
response = JSONResponse(
|
||||
{
|
||||
"configured": True,
|
||||
"authenticated": True,
|
||||
"username": username,
|
||||
"access_token": token,
|
||||
"token_type": "bearer",
|
||||
}
|
||||
)
|
||||
set_session_cookie(response, token, request)
|
||||
return response
|
||||
|
||||
|
||||
@API_V1_AUTH_ROUTER.post("/logout")
|
||||
async def logout(request: Request):
|
||||
response = JSONResponse({"success": True})
|
||||
clear_session_cookie(response, request)
|
||||
return response
|
||||
@@ -0,0 +1,36 @@
|
||||
import uuid
|
||||
from fastapi import APIRouter
|
||||
from models.api_error_model import APIErrorModel
|
||||
from models.presentation_and_path import PresentationPathAndEditPath
|
||||
from typing import List
|
||||
|
||||
from utils.asset_directory_utils import absolute_fastapi_asset_url
|
||||
|
||||
API_V1_MOCK_ROUTER = APIRouter(prefix="/api/v1/mock", tags=["Mock"])
|
||||
|
||||
|
||||
@API_V1_MOCK_ROUTER.get(
|
||||
"/presentation-generation-completed",
|
||||
response_model=List[PresentationPathAndEditPath],
|
||||
)
|
||||
async def mock_presentation_generation_completed():
|
||||
return [
|
||||
PresentationPathAndEditPath(
|
||||
presentation_id=uuid.uuid4(),
|
||||
path=absolute_fastapi_asset_url("/app_data/exports/test.pdf"),
|
||||
edit_path="/presentation?id=123",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@API_V1_MOCK_ROUTER.get(
|
||||
"/presentation-generation-failed",
|
||||
response_model=List[APIErrorModel],
|
||||
)
|
||||
async def mock_presentation_generation_completed():
|
||||
return [
|
||||
APIErrorModel(
|
||||
status_code=500,
|
||||
detail="Presentation generation failed",
|
||||
)
|
||||
]
|
||||
@@ -0,0 +1,21 @@
|
||||
from typing import Annotated, List
|
||||
from fastapi import APIRouter, Body, HTTPException
|
||||
|
||||
from utils.available_models import (
|
||||
ModelAvailabilityError,
|
||||
list_available_anthropic_models,
|
||||
)
|
||||
|
||||
ANTHROPIC_ROUTER = APIRouter(prefix="/anthropic", tags=["Anthropic"])
|
||||
|
||||
|
||||
@ANTHROPIC_ROUTER.post("/models/available", response_model=List[str])
|
||||
async def get_available_models(
|
||||
api_key: Annotated[str, Body(embed=True)],
|
||||
):
|
||||
try:
|
||||
return await list_available_anthropic_models(api_key)
|
||||
except ModelAvailabilityError as e:
|
||||
raise HTTPException(status_code=e.status_code, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -0,0 +1,147 @@
|
||||
import json
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from models.chat import (
|
||||
ChatConversationListItem,
|
||||
ChatHistoryMessageItem,
|
||||
ChatHistoryResponse,
|
||||
ChatMessageRequest,
|
||||
ChatMessageResponse,
|
||||
)
|
||||
from models.sse_response import (
|
||||
SSECompleteResponse,
|
||||
SSEErrorResponse,
|
||||
SSEResponse,
|
||||
SSEStatusResponse,
|
||||
SSETraceResponse,
|
||||
)
|
||||
from services.chat import sql_chat_history
|
||||
from services.chat import ChatTurnResult, PresentationChatService
|
||||
from services.database import get_async_session
|
||||
|
||||
|
||||
CHAT_ROUTER = APIRouter(prefix="/chat", tags=["Chat"])
|
||||
|
||||
|
||||
@CHAT_ROUTER.get("/conversations", response_model=list[ChatConversationListItem])
|
||||
async def list_chat_conversations(
|
||||
presentation_id: uuid.UUID = Query(..., description="Presentation id"),
|
||||
sql_session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
raw = await sql_chat_history.list_conversations(
|
||||
sql_session, presentation_id=presentation_id
|
||||
)
|
||||
return [
|
||||
ChatConversationListItem(
|
||||
conversation_id=uuid.UUID(str(item["conversation_id"])),
|
||||
updated_at=item.get("updated_at"),
|
||||
last_message_preview=item.get("last_message_preview"),
|
||||
)
|
||||
for item in raw
|
||||
]
|
||||
|
||||
|
||||
@CHAT_ROUTER.get("/history", response_model=ChatHistoryResponse)
|
||||
async def get_chat_history(
|
||||
presentation_id: uuid.UUID = Query(..., description="Presentation id"),
|
||||
conversation_id: uuid.UUID = Query(..., description="Conversation thread id"),
|
||||
sql_session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
rows = await sql_chat_history.load_messages_with_meta(
|
||||
sql_session,
|
||||
presentation_id=presentation_id,
|
||||
conversation_id=conversation_id,
|
||||
)
|
||||
return ChatHistoryResponse(
|
||||
presentation_id=presentation_id,
|
||||
conversation_id=conversation_id,
|
||||
messages=[
|
||||
ChatHistoryMessageItem(
|
||||
role=str(message.get("role") or ""),
|
||||
content=str(message.get("content") or ""),
|
||||
created_at=message.get("created_at")
|
||||
if isinstance(message.get("created_at"), str)
|
||||
else None,
|
||||
)
|
||||
for message in rows
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@CHAT_ROUTER.delete("/conversation", status_code=204)
|
||||
async def delete_chat_conversation(
|
||||
presentation_id: uuid.UUID = Query(..., description="Presentation id"),
|
||||
conversation_id: uuid.UUID = Query(..., description="Conversation thread id"),
|
||||
sql_session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
await sql_chat_history.delete_conversation(
|
||||
sql_session,
|
||||
presentation_id=presentation_id,
|
||||
conversation_id=conversation_id,
|
||||
)
|
||||
await sql_session.commit()
|
||||
|
||||
|
||||
@CHAT_ROUTER.post("/message", response_model=ChatMessageResponse)
|
||||
async def chat_message(
|
||||
payload: ChatMessageRequest,
|
||||
sql_session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
service = PresentationChatService(
|
||||
sql_session=sql_session,
|
||||
presentation_id=payload.presentation_id,
|
||||
conversation_id=payload.conversation_id,
|
||||
)
|
||||
result = await service.generate_reply(payload.message, payload.attachments)
|
||||
return ChatMessageResponse(
|
||||
conversation_id=result.conversation_id,
|
||||
response=result.response_text,
|
||||
tool_calls=result.tool_calls,
|
||||
)
|
||||
|
||||
|
||||
@CHAT_ROUTER.post("/message/stream")
|
||||
async def chat_message_stream(
|
||||
payload: ChatMessageRequest,
|
||||
sql_session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
service = PresentationChatService(
|
||||
sql_session=sql_session,
|
||||
presentation_id=payload.presentation_id,
|
||||
conversation_id=payload.conversation_id,
|
||||
)
|
||||
|
||||
async def inner():
|
||||
try:
|
||||
async for event_type, value in service.stream_reply(
|
||||
payload.message,
|
||||
payload.attachments,
|
||||
):
|
||||
if event_type == "chunk" and isinstance(value, str):
|
||||
yield SSEResponse(
|
||||
event="response",
|
||||
data=json.dumps({"type": "chunk", "chunk": value}),
|
||||
).to_string()
|
||||
elif event_type == "status" and isinstance(value, str):
|
||||
yield SSEStatusResponse(status=value).to_string()
|
||||
elif event_type == "trace" and isinstance(value, dict):
|
||||
yield SSETraceResponse(trace=value).to_string()
|
||||
elif event_type == "complete" and isinstance(value, ChatTurnResult):
|
||||
result = value
|
||||
complete_payload = ChatMessageResponse(
|
||||
conversation_id=result.conversation_id,
|
||||
response=result.response_text,
|
||||
tool_calls=result.tool_calls,
|
||||
)
|
||||
yield SSECompleteResponse(
|
||||
key="chat",
|
||||
value=complete_payload.model_dump(mode="json"),
|
||||
).to_string()
|
||||
except HTTPException as exc:
|
||||
yield SSEErrorResponse(detail=exc.detail).to_string()
|
||||
|
||||
return StreamingResponse(inner(), media_type="text/event-stream")
|
||||
@@ -0,0 +1,349 @@
|
||||
"""
|
||||
OpenAI Codex OAuth endpoints.
|
||||
|
||||
Flow:
|
||||
1. POST /codex/auth/initiate — start the flow, get back an auth URL + session_id
|
||||
2. Browser opens the URL, user authenticates with OpenAI
|
||||
3. OpenAI redirects to http://localhost:1455/auth/callback (captured by local server)
|
||||
4. GET /codex/auth/status/{session_id} — poll until code captured; exchanges and stores tokens
|
||||
5. POST /codex/auth/exchange — manual fallback if browser callback didn't fire
|
||||
6. POST /codex/auth/refresh — refresh a stored token
|
||||
"""
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from utils.oauth.openai_codex import (
|
||||
CodexAccountProfile,
|
||||
OAuthCallbackServer,
|
||||
TokenSuccess,
|
||||
create_authorization_flow,
|
||||
exchange_authorization_code,
|
||||
get_account_profile,
|
||||
parse_authorization_input,
|
||||
refresh_access_token,
|
||||
)
|
||||
from utils.get_env import (
|
||||
get_codex_access_token_env,
|
||||
get_codex_email_env,
|
||||
get_codex_is_pro_env,
|
||||
get_codex_refresh_token_env,
|
||||
get_codex_token_expires_env,
|
||||
get_codex_username_env,
|
||||
)
|
||||
from utils.set_env import (
|
||||
set_codex_access_token_env,
|
||||
set_codex_account_id_env,
|
||||
set_codex_email_env,
|
||||
set_codex_is_pro_env,
|
||||
set_codex_refresh_token_env,
|
||||
set_codex_token_expires_env,
|
||||
set_codex_model_env,
|
||||
set_codex_username_env,
|
||||
)
|
||||
from utils.user_config import save_codex_tokens_to_user_config
|
||||
|
||||
CODEX_AUTH_ROUTER = APIRouter(prefix="/codex/auth", tags=["Codex OAuth"])
|
||||
CHATGPT_AUTH_REQUIRED_HEADERS = {"X-Presenton-Auth-Action": "codex-reauth"}
|
||||
CHATGPT_AUTH_REQUIRED_PREFIX = "CHATGPT_AUTH_REQUIRED:"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# In-memory session store {session_id: {"verifier": str, "state": str, "server": OAuthCallbackServer}}
|
||||
# Sessions are short-lived; garbage-collected when consumed.
|
||||
# ---------------------------------------------------------------------------
|
||||
_sessions: dict[str, dict] = {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request / Response models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class InitiateResponse(BaseModel):
|
||||
session_id: str
|
||||
url: str
|
||||
instructions: str
|
||||
|
||||
|
||||
class StatusResponse(BaseModel):
|
||||
status: str # "pending" | "success" | "failed"
|
||||
account_id: Optional[str] = None
|
||||
username: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
is_pro: Optional[bool] = None
|
||||
detail: Optional[str] = None
|
||||
|
||||
|
||||
class ExchangeRequest(BaseModel):
|
||||
session_id: str
|
||||
code: str # raw code OR full redirect URL OR code#state shorthand
|
||||
|
||||
|
||||
class ExchangeResponse(BaseModel):
|
||||
account_id: Optional[str] = None
|
||||
username: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
is_pro: Optional[bool] = None
|
||||
|
||||
|
||||
class RefreshResponse(BaseModel):
|
||||
account_id: Optional[str]
|
||||
username: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
is_pro: Optional[bool] = None
|
||||
detail: str
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _parse_optional_bool(value: Optional[str]) -> Optional[bool]:
|
||||
if value is None:
|
||||
return None
|
||||
normalized = value.strip().lower()
|
||||
if normalized in {"true", "1", "yes", "y"}:
|
||||
return True
|
||||
if normalized in {"false", "0", "no", "n"}:
|
||||
return False
|
||||
return None
|
||||
|
||||
|
||||
def _store_token(result: TokenSuccess) -> CodexAccountProfile:
|
||||
"""Persist token fields in env vars and userConfig.json. Returns parsed profile."""
|
||||
set_codex_access_token_env(result.access)
|
||||
set_codex_refresh_token_env(result.refresh)
|
||||
set_codex_token_expires_env(str(result.expires))
|
||||
|
||||
profile = get_account_profile(result.access, result.id_token)
|
||||
set_codex_account_id_env(profile.account_id or "")
|
||||
set_codex_username_env(profile.username or "")
|
||||
set_codex_email_env(profile.email or "")
|
||||
set_codex_is_pro_env("" if profile.is_pro is None else str(profile.is_pro))
|
||||
|
||||
save_codex_tokens_to_user_config()
|
||||
return profile
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@CODEX_AUTH_ROUTER.post("/initiate", response_model=InitiateResponse)
|
||||
async def initiate_codex_auth():
|
||||
"""
|
||||
Start the OpenAI Codex OAuth flow.
|
||||
|
||||
Returns an authorization URL to open in the browser and a session_id to use
|
||||
when polling /status or calling /exchange. A local HTTP server is started
|
||||
on port 1455 to receive the redirect automatically.
|
||||
"""
|
||||
flow = create_authorization_flow()
|
||||
server = OAuthCallbackServer(state=flow.state)
|
||||
server_started = server.start()
|
||||
|
||||
session_id = str(uuid.uuid4())
|
||||
_sessions[session_id] = {
|
||||
"verifier": flow.verifier,
|
||||
"state": flow.state,
|
||||
"server": server,
|
||||
"server_started": server_started,
|
||||
}
|
||||
|
||||
instructions = (
|
||||
"Open the URL in your browser and complete the OpenAI login. "
|
||||
+ (
|
||||
"The callback will be captured automatically."
|
||||
if server_started
|
||||
else "Port 1455 could not be bound — paste the redirect URL or code into /exchange."
|
||||
)
|
||||
)
|
||||
|
||||
return InitiateResponse(
|
||||
session_id=session_id,
|
||||
url=flow.url,
|
||||
instructions=instructions,
|
||||
)
|
||||
|
||||
|
||||
@CODEX_AUTH_ROUTER.get("/status/{session_id}", response_model=StatusResponse)
|
||||
async def poll_codex_auth_status(session_id: str):
|
||||
"""
|
||||
Poll for the result of an ongoing OAuth flow.
|
||||
|
||||
Returns {"status": "pending"} until the callback server captures the code.
|
||||
On success the tokens are stored in environment variables and the session
|
||||
is cleaned up.
|
||||
"""
|
||||
session = _sessions.get(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="Session not found or already consumed")
|
||||
|
||||
server: OAuthCallbackServer = session["server"]
|
||||
|
||||
# Non-blocking peek — check whether the callback server already received a code
|
||||
code = server.get_code_nowait() if session.get("server_started") else None
|
||||
|
||||
if code is None:
|
||||
return StatusResponse(status="pending")
|
||||
|
||||
# We have a code — exchange it
|
||||
verifier: str = session["verifier"]
|
||||
result = exchange_authorization_code(code, verifier)
|
||||
|
||||
# Clean up session
|
||||
server.close()
|
||||
_sessions.pop(session_id, None)
|
||||
|
||||
if not isinstance(result, TokenSuccess):
|
||||
return StatusResponse(status="failed", detail=result.reason)
|
||||
|
||||
profile = _store_token(result)
|
||||
return StatusResponse(
|
||||
status="success",
|
||||
account_id=profile.account_id,
|
||||
username=profile.username,
|
||||
email=profile.email,
|
||||
is_pro=profile.is_pro,
|
||||
)
|
||||
|
||||
|
||||
@CODEX_AUTH_ROUTER.post("/exchange", response_model=ExchangeResponse)
|
||||
async def exchange_codex_code(body: ExchangeRequest):
|
||||
"""
|
||||
Manual code exchange fallback.
|
||||
|
||||
Accepts the session_id from /initiate and either:
|
||||
- a bare authorization code
|
||||
- the full redirect URL (http://localhost:1455/auth/callback?code=…&state=…)
|
||||
- the code#state shorthand
|
||||
|
||||
Exchanges the code for tokens and stores them in environment variables.
|
||||
"""
|
||||
session = _sessions.get(body.session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="Session not found or already consumed")
|
||||
|
||||
parsed = parse_authorization_input(body.code)
|
||||
code = parsed.get("code")
|
||||
incoming_state = parsed.get("state")
|
||||
|
||||
if not code:
|
||||
raise HTTPException(status_code=400, detail="Could not extract authorization code from input")
|
||||
|
||||
if incoming_state and incoming_state != session["state"]:
|
||||
raise HTTPException(status_code=400, detail="State mismatch — possible CSRF")
|
||||
|
||||
verifier: str = session["verifier"]
|
||||
server: OAuthCallbackServer = session["server"]
|
||||
|
||||
result = exchange_authorization_code(code, verifier)
|
||||
|
||||
server.close()
|
||||
_sessions.pop(body.session_id, None)
|
||||
|
||||
if not isinstance(result, TokenSuccess):
|
||||
raise HTTPException(status_code=502, detail=f"Token exchange failed: {result.reason}")
|
||||
|
||||
profile = _store_token(result)
|
||||
if not profile.account_id:
|
||||
raise HTTPException(status_code=502, detail="Token exchanged but could not extract account ID")
|
||||
|
||||
return ExchangeResponse(
|
||||
account_id=profile.account_id,
|
||||
username=profile.username,
|
||||
email=profile.email,
|
||||
is_pro=profile.is_pro,
|
||||
)
|
||||
|
||||
|
||||
@CODEX_AUTH_ROUTER.post("/refresh", response_model=RefreshResponse)
|
||||
async def refresh_codex_token():
|
||||
"""
|
||||
Refresh the stored Codex OAuth access token using the refresh token.
|
||||
|
||||
Updates environment variables with the new tokens.
|
||||
"""
|
||||
refresh_token = get_codex_refresh_token_env()
|
||||
if not refresh_token:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail=(
|
||||
f"{CHATGPT_AUTH_REQUIRED_PREFIX} ChatGPT authentication is required. "
|
||||
"Please sign in again from Settings."
|
||||
),
|
||||
headers=CHATGPT_AUTH_REQUIRED_HEADERS,
|
||||
)
|
||||
|
||||
result = refresh_access_token(refresh_token)
|
||||
if not isinstance(result, TokenSuccess):
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail=(
|
||||
f"{CHATGPT_AUTH_REQUIRED_PREFIX} Your ChatGPT session expired. "
|
||||
"Please sign in again from Settings."
|
||||
),
|
||||
headers=CHATGPT_AUTH_REQUIRED_HEADERS,
|
||||
)
|
||||
|
||||
profile = _store_token(result)
|
||||
return RefreshResponse(
|
||||
account_id=profile.account_id,
|
||||
username=profile.username,
|
||||
email=profile.email,
|
||||
is_pro=profile.is_pro,
|
||||
detail="Token refreshed successfully",
|
||||
)
|
||||
|
||||
|
||||
@CODEX_AUTH_ROUTER.get("/status", response_model=StatusResponse)
|
||||
async def get_codex_auth_status():
|
||||
"""
|
||||
Return whether a valid Codex OAuth token is currently stored.
|
||||
"""
|
||||
import time
|
||||
|
||||
access_token = get_codex_access_token_env()
|
||||
if not access_token:
|
||||
return StatusResponse(status="not_authenticated", detail="No access token stored")
|
||||
|
||||
expires_str = get_codex_token_expires_env()
|
||||
if expires_str:
|
||||
try:
|
||||
expires_ms = int(expires_str)
|
||||
now_ms = int(time.time() * 1000)
|
||||
if now_ms >= expires_ms:
|
||||
return StatusResponse(status="expired", detail="Access token has expired — call /refresh")
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
profile = get_account_profile(access_token)
|
||||
return StatusResponse(
|
||||
status="authenticated",
|
||||
account_id=profile.account_id,
|
||||
username=profile.username or get_codex_username_env(),
|
||||
email=profile.email or get_codex_email_env(),
|
||||
is_pro=(
|
||||
profile.is_pro
|
||||
if profile.is_pro is not None
|
||||
else _parse_optional_bool(get_codex_is_pro_env())
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@CODEX_AUTH_ROUTER.post("/logout")
|
||||
async def logout_codex():
|
||||
"""
|
||||
Clear all stored Codex OAuth credentials from environment variables and userConfig.json.
|
||||
"""
|
||||
set_codex_access_token_env("")
|
||||
set_codex_refresh_token_env("")
|
||||
set_codex_token_expires_env("")
|
||||
set_codex_account_id_env("")
|
||||
set_codex_username_env("")
|
||||
set_codex_email_env("")
|
||||
set_codex_is_pro_env("")
|
||||
set_codex_model_env("")
|
||||
save_codex_tokens_to_user_config(include_model=True)
|
||||
return {"detail": "Logged out successfully"}
|
||||
@@ -0,0 +1,90 @@
|
||||
import os
|
||||
from typing import Annotated, List, Optional
|
||||
from fastapi import APIRouter, Body, File, HTTPException, UploadFile
|
||||
|
||||
from constants.documents import UPLOAD_ACCEPTED_FILE_TYPES
|
||||
from models.decomposed_file_info import DecomposedFileInfo
|
||||
from services.temp_file_service import TEMP_FILE_SERVICE
|
||||
from services.documents_loader import DocumentsLoader
|
||||
import uuid
|
||||
from utils.validators import validate_files
|
||||
|
||||
FILES_ROUTER = APIRouter(prefix="/files", tags=["Files"])
|
||||
|
||||
|
||||
@FILES_ROUTER.post("/upload", response_model=List[str])
|
||||
async def upload_files(files: Optional[List[UploadFile]]):
|
||||
if not files:
|
||||
raise HTTPException(400, "Documents are required")
|
||||
|
||||
temp_dir = TEMP_FILE_SERVICE.create_temp_dir(str(uuid.uuid4()))
|
||||
|
||||
validate_files(files, True, True, 100, UPLOAD_ACCEPTED_FILE_TYPES)
|
||||
|
||||
temp_files: List[str] = []
|
||||
if files:
|
||||
for each_file in files:
|
||||
temp_path = TEMP_FILE_SERVICE.create_temp_file_path(
|
||||
each_file.filename, temp_dir
|
||||
)
|
||||
with open(temp_path, "wb") as f:
|
||||
content = await each_file.read()
|
||||
f.write(content)
|
||||
|
||||
temp_files.append(temp_path)
|
||||
|
||||
return temp_files
|
||||
|
||||
|
||||
@FILES_ROUTER.post("/decompose", response_model=List[DecomposedFileInfo])
|
||||
async def decompose_files(
|
||||
file_paths: Annotated[List[str], Body(embed=True)],
|
||||
language: Annotated[Optional[str], Body()] = None,
|
||||
):
|
||||
temp_dir = TEMP_FILE_SERVICE.create_temp_dir(str(uuid.uuid4()))
|
||||
resolved_file_paths = TEMP_FILE_SERVICE.resolve_existing_temp_paths(file_paths)
|
||||
|
||||
txt_files = []
|
||||
other_files = []
|
||||
for file_path in resolved_file_paths:
|
||||
if file_path.endswith(".txt"):
|
||||
txt_files.append(file_path)
|
||||
else:
|
||||
other_files.append(file_path)
|
||||
|
||||
documents_loader = DocumentsLoader(file_paths=other_files, presentation_language=language)
|
||||
await documents_loader.load_documents(temp_dir)
|
||||
parsed_documents = documents_loader.documents
|
||||
|
||||
response = []
|
||||
for index, parsed_doc in enumerate(parsed_documents):
|
||||
file_path = TEMP_FILE_SERVICE.create_temp_file_path(
|
||||
f"{uuid.uuid4()}.txt", temp_dir
|
||||
)
|
||||
parsed_doc = parsed_doc.replace("<br>", "\n")
|
||||
with open(file_path, "w", encoding="utf-8") as text_file:
|
||||
text_file.write(parsed_doc)
|
||||
response.append(
|
||||
DecomposedFileInfo(
|
||||
name=os.path.basename(other_files[index]), file_path=file_path
|
||||
)
|
||||
)
|
||||
|
||||
# Return the txt documents as it is
|
||||
for each_file in txt_files:
|
||||
response.append(
|
||||
DecomposedFileInfo(name=os.path.basename(each_file), file_path=each_file)
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@FILES_ROUTER.post("/update")
|
||||
async def update_files(
|
||||
file_path: Annotated[str, Body()],
|
||||
file: Annotated[UploadFile, File()],
|
||||
):
|
||||
validate_files(file, False, False, 100, UPLOAD_ACCEPTED_FILE_TYPES)
|
||||
await TEMP_FILE_SERVICE.update_temp_file_from_upload(file_path, file)
|
||||
|
||||
return {"message": "File updated successfully"}
|
||||
@@ -0,0 +1,172 @@
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, File, HTTPException, UploadFile
|
||||
from pydantic import BaseModel
|
||||
|
||||
from templates.preview import FontCheckResponse, check_fonts_in_pptx_handler
|
||||
from utils.font_uploads import (
|
||||
FONT_CONTENT_TYPES,
|
||||
delete_font_upload,
|
||||
font_upload_to_info,
|
||||
get_font_upload_url,
|
||||
list_font_uploads,
|
||||
persist_upload_file,
|
||||
)
|
||||
|
||||
|
||||
FONTS_ROUTER = APIRouter(prefix="/fonts", tags=["fonts"])
|
||||
|
||||
SUPPORTED_FONT_EXTENSIONS = FONT_CONTENT_TYPES
|
||||
|
||||
|
||||
class FontUploadResponse(BaseModel):
|
||||
success: bool
|
||||
font_name: str
|
||||
font_url: str
|
||||
font_path: str
|
||||
message: Optional[str] = None
|
||||
|
||||
|
||||
class FontListResponse(BaseModel):
|
||||
success: bool
|
||||
fonts: List[dict]
|
||||
message: Optional[str] = None
|
||||
|
||||
|
||||
class UploadedFontsResponse(BaseModel):
|
||||
fonts: List[dict]
|
||||
|
||||
|
||||
def _font_display_name(font_info) -> str:
|
||||
return (
|
||||
font_info.family_name
|
||||
or font_info.full_name
|
||||
or font_info.normalized_family_name
|
||||
or Path(font_info.filename).stem
|
||||
)
|
||||
|
||||
|
||||
def _font_original_name(filename: str) -> str:
|
||||
path = Path(filename)
|
||||
stem = path.stem
|
||||
if "_" in stem and len(stem.rsplit("_", 1)[-1]) == 8:
|
||||
stem = stem.rsplit("_", 1)[0]
|
||||
return f"{stem}{path.suffix}"
|
||||
|
||||
|
||||
@FONTS_ROUTER.post("/upload", response_model=FontUploadResponse)
|
||||
async def upload_font(
|
||||
font_file: UploadFile = File(
|
||||
..., description="Font file to upload (.ttf, .otf, .woff, .woff2, .eot)"
|
||||
)
|
||||
):
|
||||
try:
|
||||
if not font_file.filename:
|
||||
raise HTTPException(status_code=400, detail="No file name provided")
|
||||
|
||||
font_upload, _font_path = await persist_upload_file(font_file)
|
||||
font_info = await font_upload_to_info(font_upload)
|
||||
font_name = _font_display_name(font_info)
|
||||
|
||||
return FontUploadResponse(
|
||||
success=True,
|
||||
font_name=font_name,
|
||||
font_url=await get_font_upload_url(font_upload),
|
||||
font_path=font_info.path,
|
||||
message=f"Font '{font_name}' uploaded successfully",
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
print(f"Error uploading font: {exc}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Error uploading font: {exc}"
|
||||
) from exc
|
||||
|
||||
|
||||
@FONTS_ROUTER.get("/list", response_model=FontListResponse)
|
||||
async def list_fonts():
|
||||
try:
|
||||
response = await list_font_uploads()
|
||||
fonts = []
|
||||
for font_info in response.fonts:
|
||||
file_ext = Path(font_info.filename).suffix.lower()
|
||||
fonts.append(
|
||||
{
|
||||
"id": str(font_info.id),
|
||||
"filename": font_info.filename,
|
||||
"font_name": _font_display_name(font_info),
|
||||
"original_name": _font_original_name(font_info.filename),
|
||||
"font_url": font_info.url,
|
||||
"font_path": font_info.path,
|
||||
"font_type": SUPPORTED_FONT_EXTENSIONS.get(file_ext, "unknown"),
|
||||
"file_size": font_info.size_bytes,
|
||||
"family_name": font_info.family_name,
|
||||
"subfamily_name": font_info.subfamily_name,
|
||||
"full_name": font_info.full_name,
|
||||
"postscript_name": font_info.postscript_name,
|
||||
"normalized_family_name": font_info.normalized_family_name,
|
||||
"weight_class": font_info.weight_class,
|
||||
"width_class": font_info.width_class,
|
||||
"format": font_info.format,
|
||||
}
|
||||
)
|
||||
|
||||
return FontListResponse(
|
||||
success=True,
|
||||
fonts=fonts,
|
||||
message=f"Found {len(fonts)} font files",
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
print(f"Error listing fonts: {exc}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Error listing fonts: {exc}"
|
||||
) from exc
|
||||
|
||||
|
||||
@FONTS_ROUTER.get("/uploaded", response_model=UploadedFontsResponse)
|
||||
async def get_uploaded_fonts():
|
||||
try:
|
||||
response = await list_font_uploads()
|
||||
return UploadedFontsResponse(
|
||||
fonts=[
|
||||
{
|
||||
"id": str(font_info.id),
|
||||
"name": _font_display_name(font_info),
|
||||
"url": font_info.url,
|
||||
}
|
||||
for font_info in response.fonts
|
||||
]
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
print(f"Error getting uploaded fonts: {exc}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Error getting uploaded fonts: {exc}"
|
||||
) from exc
|
||||
|
||||
|
||||
FONTS_ROUTER.post("/check", response_model=FontCheckResponse)(
|
||||
check_fonts_in_pptx_handler
|
||||
)
|
||||
|
||||
|
||||
@FONTS_ROUTER.delete("/delete/{filename}")
|
||||
async def delete_font(filename: str):
|
||||
try:
|
||||
deleted = await delete_font_upload(filename)
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Font '{deleted.filename}' deleted successfully",
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
print(f"Error deleting font: {exc}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Error deleting font: {exc}"
|
||||
) from exc
|
||||
@@ -0,0 +1,16 @@
|
||||
from typing import Annotated, List
|
||||
from fastapi import APIRouter, Body, HTTPException
|
||||
|
||||
from utils.available_models import ModelAvailabilityError, list_available_google_models
|
||||
|
||||
GOOGLE_ROUTER = APIRouter(prefix="/google", tags=["Google"])
|
||||
|
||||
|
||||
@GOOGLE_ROUTER.post("/models/available", response_model=List[str])
|
||||
async def get_available_models(api_key: Annotated[str, Body(embed=True)]):
|
||||
try:
|
||||
return await list_available_google_models(api_key)
|
||||
except ModelAvailabilityError as e:
|
||||
raise HTTPException(status_code=e.status_code, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -0,0 +1,20 @@
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
from services.icon_finder_service import ICON_FINDER_SERVICE
|
||||
from utils.icon_weights import DEFAULT_ICON_WEIGHT, normalize_icon_weight
|
||||
|
||||
ICONS_ROUTER = APIRouter(prefix="/icons", tags=["Icons"])
|
||||
|
||||
|
||||
@ICONS_ROUTER.get("/search", response_model=List[str])
|
||||
async def search_icons(
|
||||
query: str,
|
||||
limit: int = 20,
|
||||
icon_weight: str = Query(default=DEFAULT_ICON_WEIGHT),
|
||||
):
|
||||
return await ICON_FINDER_SERVICE.search_icons(
|
||||
query,
|
||||
limit,
|
||||
weight=normalize_icon_weight(icon_weight),
|
||||
)
|
||||
@@ -0,0 +1,258 @@
|
||||
from io import BytesIO
|
||||
from typing import List
|
||||
from fastapi import APIRouter, Depends, File, UploadFile, HTTPException, Query, Header
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlmodel import select
|
||||
|
||||
from models.image_prompt import ImagePrompt
|
||||
from models.sql.image_asset import ImageAsset
|
||||
from services.database import get_async_session
|
||||
from services.image_generation_service import ImageGenerationService
|
||||
from utils.asset_directory_utils import (
|
||||
filesystem_image_path_to_app_data_url,
|
||||
get_images_directory,
|
||||
normalize_slide_asset_url,
|
||||
)
|
||||
from utils.get_env import get_pexels_api_key_env, get_pixabay_api_key_env
|
||||
from utils.image_provider import get_selected_image_provider
|
||||
from enums.image_provider import ImageProvider
|
||||
import os
|
||||
import uuid
|
||||
from utils.file_utils import get_file_name_with_random_uuid
|
||||
|
||||
IMAGES_ROUTER = APIRouter(prefix="/images", tags=["Images"])
|
||||
|
||||
ALLOWED_UPLOAD_IMAGE_EXTENSIONS = {
|
||||
".avif",
|
||||
".bmp",
|
||||
".gif",
|
||||
".jpeg",
|
||||
".jpg",
|
||||
".png",
|
||||
".tif",
|
||||
".tiff",
|
||||
".webp",
|
||||
}
|
||||
ALLOWED_UPLOAD_IMAGE_FORMATS = {
|
||||
"AVIF",
|
||||
"BMP",
|
||||
"GIF",
|
||||
"JPEG",
|
||||
"PNG",
|
||||
"TIFF",
|
||||
"WEBP",
|
||||
}
|
||||
|
||||
|
||||
async def _read_validated_image_upload(file: UploadFile) -> bytes:
|
||||
filename = file.filename or ""
|
||||
extension = os.path.splitext(filename)[1].lower()
|
||||
if extension not in ALLOWED_UPLOAD_IMAGE_EXTENSIONS:
|
||||
accepted = ", ".join(sorted(ALLOWED_UPLOAD_IMAGE_EXTENSIONS))
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid image file type. Accepted types: {accepted}",
|
||||
)
|
||||
|
||||
content = await file.read()
|
||||
if not content:
|
||||
raise HTTPException(status_code=400, detail="Uploaded image file is empty")
|
||||
|
||||
try:
|
||||
with Image.open(BytesIO(content)) as image:
|
||||
if image.format not in ALLOWED_UPLOAD_IMAGE_FORMATS:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Uploaded image format is not supported",
|
||||
)
|
||||
image.verify()
|
||||
except HTTPException:
|
||||
raise
|
||||
except (
|
||||
Image.DecompressionBombError,
|
||||
OSError,
|
||||
SyntaxError,
|
||||
UnidentifiedImageError,
|
||||
ValueError,
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Uploaded file is not a valid image"
|
||||
)
|
||||
|
||||
return content
|
||||
|
||||
|
||||
def _normalize_stock_provider(provider: str | None) -> str:
|
||||
normalized_provider = (provider or "").strip().lower()
|
||||
if normalized_provider in {"pixels", "pixel", "pexel"}:
|
||||
normalized_provider = "pexels"
|
||||
|
||||
if normalized_provider:
|
||||
if normalized_provider in {"pexels", "pixabay"}:
|
||||
return normalized_provider
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="provider must be either 'pexels' or 'pixabay'",
|
||||
)
|
||||
|
||||
selected_provider = get_selected_image_provider()
|
||||
if selected_provider == ImageProvider.PIXABAY:
|
||||
return "pixabay"
|
||||
return "pexels"
|
||||
|
||||
|
||||
@IMAGES_ROUTER.get("/search", response_model=List[str])
|
||||
async def search_stock_images(
|
||||
query: str,
|
||||
limit: int = Query(default=12, ge=1, le=30),
|
||||
provider: str | None = Query(default=None),
|
||||
strict_api_key: bool = Query(default=False),
|
||||
x_provider_api_key: str | None = Header(default=None, alias="X-Provider-Api-Key"),
|
||||
):
|
||||
normalized_provider = _normalize_stock_provider(provider)
|
||||
|
||||
image_generation_service = ImageGenerationService(get_images_directory())
|
||||
|
||||
if normalized_provider == "pexels":
|
||||
api_key = (x_provider_api_key or get_pexels_api_key_env() or "").strip()
|
||||
if strict_api_key and not api_key:
|
||||
raise HTTPException(status_code=401, detail="Pexels API key is required")
|
||||
|
||||
# Pexels can return cached public responses for common queries.
|
||||
# Use a nonce query in strict mode to force a real auth check.
|
||||
if strict_api_key:
|
||||
validation_query = f"__presenton_auth_check_{uuid.uuid4().hex}"
|
||||
await image_generation_service.get_image_from_pexels(
|
||||
validation_query,
|
||||
api_key=api_key,
|
||||
limit=1,
|
||||
)
|
||||
|
||||
images = await image_generation_service.get_image_from_pexels(
|
||||
query,
|
||||
api_key=api_key,
|
||||
limit=limit,
|
||||
)
|
||||
if isinstance(images, str):
|
||||
return [images] if images else []
|
||||
return images
|
||||
|
||||
api_key = (x_provider_api_key or get_pixabay_api_key_env() or "").strip()
|
||||
if strict_api_key and not api_key:
|
||||
raise HTTPException(status_code=401, detail="Pixabay API key is required")
|
||||
|
||||
images = await image_generation_service.get_image_from_pixabay(
|
||||
query,
|
||||
api_key=api_key,
|
||||
limit=limit,
|
||||
)
|
||||
if isinstance(images, str):
|
||||
return [images] if images else []
|
||||
return images
|
||||
|
||||
|
||||
@IMAGES_ROUTER.get("/generate")
|
||||
async def generate_image(
|
||||
prompt: str, sql_session: AsyncSession = Depends(get_async_session)
|
||||
):
|
||||
images_directory = get_images_directory()
|
||||
image_prompt = ImagePrompt(prompt=prompt)
|
||||
image_generation_service = ImageGenerationService(images_directory)
|
||||
|
||||
image = await image_generation_service.generate_image(image_prompt)
|
||||
if not isinstance(image, ImageAsset):
|
||||
return normalize_slide_asset_url(image) if isinstance(image, str) else image
|
||||
|
||||
sql_session.add(image)
|
||||
await sql_session.commit()
|
||||
|
||||
return filesystem_image_path_to_app_data_url(image.path)
|
||||
|
||||
|
||||
def _image_asset_api_dict(asset: ImageAsset) -> dict:
|
||||
return {
|
||||
"id": asset.id,
|
||||
"created_at": asset.created_at,
|
||||
"is_uploaded": asset.is_uploaded,
|
||||
"path": asset.path,
|
||||
"extras": asset.extras,
|
||||
"file_url": filesystem_image_path_to_app_data_url(asset.path),
|
||||
}
|
||||
|
||||
|
||||
@IMAGES_ROUTER.get("/generated")
|
||||
async def get_generated_images(sql_session: AsyncSession = Depends(get_async_session)):
|
||||
try:
|
||||
images_result = await sql_session.scalars(
|
||||
select(ImageAsset)
|
||||
.where(ImageAsset.is_uploaded == False)
|
||||
.order_by(ImageAsset.created_at.desc())
|
||||
)
|
||||
return [_image_asset_api_dict(a) for a in images_result]
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to retrieve generated images: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@IMAGES_ROUTER.post("/upload")
|
||||
async def upload_image(
|
||||
file: UploadFile = File(...), sql_session: AsyncSession = Depends(get_async_session)
|
||||
):
|
||||
try:
|
||||
content = await _read_validated_image_upload(file)
|
||||
new_filename = get_file_name_with_random_uuid(file)
|
||||
image_path = os.path.join(
|
||||
get_images_directory(), os.path.basename(new_filename)
|
||||
)
|
||||
|
||||
with open(image_path, "wb") as f:
|
||||
f.write(content)
|
||||
|
||||
image_asset = ImageAsset(path=image_path, is_uploaded=True)
|
||||
|
||||
sql_session.add(image_asset)
|
||||
await sql_session.commit()
|
||||
# Refresh to ensure all defaults are loaded
|
||||
await sql_session.refresh(image_asset)
|
||||
|
||||
return _image_asset_api_dict(image_asset)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to upload image: {str(e)}")
|
||||
|
||||
|
||||
@IMAGES_ROUTER.get("/uploaded")
|
||||
async def get_uploaded_images(sql_session: AsyncSession = Depends(get_async_session)):
|
||||
try:
|
||||
images_result = await sql_session.scalars(
|
||||
select(ImageAsset)
|
||||
.where(ImageAsset.is_uploaded == True)
|
||||
.order_by(ImageAsset.created_at.desc())
|
||||
)
|
||||
return [_image_asset_api_dict(a) for a in images_result]
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to retrieve uploaded images: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@IMAGES_ROUTER.delete("/{id}", status_code=204)
|
||||
async def delete_uploaded_image_by_id(
|
||||
id: uuid.UUID, sql_session: AsyncSession = Depends(get_async_session)
|
||||
):
|
||||
try:
|
||||
# Fetch the asset to get its actual file path
|
||||
image = await sql_session.get(ImageAsset, id)
|
||||
if not image:
|
||||
raise HTTPException(status_code=404, detail="Image not found")
|
||||
|
||||
os.remove(image.path)
|
||||
|
||||
await sql_session.delete(image)
|
||||
await sql_session.commit()
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to delete image: {str(e)}")
|
||||
@@ -0,0 +1,26 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
import aiohttp
|
||||
from utils.get_layout_by_name import get_layout_by_name
|
||||
from models.presentation_layout import PresentationLayoutModel
|
||||
|
||||
LAYOUTS_ROUTER = APIRouter(prefix="/layouts", tags=["Layouts"])
|
||||
|
||||
@LAYOUTS_ROUTER.get("/", summary="Get available layouts")
|
||||
async def get_layouts():
|
||||
url = "http://localhost:3000/api/layouts" # Adjust port if needed
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url) as response:
|
||||
if response.status != 200:
|
||||
error_text = await response.text()
|
||||
raise HTTPException(
|
||||
status_code=response.status,
|
||||
detail=f"Failed to fetch layouts: {error_text}"
|
||||
)
|
||||
layouts_json = await response.json()
|
||||
# Optionally, parse into a Pydantic model if you have one matching the structure
|
||||
return layouts_json
|
||||
|
||||
|
||||
@LAYOUTS_ROUTER.get("/{layout_name}", summary="Get layout details by ID")
|
||||
async def get_layout_detail(layout_name: str) -> PresentationLayoutModel:
|
||||
return await get_layout_by_name(layout_name)
|
||||
@@ -0,0 +1,36 @@
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import StreamingResponse
|
||||
from constants.supported_ollama_models import get_supported_ollama_models
|
||||
from models.ollama_model_metadata import OllamaModelMetadata
|
||||
from models.ollama_model_status import OllamaModelStatus
|
||||
from utils.ollama import list_available_ollama_models, pull_ollama_model, get_ollama_library_models
|
||||
|
||||
OLLAMA_ROUTER = APIRouter(prefix="/ollama", tags=["Ollama"])
|
||||
|
||||
|
||||
@OLLAMA_ROUTER.get("/models/supported", response_model=List[OllamaModelMetadata])
|
||||
async def get_supported_models():
|
||||
try:
|
||||
pulled_models = await list_available_ollama_models()
|
||||
except Exception:
|
||||
pulled_models = []
|
||||
|
||||
return get_supported_ollama_models(pulled_models)
|
||||
@OLLAMA_ROUTER.get("/models/available", response_model=List[OllamaModelStatus])
|
||||
async def get_available_models(ollama_url: str | None = None):
|
||||
return await list_available_ollama_models(ollama_url)
|
||||
|
||||
|
||||
@OLLAMA_ROUTER.get("/models/library")
|
||||
async def get_library_models():
|
||||
return get_ollama_library_models()
|
||||
|
||||
@OLLAMA_ROUTER.post("/models/pull")
|
||||
async def pull_model(model_name: str, ollama_url: str | None = None):
|
||||
await list_available_ollama_models(ollama_url)
|
||||
return StreamingResponse(
|
||||
pull_ollama_model(model_name, ollama_url),
|
||||
media_type="text/event-stream",
|
||||
)
|
||||
@@ -0,0 +1,22 @@
|
||||
from typing import Annotated, List
|
||||
from fastapi import APIRouter, Body, HTTPException
|
||||
|
||||
from utils.available_models import (
|
||||
ModelAvailabilityError,
|
||||
list_available_openai_compatible_models,
|
||||
)
|
||||
|
||||
OPENAI_ROUTER = APIRouter(prefix="/openai", tags=["OpenAI"])
|
||||
|
||||
|
||||
@OPENAI_ROUTER.post("/models/available", response_model=List[str])
|
||||
async def get_available_models(
|
||||
url: Annotated[str, Body()],
|
||||
api_key: Annotated[str, Body()],
|
||||
):
|
||||
try:
|
||||
return await list_available_openai_compatible_models(url, api_key)
|
||||
except ModelAvailabilityError as e:
|
||||
raise HTTPException(status_code=e.status_code, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -0,0 +1,267 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import traceback
|
||||
import uuid
|
||||
import dirtyjson
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from constants.presentation import MAX_NUMBER_OF_SLIDES
|
||||
from models.presentation_outline_model import PresentationOutlineModel
|
||||
from models.sql.presentation import PresentationModel
|
||||
from models.sse_response import (
|
||||
SSECompleteResponse,
|
||||
SSEErrorResponse,
|
||||
SSEResponse,
|
||||
SSEStatusResponse,
|
||||
)
|
||||
from services.temp_file_service import TEMP_FILE_SERVICE
|
||||
from services.database import get_async_session
|
||||
from services.documents_loader import DocumentsLoader
|
||||
from services.mem0_presentation_memory_service import (
|
||||
MEM0_PRESENTATION_MEMORY_SERVICE,
|
||||
)
|
||||
from utils.llm_utils import message_content_to_text
|
||||
from utils.outline_utils import (
|
||||
get_no_of_outlines_to_generate_for_n_slides,
|
||||
get_presentation_title_from_presentation_outline,
|
||||
)
|
||||
from utils.outline_limits import normalize_outline_payload
|
||||
from utils.llm_calls.generate_presentation_outlines import (
|
||||
OutlineGenerationStatus,
|
||||
generate_ppt_outline,
|
||||
get_messages as get_outline_messages,
|
||||
)
|
||||
from utils.sse import safe_sse_stream
|
||||
from utils.web_search import get_selected_web_search_provider, get_web_search_route
|
||||
|
||||
OUTLINES_ROUTER = APIRouter(prefix="/outlines", tags=["Outlines"])
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@OUTLINES_ROUTER.get("/{id}", response_model=PresentationOutlineModel)
|
||||
async def get_outline(
|
||||
id: uuid.UUID,
|
||||
sql_session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
presentation = await sql_session.get(PresentationModel, id)
|
||||
if not presentation:
|
||||
raise HTTPException(status_code=404, detail="Presentation not found")
|
||||
|
||||
if not presentation.outlines:
|
||||
return PresentationOutlineModel(slides=[])
|
||||
|
||||
return PresentationOutlineModel(**presentation.outlines)
|
||||
|
||||
|
||||
@OUTLINES_ROUTER.put("/{id}", response_model=PresentationOutlineModel)
|
||||
async def update_outline(
|
||||
id: uuid.UUID,
|
||||
outline: PresentationOutlineModel,
|
||||
sql_session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
presentation = await sql_session.get(PresentationModel, id)
|
||||
if not presentation:
|
||||
raise HTTPException(status_code=404, detail="Presentation not found")
|
||||
|
||||
presentation.outlines = outline.model_dump(mode="json")
|
||||
presentation.n_slides = len(outline.slides)
|
||||
presentation.title = get_presentation_title_from_presentation_outline(outline)
|
||||
|
||||
sql_session.add(presentation)
|
||||
await sql_session.commit()
|
||||
|
||||
await MEM0_PRESENTATION_MEMORY_SERVICE.store_generated_outlines(
|
||||
presentation.id,
|
||||
presentation.outlines,
|
||||
)
|
||||
|
||||
return outline
|
||||
|
||||
|
||||
@OUTLINES_ROUTER.get("/stream/{id}")
|
||||
async def stream_outlines(
|
||||
id: uuid.UUID, sql_session: AsyncSession = Depends(get_async_session)
|
||||
):
|
||||
presentation = await sql_session.get(PresentationModel, id)
|
||||
|
||||
if not presentation:
|
||||
raise HTTPException(status_code=404, detail="Presentation not found")
|
||||
|
||||
search_route, actual_search_provider = get_web_search_route()
|
||||
LOGGER.info(
|
||||
"Starting outline stream: presentation_id=%s web_search_enabled=%s "
|
||||
"selected_web_search_provider=%s web_search_route=%s actual_web_search_provider=%s",
|
||||
presentation.id,
|
||||
presentation.web_search,
|
||||
get_selected_web_search_provider().value,
|
||||
search_route,
|
||||
(
|
||||
actual_search_provider.value
|
||||
if actual_search_provider
|
||||
else ("model-native" if search_route == "native" else "none")
|
||||
),
|
||||
)
|
||||
|
||||
temp_dir = TEMP_FILE_SERVICE.create_temp_dir()
|
||||
|
||||
async def inner():
|
||||
yield SSEStatusResponse(
|
||||
status="Preparing your presentation outline"
|
||||
).to_string()
|
||||
|
||||
additional_context = ""
|
||||
if presentation.file_paths:
|
||||
documents_loader = DocumentsLoader(
|
||||
file_paths=presentation.file_paths,
|
||||
presentation_language=presentation.language,
|
||||
)
|
||||
await documents_loader.load_documents(temp_dir)
|
||||
documents = documents_loader.documents
|
||||
if documents:
|
||||
additional_context = "\n\n".join(documents)
|
||||
|
||||
presentation_outlines_text = ""
|
||||
|
||||
if presentation.n_slides > 0:
|
||||
n_slides_to_generate = get_no_of_outlines_to_generate_for_n_slides(
|
||||
n_slides=presentation.n_slides,
|
||||
toc=presentation.include_table_of_contents,
|
||||
title_slide=presentation.include_title_slide,
|
||||
)
|
||||
else:
|
||||
n_slides_to_generate = None
|
||||
|
||||
outline_messages = get_outline_messages(
|
||||
presentation.content,
|
||||
n_slides_to_generate,
|
||||
presentation.language,
|
||||
additional_context,
|
||||
presentation.tone,
|
||||
presentation.verbosity,
|
||||
presentation.instructions,
|
||||
presentation.include_title_slide,
|
||||
presentation.include_table_of_contents,
|
||||
)
|
||||
await MEM0_PRESENTATION_MEMORY_SERVICE.store_generation_context(
|
||||
presentation_id=presentation.id,
|
||||
system_prompt=(
|
||||
message_content_to_text(outline_messages[0].content)
|
||||
if len(outline_messages) > 0
|
||||
else None
|
||||
),
|
||||
user_prompt=(
|
||||
message_content_to_text(outline_messages[1].content)
|
||||
if len(outline_messages) > 1
|
||||
else None
|
||||
),
|
||||
extracted_document_text=additional_context,
|
||||
source_content=presentation.content,
|
||||
instructions=presentation.instructions,
|
||||
)
|
||||
|
||||
async for chunk in generate_ppt_outline(
|
||||
presentation.content,
|
||||
n_slides_to_generate,
|
||||
presentation.language,
|
||||
additional_context,
|
||||
presentation.tone,
|
||||
presentation.verbosity,
|
||||
presentation.instructions,
|
||||
presentation.include_title_slide,
|
||||
presentation.web_search,
|
||||
presentation.include_table_of_contents,
|
||||
emit_statuses=True,
|
||||
):
|
||||
# Give control to the event loop
|
||||
await asyncio.sleep(0)
|
||||
|
||||
if isinstance(chunk, OutlineGenerationStatus):
|
||||
LOGGER.info(
|
||||
"Outline generation status: presentation_id=%s status=%s",
|
||||
presentation.id,
|
||||
chunk.message,
|
||||
)
|
||||
yield SSEStatusResponse(status=chunk.message).to_string()
|
||||
continue
|
||||
|
||||
if isinstance(chunk, HTTPException):
|
||||
yield SSEErrorResponse(detail=chunk.detail).to_string()
|
||||
return
|
||||
|
||||
yield SSEResponse(
|
||||
event="response",
|
||||
data=json.dumps({"type": "chunk", "chunk": chunk}),
|
||||
).to_string()
|
||||
|
||||
presentation_outlines_text += chunk
|
||||
|
||||
try:
|
||||
presentation_outlines_json = dict(
|
||||
dirtyjson.loads(presentation_outlines_text)
|
||||
)
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
yield SSEErrorResponse(
|
||||
detail=f"Failed to generate presentation outlines. Please try again. {str(e)}",
|
||||
).to_string()
|
||||
return
|
||||
|
||||
presentation_outlines = PresentationOutlineModel(
|
||||
**normalize_outline_payload(
|
||||
presentation_outlines_json,
|
||||
MAX_NUMBER_OF_SLIDES,
|
||||
)
|
||||
)
|
||||
|
||||
if (
|
||||
n_slides_to_generate is not None
|
||||
and len(presentation_outlines.slides) != n_slides_to_generate
|
||||
):
|
||||
yield SSEErrorResponse(
|
||||
detail=(
|
||||
"Failed to generate presentation outlines with requested "
|
||||
"number of slides. Please try again."
|
||||
)
|
||||
).to_string()
|
||||
return
|
||||
|
||||
if n_slides_to_generate is not None:
|
||||
presentation_outlines.slides = presentation_outlines.slides[
|
||||
:n_slides_to_generate
|
||||
]
|
||||
|
||||
if presentation.n_slides <= 0:
|
||||
presentation.n_slides = len(presentation_outlines.slides)
|
||||
|
||||
presentation.outlines = presentation_outlines.model_dump()
|
||||
presentation.title = get_presentation_title_from_presentation_outline(
|
||||
presentation_outlines
|
||||
)
|
||||
|
||||
sql_session.add(presentation)
|
||||
await sql_session.commit()
|
||||
|
||||
await MEM0_PRESENTATION_MEMORY_SERVICE.store_generated_outlines(
|
||||
presentation.id,
|
||||
presentation.outlines,
|
||||
)
|
||||
|
||||
yield SSECompleteResponse(
|
||||
key="presentation", value=presentation.model_dump(mode="json")
|
||||
).to_string()
|
||||
|
||||
async def rollback_stream_session():
|
||||
await sql_session.rollback()
|
||||
|
||||
return StreamingResponse(
|
||||
safe_sse_stream(
|
||||
inner(),
|
||||
logger=LOGGER,
|
||||
error_detail="Failed to generate presentation outlines. Please try again.",
|
||||
on_error=rollback_stream_session,
|
||||
),
|
||||
media_type="text/event-stream",
|
||||
)
|
||||
@@ -0,0 +1,118 @@
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import subprocess
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, UploadFile, File, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from services.documents_loader import DocumentsLoader
|
||||
from utils.asset_directory_utils import absolute_fastapi_asset_url, get_images_directory
|
||||
import uuid
|
||||
from constants.documents import PDF_MIME_TYPES
|
||||
|
||||
|
||||
PDF_SLIDES_ROUTER = APIRouter(prefix="/pdf-slides", tags=["PDF Slides"])
|
||||
|
||||
|
||||
class PdfSlideData(BaseModel):
|
||||
slide_number: int
|
||||
screenshot_url: str
|
||||
|
||||
|
||||
class PdfSlidesResponse(BaseModel):
|
||||
success: bool
|
||||
slides: List[PdfSlideData]
|
||||
total_slides: int
|
||||
|
||||
|
||||
@PDF_SLIDES_ROUTER.post("/process", response_model=PdfSlidesResponse)
|
||||
async def process_pdf_slides(
|
||||
pdf_file: UploadFile = File(..., description="PDF file to process")
|
||||
):
|
||||
"""
|
||||
Process a PDF file to extract slide screenshots.
|
||||
|
||||
This endpoint:
|
||||
1. Validates the uploaded PDF file
|
||||
2. Uses the Python PDF renderer to convert PDF pages to PNG images
|
||||
3. Returns screenshot URLs for each slide/page
|
||||
|
||||
Note: Font installation is not needed since PDFs already have fonts embedded.
|
||||
"""
|
||||
|
||||
# Validate PDF file
|
||||
if pdf_file.content_type not in PDF_MIME_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid file type. Expected PDF file, got {pdf_file.content_type}",
|
||||
)
|
||||
# Enforce 100MB size limit
|
||||
if (
|
||||
hasattr(pdf_file, "size")
|
||||
and pdf_file.size
|
||||
and pdf_file.size > (100 * 1024 * 1024)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="PDF file exceeded max upload size of 100 MB",
|
||||
)
|
||||
|
||||
# Create temporary directory for processing
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
try:
|
||||
# Save uploaded PDF file
|
||||
pdf_path = os.path.join(temp_dir, "presentation.pdf")
|
||||
with open(pdf_path, "wb") as f:
|
||||
pdf_content = await pdf_file.read()
|
||||
f.write(pdf_content)
|
||||
|
||||
# Generate screenshots from PDF using the Python PDF renderer.
|
||||
screenshot_paths = await DocumentsLoader.get_page_images_from_pdf_async(
|
||||
pdf_path, temp_dir
|
||||
)
|
||||
print(f"Generated {len(screenshot_paths)} PDF screenshots")
|
||||
|
||||
# Move screenshots to images directory and generate URLs
|
||||
images_dir = get_images_directory()
|
||||
presentation_id = uuid.uuid4()
|
||||
presentation_images_dir = os.path.join(images_dir, str(presentation_id))
|
||||
os.makedirs(presentation_images_dir, exist_ok=True)
|
||||
|
||||
slides_data = []
|
||||
|
||||
for i, screenshot_path in enumerate(screenshot_paths, 1):
|
||||
# Move screenshot to permanent location
|
||||
screenshot_filename = f"slide_{i}.png"
|
||||
permanent_screenshot_path = os.path.join(
|
||||
presentation_images_dir, screenshot_filename
|
||||
)
|
||||
|
||||
if (
|
||||
os.path.exists(screenshot_path)
|
||||
and os.path.getsize(screenshot_path) > 0
|
||||
):
|
||||
# Use shutil.copy2 instead of os.rename to handle cross-device moves
|
||||
shutil.copy2(screenshot_path, permanent_screenshot_path)
|
||||
screenshot_url = absolute_fastapi_asset_url(
|
||||
f"/app_data/images/{presentation_id}/{screenshot_filename}"
|
||||
)
|
||||
else:
|
||||
# Fallback if screenshot generation failed or file is empty placeholder
|
||||
screenshot_url = absolute_fastapi_asset_url(
|
||||
"/static/images/replaceable_template_image.png"
|
||||
)
|
||||
|
||||
slides_data.append(
|
||||
PdfSlideData(slide_number=i, screenshot_url=screenshot_url)
|
||||
)
|
||||
|
||||
return PdfSlidesResponse(
|
||||
success=True, slides=slides_data, total_slides=len(slides_data)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error processing PDF slides: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to process PDF: {str(e)}"
|
||||
)
|
||||
@@ -0,0 +1,500 @@
|
||||
import os
|
||||
import shutil
|
||||
import zipfile
|
||||
import tempfile
|
||||
import uuid
|
||||
from typing import List, Optional, Dict
|
||||
from fastapi import APIRouter, UploadFile, File, HTTPException
|
||||
from pydantic import BaseModel
|
||||
import aiohttp
|
||||
import asyncio
|
||||
import xml.etree.ElementTree as ET
|
||||
import re
|
||||
|
||||
from templates.fonts_and_slides_preview import (
|
||||
_PreviewLogger,
|
||||
render_pptx_slides_to_images,
|
||||
)
|
||||
from utils.asset_directory_utils import absolute_fastapi_asset_url, get_images_directory
|
||||
from constants.documents import POWERPOINT_TYPES
|
||||
|
||||
|
||||
PPTX_SLIDES_ROUTER = APIRouter(prefix="/pptx-slides", tags=["PPTX Slides"])
|
||||
|
||||
|
||||
class SlideData(BaseModel):
|
||||
slide_number: int
|
||||
screenshot_url: str
|
||||
xml_content: str
|
||||
normalized_fonts: List[str]
|
||||
|
||||
|
||||
class FontAnalysisResult(BaseModel):
|
||||
internally_supported_fonts: List[
|
||||
Dict[str, str]
|
||||
] # [{"name": "Open Sans", "google_fonts_url": "..."}]
|
||||
not_supported_fonts: List[str] # ["Custom Font Name"]
|
||||
|
||||
|
||||
class PptxSlidesResponse(BaseModel):
|
||||
success: bool
|
||||
slides: List[SlideData]
|
||||
total_slides: int
|
||||
fonts: Optional[FontAnalysisResult] = None
|
||||
|
||||
|
||||
# NEW: Fonts-only router and response for PPTX
|
||||
class PptxFontsResponse(BaseModel):
|
||||
success: bool
|
||||
fonts: FontAnalysisResult
|
||||
|
||||
|
||||
PPTX_FONTS_ROUTER = APIRouter(prefix="/pptx-fonts", tags=["PPTX Fonts"])
|
||||
|
||||
# NEW: Normalize font family names by removing style/weight/stretch descriptors and splitting camel case
|
||||
_STYLE_TOKENS = {
|
||||
# styles
|
||||
"italic",
|
||||
"italics",
|
||||
"ital",
|
||||
"oblique",
|
||||
"roman",
|
||||
# combined style shortcuts
|
||||
"bolditalic",
|
||||
"bolditalics",
|
||||
# weights
|
||||
"thin",
|
||||
"hairline",
|
||||
"extralight",
|
||||
"ultralight",
|
||||
"light",
|
||||
"demilight",
|
||||
"semilight",
|
||||
"book",
|
||||
"regular",
|
||||
"normal",
|
||||
"medium",
|
||||
"semibold",
|
||||
"demibold",
|
||||
"bold",
|
||||
"extrabold",
|
||||
"ultrabold",
|
||||
"black",
|
||||
"extrablack",
|
||||
"ultrablack",
|
||||
"heavy",
|
||||
# width/stretch
|
||||
"narrow",
|
||||
"condensed",
|
||||
"semicondensed",
|
||||
"extracondensed",
|
||||
"ultracondensed",
|
||||
"expanded",
|
||||
"semiexpanded",
|
||||
"extraexpanded",
|
||||
"ultraexpanded",
|
||||
}
|
||||
# Modifiers commonly used with style tokens
|
||||
_STYLE_MODIFIERS = {"semi", "demi", "extra", "ultra"}
|
||||
|
||||
|
||||
def _insert_spaces_in_camel_case(value: str) -> str:
|
||||
# Insert space before capital letters preceded by lowercase or digits (e.g., MontserratBold -> Montserrat Bold)
|
||||
value = re.sub(r"(?<=[a-z0-9])([A-Z])", r" \1", value)
|
||||
# Handle sequences like BoldItalic -> Bold Italic
|
||||
value = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1 \2", value)
|
||||
return value
|
||||
|
||||
|
||||
def normalize_font_family_name(raw_name: str) -> str:
|
||||
if not raw_name:
|
||||
return raw_name
|
||||
# Replace separators with spaces
|
||||
name = raw_name.replace("_", " ").replace("-", " ")
|
||||
# Insert spaces in camel case
|
||||
name = _insert_spaces_in_camel_case(name)
|
||||
# Collapse multiple spaces
|
||||
name = re.sub(r"\s+", " ", name).strip()
|
||||
# Lowercase helper for matching but keep original casing for output
|
||||
lower_name = name.lower()
|
||||
# Quick cut: if the full string ends with a pure style suffix, trim it
|
||||
for style in sorted(_STYLE_TOKENS, key=len, reverse=True):
|
||||
if lower_name.endswith(" " + style):
|
||||
name = name[: -(len(style) + 1)]
|
||||
lower_name = lower_name[: -(len(style) + 1)]
|
||||
break
|
||||
# Tokenize
|
||||
tokens_original = name.split(" ")
|
||||
tokens_filtered: List[str] = []
|
||||
for index, tok in enumerate(tokens_original):
|
||||
lower_tok = tok.lower()
|
||||
# Always keep the first token to avoid stripping families like "Black Ops One"
|
||||
if index == 0:
|
||||
tokens_filtered.append(tok)
|
||||
continue
|
||||
# Drop style tokens and standalone modifiers
|
||||
if lower_tok in _STYLE_TOKENS or lower_tok in _STYLE_MODIFIERS:
|
||||
continue
|
||||
tokens_filtered.append(tok)
|
||||
# If everything except first token was dropped and first token is a style token (unlikely), fallback to original
|
||||
if not tokens_filtered:
|
||||
tokens_filtered = tokens_original
|
||||
normalized = " ".join(tokens_filtered).strip()
|
||||
# Final cleanup of leftover multiple spaces
|
||||
normalized = re.sub(r"\s+", " ", normalized)
|
||||
return normalized
|
||||
|
||||
|
||||
def extract_fonts_from_oxml(xml_content: str) -> List[str]:
|
||||
"""
|
||||
Extract font names from OXML content.
|
||||
|
||||
Args:
|
||||
xml_content: OXML content as string
|
||||
|
||||
Returns:
|
||||
List of unique font names found in the OXML
|
||||
"""
|
||||
fonts = set()
|
||||
|
||||
try:
|
||||
# Parse the XML content
|
||||
root = ET.fromstring(xml_content)
|
||||
|
||||
# Define namespaces commonly used in OXML
|
||||
namespaces = {
|
||||
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
|
||||
"p": "http://schemas.openxmlformats.org/presentationml/2006/main",
|
||||
"r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
|
||||
}
|
||||
|
||||
# Search for font references in various OXML elements
|
||||
# Look for latin fonts
|
||||
for font_elem in root.findall(".//a:latin", namespaces):
|
||||
if "typeface" in font_elem.attrib:
|
||||
fonts.add(font_elem.attrib["typeface"])
|
||||
|
||||
# Look for east asian fonts
|
||||
for font_elem in root.findall(".//a:ea", namespaces):
|
||||
if "typeface" in font_elem.attrib:
|
||||
fonts.add(font_elem.attrib["typeface"])
|
||||
|
||||
# Look for complex script fonts
|
||||
for font_elem in root.findall(".//a:cs", namespaces):
|
||||
if "typeface" in font_elem.attrib:
|
||||
fonts.add(font_elem.attrib["typeface"])
|
||||
|
||||
# Look for font references in theme elements
|
||||
for font_elem in root.findall(".//a:font", namespaces):
|
||||
if "typeface" in font_elem.attrib:
|
||||
fonts.add(font_elem.attrib["typeface"])
|
||||
|
||||
# Look for rPr (run properties) font references
|
||||
for rpr_elem in root.findall(".//a:rPr", namespaces):
|
||||
for font_elem in rpr_elem.findall(".//a:latin", namespaces):
|
||||
if "typeface" in font_elem.attrib:
|
||||
fonts.add(font_elem.attrib["typeface"])
|
||||
|
||||
# Also search without namespace prefix for compatibility
|
||||
for font_elem in root.findall(".//latin"):
|
||||
if "typeface" in font_elem.attrib:
|
||||
fonts.add(font_elem.attrib["typeface"])
|
||||
|
||||
# Regex fallback for fonts that might be missed
|
||||
font_pattern = r'typeface="([^"]+)"'
|
||||
regex_fonts = re.findall(font_pattern, xml_content)
|
||||
fonts.update(regex_fonts)
|
||||
|
||||
# Filter out system fonts and empty values
|
||||
system_fonts = {"+mn-lt", "+mj-lt", "+mn-ea", "+mj-ea", "+mn-cs", "+mj-cs", ""}
|
||||
fonts = {font for font in fonts if font not in system_fonts and font.strip()}
|
||||
|
||||
return list(fonts)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error extracting fonts from OXML: {e}")
|
||||
return []
|
||||
|
||||
|
||||
async def check_google_font_availability(font_name: str) -> bool:
|
||||
"""
|
||||
Check if a font is available in Google Fonts.
|
||||
|
||||
Args:
|
||||
font_name: Name of the font to check
|
||||
|
||||
Returns:
|
||||
True if font is available in Google Fonts, False otherwise
|
||||
"""
|
||||
try:
|
||||
formatted_name = font_name.replace(" ", "+")
|
||||
url = f"https://fonts.googleapis.com/css2?family={formatted_name}&display=swap"
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.head(
|
||||
url, timeout=aiohttp.ClientTimeout(total=10)
|
||||
) as response:
|
||||
return response.status == 200
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error checking Google Font availability for {font_name}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def analyze_fonts_in_all_slides(slide_xmls: List[str]) -> FontAnalysisResult:
|
||||
"""
|
||||
Analyze fonts across all slides and determine Google Fonts availability.
|
||||
|
||||
Args:
|
||||
slide_xmls: List of OXML content strings from all slides
|
||||
|
||||
Returns:
|
||||
FontAnalysisResult with supported and unsupported fonts
|
||||
"""
|
||||
# Extract fonts from all slides
|
||||
raw_fonts = set()
|
||||
for xml_content in slide_xmls:
|
||||
slide_fonts = extract_fonts_from_oxml(xml_content)
|
||||
raw_fonts.update(slide_fonts)
|
||||
|
||||
# Normalize to root families (e.g., "Montserrat Italic" -> "Montserrat")
|
||||
normalized_fonts = {normalize_font_family_name(f) for f in raw_fonts}
|
||||
# Remove empties if any
|
||||
normalized_fonts = {f for f in normalized_fonts if f}
|
||||
|
||||
if not normalized_fonts:
|
||||
return FontAnalysisResult(internally_supported_fonts=[], not_supported_fonts=[])
|
||||
|
||||
# Check each normalized font's availability in Google Fonts concurrently
|
||||
tasks = [check_google_font_availability(font) for font in normalized_fonts]
|
||||
results = await asyncio.gather(*tasks)
|
||||
|
||||
internally_supported_fonts = []
|
||||
not_supported_fonts = []
|
||||
|
||||
for font, is_available in zip(normalized_fonts, results):
|
||||
if is_available:
|
||||
formatted_name = font.replace(" ", "+")
|
||||
google_fonts_url = f"https://fonts.googleapis.com/css2?family={formatted_name}&display=swap"
|
||||
internally_supported_fonts.append(
|
||||
{"name": font, "google_fonts_url": google_fonts_url}
|
||||
)
|
||||
else:
|
||||
not_supported_fonts.append(font)
|
||||
|
||||
return FontAnalysisResult(
|
||||
internally_supported_fonts=internally_supported_fonts, not_supported_fonts=[]
|
||||
)
|
||||
|
||||
|
||||
@PPTX_SLIDES_ROUTER.post("/process", response_model=PptxSlidesResponse)
|
||||
async def process_pptx_slides(
|
||||
pptx_file: UploadFile = File(..., description="PPTX file to process"),
|
||||
fonts: Optional[List[UploadFile]] = File(None, description="Optional font files"),
|
||||
):
|
||||
"""
|
||||
Process a PPTX file to extract slide screenshots and XML content.
|
||||
|
||||
This endpoint:
|
||||
1. Validates the uploaded PPTX file
|
||||
2. Loads any provided font files for Chromium rendering
|
||||
3. Unzips the PPTX to extract slide XMLs
|
||||
4. Converts PPTX slides to HTML and renders screenshots with Chromium
|
||||
5. Returns both screenshot URLs and XML content for each slide
|
||||
"""
|
||||
|
||||
# Validate PPTX file
|
||||
if pptx_file.content_type not in POWERPOINT_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid file type. Expected PPTX file, got {pptx_file.content_type}",
|
||||
)
|
||||
# Enforce 100MB size limit
|
||||
if (
|
||||
hasattr(pptx_file, "size")
|
||||
and pptx_file.size
|
||||
and pptx_file.size > (100 * 1024 * 1024)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="PPTX file exceeded max upload size of 100 MB",
|
||||
)
|
||||
|
||||
# Create temporary directory for processing
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
if True:
|
||||
# Save uploaded PPTX file
|
||||
pptx_path = os.path.join(temp_dir, "presentation.pptx")
|
||||
with open(pptx_path, "wb") as f:
|
||||
pptx_content = await pptx_file.read()
|
||||
f.write(pptx_content)
|
||||
|
||||
font_paths = await _save_fonts(fonts or [], temp_dir)
|
||||
|
||||
# Extract slide XMLs from PPTX
|
||||
slide_xmls = _extract_slide_xmls(pptx_path, temp_dir)
|
||||
|
||||
screenshot_paths = await render_pptx_slides_to_images(
|
||||
modified_pptx_path=pptx_path,
|
||||
font_paths_for_install=font_paths,
|
||||
max_slides=None,
|
||||
logger=_PreviewLogger(),
|
||||
)
|
||||
if len(screenshot_paths) != len(slide_xmls):
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=(
|
||||
"PPTX preview renderer returned an unexpected slide count: "
|
||||
f"expected {len(slide_xmls)}, got {len(screenshot_paths)}"
|
||||
),
|
||||
)
|
||||
print(f"Screenshot paths: {screenshot_paths}")
|
||||
|
||||
# Analyze fonts across all slides
|
||||
font_analysis = await analyze_fonts_in_all_slides(slide_xmls)
|
||||
print(
|
||||
f"Font analysis completed: {len(font_analysis.internally_supported_fonts)} supported, {len(font_analysis.not_supported_fonts)} not supported"
|
||||
)
|
||||
|
||||
# Move screenshots to images directory and generate URLs
|
||||
images_dir = get_images_directory()
|
||||
presentation_id = uuid.uuid4()
|
||||
presentation_images_dir = os.path.join(images_dir, str(presentation_id))
|
||||
os.makedirs(presentation_images_dir, exist_ok=True)
|
||||
|
||||
slides_data = []
|
||||
|
||||
for i, (xml_content, screenshot_path) in enumerate(
|
||||
zip(slide_xmls, screenshot_paths), 1
|
||||
):
|
||||
# Move screenshot to permanent location
|
||||
screenshot_filename = f"slide_{i}.png"
|
||||
permanent_screenshot_path = os.path.join(
|
||||
presentation_images_dir, screenshot_filename
|
||||
)
|
||||
|
||||
if (
|
||||
os.path.exists(screenshot_path)
|
||||
and os.path.getsize(screenshot_path) > 0
|
||||
):
|
||||
# Use shutil.copy2 instead of os.rename to handle cross-device moves
|
||||
shutil.copy2(screenshot_path, permanent_screenshot_path)
|
||||
screenshot_url = absolute_fastapi_asset_url(
|
||||
f"/app_data/images/{presentation_id}/{screenshot_filename}"
|
||||
)
|
||||
else:
|
||||
# Fallback if screenshot generation failed or file is empty placeholder
|
||||
screenshot_url = absolute_fastapi_asset_url(
|
||||
"/static/images/replaceable_template_image.png"
|
||||
)
|
||||
|
||||
# Compute normalized fonts for this slide
|
||||
raw_slide_fonts = extract_fonts_from_oxml(xml_content)
|
||||
normalized_fonts = sorted(
|
||||
{normalize_font_family_name(f) for f in raw_slide_fonts if f}
|
||||
)
|
||||
|
||||
slides_data.append(
|
||||
SlideData(
|
||||
slide_number=i,
|
||||
screenshot_url=screenshot_url,
|
||||
xml_content=xml_content,
|
||||
normalized_fonts=normalized_fonts,
|
||||
)
|
||||
)
|
||||
|
||||
return PptxSlidesResponse(
|
||||
success=True,
|
||||
slides=slides_data,
|
||||
total_slides=len(slides_data),
|
||||
fonts=font_analysis,
|
||||
)
|
||||
|
||||
|
||||
# NEW: Fonts-only endpoint leveraging the same font extraction/analysis
|
||||
@PPTX_FONTS_ROUTER.post("/process", response_model=PptxFontsResponse)
|
||||
async def process_pptx_fonts(
|
||||
pptx_file: UploadFile = File(..., description="PPTX file to analyze fonts from")
|
||||
):
|
||||
"""
|
||||
Analyze a PPTX file and return only the fonts used in the document.
|
||||
|
||||
Uses the exact same font extraction and analysis utilities as the /pptx-slides endpoint.
|
||||
"""
|
||||
# Validate PPTX file
|
||||
if pptx_file.content_type not in POWERPOINT_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid file type. Expected PPTX file, got {pptx_file.content_type}",
|
||||
)
|
||||
|
||||
# Create temporary directory for processing
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
# Save uploaded PPTX file
|
||||
pptx_path = os.path.join(temp_dir, "presentation.pptx")
|
||||
with open(pptx_path, "wb") as f:
|
||||
pptx_content = await pptx_file.read()
|
||||
f.write(pptx_content)
|
||||
|
||||
# Extract slide XMLs from PPTX
|
||||
slide_xmls = _extract_slide_xmls(pptx_path, temp_dir)
|
||||
|
||||
# Analyze fonts across all slides (same logic as in /pptx-slides)
|
||||
font_analysis = await analyze_fonts_in_all_slides(slide_xmls)
|
||||
|
||||
return PptxFontsResponse(
|
||||
success=True,
|
||||
fonts=font_analysis,
|
||||
)
|
||||
|
||||
|
||||
async def _save_fonts(fonts: List[UploadFile], temp_dir: str) -> List[str]:
|
||||
"""Save provided fonts so the HTML preview renderer can load them."""
|
||||
fonts_dir = os.path.join(temp_dir, "fonts")
|
||||
os.makedirs(fonts_dir, exist_ok=True)
|
||||
font_paths: List[str] = []
|
||||
|
||||
for font_file in fonts:
|
||||
font_path = os.path.join(fonts_dir, font_file.filename)
|
||||
with open(font_path, "wb") as f:
|
||||
font_content = await font_file.read()
|
||||
f.write(font_content)
|
||||
font_paths.append(font_path)
|
||||
|
||||
return font_paths
|
||||
|
||||
|
||||
def _extract_slide_xmls(pptx_path: str, temp_dir: str) -> List[str]:
|
||||
"""Extract slide XML content from PPTX file."""
|
||||
slide_xmls = []
|
||||
extract_dir = os.path.join(temp_dir, "pptx_extract")
|
||||
|
||||
try:
|
||||
# Unzip PPTX file
|
||||
with zipfile.ZipFile(pptx_path, "r") as zip_ref:
|
||||
zip_ref.extractall(extract_dir)
|
||||
|
||||
# Look for slides in ppt/slides/ directory
|
||||
slides_dir = os.path.join(extract_dir, "ppt", "slides")
|
||||
|
||||
if not os.path.exists(slides_dir):
|
||||
raise Exception("No slides directory found in PPTX file")
|
||||
|
||||
# Get all slide XML files and sort them numerically
|
||||
slide_files = [
|
||||
f
|
||||
for f in os.listdir(slides_dir)
|
||||
if f.startswith("slide") and f.endswith(".xml")
|
||||
]
|
||||
slide_files.sort(key=lambda x: int(x.replace("slide", "").replace(".xml", "")))
|
||||
|
||||
# Read XML content from each slide
|
||||
for slide_file in slide_files:
|
||||
slide_path = os.path.join(slides_dir, slide_file)
|
||||
with open(slide_path, "r", encoding="utf-8") as f:
|
||||
slide_xmls.append(f.read())
|
||||
|
||||
return slide_xmls
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to extract slide XMLs: {str(e)}")
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,148 @@
|
||||
import logging
|
||||
from typing import Annotated, Optional
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
import uuid
|
||||
|
||||
from models.sql.presentation import PresentationModel
|
||||
from models.sql.slide import SlideModel
|
||||
from services.database import get_async_session
|
||||
from services.image_generation_service import ImageGenerationService
|
||||
from services.mem0_presentation_memory_service import (
|
||||
MEM0_PRESENTATION_MEMORY_SERVICE,
|
||||
)
|
||||
from utils.asset_directory_utils import get_images_directory
|
||||
from utils.llm_calls.edit_slide import get_edited_slide_content
|
||||
from utils.llm_calls.edit_slide_html import get_edited_slide_html
|
||||
from utils.llm_calls.select_slide_type_on_edit import get_slide_layout_from_prompt
|
||||
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")
|
||||
async def edit_slide(
|
||||
id: Annotated[uuid.UUID, Body()],
|
||||
prompt: Annotated[str, Body()],
|
||||
sql_session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
slide = await sql_session.get(SlideModel, id)
|
||||
if not slide:
|
||||
raise HTTPException(status_code=404, detail="Slide not found")
|
||||
presentation = await sql_session.get(PresentationModel, slide.presentation)
|
||||
if not presentation:
|
||||
raise HTTPException(status_code=404, detail="Presentation not found")
|
||||
|
||||
memory_context = await MEM0_PRESENTATION_MEMORY_SERVICE.retrieve_context(
|
||||
presentation.id,
|
||||
prompt,
|
||||
)
|
||||
|
||||
presentation_layout = presentation.get_layout()
|
||||
slide_layout = await get_slide_layout_from_prompt(
|
||||
prompt,
|
||||
presentation_layout,
|
||||
slide,
|
||||
memory_context,
|
||||
)
|
||||
|
||||
edited_slide_content = await get_edited_slide_content(
|
||||
prompt,
|
||||
slide,
|
||||
presentation.language,
|
||||
slide_layout,
|
||||
presentation.tone,
|
||||
presentation.verbosity,
|
||||
presentation.instructions,
|
||||
memory_context,
|
||||
)
|
||||
|
||||
image_generation_service = ImageGenerationService(get_images_directory())
|
||||
|
||||
# This will mutate edited_slide_content
|
||||
image_warnings: list[dict] = []
|
||||
new_assets = await process_old_and_new_slides_and_fetch_assets(
|
||||
image_generation_service,
|
||||
slide.content,
|
||||
edited_slide_content,
|
||||
icon_weight=presentation.get_layout().icon_weight,
|
||||
use_template_v2_asset_fields=slide.layout_group.startswith("template-v2"),
|
||||
allow_image_fallback=True,
|
||||
image_warnings=image_warnings,
|
||||
)
|
||||
for warning in image_warnings:
|
||||
LOGGER.warning(
|
||||
"Slide edit image generation warning: presentation_id=%s slide_id=%s detail=%s",
|
||||
presentation.id,
|
||||
slide.id,
|
||||
warning.get("detail"),
|
||||
)
|
||||
|
||||
# Always assign a new unique id to the slide
|
||||
slide.id = uuid.uuid4()
|
||||
|
||||
sql_session.add(slide)
|
||||
slide.content = edited_slide_content
|
||||
slide.layout = slide_layout.id
|
||||
slide.speaker_note = edited_slide_content.get("__speaker_note__", "")
|
||||
sql_session.add_all(new_assets)
|
||||
await sql_session.commit()
|
||||
|
||||
await MEM0_PRESENTATION_MEMORY_SERVICE.store_slide_edit(
|
||||
presentation_id=presentation.id,
|
||||
slide_index=slide.index,
|
||||
edit_prompt=prompt,
|
||||
edited_slide_content=edited_slide_content,
|
||||
)
|
||||
|
||||
return slide
|
||||
|
||||
|
||||
@SLIDE_ROUTER.post("/edit-html", response_model=SlideModel)
|
||||
async def edit_slide_html(
|
||||
id: Annotated[uuid.UUID, Body()],
|
||||
prompt: Annotated[str, Body()],
|
||||
html: Annotated[Optional[str], Body()] = None,
|
||||
sql_session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
slide = await sql_session.get(SlideModel, id)
|
||||
if not slide:
|
||||
raise HTTPException(status_code=404, detail="Slide not found")
|
||||
|
||||
presentation = await sql_session.get(PresentationModel, slide.presentation)
|
||||
if not presentation:
|
||||
raise HTTPException(status_code=404, detail="Presentation not found")
|
||||
|
||||
html_to_edit = html or slide.html_content
|
||||
if not html_to_edit:
|
||||
raise HTTPException(status_code=400, detail="No HTML to edit")
|
||||
|
||||
memory_context = await MEM0_PRESENTATION_MEMORY_SERVICE.retrieve_context(
|
||||
presentation.id,
|
||||
prompt,
|
||||
)
|
||||
|
||||
edited_slide_html = await get_edited_slide_html(
|
||||
prompt,
|
||||
html_to_edit,
|
||||
memory_context,
|
||||
)
|
||||
|
||||
# Always assign a new unique id to the slide
|
||||
# This is to ensure that the nextjs can track slide updates
|
||||
slide.id = uuid.uuid4()
|
||||
|
||||
sql_session.add(slide)
|
||||
slide.html_content = edited_slide_html
|
||||
await sql_session.commit()
|
||||
|
||||
await MEM0_PRESENTATION_MEMORY_SERVICE.store_slide_edit(
|
||||
presentation_id=presentation.id,
|
||||
slide_index=slide.index,
|
||||
edit_prompt=prompt,
|
||||
edited_slide_content=edited_slide_html,
|
||||
)
|
||||
|
||||
return slide
|
||||
@@ -0,0 +1,394 @@
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from models.sql.presentation_layout_code import PresentationLayoutCodeModel
|
||||
from models.sql.template import TemplateModel
|
||||
from services.database import get_async_session
|
||||
|
||||
LAYOUT_MANAGEMENT_ROUTER = APIRouter(
|
||||
prefix="/template-management", tags=["template-management"]
|
||||
)
|
||||
|
||||
|
||||
class LayoutData(BaseModel):
|
||||
presentation: UUID
|
||||
layout_id: str
|
||||
layout_name: str
|
||||
layout_code: str
|
||||
fonts: Optional[List[str]] = None
|
||||
|
||||
|
||||
class SaveLayoutsRequest(BaseModel):
|
||||
layouts: list[LayoutData]
|
||||
|
||||
|
||||
class SaveLayoutsResponse(BaseModel):
|
||||
success: bool
|
||||
saved_count: int
|
||||
message: Optional[str] = None
|
||||
|
||||
|
||||
class GetLayoutsResponse(BaseModel):
|
||||
success: bool
|
||||
layouts: list[LayoutData]
|
||||
message: Optional[str] = None
|
||||
template: Optional[dict] = None
|
||||
fonts: Optional[List[str]] = None
|
||||
|
||||
|
||||
class PresentationSummary(BaseModel):
|
||||
presentation_id: UUID
|
||||
layout_count: int
|
||||
last_updated_at: Optional[datetime] = None
|
||||
template: Optional[dict] = None
|
||||
|
||||
|
||||
class GetPresentationSummaryResponse(BaseModel):
|
||||
success: bool
|
||||
presentations: List[PresentationSummary]
|
||||
total_presentations: int
|
||||
total_layouts: int
|
||||
message: Optional[str] = None
|
||||
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
success: bool = False
|
||||
detail: str
|
||||
error_code: Optional[str] = None
|
||||
|
||||
|
||||
class TemplateCreateRequest(BaseModel):
|
||||
id: UUID
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class TemplateCreateResponse(BaseModel):
|
||||
success: bool
|
||||
template: dict
|
||||
message: Optional[str] = None
|
||||
|
||||
|
||||
@LAYOUT_MANAGEMENT_ROUTER.post(
|
||||
"/save-templates",
|
||||
response_model=SaveLayoutsResponse,
|
||||
responses={
|
||||
400: {"model": ErrorResponse, "description": "Validation error"},
|
||||
500: {"model": ErrorResponse, "description": "Internal server error"},
|
||||
},
|
||||
)
|
||||
async def save_layouts(
|
||||
request: SaveLayoutsRequest, session: AsyncSession = Depends(get_async_session)
|
||||
):
|
||||
"""
|
||||
Save multiple layouts for presentations.
|
||||
|
||||
Args:
|
||||
request: JSON request containing array of layout data
|
||||
session: Database session
|
||||
|
||||
Returns:
|
||||
SaveLayoutsResponse with success status and count of saved layouts
|
||||
|
||||
Raises:
|
||||
HTTPException: 400 for validation errors, 500 for server errors
|
||||
"""
|
||||
try:
|
||||
if not request.layouts:
|
||||
raise HTTPException(status_code=400, detail="Layouts array cannot be empty")
|
||||
|
||||
if len(request.layouts) > 50:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Cannot save more than 50 layouts at once"
|
||||
)
|
||||
|
||||
saved_count = 0
|
||||
|
||||
for i, layout_data in enumerate(request.layouts):
|
||||
if (
|
||||
not layout_data.presentation
|
||||
or not str(layout_data.presentation).strip()
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Layout {i+1}: presentation_id cannot be empty",
|
||||
)
|
||||
|
||||
if not layout_data.layout_id or not layout_data.layout_id.strip():
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Layout {i+1}: layout_id cannot be empty"
|
||||
)
|
||||
|
||||
if not layout_data.layout_name or not layout_data.layout_name.strip():
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Layout {i+1}: layout_name cannot be empty"
|
||||
)
|
||||
|
||||
if not layout_data.layout_code or not layout_data.layout_code.strip():
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Layout {i+1}: layout_code cannot be empty"
|
||||
)
|
||||
|
||||
stmt = select(PresentationLayoutCodeModel).where(
|
||||
PresentationLayoutCodeModel.presentation == layout_data.presentation,
|
||||
PresentationLayoutCodeModel.layout_id == layout_data.layout_id,
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
existing_layout = result.scalar_one_or_none()
|
||||
|
||||
if existing_layout:
|
||||
existing_layout.layout_name = layout_data.layout_name
|
||||
existing_layout.layout_code = layout_data.layout_code
|
||||
existing_layout.fonts = layout_data.fonts
|
||||
existing_layout.updated_at = datetime.now()
|
||||
else:
|
||||
new_layout = PresentationLayoutCodeModel(
|
||||
presentation=layout_data.presentation,
|
||||
layout_id=layout_data.layout_id,
|
||||
layout_name=layout_data.layout_name,
|
||||
layout_code=layout_data.layout_code,
|
||||
fonts=layout_data.fonts,
|
||||
)
|
||||
session.add(new_layout)
|
||||
|
||||
saved_count += 1
|
||||
|
||||
await session.commit()
|
||||
|
||||
return SaveLayoutsResponse(
|
||||
success=True,
|
||||
saved_count=saved_count,
|
||||
message=f"Successfully saved {saved_count} layout(s)",
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
await session.rollback()
|
||||
raise
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
print(f"Unexpected error saving layouts: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Internal server error while saving layouts: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@LAYOUT_MANAGEMENT_ROUTER.get(
|
||||
"/get-templates/{presentation}",
|
||||
response_model=GetLayoutsResponse,
|
||||
responses={
|
||||
400: {"model": ErrorResponse, "description": "Invalid presentation ID"},
|
||||
404: {
|
||||
"model": ErrorResponse,
|
||||
"description": "No layouts found for presentation",
|
||||
},
|
||||
500: {"model": ErrorResponse, "description": "Internal server error"},
|
||||
},
|
||||
)
|
||||
async def get_layouts(
|
||||
presentation: UUID, session: AsyncSession = Depends(get_async_session)
|
||||
):
|
||||
"""
|
||||
Retrieve all layouts for a specific presentation.
|
||||
"""
|
||||
try:
|
||||
if not presentation or len(str(presentation).strip()) == 0:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Presentation ID cannot be empty"
|
||||
)
|
||||
|
||||
stmt = select(PresentationLayoutCodeModel).where(
|
||||
PresentationLayoutCodeModel.presentation == presentation
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
layouts_db = result.scalars().all()
|
||||
|
||||
if not layouts_db:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"No layouts found for presentation ID: {presentation}",
|
||||
)
|
||||
|
||||
layouts = [
|
||||
LayoutData(
|
||||
presentation=layout.presentation,
|
||||
layout_id=layout.layout_id,
|
||||
layout_name=layout.layout_name,
|
||||
layout_code=layout.layout_code,
|
||||
fonts=layout.fonts,
|
||||
)
|
||||
for layout in layouts_db
|
||||
]
|
||||
|
||||
aggregated_fonts: set[str] = set()
|
||||
for layout in layouts_db:
|
||||
if layout.fonts:
|
||||
aggregated_fonts.update([f for f in layout.fonts if isinstance(f, str)])
|
||||
fonts_list = sorted(list(aggregated_fonts)) if aggregated_fonts else None
|
||||
|
||||
template_meta = await session.get(TemplateModel, presentation)
|
||||
template = None
|
||||
if template_meta:
|
||||
template = {
|
||||
"id": template_meta.id,
|
||||
"name": template_meta.name,
|
||||
"description": template_meta.description,
|
||||
"created_at": template_meta.created_at,
|
||||
}
|
||||
|
||||
return GetLayoutsResponse(
|
||||
success=True,
|
||||
layouts=layouts,
|
||||
message=f"Retrieved {len(layouts)} layout(s) for presentation {presentation}",
|
||||
template=template,
|
||||
fonts=fonts_list,
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"Error retrieving layouts for presentation {presentation}: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Internal server error while retrieving layouts: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@LAYOUT_MANAGEMENT_ROUTER.get(
|
||||
"/summary",
|
||||
response_model=GetPresentationSummaryResponse,
|
||||
summary="Get all presentations with layout counts",
|
||||
description="Retrieve a summary of all presentations and the number of layouts in each",
|
||||
responses={
|
||||
200: {
|
||||
"model": GetPresentationSummaryResponse,
|
||||
"description": "Presentations summary retrieved successfully",
|
||||
},
|
||||
500: {"model": ErrorResponse, "description": "Internal server error"},
|
||||
},
|
||||
)
|
||||
async def get_presentations_summary(
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""Get summary of all presentations with their layout counts."""
|
||||
try:
|
||||
stmt = select(
|
||||
PresentationLayoutCodeModel.presentation,
|
||||
func.count(PresentationLayoutCodeModel.id).label("layout_count"),
|
||||
func.max(PresentationLayoutCodeModel.updated_at).label("last_updated_at"),
|
||||
).group_by(PresentationLayoutCodeModel.presentation)
|
||||
|
||||
result = await session.execute(stmt)
|
||||
presentation_data = result.all()
|
||||
|
||||
presentations = []
|
||||
for row in presentation_data:
|
||||
template_meta = await session.get(TemplateModel, row.presentation)
|
||||
template = None
|
||||
if template_meta:
|
||||
template = {
|
||||
"id": template_meta.id,
|
||||
"name": template_meta.name,
|
||||
"description": template_meta.description,
|
||||
"created_at": template_meta.created_at,
|
||||
}
|
||||
presentations.append(
|
||||
PresentationSummary(
|
||||
presentation_id=row.presentation,
|
||||
layout_count=row.layout_count,
|
||||
last_updated_at=row.last_updated_at,
|
||||
template=template,
|
||||
)
|
||||
)
|
||||
|
||||
total_presentations = len(presentations)
|
||||
total_layouts = sum(p.layout_count for p in presentations)
|
||||
|
||||
return GetPresentationSummaryResponse(
|
||||
success=True,
|
||||
presentations=presentations,
|
||||
total_presentations=total_presentations,
|
||||
total_layouts=total_layouts,
|
||||
message=f"Retrieved {total_presentations} presentation(s) with {total_layouts} total layout(s)",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error retrieving presentations summary: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Internal server error while retrieving presentations summary: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@LAYOUT_MANAGEMENT_ROUTER.post(
|
||||
"/templates",
|
||||
response_model=TemplateCreateResponse,
|
||||
responses={
|
||||
400: {"model": ErrorResponse, "description": "Validation error"},
|
||||
500: {"model": ErrorResponse, "description": "Internal server error"},
|
||||
},
|
||||
)
|
||||
async def create_template(
|
||||
request: TemplateCreateRequest,
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
try:
|
||||
if not request.id or not request.name:
|
||||
raise HTTPException(status_code=400, detail="id and name are required")
|
||||
|
||||
existing = await session.get(TemplateModel, request.id)
|
||||
if existing:
|
||||
existing.name = request.name
|
||||
existing.description = request.description
|
||||
else:
|
||||
session.add(
|
||||
TemplateModel(
|
||||
id=request.id, name=request.name, description=request.description
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
template = await session.get(TemplateModel, request.id)
|
||||
return TemplateCreateResponse(
|
||||
success=True,
|
||||
template={
|
||||
"id": template.id,
|
||||
"name": template.name,
|
||||
"description": template.description,
|
||||
"created_at": template.created_at,
|
||||
},
|
||||
message="Template saved",
|
||||
)
|
||||
except HTTPException:
|
||||
await session.rollback()
|
||||
raise
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to save template: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@LAYOUT_MANAGEMENT_ROUTER.delete("/delete-templates/{template_id}", status_code=204)
|
||||
async def delete_template(
|
||||
template_id: UUID,
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
try:
|
||||
await session.execute(
|
||||
delete(TemplateModel).where(TemplateModel.id == template_id)
|
||||
)
|
||||
await session.execute(
|
||||
delete(PresentationLayoutCodeModel).where(
|
||||
PresentationLayoutCodeModel.presentation == template_id,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
raise HTTPException(status_code=500, detail="Failed to delete template")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,190 @@
|
||||
import copy
|
||||
import uuid
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlmodel import select
|
||||
|
||||
from models.sql.image_asset import ImageAsset
|
||||
from models.sql.key_value import KeyValueSqlModel
|
||||
from services.database import get_async_session
|
||||
from utils.asset_directory_utils import normalize_slide_asset_url
|
||||
|
||||
THEMES_ROUTER = APIRouter(prefix="/themes", tags=["Themes"])
|
||||
THEMES_STORAGE_KEY = "presentation_custom_themes"
|
||||
|
||||
|
||||
class ThemeRequest(BaseModel):
|
||||
name: str
|
||||
description: str
|
||||
company_name: Optional[str] = None
|
||||
logo: Optional[str] = None
|
||||
logo_url: Optional[str] = None
|
||||
data: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ThemeUpdateRequest(BaseModel):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
company_name: Optional[str] = None
|
||||
logo: Optional[str] = None
|
||||
logo_url: Optional[str] = None
|
||||
data: Optional[dict[str, Any]] = None
|
||||
|
||||
|
||||
class ThemeResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
user: str
|
||||
logo: Optional[str] = None
|
||||
logo_url: Optional[str] = None
|
||||
company_name: Optional[str] = None
|
||||
data: dict[str, Any]
|
||||
|
||||
|
||||
def _normalize_theme(theme: dict[str, Any]) -> ThemeResponse:
|
||||
raw_logo_url = theme.get("logo_url")
|
||||
if raw_logo_url is None:
|
||||
logo_url = None
|
||||
elif isinstance(raw_logo_url, str):
|
||||
s = raw_logo_url.strip()
|
||||
logo_url = normalize_slide_asset_url(s) if s else None
|
||||
else:
|
||||
logo_url = raw_logo_url
|
||||
return ThemeResponse(
|
||||
id=str(theme["id"]),
|
||||
name=theme["name"],
|
||||
description=theme["description"],
|
||||
user=theme.get("user", "local"),
|
||||
logo=theme.get("logo"),
|
||||
logo_url=logo_url,
|
||||
company_name=theme.get("company_name"),
|
||||
data=theme.get("data", {}),
|
||||
)
|
||||
|
||||
|
||||
async def _get_themes_row(sql_session: AsyncSession) -> Optional[KeyValueSqlModel]:
|
||||
return await sql_session.scalar(
|
||||
select(KeyValueSqlModel).where(KeyValueSqlModel.key == THEMES_STORAGE_KEY)
|
||||
)
|
||||
|
||||
|
||||
def _read_themes_from_row(row: Optional[KeyValueSqlModel]) -> list[dict[str, Any]]:
|
||||
if not row:
|
||||
return []
|
||||
value = row.value if isinstance(row.value, dict) else {}
|
||||
themes = value.get("themes", [])
|
||||
if not isinstance(themes, list):
|
||||
return []
|
||||
return copy.deepcopy(themes)
|
||||
|
||||
|
||||
async def _resolve_logo_url(
|
||||
sql_session: AsyncSession, logo: Optional[str]
|
||||
) -> Optional[str]:
|
||||
if not logo:
|
||||
return None
|
||||
try:
|
||||
logo_uuid = uuid.UUID(str(logo))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail="Invalid logo id") from exc
|
||||
|
||||
image_asset = await sql_session.get(ImageAsset, logo_uuid)
|
||||
if not image_asset:
|
||||
raise HTTPException(status_code=404, detail="Logo not found")
|
||||
return normalize_slide_asset_url(image_asset.path)
|
||||
|
||||
|
||||
@THEMES_ROUTER.get("/default", response_model=List[dict[str, Any]])
|
||||
async def get_default_themes():
|
||||
# Built-in themes are provided by Next.js constants in this project.
|
||||
return []
|
||||
|
||||
|
||||
@THEMES_ROUTER.get("/all", response_model=List[ThemeResponse])
|
||||
async def get_themes(sql_session: AsyncSession = Depends(get_async_session)):
|
||||
row = await _get_themes_row(sql_session)
|
||||
themes = _read_themes_from_row(row)
|
||||
return [_normalize_theme(theme) for theme in themes]
|
||||
|
||||
|
||||
@THEMES_ROUTER.post("/create", response_model=ThemeResponse)
|
||||
async def create_theme(
|
||||
payload: ThemeRequest, sql_session: AsyncSession = Depends(get_async_session)
|
||||
):
|
||||
row = await _get_themes_row(sql_session)
|
||||
themes = _read_themes_from_row(row)
|
||||
logo_url = payload.logo_url or await _resolve_logo_url(sql_session, payload.logo)
|
||||
|
||||
theme = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"name": payload.name,
|
||||
"description": payload.description,
|
||||
"user": "local",
|
||||
"logo": payload.logo,
|
||||
"logo_url": logo_url,
|
||||
"company_name": payload.company_name,
|
||||
"data": payload.data,
|
||||
}
|
||||
themes.append(theme)
|
||||
|
||||
if row:
|
||||
row.value = {"themes": themes}
|
||||
sql_session.add(row)
|
||||
else:
|
||||
sql_session.add(KeyValueSqlModel(key=THEMES_STORAGE_KEY, value={"themes": themes}))
|
||||
|
||||
await sql_session.commit()
|
||||
return _normalize_theme(theme)
|
||||
|
||||
|
||||
@THEMES_ROUTER.patch("/update/{theme_id}", response_model=ThemeResponse)
|
||||
async def update_theme(
|
||||
theme_id: str,
|
||||
payload: ThemeUpdateRequest,
|
||||
sql_session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
row = await _get_themes_row(sql_session)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Theme not found")
|
||||
|
||||
themes = _read_themes_from_row(row)
|
||||
theme = next((item for item in themes if item.get("id") == theme_id), None)
|
||||
if not theme:
|
||||
raise HTTPException(status_code=404, detail="Theme not found")
|
||||
|
||||
if payload.name is not None:
|
||||
theme["name"] = payload.name
|
||||
if payload.description is not None:
|
||||
theme["description"] = payload.description
|
||||
if payload.company_name is not None:
|
||||
theme["company_name"] = payload.company_name
|
||||
if payload.data is not None:
|
||||
theme["data"] = payload.data
|
||||
if payload.logo is not None:
|
||||
theme["logo"] = payload.logo
|
||||
theme["logo_url"] = await _resolve_logo_url(sql_session, payload.logo)
|
||||
elif payload.logo_url is not None:
|
||||
theme["logo_url"] = payload.logo_url
|
||||
|
||||
row.value = {"themes": themes}
|
||||
sql_session.add(row)
|
||||
await sql_session.commit()
|
||||
return _normalize_theme(theme)
|
||||
|
||||
|
||||
@THEMES_ROUTER.delete("/delete/{theme_id}", status_code=204)
|
||||
async def delete_theme(
|
||||
theme_id: str, sql_session: AsyncSession = Depends(get_async_session)
|
||||
):
|
||||
row = await _get_themes_row(sql_session)
|
||||
if not row:
|
||||
return
|
||||
|
||||
themes = _read_themes_from_row(row)
|
||||
row.value = {"themes": [theme for theme in themes if theme.get("id") != theme_id]}
|
||||
sql_session.add(row)
|
||||
await sql_session.commit()
|
||||
@@ -0,0 +1,75 @@
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
|
||||
from models.theme_data import ThemeData
|
||||
from utils.theme_utils import (
|
||||
IS_DARK_BELOW,
|
||||
generate_color_palette,
|
||||
get_lightness_key_at_distance,
|
||||
)
|
||||
|
||||
THEME_ROUTER = APIRouter(prefix="/theme", tags=["V3 Theme"])
|
||||
|
||||
|
||||
class GenerateThemeRequestV3(BaseModel):
|
||||
primary: Optional[str] = None
|
||||
background: Optional[str] = None
|
||||
accent_1: Optional[str] = None
|
||||
accent_2: Optional[str] = None
|
||||
text_1: Optional[str] = None
|
||||
text_2: Optional[str] = None
|
||||
|
||||
|
||||
@THEME_ROUTER.post("/generate", response_model=ThemeData)
|
||||
async def generate_theme_v3(request: GenerateThemeRequestV3) -> ThemeData:
|
||||
color_palette = generate_color_palette(
|
||||
request.primary,
|
||||
request.background,
|
||||
request.accent_1,
|
||||
request.accent_2,
|
||||
request.text_1,
|
||||
request.text_2,
|
||||
)
|
||||
|
||||
is_dark_theme = color_palette.background_lightness < IS_DARK_BELOW
|
||||
graph_colors = list(color_palette.primary_variations.values())
|
||||
|
||||
if not is_dark_theme:
|
||||
graph_colors.reverse()
|
||||
|
||||
theme_data = ThemeData(
|
||||
primary=color_palette.primary,
|
||||
background=color_palette.background,
|
||||
card=color_palette.background_variations[
|
||||
get_lightness_key_at_distance(
|
||||
color_palette.background_lightness,
|
||||
min_distance=1,
|
||||
max_distance=1,
|
||||
prefer_dark=not is_dark_theme,
|
||||
)
|
||||
],
|
||||
stroke=color_palette.background_variations[
|
||||
get_lightness_key_at_distance(
|
||||
color_palette.background_lightness,
|
||||
min_distance=2,
|
||||
max_distance=2,
|
||||
prefer_dark=not is_dark_theme,
|
||||
)
|
||||
],
|
||||
background_text=color_palette.text_1,
|
||||
primary_text=color_palette.text_2,
|
||||
graph_0=graph_colors[0],
|
||||
graph_1=graph_colors[1],
|
||||
graph_2=graph_colors[2],
|
||||
graph_3=graph_colors[3],
|
||||
graph_4=graph_colors[4],
|
||||
graph_5=graph_colors[5],
|
||||
graph_6=graph_colors[6],
|
||||
graph_7=graph_colors[7],
|
||||
graph_8=graph_colors[8],
|
||||
graph_9=graph_colors[9],
|
||||
)
|
||||
return theme_data
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from api.v1.ppt.endpoints.anthropic import ANTHROPIC_ROUTER
|
||||
from api.v1.ppt.endpoints.chat import CHAT_ROUTER
|
||||
from api.v1.ppt.endpoints.codex_auth import CODEX_AUTH_ROUTER
|
||||
from api.v1.ppt.endpoints.google import GOOGLE_ROUTER
|
||||
from api.v1.ppt.endpoints.openai import OPENAI_ROUTER
|
||||
from api.v1.ppt.endpoints.files import FILES_ROUTER
|
||||
from api.v1.ppt.endpoints.pptx_slides import PPTX_SLIDES_ROUTER
|
||||
from api.v1.ppt.endpoints.pdf_slides import PDF_SLIDES_ROUTER
|
||||
from api.v1.ppt.endpoints.fonts import FONTS_ROUTER
|
||||
from api.v1.ppt.endpoints.icons import ICONS_ROUTER
|
||||
from api.v1.ppt.endpoints.images import IMAGES_ROUTER
|
||||
from api.v1.ppt.endpoints.ollama import OLLAMA_ROUTER
|
||||
from api.v1.ppt.endpoints.outlines import OUTLINES_ROUTER
|
||||
from api.v1.ppt.endpoints.slide import SLIDE_ROUTER
|
||||
from api.v1.ppt.endpoints.templates import TEMPLATE_ASSETS_ROUTER, TEMPLATES_ROUTER
|
||||
from api.v1.ppt.endpoints.presentation import PRESENTATION_ROUTER
|
||||
from api.v1.ppt.endpoints.pptx_slides import PPTX_FONTS_ROUTER
|
||||
from api.v1.ppt.endpoints.theme import THEMES_ROUTER
|
||||
from api.v1.ppt.endpoints.theme_generate import THEME_ROUTER
|
||||
|
||||
|
||||
API_V1_PPT_ROUTER = APIRouter(prefix="/api/v1/ppt")
|
||||
|
||||
API_V1_PPT_ROUTER.include_router(FILES_ROUTER)
|
||||
API_V1_PPT_ROUTER.include_router(FONTS_ROUTER)
|
||||
API_V1_PPT_ROUTER.include_router(OUTLINES_ROUTER)
|
||||
API_V1_PPT_ROUTER.include_router(PPTX_SLIDES_ROUTER)
|
||||
API_V1_PPT_ROUTER.include_router(SLIDE_ROUTER)
|
||||
API_V1_PPT_ROUTER.include_router(IMAGES_ROUTER)
|
||||
API_V1_PPT_ROUTER.include_router(ICONS_ROUTER)
|
||||
API_V1_PPT_ROUTER.include_router(OLLAMA_ROUTER)
|
||||
API_V1_PPT_ROUTER.include_router(PDF_SLIDES_ROUTER)
|
||||
API_V1_PPT_ROUTER.include_router(OPENAI_ROUTER)
|
||||
API_V1_PPT_ROUTER.include_router(ANTHROPIC_ROUTER)
|
||||
API_V1_PPT_ROUTER.include_router(GOOGLE_ROUTER)
|
||||
API_V1_PPT_ROUTER.include_router(CODEX_AUTH_ROUTER)
|
||||
API_V1_PPT_ROUTER.include_router(PPTX_FONTS_ROUTER)
|
||||
API_V1_PPT_ROUTER.include_router(PRESENTATION_ROUTER)
|
||||
API_V1_PPT_ROUTER.include_router(THEMES_ROUTER)
|
||||
API_V1_PPT_ROUTER.include_router(THEME_ROUTER)
|
||||
API_V1_PPT_ROUTER.include_router(CHAT_ROUTER)
|
||||
API_V1_PPT_ROUTER.include_router(TEMPLATE_ASSETS_ROUTER)
|
||||
API_V1_PPT_ROUTER.include_router(TEMPLATES_ROUTER)
|
||||
@@ -0,0 +1,53 @@
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Path
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from enums.webhook_event import WebhookEvent
|
||||
from models.sql.webhook_subscription import WebhookSubscription
|
||||
from services.database import get_async_session
|
||||
|
||||
API_V1_WEBHOOK_ROUTER = APIRouter(prefix="/api/v1/webhook", tags=["Webhook"])
|
||||
|
||||
|
||||
class SubscribeToWebhookRequest(BaseModel):
|
||||
url: str = Field(description="The URL to send the webhook to")
|
||||
secret: Optional[str] = Field(None, description="The secret to use for the webhook")
|
||||
event: WebhookEvent = Field(description="The event to subscribe to")
|
||||
|
||||
|
||||
class SubscribeToWebhookResponse(BaseModel):
|
||||
id: str
|
||||
|
||||
|
||||
@API_V1_WEBHOOK_ROUTER.post(
|
||||
"/subscribe", response_model=SubscribeToWebhookResponse, status_code=201
|
||||
)
|
||||
async def subscribe_to_webhook(
|
||||
body: SubscribeToWebhookRequest,
|
||||
sql_session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
webhook_subscription = WebhookSubscription(
|
||||
url=body.url,
|
||||
secret=body.secret,
|
||||
event=body.event,
|
||||
)
|
||||
sql_session.add(webhook_subscription)
|
||||
await sql_session.commit()
|
||||
return SubscribeToWebhookResponse(id=webhook_subscription.id)
|
||||
|
||||
|
||||
@API_V1_WEBHOOK_ROUTER.delete("/unsubscribe", status_code=204)
|
||||
async def unsubscribe_to_webhook(
|
||||
id: str = Body(
|
||||
embed=True, description="The ID of the webhook subscription to unsubscribe from"
|
||||
),
|
||||
sql_session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
|
||||
webhook_subscription = await sql_session.get(WebhookSubscription, id)
|
||||
if not webhook_subscription:
|
||||
raise HTTPException(404, "Webhook subscription not found")
|
||||
|
||||
await sql_session.delete(webhook_subscription)
|
||||
await sql_session.commit()
|
||||
Reference in New Issue
Block a user