chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:08:41 +08:00
commit bcc7968ff6
9988 changed files with 1016778 additions and 0 deletions
View File
+123
View File
@@ -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()
+107
View File
@@ -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")
+87
View File
@@ -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
+107
View File
@@ -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
+36
View File
@@ -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
+45
View File
@@ -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)
+53
View File
@@ -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()