diff --git a/.github/workflows/test-all.yml b/.github/workflows/test-all.yml index a8a78d2d..e70acc30 100644 --- a/.github/workflows/test-all.yml +++ b/.github/workflows/test-all.yml @@ -33,7 +33,8 @@ jobs: libreoffice \ fontconfig \ chromium \ - chromium-driver + chromium-driver \ + imagemagick - name: Install Python dependencies run: | @@ -81,7 +82,8 @@ jobs: libreoffice \ fontconfig \ chromium \ - chromium-driver + chromium-driver \ + imagemagick - name: Install Python dependencies run: | diff --git a/Dockerfile b/Dockerfile index b9c1bf32..8f228620 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,62 +1,47 @@ +# syntax=docker/dockerfile:1.4 FROM python:3.11-slim-bookworm -# Install Node.js and npm -RUN apt-get update && apt-get install -y \ - nginx \ - curl \ - libreoffice \ - fontconfig \ - chromium \ - zstd - - -# Install Node.js 20 using NodeSource repository -RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \ - apt-get install -y nodejs - - -# Create a working directory -WORKDIR /app - -# Set environment variables -ENV APP_DATA_DIRECTORY=/app_data -ENV TEMP_DIRECTORY=/tmp/presenton -ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium - - -# Install ollama -RUN curl -fsSL https://ollama.com/install.sh | sh - -# Install dependencies for FastAPI -RUN pip install alembic aiohttp aiomysql aiosqlite asyncpg fastapi[standard] \ - pathvalidate pdfplumber chromadb sqlmodel \ - anthropic google-genai openai fastmcp dirtyjson -RUN pip install docling --extra-index-url https://download.pytorch.org/whl/cpu - -# Install dependencies for Next.js -WORKDIR /app/servers/nextjs -COPY servers/nextjs/package.json servers/nextjs/package-lock.json ./ -RUN npm install - - -# Copy Next.js app -COPY servers/nextjs/ /app/servers/nextjs/ - -# Build the Next.js app -WORKDIR /app/servers/nextjs -RUN npm run build - WORKDIR /app -# Copy FastAPI -COPY servers/fastapi/ ./servers/fastapi/ -COPY start.js LICENSE NOTICE ./ +# Docling + CPU torch: declared in pyproject.toml; lockfile uses PyTorch CPU index. +# UV_EXTRA_INDEX_URL mirrors the old `pip install docling --extra-index-url .../cpu`. +ENV APP_DATA_DIRECTORY=/app_data \ + TEMP_DIRECTORY=/tmp/presenton \ + PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium \ + UV_SYSTEM_PYTHON=1 \ + UV_COMPILE_BYTECODE=1 \ + UV_LINK_MODE=copy \ + UV_EXTRA_INDEX_URL=https://download.pytorch.org/whl/cpu \ + PATH="/root/.local/bin:${PATH}" -# Copy nginx configuration +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates curl \ + nginx libreoffice fontconfig chromium imagemagick zstd \ + && curl -LsSf https://astral.sh/uv/install.sh | sh \ + && rm -rf /var/lib/apt/lists/* + +RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \ + && apt-get install -y nodejs \ + && rm -rf /var/lib/apt/lists/* + +RUN curl -fsSL https://ollama.com/install.sh | sh + +COPY servers/fastapi /app/servers/fastapi +WORKDIR /app/servers/fastapi +RUN --mount=type=cache,target=/root/.cache/uv \ + uv export --frozen --no-dev --no-emit-project -o /tmp/requirements.txt \ + && uv pip install --system -r /tmp/requirements.txt \ + && uv pip install --system --no-deps . + +WORKDIR /app/servers/nextjs +COPY servers/nextjs/package.json servers/nextjs/package-lock.json ./ +RUN npm install +COPY servers/nextjs/ /app/servers/nextjs/ +RUN npm run build + +WORKDIR /app +COPY start.js LICENSE NOTICE ./ COPY nginx.conf /etc/nginx/nginx.conf -# Expose the port EXPOSE 80 - -# Start the servers -CMD ["node", "/app/start.js"] \ No newline at end of file +CMD ["node", "/app/start.js"] diff --git a/Dockerfile.dev b/Dockerfile.dev index 72732d7e..e704c0e2 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -1,43 +1,41 @@ +# syntax=docker/dockerfile:1.4 FROM python:3.11-slim-bookworm -# Install Node.js and npm -RUN apt-get update && apt-get install -y \ - nginx \ - curl \ - libreoffice \ - fontconfig \ - chromium \ - zstd - -# Install Node.js 20 using NodeSource repository -RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \ - apt-get install -y nodejs - - -# Change working directory WORKDIR /app -RUN ls -a +# Docling is in pyproject.toml; uv.lock pins torch to this index (same as former: +# pip install docling --extra-index-url https://download.pytorch.org/whl/cpu +# UV_EXTRA_INDEX_URL keeps CPU wheels available if the lock is refreshed in Docker.) +ENV APP_DATA_DIRECTORY=/app_data \ + TEMP_DIRECTORY=/tmp/presenton \ + PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium \ + UV_SYSTEM_PYTHON=1 \ + UV_COMPILE_BYTECODE=1 \ + UV_LINK_MODE=copy \ + UV_EXTRA_INDEX_URL=https://download.pytorch.org/whl/cpu \ + PATH="/root/.local/bin:${PATH}" -# Set environment variables -ENV APP_DATA_DIRECTORY=/app_data -ENV TEMP_DIRECTORY=/tmp/presenton -ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates curl \ + nginx libreoffice fontconfig chromium imagemagick zstd \ + && curl -LsSf https://astral.sh/uv/install.sh | sh \ + && rm -rf /var/lib/apt/lists/* -# Install ollama -# RUN curl -fsSL http://ollama.com/install.sh | sh +RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \ + && apt-get install -y nodejs \ + && rm -rf /var/lib/apt/lists/* -# Install dependencies for FastAPI -RUN pip install alembic aiohttp aiomysql aiosqlite asyncpg fastapi[standard] \ - pathvalidate pdfplumber chromadb sqlmodel \ - anthropic google-genai openai fastmcp dirtyjson -RUN pip install docling --extra-index-url https://download.pytorch.org/whl/cpu +# Bind mount `.:/app` hides any .venv under servers/fastapi at runtime — install deps into +# system site-packages (same interpreter `start.js` uses as `python`). +COPY servers/fastapi /app/servers/fastapi +WORKDIR /app/servers/fastapi +RUN --mount=type=cache,target=/root/.cache/uv \ + uv export --frozen --no-dev --no-emit-project -o /tmp/requirements.txt \ + && uv pip install --system -r /tmp/requirements.txt \ + && uv pip install --system --no-deps . -# Copy nginx configuration +WORKDIR /app COPY nginx.conf /etc/nginx/nginx.conf -# Expose the port EXPOSE 80 - -# Start the servers CMD ["node", "/app/start.js", "--dev"] diff --git a/docker-compose.yml b/docker-compose.yml index 05c189f8..707a52fe 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -94,6 +94,8 @@ services: - .:/app - ./app_data:/app_data environment: + # Dockerfile.dev does not install ollama; use a host daemon via OLLAMA_URL or omit. + - START_EMBEDDED_OLLAMA=false - MIGRATE_DATABASE_ON_STARTUP=true - CAN_CHANGE_KEYS=${CAN_CHANGE_KEYS} - LLM=${LLM} @@ -138,6 +140,7 @@ services: - .:/app - ./app_data:/app_data environment: + - START_EMBEDDED_OLLAMA=false - MIGRATE_DATABASE_ON_STARTUP=true - CAN_CHANGE_KEYS=${CAN_CHANGE_KEYS} - LLM=${LLM} diff --git a/electron/servers/fastapi/migrations.py b/electron/servers/fastapi/migrations.py index 4cb75de0..335c4ef4 100644 --- a/electron/servers/fastapi/migrations.py +++ b/electron/servers/fastapi/migrations.py @@ -11,6 +11,8 @@ from utils.get_env import get_migrate_database_on_startup_env LEGACY_BASELINE_REVISION = "00b3c27a13bc" +# Revision before 95b5127e93cd (template_create_infos); used when DB has theme but not that table. +REVISION_BEFORE_TEMPLATE_CREATE_INFO = "82abdbc476a7" async def migrate_database_on_startup() -> None: @@ -49,6 +51,7 @@ def _run_migrations() -> None: database_url = _to_sync_database_url(database_url) config.set_main_option("sqlalchemy.url", database_url) + _repair_orphan_alembic_revision(config, database_url) _stamp_legacy_database_if_needed(config, database_url) try: @@ -62,6 +65,52 @@ def _run_migrations() -> None: raise +def _repair_orphan_alembic_revision(config: Config, database_url: str) -> None: + """ + If alembic_version points at a revision id that no longer exists in alembic/versions + (removed branch, old image, etc.), re-stamp from the live schema so upgrade can run. + """ + script = ScriptDirectory.from_config(config) + known = {rev.revision for rev in script.walk_revisions()} + heads = script.get_heads() + if len(heads) != 1: + return + head = heads[0] + + engine = create_engine(database_url) + try: + with engine.connect() as connection: + inspector = inspect(connection) + tables = set(inspector.get_table_names()) + if "alembic_version" not in tables: + return + version_num = connection.execute( + text("SELECT version_num FROM alembic_version LIMIT 1") + ).scalar_one_or_none() + if not version_num or version_num in known: + return + print( + f"Alembic revision {version_num!r} is missing from the codebase; " + "inferring applied migrations from schema and re-stamping.", + flush=True, + ) + target = _infer_revision_from_schema(inspector, tables, head) + command.stamp(config, target) + finally: + engine.dispose() + + +def _infer_revision_from_schema(inspector, tables: set[str], head_revision: str) -> str: + """Best-effort: map existing SQLite/Postgres schema to our linear migration chain.""" + if "template_create_infos" in tables: + return head_revision + if "presentations" in tables: + cols = {c["name"] for c in inspector.get_columns("presentations")} + if "theme" in cols: + return REVISION_BEFORE_TEMPLATE_CREATE_INFO + return LEGACY_BASELINE_REVISION + + def _stamp_legacy_database_if_needed(config: Config, database_url: str) -> None: """ If the DB has app tables but no migration reference in alembic_version, diff --git a/servers/fastapi/alembic/env.py b/servers/fastapi/alembic/env.py index 8f77371d..5524c68a 100644 --- a/servers/fastapi/alembic/env.py +++ b/servers/fastapi/alembic/env.py @@ -24,6 +24,7 @@ from models.sql.presentation_layout_code import ( # noqa: F401, E402 ) from models.sql.slide import SlideModel # noqa: F401, E402 from models.sql.template import TemplateModel # noqa: F401, E402 +from models.sql.template_create_info import TemplateCreateInfoModel # noqa: F401, E402 from models.sql.webhook_subscription import WebhookSubscription # noqa: F401, E402 alembic_config = context.config @@ -33,6 +34,20 @@ if alembic_config.config_file_name is not None: target_metadata = SQLModel.metadata +# alembic.ini sets this so Config validates; treat it as "unset" for URL resolution. +_CLI_PLACEHOLDER_DB_URL = "sqlite:///placeholder" + + +def _to_sync_database_url(database_url: str) -> str: + # Preserve slash counts for sqlite URLs so Windows paths stay valid. + if database_url.startswith("sqlite+aiosqlite:///"): + return "sqlite:///" + database_url[len("sqlite+aiosqlite:///") :] + if database_url.startswith("postgresql+asyncpg://"): + return "postgresql://" + database_url[len("postgresql+asyncpg://") :] + if database_url.startswith("mysql+aiomysql://"): + return "mysql://" + database_url[len("mysql+aiomysql://") :] + return database_url + def _get_url() -> str: """ @@ -40,18 +55,13 @@ def _get_url() -> str: falling back to the DATABASE_URL environment variable or a local SQLite DB. """ configured = alembic_config.get_main_option("sqlalchemy.url") - if configured: + if configured and configured != _CLI_PLACEHOLDER_DB_URL: return configured from utils.db_utils import get_database_url_and_connect_args url, _ = get_database_url_and_connect_args() - return ( - url - .replace("sqlite+aiosqlite://", "sqlite:///") - .replace("postgresql+asyncpg://", "postgresql://") - .replace("mysql+aiomysql://", "mysql://") - ) + return _to_sync_database_url(url) def run_migrations_offline() -> None: diff --git a/servers/fastapi/alembic/versions/95b5127e93cd_template_create_info.py b/servers/fastapi/alembic/versions/95b5127e93cd_template_create_info.py new file mode 100644 index 00000000..d1ec463e --- /dev/null +++ b/servers/fastapi/alembic/versions/95b5127e93cd_template_create_info.py @@ -0,0 +1,39 @@ +"""template create info + +Revision ID: 95b5127e93cd +Revises: 82abdbc476a7 +Create Date: 2026-04-08 13:44:21.132802 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + + +# revision identifiers, used by Alembic. +revision: str = '95b5127e93cd' +down_revision: Union[str, None] = '82abdbc476a7' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('template_create_infos', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('fonts', sa.JSON(), nullable=True), + sa.Column('pptx_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('slide_htmls', sa.JSON(), nullable=False), + sa.Column('slide_image_urls', sa.JSON(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('template_create_infos') + # ### end Alembic commands ### diff --git a/servers/fastapi/api/main.py b/servers/fastapi/api/main.py index d207a76e..d0f9ae80 100644 --- a/servers/fastapi/api/main.py +++ b/servers/fastapi/api/main.py @@ -1,20 +1,34 @@ +import os + from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles + from api.lifespan import app_lifespan from api.middlewares import UserConfigEnvUpdateMiddleware +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 api.v1.mock.router import API_V1_MOCK_ROUTER - +from utils.get_env import get_app_data_directory_env +from utils.path_helpers import get_resource_path 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) +# 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( diff --git a/servers/fastapi/api/v1/ppt/background_tasks.py b/servers/fastapi/api/v1/ppt/background_tasks.py index dddba98a..68f7e45e 100644 --- a/servers/fastapi/api/v1/ppt/background_tasks.py +++ b/servers/fastapi/api/v1/ppt/background_tasks.py @@ -21,6 +21,14 @@ async def pull_ollama_model_background_task(model: str): try: async for event in pull_ollama_model(model): + if "error" in event: + saved_model_status.status = "error" + saved_model_status.done = True + saved_model_status.error = event["error"] + await upsert_ollama_pull_status(session, model, saved_model_status) + await session.close() + return + log_event_count += 1 if log_event_count != 1 and log_event_count % 20 != 0: continue @@ -39,6 +47,7 @@ async def pull_ollama_model_background_task(model: str): except Exception as e: saved_model_status.status = "error" saved_model_status.done = True + saved_model_status.error = str(e) await upsert_ollama_pull_status(session, model, saved_model_status) await session.close() raise HTTPException( @@ -49,6 +58,7 @@ async def pull_ollama_model_background_task(model: str): saved_model_status.done = True saved_model_status.status = "pulled" saved_model_status.downloaded = saved_model_status.size + saved_model_status.error = None await upsert_ollama_pull_status(session, model, saved_model_status) await session.close() diff --git a/servers/fastapi/api/v1/ppt/endpoints/codex_auth.py b/servers/fastapi/api/v1/ppt/endpoints/codex_auth.py index c6576888..da28db6d 100644 --- a/servers/fastapi/api/v1/ppt/endpoints/codex_auth.py +++ b/servers/fastapi/api/v1/ppt/endpoints/codex_auth.py @@ -16,25 +16,32 @@ 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_id, + 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 @@ -60,6 +67,9 @@ class InitiateResponse(BaseModel): 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 @@ -69,11 +79,17 @@ class ExchangeRequest(BaseModel): class ExchangeResponse(BaseModel): - account_id: str + 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 @@ -81,16 +97,31 @@ class RefreshResponse(BaseModel): # Helper # --------------------------------------------------------------------------- -def _store_token(result: TokenSuccess) -> Optional[str]: - """Persist token fields in env vars and userConfig.json. Returns account_id or None.""" +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)) - account_id = get_account_id(result.access) - if account_id: - set_codex_account_id_env(account_id) + + 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 account_id + return profile # --------------------------------------------------------------------------- @@ -166,8 +197,14 @@ async def poll_codex_auth_status(session_id: str): if not isinstance(result, TokenSuccess): return StatusResponse(status="failed", detail=result.reason) - account_id = _store_token(result) - return StatusResponse(status="success", account_id=account_id) + 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) @@ -207,11 +244,16 @@ async def exchange_codex_code(body: ExchangeRequest): if not isinstance(result, TokenSuccess): raise HTTPException(status_code=502, detail=f"Token exchange failed: {result.reason}") - account_id = _store_token(result) - if not account_id: + 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=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) @@ -232,9 +274,12 @@ async def refresh_codex_token(): if not isinstance(result, TokenSuccess): raise HTTPException(status_code=502, detail=f"Token refresh failed: {result.reason}") - account_id = _store_token(result) + profile = _store_token(result) return RefreshResponse( - account_id=account_id, + account_id=profile.account_id, + username=profile.username, + email=profile.email, + is_pro=profile.is_pro, detail="Token refreshed successfully", ) @@ -260,8 +305,18 @@ async def get_codex_auth_status(): except (ValueError, TypeError): pass - account_id = get_account_id(access_token) - return StatusResponse(status="authenticated", account_id=account_id) + 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") @@ -273,6 +328,9 @@ async def logout_codex(): 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() return {"detail": "Logged out successfully"} diff --git a/servers/fastapi/api/v1/ppt/endpoints/files.py b/servers/fastapi/api/v1/ppt/endpoints/files.py index 5f7d88ce..5e517aa8 100644 --- a/servers/fastapi/api/v1/ppt/endpoints/files.py +++ b/servers/fastapi/api/v1/ppt/endpoints/files.py @@ -59,7 +59,7 @@ async def decompose_files(file_paths: Annotated[List[str], Body(embed=True)]): f"{uuid.uuid4()}.txt", temp_dir ) parsed_doc = parsed_doc.replace("
", "\n") - with open(file_path, "w") as text_file: + with open(file_path, "w", encoding="utf-8") as text_file: text_file.write(parsed_doc) response.append( DecomposedFileInfo( diff --git a/servers/fastapi/api/v1/ppt/endpoints/fonts.py b/servers/fastapi/api/v1/ppt/endpoints/fonts.py index 0160698f..af6a9b8f 100644 --- a/servers/fastapi/api/v1/ppt/endpoints/fonts.py +++ b/servers/fastapi/api/v1/ppt/endpoints/fonts.py @@ -1,251 +1,335 @@ import os import uuid -from typing import Any, List, Optional - -from fastapi import APIRouter, Depends, File, HTTPException, UploadFile +import shutil +from typing import List, Dict, Any, Optional +from fastapi import APIRouter, HTTPException, File, UploadFile from pydantic import BaseModel -from sqlalchemy.ext.asyncio import AsyncSession -from sqlmodel import select - -from models.sql.key_value import KeyValueSqlModel -from services.database import get_async_session -from utils.get_env import get_app_data_directory_env +from templates.preview import FontCheckResponse, check_fonts_in_pptx_handler +from utils.asset_directory_utils import get_app_data_directory_env try: from fontTools.ttLib import TTFont - + from fontTools.ttLib.tables._n_a_m_e import table__n_a_m_e FONTTOOLS_AVAILABLE = True except ImportError: FONTTOOLS_AVAILABLE = False FONTS_ROUTER = APIRouter(prefix="/fonts", tags=["fonts"]) -FONTS_STORAGE_KEY = "presentation_uploaded_fonts" +# Supported font file extensions SUPPORTED_FONT_EXTENSIONS = { - ".ttf": "font/ttf", - ".otf": "font/otf", - ".woff": "font/woff", - ".woff2": "font/woff2", - ".eot": "application/vnd.ms-fontobject", + '.ttf': 'font/ttf', + '.otf': 'font/otf', + '.woff': 'font/woff', + '.woff2': 'font/woff2', + '.eot': 'application/vnd.ms-fontobject' } - -class FontDetail(BaseModel): - id: str - name: str - url: str - - -class FontUploadResponse(FontDetail): - success: bool = True +class FontUploadResponse(BaseModel): + success: bool font_name: str font_url: str font_path: str - + message: Optional[str] = None class FontListResponse(BaseModel): - fonts: List[FontDetail] + success: bool + fonts: List[dict] + message: Optional[str] = None -def _get_fonts_directory() -> str: +class UploadedFontsResponse(BaseModel): + fonts: List[dict] + + +def get_fonts_directory() -> str: + """Get the fonts directory path, create if it doesn't exist""" app_data_dir = get_app_data_directory_env() or "/tmp/presenton" fonts_dir = os.path.join(app_data_dir, "fonts") os.makedirs(fonts_dir, exist_ok=True) return fonts_dir -def _extract_font_name_from_file(file_path: str, filename: str) -> str: - fallback_name = os.path.splitext(filename)[0] - - if not FONTTOOLS_AVAILABLE: - return fallback_name - - try: - font = TTFont(file_path) - if "name" not in font: - font.close() - return fallback_name - - name_table = font["name"] - for name_id in [1, 4, 6]: - for record in name_table.names: - if record.nameID == name_id: - if record.langID in [0x409, 0]: - font_name = record.toUnicode().strip() - if font_name: - font.close() - return font_name - - for record in name_table.names: - if record.nameID == 1: - font_name = record.toUnicode().strip() - if font_name: - font.close() - return font_name - - font.close() - except Exception: - return fallback_name - - return fallback_name - - -def _is_valid_font_file(file: UploadFile) -> bool: +def is_valid_font_file(file: UploadFile) -> bool: + """Validate font file by extension and MIME type""" if not file.filename: return False - + file_ext = os.path.splitext(file.filename)[1].lower() if file_ext not in SUPPORTED_FONT_EXTENSIONS: return False - - content_type = (file.content_type or "").lower() - valid_mime_types = { - "font/ttf", - "font/otf", - "font/woff", - "font/woff2", - "application/font-ttf", - "application/font-otf", - "application/font-woff", - "application/font-woff2", - "application/x-font-ttf", - "application/x-font-otf", - "font/truetype", - "font/opentype", - "application/octet-stream", - "", - } - + + # Check MIME type + content_type = file.content_type or "" + valid_mime_types = [ + "font/ttf", "font/otf", "font/woff", "font/woff2", + "application/font-ttf", "application/font-otf", + "application/font-woff", "application/font-woff2", + "application/x-font-ttf", "application/x-font-otf", + "font/truetype", "font/opentype" + ] + return content_type in valid_mime_types -async def _get_fonts_row(sql_session: AsyncSession) -> Optional[KeyValueSqlModel]: - return await sql_session.scalar( - select(KeyValueSqlModel).where(KeyValueSqlModel.key == FONTS_STORAGE_KEY) - ) - - -def _read_fonts_from_row(row: Optional[KeyValueSqlModel]) -> list[dict[str, Any]]: - if not row: - return [] - value = row.value if isinstance(row.value, dict) else {} - fonts = value.get("fonts", []) - return fonts if isinstance(fonts, list) else [] +def extract_font_name_from_file(file_path: str) -> str: + """Extract the actual font family name from font file metadata""" + if not FONTTOOLS_AVAILABLE: + # Fallback to filename parsing if fonttools not available + filename = os.path.basename(file_path) + base_name = os.path.splitext(filename)[0] + if '_' in filename and len(filename.split('_')[-1].split('.')[0]) == 8: + # Remove UUID part + parts = filename.split('_') + if len(parts) > 1: + return '_'.join(parts[:-1]) + return base_name + + try: + font = TTFont(file_path) + + # Try to get font family name from name table + if 'name' in font: + name_table = font['name'] + + # Preferred order: Family name (ID 1), then Full name (ID 4), then PostScript name (ID 6) + for name_id in [1, 4, 6]: + for record in name_table.names: + if record.nameID == name_id: + # Prefer English names + if record.langID == 0x409 or record.langID == 0: # English + font_name = record.toUnicode().strip() + if font_name: + font.close() + return font_name + + # If no English name found, use any available family name + for record in name_table.names: + if record.nameID == 1: # Family name + font_name = record.toUnicode().strip() + if font_name: + font.close() + return font_name + + font.close() + except Exception as e: + # If font parsing fails, fallback to filename + print(f"Error reading font metadata from {file_path}: {e}") + + # Fallback to filename parsing + filename = os.path.basename(file_path) + base_name = os.path.splitext(filename)[0] + if '_' in filename and len(filename.split('_')[-1].split('.')[0]) == 8: + # Remove UUID part + parts = filename.split('_') + if len(parts) > 1: + return '_'.join(parts[:-1]) + return base_name @FONTS_ROUTER.post("/upload", response_model=FontUploadResponse) async def upload_font( - file: Optional[UploadFile] = File( - None, description="Font file to upload (.ttf, .otf, .woff, .woff2, .eot)" - ), - font_file: Optional[UploadFile] = File(None), - sql_session: AsyncSession = Depends(get_async_session), + font_file: UploadFile = File(..., description="Font file to upload (.ttf, .otf, .woff, .woff2, .eot)") ): - upload_file = file or font_file - if not upload_file: - raise HTTPException(status_code=400, detail="No file provided") - - if not upload_file.filename: - raise HTTPException(status_code=400, detail="No file name provided") - - if not _is_valid_font_file(upload_file): + """ + Upload a font file and save it to the fonts directory. + + Args: + font_file: Uploaded font file + + Returns: + FontUploadResponse with font details and accessible URL + + Raises: + HTTPException: If file validation fails or upload error occurs + """ + try: + # Validate file + if not font_file.filename: + raise HTTPException( + status_code=400, + detail="No file name provided" + ) + + if not is_valid_font_file(font_file): + raise HTTPException( + status_code=400, + detail=f"Invalid font file. Supported formats: {', '.join(SUPPORTED_FONT_EXTENSIONS.keys())}" + ) + + # Generate unique filename to avoid conflicts + file_ext = os.path.splitext(font_file.filename)[1].lower() + base_name = os.path.splitext(font_file.filename)[0] + unique_filename = f"{base_name}_{str(uuid.uuid4())[:8]}{file_ext}" + + # Get fonts directory + fonts_dir = get_fonts_directory() + font_path = os.path.join(fonts_dir, unique_filename) + + # Save the uploaded file + with open(font_path, "wb") as buffer: + shutil.copyfileobj(font_file.file, buffer) + + # Generate accessible URL + font_url = f"/app_data/fonts/{unique_filename}" + + return FontUploadResponse( + success=True, + font_name=base_name, + font_url=font_url, + font_path=font_path, + message=f"Font '{base_name}' uploaded successfully" + ) + + except HTTPException: + # Re-raise HTTP exceptions as-is + raise + except Exception as e: + print(f"Error uploading font: {str(e)}") raise HTTPException( - status_code=400, - detail=f"Invalid font file. Supported formats: {', '.join(SUPPORTED_FONT_EXTENSIONS.keys())}", + status_code=500, + detail=f"Error uploading font: {str(e)}" ) - file_ext = os.path.splitext(upload_file.filename)[1].lower() - unique_filename = f"{uuid.uuid4().hex}{file_ext}" - fonts_dir = _get_fonts_directory() - font_path = os.path.join(fonts_dir, unique_filename) +@FONTS_ROUTER.get("/list", response_model=FontListResponse) +async def list_fonts(): + """ + List all uploaded fonts with their accessible URLs. + + Returns: + FontListResponse with list of available fonts + """ try: - contents = await upload_file.read() - with open(font_path, "wb") as buffer: - buffer.write(contents) - except Exception as exc: - raise HTTPException(status_code=500, detail="Error uploading font") from exc - - font_name = _extract_font_name_from_file(font_path, upload_file.filename) - font_url = f"/app_data/fonts/{unique_filename}" - font_detail = { - "id": str(uuid.uuid4()), - "name": font_name, - "url": font_url, - "path": font_path, - } - - row = await _get_fonts_row(sql_session) - fonts = _read_fonts_from_row(row) - fonts.append(font_detail) - - if row: - row.value = {"fonts": fonts} - sql_session.add(row) - else: - sql_session.add(KeyValueSqlModel(key=FONTS_STORAGE_KEY, value={"fonts": fonts})) - await sql_session.commit() - - return FontUploadResponse( - id=font_detail["id"], - name=font_detail["name"], - url=font_detail["url"], - font_name=font_detail["name"], - font_url=font_detail["url"], - font_path=font_detail["path"], - ) + fonts_dir = get_fonts_directory() + fonts = [] + + # Get all font files in the directory + if os.path.exists(fonts_dir): + for filename in os.listdir(fonts_dir): + file_path = os.path.join(fonts_dir, filename) + + if os.path.isfile(file_path): + file_ext = os.path.splitext(filename)[1].lower() + + if file_ext in SUPPORTED_FONT_EXTENSIONS: + # Get the real font name from file metadata + font_name = extract_font_name_from_file(file_path) + + # Extract original name (remove UUID suffix for display) + base_name = filename + if '_' in filename and len(filename.split('_')[-1].split('.')[0]) == 8: + # Remove UUID part for original_name display + parts = filename.split('_') + if len(parts) > 1: + base_name = '_'.join(parts[:-1]) + file_ext + + fonts.append({ + "filename": filename, + "font_name": font_name, # Real font family name from metadata + "original_name": base_name, + "font_url": f"/app_data/fonts/{filename}", + "font_type": SUPPORTED_FONT_EXTENSIONS.get(file_ext, 'unknown'), + "file_size": os.path.getsize(file_path) + }) + + return FontListResponse( + success=True, + fonts=fonts, + message=f"Found {len(fonts)} font files" + ) + + except Exception as e: + print(f"Error listing fonts: {str(e)}") + raise HTTPException( + status_code=500, + detail=f"Error listing fonts: {str(e)}" + ) -@FONTS_ROUTER.get("/uploaded", response_model=FontListResponse) -async def get_uploaded_fonts(sql_session: AsyncSession = Depends(get_async_session)): - row = await _get_fonts_row(sql_session) - fonts = _read_fonts_from_row(row) +@FONTS_ROUTER.get("/uploaded", response_model=UploadedFontsResponse) +async def get_uploaded_fonts(): + """ + Compatibility endpoint used by frontend theme flow. + Returns uploaded fonts as a compact list with id/name/url fields. + """ + try: + fonts_dir = get_fonts_directory() + fonts = [] - valid_fonts = [] - for font in fonts: - path = font.get("path") - if isinstance(path, str) and os.path.exists(path): - valid_fonts.append(font) + if os.path.exists(fonts_dir): + for filename in os.listdir(fonts_dir): + file_path = os.path.join(fonts_dir, filename) + if not os.path.isfile(file_path): + continue - if row and len(valid_fonts) != len(fonts): - row.value = {"fonts": valid_fonts} - sql_session.add(row) - await sql_session.commit() + file_ext = os.path.splitext(filename)[1].lower() + if file_ext not in SUPPORTED_FONT_EXTENSIONS: + continue - return FontListResponse( - fonts=[ - FontDetail( - id=str(item.get("id", "")), - name=str(item.get("name", "")), - url=str(item.get("url", "")), + font_name = extract_font_name_from_file(file_path) + fonts.append( + { + "id": filename, + "name": font_name, + "url": f"/app_data/fonts/{filename}", + } + ) + + return UploadedFontsResponse(fonts=fonts) + + except Exception as e: + print(f"Error getting uploaded fonts: {str(e)}") + raise HTTPException( + status_code=500, + detail=f"Error getting uploaded fonts: {str(e)}" + ) + + +FONTS_ROUTER.post("/check", response_model=FontCheckResponse)(check_fonts_in_pptx_handler) + + +@FONTS_ROUTER.delete("/delete/{filename}") +async def delete_font(filename: str): + """ + Delete a font file from the fonts directory. + + Args: + filename: Name of the font file to delete + + Returns: + Success message + """ + try: + fonts_dir = get_fonts_directory() + font_path = os.path.join(fonts_dir, filename) + + if not os.path.exists(font_path): + raise HTTPException( + status_code=404, + detail=f"Font file '{filename}' not found" ) - for item in valid_fonts - ] - ) - - -@FONTS_ROUTER.delete("/{font_id}", status_code=204) -async def delete_uploaded_font( - font_id: str, sql_session: AsyncSession = Depends(get_async_session) -): - row = await _get_fonts_row(sql_session) - if not row: - raise HTTPException(status_code=404, detail="Font not found") - - fonts = _read_fonts_from_row(row) - target_font = next((item for item in fonts if str(item.get("id")) == font_id), None) - if not target_font: - raise HTTPException(status_code=404, detail="Font not found") - - path = target_font.get("path") - if isinstance(path, str) and os.path.exists(path): - try: - os.remove(path) - except OSError: - # Keep metadata cleanup resilient even if local file is already gone/locked. - pass - - updated_fonts = [item for item in fonts if str(item.get("id")) != font_id] - row.value = {"fonts": updated_fonts} - sql_session.add(row) - await sql_session.commit() \ No newline at end of file + + # Validate it's actually a font file before deleting + file_ext = os.path.splitext(filename.lower())[1] + if file_ext not in SUPPORTED_FONT_EXTENSIONS: + raise HTTPException( + status_code=400, + detail="File is not a recognized font format" + ) + + os.remove(font_path) + + return { + "success": True, + "message": f"Font '{filename}' deleted successfully" + } + + except HTTPException: + raise + except Exception as e: + print(f"Error deleting font: {str(e)}") + raise HTTPException( + status_code=500, + detail=f"Error deleting font: {str(e)}" + ) diff --git a/servers/fastapi/api/v1/ppt/endpoints/images.py b/servers/fastapi/api/v1/ppt/endpoints/images.py index 62731f5a..525b5f36 100644 --- a/servers/fastapi/api/v1/ppt/endpoints/images.py +++ b/servers/fastapi/api/v1/ppt/endpoints/images.py @@ -1,5 +1,5 @@ from typing import List -from fastapi import APIRouter, Depends, File, UploadFile, HTTPException +from fastapi import APIRouter, Depends, File, UploadFile, HTTPException, Query, Header from sqlalchemy.ext.asyncio import AsyncSession from sqlmodel import select @@ -8,6 +8,9 @@ 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 get_images_directory +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 @@ -15,6 +18,75 @@ from utils.file_utils import get_file_name_with_random_uuid IMAGES_ROUTER = APIRouter(prefix="/images", tags=["Images"]) +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) @@ -30,17 +102,22 @@ async def generate_image( sql_session.add(image) await sql_session.commit() - return image.path + return image.file_url @IMAGES_ROUTER.get("/generated", response_model=List[ImageAsset]) async def get_generated_images(sql_session: AsyncSession = Depends(get_async_session)): try: - images = await sql_session.scalars( + images_result = await sql_session.scalars( select(ImageAsset) .where(ImageAsset.is_uploaded == False) .order_by(ImageAsset.created_at.desc()) ) + images = list(images_result) + for image in images: + # Ensure path exposed to the frontend is a web-safe URL + if hasattr(image, "file_url"): + image.path = image.file_url # type: ignore[attr-defined] return images except Exception as e: raise HTTPException( @@ -65,6 +142,12 @@ async def upload_image( sql_session.add(image_asset) await sql_session.commit() + # Refresh to ensure all defaults are loaded + await sql_session.refresh(image_asset) + + # Expose a web-safe URL in the path field for the frontend + if hasattr(image_asset, "file_url"): + image_asset.path = image_asset.file_url # type: ignore[attr-defined] return image_asset except Exception as e: @@ -74,11 +157,16 @@ async def upload_image( @IMAGES_ROUTER.get("/uploaded", response_model=List[ImageAsset]) async def get_uploaded_images(sql_session: AsyncSession = Depends(get_async_session)): try: - images = await sql_session.scalars( + images_result = await sql_session.scalars( select(ImageAsset) .where(ImageAsset.is_uploaded == True) .order_by(ImageAsset.created_at.desc()) ) + images = list(images_result) + for image in images: + # Ensure path exposed to the frontend is a web-safe URL + if hasattr(image, "file_url"): + image.path = image.file_url # type: ignore[attr-defined] return images except Exception as e: raise HTTPException( diff --git a/servers/fastapi/api/v1/ppt/endpoints/layouts.py b/servers/fastapi/api/v1/ppt/endpoints/layouts.py index 6e1051bc..63d71d08 100644 --- a/servers/fastapi/api/v1/ppt/endpoints/layouts.py +++ b/servers/fastapi/api/v1/ppt/endpoints/layouts.py @@ -1,6 +1,5 @@ from fastapi import APIRouter, HTTPException import aiohttp -from typing import List, Any from utils.get_layout_by_name import get_layout_by_name from models.presentation_layout import PresentationLayoutModel diff --git a/servers/fastapi/api/v1/ppt/endpoints/outlines.py b/servers/fastapi/api/v1/ppt/endpoints/outlines.py index 764f5b27..421832ff 100644 --- a/servers/fastapi/api/v1/ppt/endpoints/outlines.py +++ b/servers/fastapi/api/v1/ppt/endpoints/outlines.py @@ -1,6 +1,5 @@ import asyncio import json -import math import traceback import uuid import dirtyjson @@ -19,8 +18,11 @@ from models.sse_response import ( from services.temp_file_service import TEMP_FILE_SERVICE from services.database import get_async_session from services.documents_loader import DocumentsLoader +from utils.outline_utils import ( + get_no_of_outlines_to_generate_for_n_slides, + get_presentation_title_from_presentation_outline, +) from utils.llm_calls.generate_presentation_outlines import generate_ppt_outline -from utils.ppt_utils import get_presentation_title_from_outlines OUTLINES_ROUTER = APIRouter(prefix="/outlines", tags=["Outlines"]) @@ -43,7 +45,10 @@ async def stream_outlines( additional_context = "" if presentation.file_paths: - documents_loader = DocumentsLoader(file_paths=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: @@ -51,12 +56,14 @@ async def stream_outlines( presentation_outlines_text = "" - n_slides_to_generate = presentation.n_slides - if presentation.include_table_of_contents: - needed_toc_count = math.ceil((presentation.n_slides - 1) / 10) - n_slides_to_generate -= math.ceil( - (presentation.n_slides - needed_toc_count) / 10 + 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 async for chunk in generate_ppt_outline( presentation.content, @@ -68,6 +75,7 @@ async def stream_outlines( presentation.instructions, presentation.include_title_slide, presentation.web_search, + presentation.include_table_of_contents, ): # Give control to the event loop await asyncio.sleep(0) @@ -96,12 +104,30 @@ async def stream_outlines( presentation_outlines = PresentationOutlineModel(**presentation_outlines_json) - presentation_outlines.slides = presentation_outlines.slides[ - :n_slides_to_generate - ] + 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_outlines(presentation_outlines) + presentation.title = get_presentation_title_from_presentation_outline( + presentation_outlines + ) sql_session.add(presentation) await sql_session.commit() diff --git a/servers/fastapi/api/v1/ppt/endpoints/presentation.py b/servers/fastapi/api/v1/ppt/endpoints/presentation.py index f03a3ec6..08f9ff04 100644 --- a/servers/fastapi/api/v1/ppt/endpoints/presentation.py +++ b/servers/fastapi/api/v1/ppt/endpoints/presentation.py @@ -1,18 +1,17 @@ import asyncio from datetime import datetime import json -import math import os import random import traceback from typing import Annotated, List, Literal, Optional, Tuple import dirtyjson -from fastapi import APIRouter, BackgroundTasks, Body, Depends, HTTPException, Path, Request +from fastapi import APIRouter, BackgroundTasks, Body, Depends, HTTPException, Path from fastapi.responses import StreamingResponse from sqlalchemy import delete from sqlalchemy.ext.asyncio import AsyncSession from sqlmodel import select -from constants.presentation import DEFAULT_TEMPLATES +from constants.presentation import DEFAULT_TEMPLATES, MAX_NUMBER_OF_SLIDES from enums.webhook_event import WebhookEvent from models.api_error_model import APIErrorModel from models.generate_presentation_request import GeneratePresentationRequest @@ -25,21 +24,19 @@ from models.presentation_outline_model import ( from enums.tone import Tone from enums.verbosity import Verbosity from models.pptx_models import PptxPresentationModel -from models.presentation_layout import PresentationLayoutModel from models.presentation_structure_model import PresentationStructureModel from models.presentation_with_slides import ( PresentationWithSlides, ) from models.sql.template import TemplateModel - from services.documents_loader import DocumentsLoader from services.webhook_service import WebhookService -from utils.get_layout_by_name import get_layout_by_name from services.image_generation_service import ImageGenerationService from utils.dict_utils import deep_update from utils.export_utils import export_presentation from utils.llm_calls.generate_presentation_outlines import generate_ppt_outline from models.sql.slide import SlideModel +from models.sql.presentation_layout_code import PresentationLayoutCodeModel from models.sse_response import SSECompleteResponse, SSEErrorResponse, SSEResponse from services.database import get_async_session @@ -58,23 +55,88 @@ from utils.llm_calls.generate_slide_content import ( get_slide_content_from_type_and_outline, ) from utils.ppt_utils import ( - get_presentation_title_from_outlines, select_toc_or_list_slide_layout_index, ) +from utils.outline_utils import ( + get_images_for_slides_from_outline, + get_no_of_outlines_to_generate_for_n_slides, + get_no_of_toc_required_for_n_outlines, + get_presentation_outline_model_with_toc, + get_presentation_title_from_presentation_outline, +) from utils.process_slides import ( process_slide_add_placeholder_assets, process_slide_and_fetch_assets, ) +from utils.get_layout_by_name import get_layout_by_name +from models.presentation_layout import PresentationLayoutModel import uuid PRESENTATION_ROUTER = APIRouter(prefix="/presentation", tags=["Presentation"]) +def _extract_custom_template_id(layout_name: Optional[str]) -> Optional[uuid.UUID]: + if not layout_name or not layout_name.startswith("custom-"): + return None + try: + return uuid.UUID(layout_name.replace("custom-", "")) + except Exception: + return None + + +async def _resolve_presentation_fonts( + presentation: PresentationModel, + slides: List[SlideModel], + sql_session: AsyncSession, +): + candidate_template_ids: List[uuid.UUID] = [] + seen = set() + + layout_name = None + if isinstance(presentation.layout, dict): + layout_name = presentation.layout.get("name") + layout_template_id = _extract_custom_template_id(layout_name) + if layout_template_id and layout_template_id not in seen: + candidate_template_ids.append(layout_template_id) + seen.add(layout_template_id) + + for slide in slides: + template_id = _extract_custom_template_id(slide.layout_group) + if template_id and template_id not in seen: + candidate_template_ids.append(template_id) + seen.add(template_id) + + for template_id in candidate_template_ids: + result = await sql_session.execute( + select(PresentationLayoutCodeModel.fonts).where( + PresentationLayoutCodeModel.presentation == template_id + ) + ) + fonts_list = result.scalars().all() + for fonts in fonts_list: + if fonts is not None: + return fonts + + return None + + +def _insert_toc_layouts( + structure: PresentationStructureModel, + n_toc_slides: int, + include_title_slide: bool, + toc_slide_layout_index: int, +): + if n_toc_slides <= 0 or toc_slide_layout_index == -1: + return + + insertion_index = 1 if include_title_slide else 0 + for i in range(n_toc_slides): + structure.slides.insert(insertion_index + i, toc_slide_layout_index) + + @PRESENTATION_ROUTER.get("/all", response_model=List[PresentationWithSlides]) async def get_all_presentations(sql_session: AsyncSession = Depends(get_async_session)): - presentations_with_slides = [] - query = ( select(PresentationModel, SlideModel) .join( @@ -86,13 +148,17 @@ async def get_all_presentations(sql_session: AsyncSession = Depends(get_async_se results = await sql_session.execute(query) rows = results.all() - presentations_with_slides = [ - PresentationWithSlides( - **presentation.model_dump(), - slides=[first_slide], + presentations_with_slides = [] + for presentation, first_slide in rows: + slides = [first_slide] + fonts = await _resolve_presentation_fonts(presentation, slides, sql_session) + presentations_with_slides.append( + PresentationWithSlides( + **presentation.model_dump(), + slides=slides, + fonts=fonts, + ) ) - for presentation, first_slide in rows - ] return presentations_with_slides @@ -103,14 +169,17 @@ async def get_presentation( presentation = await sql_session.get(PresentationModel, id) if not presentation: raise HTTPException(404, "Presentation not found") - slides = await sql_session.scalars( + slides_result = await sql_session.scalars( select(SlideModel) .where(SlideModel.presentation == id) .order_by(SlideModel.index) ) + slides = list(slides_result) + fonts = await _resolve_presentation_fonts(presentation, slides, sql_session) return PresentationWithSlides( **presentation.model_dump(), slides=slides, + fonts=fonts, ) @@ -129,8 +198,8 @@ async def delete_presentation( @PRESENTATION_ROUTER.post("/create", response_model=PresentationModel) async def create_presentation( content: Annotated[str, Body()], - n_slides: Annotated[int, Body()], - language: Annotated[str, Body()], + n_slides: Annotated[Optional[int], Body()] = None, + language: Annotated[Optional[str], Body()] = None, file_paths: Annotated[Optional[List[str]], Body()] = None, tone: Annotated[Tone, Body()] = Tone.DEFAULT, verbosity: Annotated[Verbosity, Body()] = Verbosity.STANDARD, @@ -138,23 +207,37 @@ async def create_presentation( include_table_of_contents: Annotated[bool, Body()] = False, include_title_slide: Annotated[bool, Body()] = True, web_search: Annotated[bool, Body()] = False, - theme: Annotated[Optional[dict], Body()] = None, sql_session: AsyncSession = Depends(get_async_session), ): - if include_table_of_contents and n_slides < 3: + if n_slides is not None and n_slides < 1: + raise HTTPException( + status_code=400, + detail="Number of slides must be greater than 0", + ) + + if n_slides is not None and n_slides > MAX_NUMBER_OF_SLIDES: + raise HTTPException( + status_code=400, + detail=f"Number of slides cannot be greater than {MAX_NUMBER_OF_SLIDES}", + ) + + if include_table_of_contents and n_slides is not None and n_slides < 3: raise HTTPException( status_code=400, detail="Number of slides cannot be less than 3 if table of contents is included", ) presentation_id = uuid.uuid4() + language_to_store = (language or "").strip() + # DB schema stores an int; 0 is used as internal marker for auto slide count. + n_slides_to_store = n_slides if n_slides is not None else 0 presentation = PresentationModel( id=presentation_id, content=content, - n_slides=n_slides, - language=language, + n_slides=n_slides_to_store, + language=language_to_store, file_paths=file_paths, tone=tone.value, verbosity=verbosity.value, @@ -162,7 +245,6 @@ async def create_presentation( include_table_of_contents=include_table_of_contents, include_title_slide=include_title_slide, web_search=web_search, - theme=theme, ) sql_session.add(presentation) @@ -212,40 +294,24 @@ async def prepare_presentation( presentation_structure.slides[index] = random_slide_index if presentation.include_table_of_contents: - n_toc_slides = presentation.n_slides - total_outlines + n_toc_slides = get_no_of_toc_required_for_n_outlines( + n_outlines=total_outlines, + title_slide=presentation.include_title_slide, + target_total_slides=(presentation.n_slides if presentation.n_slides > 0 else None), + ) toc_slide_layout_index = select_toc_or_list_slide_layout_index(layout) - if toc_slide_layout_index != -1: - outline_index = 1 if presentation.include_title_slide else 0 - for i in range(n_toc_slides): - outlines_to = outline_index + 10 - if total_outlines == outlines_to: - outlines_to -= 1 - - presentation_structure.slides.insert( - i + 1 if presentation.include_title_slide else i, - toc_slide_layout_index, - ) - toc_outline = "Table of Contents\n\n" - - for outline in presentation_outline_model.slides[ - outline_index:outlines_to - ]: - page_number = ( - outline_index - i + n_toc_slides + 1 - if presentation.include_title_slide - else outline_index - i + n_toc_slides - ) - toc_outline += f"Slide page number: {page_number}\n Slide Content: {outline.content[:100]}\n\n" - outline_index += 1 - - outline_index += 1 - - presentation_outline_model.slides.insert( - i + 1 if presentation.include_title_slide else i, - SlideOutlineModel( - content=toc_outline, - ), - ) + _insert_toc_layouts( + presentation_structure, + n_toc_slides, + presentation.include_title_slide, + toc_slide_layout_index, + ) + if toc_slide_layout_index != -1 and n_toc_slides > 0: + presentation_outline_model = get_presentation_outline_model_with_toc( + outline=presentation_outline_model, + n_toc_slides=n_toc_slides, + title_slide=presentation.include_title_slide, + ) sql_session.add(presentation) presentation.outlines = presentation_outline_model.model_dump(mode="json") @@ -281,6 +347,7 @@ async def stream_presentation( structure = presentation.get_structure() layout = presentation.get_layout() outline = presentation.get_presentation_outline() + image_urls_for_slides = get_images_for_slides_from_outline(outline.slides) # These tasks will be gathered and awaited after all slides are generated async_assets_generation_tasks = [] @@ -321,7 +388,17 @@ async def stream_presentation( # This will mutate slide - start task immediately so it runs in parallel with next slide LLM generation async_assets_generation_tasks.append( - asyncio.create_task(process_slide_and_fetch_assets(image_generation_service, slide)) + asyncio.create_task( + process_slide_and_fetch_assets( + image_generation_service, + slide, + outline_image_urls=( + image_urls_for_slides[i] + if i < len(image_urls_for_slides) + else None + ), + ) + ) ) yield SSEResponse( @@ -353,6 +430,7 @@ async def stream_presentation( response = PresentationWithSlides( **presentation.model_dump(), slides=slides, + fonts=await _resolve_presentation_fonts(presentation, slides, sql_session), ) yield SSECompleteResponse( @@ -365,7 +443,6 @@ async def stream_presentation( @PRESENTATION_ROUTER.patch("/update", response_model=PresentationWithSlides) async def update_presentation( - request: Request, id: Annotated[uuid.UUID, Body()], n_slides: Annotated[Optional[int], Body()] = None, title: Annotated[Optional[str], Body()] = None, @@ -378,18 +455,15 @@ async def update_presentation( raise HTTPException(status_code=404, detail="Presentation not found") presentation_update_dict = {} - request_body = await request.json() - theme_provided = "theme" in request_body - if n_slides: + if n_slides is not None: presentation_update_dict["n_slides"] = n_slides if title: presentation_update_dict["title"] = title - if theme_provided: + if theme or theme is None: presentation_update_dict["theme"] = theme - if n_slides or title or theme_provided: + if presentation_update_dict: presentation.sqlmodel_update(presentation_update_dict) - if slides: # Just to make sure id is UUID for slide in slides: @@ -403,9 +477,17 @@ async def update_presentation( await sql_session.commit() + response_slides = slides or [] + fonts = await _resolve_presentation_fonts( + presentation, + response_slides, + sql_session, + ) + return PresentationWithSlides( **presentation.model_dump(), - slides=slides or [], + slides=response_slides, + fonts=fonts, ) @@ -435,6 +517,11 @@ async def export_presentation_as_pptx_or_pdf( ] = "pptx", sql_session: AsyncSession = Depends(get_async_session), ): + """ + Export a presentation as PPTX or PDF. + This Api is used to export via the nextjs app i.e using the puppeteer to export the presentation. + + """ presentation = await sql_session.get(PresentationModel, id) if not presentation: @@ -466,13 +553,28 @@ async def check_if_api_request_is_valid( detail="Either content or slides markdown or files is required to generate presentation", ) - # Making sure number of slides is greater than 0 - if request.n_slides <= 0: + if request.n_slides is not None and request.n_slides <= 0: raise HTTPException( status_code=400, detail="Number of slides must be greater than 0", ) + if request.n_slides is not None and request.n_slides > MAX_NUMBER_OF_SLIDES: + raise HTTPException( + status_code=400, + detail=f"Number of slides cannot be greater than {MAX_NUMBER_OF_SLIDES}", + ) + + if ( + request.include_table_of_contents + and request.n_slides is not None + and request.n_slides < 3 + ): + raise HTTPException( + status_code=400, + detail="Number of slides cannot be less than 3 if table of contents is included", + ) + # Checking if template is valid if request.template not in DEFAULT_TEMPLATES: request.template = request.template.lower() @@ -503,6 +605,7 @@ async def generate_presentation_handler( ): try: using_slides_markdown = False + language_to_use = (request.language or "").strip() or None if request.slides_markdown: using_slides_markdown = True @@ -519,7 +622,10 @@ async def generate_presentation_handler( await sql_session.commit() if request.files: - documents_loader = DocumentsLoader(file_paths=request.files) + documents_loader = DocumentsLoader( + file_paths=request.files, + presentation_language=request.language, + ) await documents_loader.load_documents() documents = documents_loader.documents if documents: @@ -527,30 +633,27 @@ async def generate_presentation_handler( # Finding number of slides to generate by considering table of contents n_slides_to_generate = request.n_slides - if request.include_table_of_contents: - needed_toc_count = math.ceil( - ( - (request.n_slides - 1) - if request.include_title_slide - else request.n_slides + if request.include_table_of_contents and request.n_slides is not None: + n_slides_to_generate = ( + get_no_of_outlines_to_generate_for_n_slides( + n_slides=request.n_slides, + toc=True, + title_slide=request.include_title_slide, ) - / 10 - ) - n_slides_to_generate -= math.ceil( - (request.n_slides - needed_toc_count) / 10 ) presentation_outlines_text = "" async for chunk in generate_ppt_outline( request.content, n_slides_to_generate, - request.language, + language_to_use, additional_context, request.tone.value, request.verbosity.value, request.instructions, request.include_title_slide, request.web_search, + request.include_table_of_contents, ): if isinstance(chunk, HTTPException): @@ -571,7 +674,20 @@ async def generate_presentation_handler( presentation_outlines = PresentationOutlineModel( **presentation_outlines_json ) - total_outlines = n_slides_to_generate + + if ( + n_slides_to_generate is not None + and len(presentation_outlines.slides) != n_slides_to_generate + ): + raise HTTPException( + status_code=400, + detail=( + "Failed to generate presentation outlines with requested " + "number of slides. Please try again." + ), + ) + + total_outlines = len(presentation_outlines.slides) else: # Setting outlines to slides markdown @@ -619,50 +735,42 @@ async def generate_presentation_handler( if presentation_structure.slides[index] >= total_slide_layouts: presentation_structure.slides[index] = random_slide_index - # Injecting table of contents to the presentation structure and outlines - if request.include_table_of_contents and not using_slides_markdown: - n_toc_slides = request.n_slides - total_outlines + should_include_toc = ( + request.include_table_of_contents and not using_slides_markdown + ) + if should_include_toc: + n_toc_slides = get_no_of_toc_required_for_n_outlines( + n_outlines=total_outlines, + title_slide=request.include_title_slide, + target_total_slides=request.n_slides, + ) toc_slide_layout_index = select_toc_or_list_slide_layout_index(layout_model) - if toc_slide_layout_index != -1: - outline_index = 1 if request.include_title_slide else 0 - for i in range(n_toc_slides): - outlines_to = outline_index + 10 - if total_outlines == outlines_to: - outlines_to -= 1 + _insert_toc_layouts( + presentation_structure, + n_toc_slides, + request.include_title_slide, + toc_slide_layout_index, + ) + if toc_slide_layout_index != -1 and n_toc_slides > 0: + presentation_outlines = get_presentation_outline_model_with_toc( + outline=presentation_outlines, + n_toc_slides=n_toc_slides, + title_slide=request.include_title_slide, + ) - presentation_structure.slides.insert( - i + 1 if request.include_title_slide else i, - toc_slide_layout_index, - ) - toc_outline = "Table of Contents\n\n" - - for outline in presentation_outlines.slides[ - outline_index:outlines_to - ]: - page_number = ( - outline_index - i + n_toc_slides + 1 - if request.include_title_slide - else outline_index - i + n_toc_slides - ) - toc_outline += f"Slide page number: {page_number}\n Slide Content: {outline.content[:100]}\n\n" - outline_index += 1 - - outline_index += 1 - - presentation_outlines.slides.insert( - i + 1 if request.include_title_slide else i, - SlideOutlineModel( - content=toc_outline, - ), - ) + final_n_slides = request.n_slides + if final_n_slides is None: + final_n_slides = len(presentation_outlines.slides) # Create PresentationModel presentation = PresentationModel( id=presentation_id, content=request.content, - n_slides=request.n_slides, - language=request.language, - title=get_presentation_title_from_outlines(presentation_outlines), + n_slides=final_n_slides, + language=language_to_use or "", + title=get_presentation_title_from_presentation_outline( + presentation_outlines + ), outlines=presentation_outlines.model_dump(), layout=layout_model.model_dump(), structure=presentation_structure.model_dump(), @@ -699,7 +807,7 @@ async def generate_presentation_handler( get_slide_content_from_type_and_outline( slide_layouts[i], presentation_outlines.slides[i], - request.language, + language_to_use, request.tone.value, request.verbosity.value, request.instructions, @@ -724,10 +832,23 @@ async def generate_presentation_handler( slides.append(slide) batch_slides.append(slide) + if using_slides_markdown: + image_urls_for_batch = get_images_for_slides_from_outline( + presentation_outlines.slides[start:end] + ) + else: + image_urls_for_batch = [[] for _ in batch_slides] + # Start asset fetch tasks immediately so they run in parallel with next batch's LLM calls asset_tasks = [ - asyncio.create_task(process_slide_and_fetch_assets(image_generation_service, slide)) - for slide in batch_slides + asyncio.create_task( + process_slide_and_fetch_assets( + image_generation_service, + slide, + outline_image_urls=image_urls_for_batch[offset], + ) + ) + for offset, slide in enumerate(batch_slides) ] async_assets_generation_tasks.extend(asset_tasks) @@ -819,6 +940,8 @@ async def generate_presentation_sync( return await generate_presentation_handler( request, presentation_id, None, sql_session ) + except HTTPException: + raise except Exception: traceback.print_exc() raise HTTPException(status_code=500, detail="Presentation generation failed") diff --git a/servers/fastapi/api/v1/ppt/endpoints/theme.py b/servers/fastapi/api/v1/ppt/endpoints/theme.py index 4ddd38d9..f597b82f 100644 --- a/servers/fastapi/api/v1/ppt/endpoints/theme.py +++ b/servers/fastapi/api/v1/ppt/endpoints/theme.py @@ -1,3 +1,4 @@ +import copy import uuid from typing import Any, List, Optional @@ -67,7 +68,9 @@ def _read_themes_from_row(row: Optional[KeyValueSqlModel]) -> list[dict[str, Any return [] value = row.value if isinstance(row.value, dict) else {} themes = value.get("themes", []) - return themes if isinstance(themes, list) else [] + if not isinstance(themes, list): + return [] + return copy.deepcopy(themes) async def _resolve_logo_url( diff --git a/servers/fastapi/constants/documents.py b/servers/fastapi/constants/documents.py index 9d5fef16..c4f00ec3 100644 --- a/servers/fastapi/constants/documents.py +++ b/servers/fastapi/constants/documents.py @@ -3,6 +3,8 @@ TEXT_MIME_TYPES = ["text/plain"] POWERPOINT_TYPES = [ "application/vnd.openxmlformats-officedocument.presentationml.presentation" ] +# Alias used by font/PPTX validation helpers shared with the Electron server tree. +PPTX_MIME_TYPES = POWERPOINT_TYPES WORD_TYPES = [ "application/msword", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", diff --git a/servers/fastapi/constants/llm.py b/servers/fastapi/constants/llm.py index 2b5613c2..21eacb73 100644 --- a/servers/fastapi/constants/llm.py +++ b/servers/fastapi/constants/llm.py @@ -4,4 +4,4 @@ OPENAI_URL = "https://api.openai.com/v1" DEFAULT_OPENAI_MODEL = "gpt-4.1" DEFAULT_GOOGLE_MODEL = "models/gemini-2.5-flash" DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-4-20250514" -DEFAULT_CODEX_MODEL = "gpt-5.4-mini" +DEFAULT_CODEX_MODEL = "gpt-5.2-codex" diff --git a/servers/fastapi/constants/presentation.py b/servers/fastapi/constants/presentation.py index 22d6b012..bbfe99fa 100644 --- a/servers/fastapi/constants/presentation.py +++ b/servers/fastapi/constants/presentation.py @@ -1 +1,2 @@ DEFAULT_TEMPLATES = ["general", "modern", "standard", "swift"] +MAX_NUMBER_OF_SLIDES = 50 diff --git a/servers/fastapi/constants/supported_ollama_models.py b/servers/fastapi/constants/supported_ollama_models.py index a02a7a18..b6126074 100644 --- a/servers/fastapi/constants/supported_ollama_models.py +++ b/servers/fastapi/constants/supported_ollama_models.py @@ -171,10 +171,141 @@ SUPPORTED_GPT_OSS_MODELS = { ), } +SUPPORTED_GEMMA4_MODELS = { + "gemma4:latest": OllamaModelMetadata( + label="Gemma 4:latest", + value="gemma4:latest", + size="9.6GB", + ), + "gemma4:e2b": OllamaModelMetadata( + label="Gemma 4:e2b", + value="gemma4:e2b", + size="7.2GB", + ), + "gemma4:e4b": OllamaModelMetadata( + label="Gemma 4:e4b", + value="gemma4:e4b", + size="9.6GB", + ), + "gemma4:26b": OllamaModelMetadata( + label="Gemma 4:26b", + value="gemma4:26b", + size="18GB", + ), + "gemma4:31b": OllamaModelMetadata( + label="Gemma 4:31b", + value="gemma4:31b", + size="20GB", + ), + + # e2b variants + "gemma4:e2b-it-q4_K_M": OllamaModelMetadata( + label="Gemma 4:e2b-it-q4_K_M", + value="gemma4:e2b-it-q4_K_M", + size="7.2GB", + ), + "gemma4:e2b-it-q8_0": OllamaModelMetadata( + label="Gemma 4:e2b-it-q8_0", + value="gemma4:e2b-it-q8_0", + size="8.1GB", + ), + "gemma4:e2b-it-bf16": OllamaModelMetadata( + label="Gemma 4:e2b-it-bf16", + value="gemma4:e2b-it-bf16", + size="10GB", + ), + + # e4b variants + "gemma4:e4b-it-q4_K_M": OllamaModelMetadata( + label="Gemma 4:e4b-it-q4_K_M", + value="gemma4:e4b-it-q4_K_M", + size="9.6GB", + ), + "gemma4:e4b-it-q8_0": OllamaModelMetadata( + label="Gemma 4:e4b-it-q8_0", + value="gemma4:e4b-it-q8_0", + size="12GB", + ), + "gemma4:e4b-it-bf16": OllamaModelMetadata( + label="Gemma 4:e4b-it-bf16", + value="gemma4:e4b-it-bf16", + size="16GB", + ), + + # 26b variants + "gemma4:26b-a4b-it-q4_K_M": OllamaModelMetadata( + label="Gemma 4:26b-a4b-it-q4_K_M", + value="gemma4:26b-a4b-it-q4_K_M", + size="18GB", + ), + "gemma4:26b-a4b-it-q8_0": OllamaModelMetadata( + label="Gemma 4:26b-a4b-it-q8_0", + value="gemma4:26b-a4b-it-q8_0", + size="28GB", + ), + + # 31b variants + "gemma4:31b-it-q4_K_M": OllamaModelMetadata( + label="Gemma 4:31b-it-q4_K_M", + value="gemma4:31b-it-q4_K_M", + size="20GB", + ), + "gemma4:31b-it-q8_0": OllamaModelMetadata( + label="Gemma 4:31b-it-q8_0", + value="gemma4:31b-it-q8_0", + size="34GB", + ), + "gemma4:31b-it-bf16": OllamaModelMetadata( + label="Gemma 4:31b-it-bf16", + value="gemma4:31b-it-bf16", + size="63GB", + ) +} + +SUPPORTED_QWEN35_MODELS = { + "qwen3.5:latest": OllamaModelMetadata( + label="Qwen 3.5:latest", + value="qwen3.5:latest", + size="6.6GB", + ), + "qwen3.5:2b": OllamaModelMetadata( + label="Qwen 3.5:2b", + value="qwen3.5:2b", + size="2.7GB", + ), + "qwen3.5:4b": OllamaModelMetadata( + label="Qwen 3.5:4b", + value="qwen3.5:4b", + size="3.4GB", + ), + "qwen3.5:9b": OllamaModelMetadata( + label="Qwen 3.5:9b", + value="qwen3.5:9b", + size="6.6GB", + ), + "qwen3.5:27b": OllamaModelMetadata( + label="Qwen 3.5:27b", + value="qwen3.5:27b", + size="17GB", + ), + "qwen3.5:35b": OllamaModelMetadata( + label="Qwen 3.5:35b", + value="qwen3.5:35b", + size="24GB", + ), + "qwen3.5:122b": OllamaModelMetadata( + label="Qwen 3.5:122b", + value="qwen3.5:122b", + size="81GB", + ) +} + SUPPORTED_OLLAMA_MODELS = { **SUPPORTED_OLLAMA_MODELS, **SUPPORTED_GEMMA_MODELS, **SUPPORTED_DEEPSEEK_MODELS, **SUPPORTED_QWEN_MODELS, **SUPPORTED_GPT_OSS_MODELS, + **SUPPORTED_GEMMA4_MODELS, + **SUPPORTED_QWEN35_MODELS, } diff --git a/servers/fastapi/migrations.py b/servers/fastapi/migrations.py index 4cb75de0..335c4ef4 100644 --- a/servers/fastapi/migrations.py +++ b/servers/fastapi/migrations.py @@ -11,6 +11,8 @@ from utils.get_env import get_migrate_database_on_startup_env LEGACY_BASELINE_REVISION = "00b3c27a13bc" +# Revision before 95b5127e93cd (template_create_infos); used when DB has theme but not that table. +REVISION_BEFORE_TEMPLATE_CREATE_INFO = "82abdbc476a7" async def migrate_database_on_startup() -> None: @@ -49,6 +51,7 @@ def _run_migrations() -> None: database_url = _to_sync_database_url(database_url) config.set_main_option("sqlalchemy.url", database_url) + _repair_orphan_alembic_revision(config, database_url) _stamp_legacy_database_if_needed(config, database_url) try: @@ -62,6 +65,52 @@ def _run_migrations() -> None: raise +def _repair_orphan_alembic_revision(config: Config, database_url: str) -> None: + """ + If alembic_version points at a revision id that no longer exists in alembic/versions + (removed branch, old image, etc.), re-stamp from the live schema so upgrade can run. + """ + script = ScriptDirectory.from_config(config) + known = {rev.revision for rev in script.walk_revisions()} + heads = script.get_heads() + if len(heads) != 1: + return + head = heads[0] + + engine = create_engine(database_url) + try: + with engine.connect() as connection: + inspector = inspect(connection) + tables = set(inspector.get_table_names()) + if "alembic_version" not in tables: + return + version_num = connection.execute( + text("SELECT version_num FROM alembic_version LIMIT 1") + ).scalar_one_or_none() + if not version_num or version_num in known: + return + print( + f"Alembic revision {version_num!r} is missing from the codebase; " + "inferring applied migrations from schema and re-stamping.", + flush=True, + ) + target = _infer_revision_from_schema(inspector, tables, head) + command.stamp(config, target) + finally: + engine.dispose() + + +def _infer_revision_from_schema(inspector, tables: set[str], head_revision: str) -> str: + """Best-effort: map existing SQLite/Postgres schema to our linear migration chain.""" + if "template_create_infos" in tables: + return head_revision + if "presentations" in tables: + cols = {c["name"] for c in inspector.get_columns("presentations")} + if "theme" in cols: + return REVISION_BEFORE_TEMPLATE_CREATE_INFO + return LEGACY_BASELINE_REVISION + + def _stamp_legacy_database_if_needed(config: Config, database_url: str) -> None: """ If the DB has app tables but no migration reference in alembic_version, diff --git a/servers/fastapi/models/generate_presentation_request.py b/servers/fastapi/models/generate_presentation_request.py index a8fbf44e..d55f7658 100644 --- a/servers/fastapi/models/generate_presentation_request.py +++ b/servers/fastapi/models/generate_presentation_request.py @@ -18,9 +18,13 @@ class GeneratePresentationRequest(BaseModel): default=Verbosity.STANDARD, description="How verbose the presentation should be" ) web_search: bool = Field(default=False, description="Whether to enable web search") - n_slides: int = Field(default=8, description="Number of slides to generate") - language: str = Field( - default="English", description="Language for the presentation" + n_slides: Optional[int] = Field( + default=None, + description="Number of slides to generate. If omitted, model auto-detects slide count.", + ) + language: Optional[str] = Field( + default=None, + description="Language for the presentation. If omitted, model auto-detects language.", ) template: str = Field( default="general", description="Template to use for the presentation" diff --git a/servers/fastapi/models/ollama_model_status.py b/servers/fastapi/models/ollama_model_status.py index 521bc2ed..06fd4e28 100644 --- a/servers/fastapi/models/ollama_model_status.py +++ b/servers/fastapi/models/ollama_model_status.py @@ -8,3 +8,4 @@ class OllamaModelStatus(BaseModel): downloaded: Optional[int] = None status: str done: bool + error: Optional[str] = None diff --git a/servers/fastapi/models/pptx_models.py b/servers/fastapi/models/pptx_models.py index 80da5cd9..84ef55d3 100644 --- a/servers/fastapi/models/pptx_models.py +++ b/servers/fastapi/models/pptx_models.py @@ -1,7 +1,7 @@ from enum import Enum -from typing import Annotated, List, Literal, Optional +from typing import Annotated, List, Literal, Optional, Union from annotated_types import Len -from pydantic import BaseModel +from pydantic import BaseModel, Discriminator, field_validator from pptx.util import Pt from pptx.enum.text import PP_ALIGN from pptx.enum.shapes import MSO_AUTO_SHAPE_TYPE, MSO_CONNECTOR_TYPE @@ -130,6 +130,16 @@ class PptxAutoShapeBoxModel(PptxShapeModel): text_wrap: bool = True border_radius: Optional[int] = None paragraphs: Optional[List[PptxParagraphModel]] = None + + @field_validator('border_radius', mode='before') + @classmethod + def convert_border_radius_to_int(cls, v): + """Convert float border_radius values to int.""" + if v is None: + return None + if isinstance(v, float): + return int(round(v)) + return v class PptxPictureBoxModel(PptxShapeModel): @@ -143,6 +153,16 @@ class PptxPictureBoxModel(PptxShapeModel): shape: Optional[PptxBoxShapeEnum] = None object_fit: Optional[PptxObjectFitModel] = None picture: PptxPictureModel + + @field_validator('border_radius', mode='before') + @classmethod + def convert_border_radius_list_to_int(cls, v): + """Convert float values in border_radius list to int.""" + if v is None: + return None + if isinstance(v, list): + return [int(round(item)) if isinstance(item, float) else int(item) for item in v] + return v class PptxConnectorModel(PptxShapeModel): @@ -154,15 +174,22 @@ class PptxConnectorModel(PptxShapeModel): opacity: float = 1.0 +# Define a discriminated union for shapes +PptxShapeUnion = Annotated[ + Union[ + PptxTextBoxModel, + PptxAutoShapeBoxModel, + PptxConnectorModel, + PptxPictureBoxModel, + ], + Discriminator("shape_type"), +] + + class PptxSlideModel(BaseModel): background: Optional[PptxFillModel] = None note: Optional[str] = None - shapes: List[ - PptxTextBoxModel - | PptxAutoShapeBoxModel - | PptxConnectorModel - | PptxPictureBoxModel - ] + shapes: List[PptxShapeUnion] class PptxPresentationModel(BaseModel): diff --git a/servers/fastapi/models/presentation_with_slides.py b/servers/fastapi/models/presentation_with_slides.py index a8a8e3b7..f991c6ae 100644 --- a/servers/fastapi/models/presentation_with_slides.py +++ b/servers/fastapi/models/presentation_with_slides.py @@ -1,4 +1,4 @@ -from typing import List, Optional +from typing import Any, List, Optional from datetime import datetime import uuid @@ -17,5 +17,6 @@ class PresentationWithSlides(BaseModel): updated_at: datetime tone: Optional[str] = None verbosity: Optional[str] = None - theme: Optional[dict] = None slides: List[SlideModel] + theme: Optional[dict] = None + fonts: Optional[Any] = None diff --git a/servers/fastapi/models/sql/image_asset.py b/servers/fastapi/models/sql/image_asset.py index 2a6aaf9f..8639f6ce 100644 --- a/servers/fastapi/models/sql/image_asset.py +++ b/servers/fastapi/models/sql/image_asset.py @@ -1,11 +1,27 @@ from datetime import datetime from typing import Optional +import os import uuid from sqlalchemy import JSON, Column, DateTime from sqlmodel import Field, SQLModel from utils.datetime_utils import get_current_utc_datetime +from utils.get_env import get_app_data_directory_env, get_next_public_fast_api_env +from utils.path_helpers import get_resource_path + + +def _with_fastapi_origin(path: str) -> str: + """Prefix relative web paths with FastAPI origin when available.""" + if path.startswith("http://") or path.startswith("https://"): + return path + + fastapi_origin = (get_next_public_fast_api_env() or "").strip() + if not fastapi_origin: + return path + + normalized_path = path if path.startswith("/") else f"/{path}" + return f"{fastapi_origin.rstrip('/')}{normalized_path}" class ImageAsset(SQLModel, table=True): @@ -18,12 +34,45 @@ class ImageAsset(SQLModel, table=True): is_uploaded: bool = Field(default=False) path: str extras: Optional[dict] = Field(sa_column=Column(JSON), default=None) - + @property def file_url(self) -> str: """ - Non-Electron backend helper for parity with the Electron ImageAsset model. - For now this simply returns the stored path, allowing frontends to use - `image.file_url or image.path` without breaking development workflows. + Returns a web path suitable for FastAPI static serving. + - HTTP(S) URLs are returned as-is. + - Files under APP_DATA are exposed under /app_data. + - Files under the packaged static directory are exposed under /static. """ - return self.path + path = self.path + + # Already an absolute web URL + if path.startswith("http://") or path.startswith("https://"): + return path + + # Already a web path under known mounts + if path.startswith("/app_data/") or path.startswith("/static/"): + return _with_fastapi_origin(path) + + # Normalize filesystem path + real_path = os.path.realpath(path) + + # Map APP_DATA files to /app_data/... + app_data_dir = get_app_data_directory_env() + if app_data_dir: + app_data_dir_real = os.path.realpath(app_data_dir) + if real_path.startswith(app_data_dir_real): + rel = os.path.relpath(real_path, app_data_dir_real) + rel_web = rel.replace(os.sep, "/") + return _with_fastapi_origin(f"/app_data/{rel_web}") + + # Map packaged static assets to /static/... + static_root = get_resource_path("static") + static_root_real = os.path.realpath(static_root) + if real_path.startswith(static_root_real): + rel = os.path.relpath(real_path, static_root_real) + rel_web = rel.replace(os.sep, "/") + return _with_fastapi_origin(f"/static/{rel_web}") + + # Fallback: return the original path (may be absolute or relative); + # frontend can decide how to handle unusual cases. + return path diff --git a/servers/fastapi/models/sql/presentation.py b/servers/fastapi/models/sql/presentation.py index f619d4c2..bbe4ec9d 100644 --- a/servers/fastapi/models/sql/presentation.py +++ b/servers/fastapi/models/sql/presentation.py @@ -4,9 +4,9 @@ import uuid from sqlalchemy import JSON, Column, DateTime, String from sqlmodel import Boolean, Field, SQLModel -from models.presentation_layout import PresentationLayoutModel from models.presentation_outline_model import PresentationOutlineModel from models.presentation_structure_model import PresentationStructureModel +from models.presentation_layout import PresentationLayoutModel from utils.datetime_utils import get_current_utc_datetime @@ -35,13 +35,13 @@ class PresentationModel(SQLModel, table=True): ) layout: Optional[dict] = Field(sa_column=Column(JSON), default=None) structure: Optional[dict] = Field(sa_column=Column(JSON), default=None) - theme: Optional[dict] = Field(sa_column=Column(JSON), default=None) instructions: Optional[str] = Field(sa_column=Column(String), default=None) tone: Optional[str] = Field(sa_column=Column(String), default=None) verbosity: Optional[str] = Field(sa_column=Column(String), default=None) include_table_of_contents: bool = Field(sa_column=Column(Boolean), default=False) include_title_slide: bool = Field(sa_column=Column(Boolean), default=True) web_search: bool = Field(sa_column=Column(Boolean), default=False) + theme: Optional[dict] = Field(sa_column=Column(JSON), default=None) def get_new_presentation(self): return PresentationModel( @@ -54,7 +54,6 @@ class PresentationModel(SQLModel, table=True): outlines=self.outlines, layout=self.layout, structure=self.structure, - theme=self.theme, instructions=self.instructions, tone=self.tone, verbosity=self.verbosity, diff --git a/servers/fastapi/models/sql/presentation_layout_code.py b/servers/fastapi/models/sql/presentation_layout_code.py index fe57c01e..636ba585 100644 --- a/servers/fastapi/models/sql/presentation_layout_code.py +++ b/servers/fastapi/models/sql/presentation_layout_code.py @@ -1,15 +1,14 @@ from datetime import datetime -from typing import Optional, List +from typing import Optional import uuid -from sqlalchemy import Column, DateTime, Text, JSON -from sqlmodel import SQLModel, Field + +from sqlalchemy import JSON, Column, DateTime, Text +from sqlmodel import Field, SQLModel from utils.datetime_utils import get_current_utc_datetime class PresentationLayoutCodeModel(SQLModel, table=True): - """Model for storing presentation layout codes""" - __tablename__ = "presentation_layout_codes" id: Optional[int] = Field(default=None, primary_key=True) @@ -19,8 +18,10 @@ class PresentationLayoutCodeModel(SQLModel, table=True): layout_code: str = Field( sa_column=Column(Text), description="TSX/React component code for the layout" ) - fonts: Optional[List[str]] = Field( - sa_column=Column(JSON), default=None, description="Optional list of font links" + fonts: Optional[dict[str, str] | list[str]] = Field( + default=None, + sa_column=Column(JSON, nullable=True), + description="Optional font metadata associated with the layout", ) created_at: datetime = Field( sa_column=Column( diff --git a/servers/fastapi/models/sql/slide.py b/servers/fastapi/models/sql/slide.py index f91ba8cf..0ed5d69c 100644 --- a/servers/fastapi/models/sql/slide.py +++ b/servers/fastapi/models/sql/slide.py @@ -15,7 +15,7 @@ class SlideModel(SQLModel, table=True): layout: str index: int content: dict = Field(sa_column=Column(JSON)) - html_content: Optional[str] + html_content: Optional[str] = None speaker_note: Optional[str] = None properties: Optional[dict] = Field(sa_column=Column(JSON)) diff --git a/servers/fastapi/models/sql/template.py b/servers/fastapi/models/sql/template.py index a5ca53fe..f3727151 100644 --- a/servers/fastapi/models/sql/template.py +++ b/servers/fastapi/models/sql/template.py @@ -1,8 +1,9 @@ from datetime import datetime from typing import Optional import uuid + from sqlalchemy import Column, DateTime -from sqlmodel import SQLModel, Field +from sqlmodel import Field, SQLModel from utils.datetime_utils import get_current_utc_datetime diff --git a/servers/fastapi/models/sql/template_create_info.py b/servers/fastapi/models/sql/template_create_info.py new file mode 100644 index 00000000..ce363587 --- /dev/null +++ b/servers/fastapi/models/sql/template_create_info.py @@ -0,0 +1,25 @@ +from datetime import datetime +import uuid + +from sqlalchemy import JSON, Column, DateTime +from sqlmodel import Field, SQLModel + +from utils.datetime_utils import get_current_utc_datetime + + +class TemplateCreateInfoModel(SQLModel, table=True): + __tablename__ = "template_create_infos" + + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + fonts: dict[str, str] | None = Field( + default=None, + sa_column=Column(JSON, nullable=True), + ) + pptx_url: str | None = Field(default=None) + slide_htmls: list[str] = Field(sa_column=Column(JSON, nullable=False)) + slide_image_urls: list[str] = Field(sa_column=Column(JSON, nullable=False)) + created_at: datetime = Field( + sa_column=Column( + DateTime(timezone=True), nullable=False, default=get_current_utc_datetime + ) + ) diff --git a/servers/fastapi/models/user_config.py b/servers/fastapi/models/user_config.py index c26a6cb0..05b050d7 100644 --- a/servers/fastapi/models/user_config.py +++ b/servers/fastapi/models/user_config.py @@ -55,3 +55,6 @@ class UserConfig(BaseModel): CODEX_REFRESH_TOKEN: Optional[str] = None CODEX_TOKEN_EXPIRES: Optional[str] = None CODEX_ACCOUNT_ID: Optional[str] = None + CODEX_USERNAME: Optional[str] = None + CODEX_EMAIL: Optional[str] = None + CODEX_IS_PRO: Optional[bool] = None diff --git a/servers/fastapi/pyproject.toml b/servers/fastapi/pyproject.toml index 71922634..9f6ce80b 100644 --- a/servers/fastapi/pyproject.toml +++ b/servers/fastapi/pyproject.toml @@ -1,3 +1,7 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + [project] name = "presenton-backend" version = "0.1.0" @@ -14,6 +18,7 @@ dependencies = [ "dirtyjson>=1.0.8", "docling>=2.43.0", "fastapi[standard]>=0.116.1", + "fastembed-vectorstore>=0.5.2", "fastmcp>=2.11.0", "google-genai>=1.28.0", "nltk>=3.9.1", @@ -34,4 +39,4 @@ url = "https://download.pytorch.org/whl/cpu" [tool.setuptools.packages.find] where = ["."] -include = ["api*", "enums*", "models*", "services*", "constants*", "utils*"] +include = ["api*", "enums*", "models*", "services*", "constants*", "utils*", "templates*"] diff --git a/servers/fastapi/server.py b/servers/fastapi/server.py index 4fd1ddca..597dff90 100644 --- a/servers/fastapi/server.py +++ b/servers/fastapi/server.py @@ -1,5 +1,7 @@ -import uvicorn import argparse +import os + +import uvicorn if __name__ == "__main__": parser = argparse.ArgumentParser(description="Run the FastAPI server") @@ -11,7 +13,9 @@ if __name__ == "__main__": ) args = parser.parse_args() reload = args.reload == "true" - + host = "127.0.0.1" + os.environ["FASTAPI_PUBLIC_URL"] = f"http://{host}:{args.port}" + uvicorn.run( "api.main:app", host="127.0.0.1", diff --git a/servers/fastapi/services/database.py b/servers/fastapi/services/database.py index 5557451a..dcc03b01 100644 --- a/servers/fastapi/services/database.py +++ b/servers/fastapi/services/database.py @@ -6,7 +6,6 @@ from sqlalchemy.ext.asyncio import ( async_sessionmaker, AsyncSession, ) -from sqlalchemy import text from sqlmodel import SQLModel from models.sql.async_presentation_generation_status import ( @@ -15,12 +14,15 @@ from models.sql.async_presentation_generation_status import ( from models.sql.image_asset import ImageAsset from models.sql.key_value import KeyValueSqlModel from models.sql.ollama_pull_status import OllamaPullStatus -from models.sql.presentation import PresentationModel -from models.sql.slide import SlideModel from models.sql.presentation_layout_code import PresentationLayoutCodeModel +from models.sql.presentation import PresentationModel from models.sql.template import TemplateModel +from models.sql.template_create_info import TemplateCreateInfoModel +from models.sql.slide import SlideModel from models.sql.webhook_subscription import WebhookSubscription from utils.db_utils import get_database_url_and_connect_args +from utils.get_env import get_app_data_directory_env +from utils.get_env import get_migrate_database_on_startup_env database_url, connect_args = get_database_url_and_connect_args() @@ -34,8 +36,9 @@ async def get_async_session() -> AsyncGenerator[AsyncSession, None]: yield session -# Container DB (Lives inside the container) -container_db_url = "sqlite+aiosqlite:////app/container.db" +# Container DB (Lives inside the app data directory) +_app_data_dir = get_app_data_directory_env() or "/tmp/presenton" +container_db_url = f"sqlite+aiosqlite:///{os.path.join(_app_data_dir, 'container.db')}" container_db_engine: AsyncEngine = create_async_engine( container_db_url, connect_args={"check_same_thread": False} ) @@ -51,28 +54,25 @@ async def get_container_db_async_session() -> AsyncGenerator[AsyncSession, None] # Create Database and Tables async def create_db_and_tables(): - async with sql_engine.begin() as conn: - await conn.run_sync( - lambda sync_conn: SQLModel.metadata.create_all( - sync_conn, - tables=[ - PresentationModel.__table__, - SlideModel.__table__, - KeyValueSqlModel.__table__, - ImageAsset.__table__, - PresentationLayoutCodeModel.__table__, - TemplateModel.__table__, - WebhookSubscription.__table__, - AsyncPresentationGenerationTaskModel.__table__, - ], + should_run_alembic = get_migrate_database_on_startup_env() in ["true", "True"] + if not should_run_alembic: + async with sql_engine.begin() as conn: + await conn.run_sync( + lambda sync_conn: SQLModel.metadata.create_all( + sync_conn, + tables=[ + PresentationModel.__table__, + SlideModel.__table__, + KeyValueSqlModel.__table__, + ImageAsset.__table__, + PresentationLayoutCodeModel.__table__, + TemplateCreateInfoModel.__table__, + TemplateModel.__table__, + WebhookSubscription.__table__, + AsyncPresentationGenerationTaskModel.__table__, + ], + ) ) - ) - # Lightweight schema migration for existing DBs: ensure `presentations.theme` exists. - if database_url.startswith("sqlite"): - result = await conn.execute(text("PRAGMA table_info(presentations)")) - column_names = {row[1] for row in result.fetchall()} - if "theme" not in column_names: - await conn.execute(text("ALTER TABLE presentations ADD COLUMN theme JSON")) async with container_db_engine.begin() as conn: await conn.run_sync( diff --git a/servers/fastapi/services/icon_finder_service.py b/servers/fastapi/services/icon_finder_service.py index 3bf75c36..87ead309 100644 --- a/servers/fastapi/services/icon_finder_service.py +++ b/servers/fastapi/services/icon_finder_service.py @@ -1,56 +1,134 @@ import asyncio import json -import chromadb -from chromadb.config import Settings -from chromadb.utils.embedding_functions import ONNXMiniLM_L6_V2 +import os +from fastembed_vectorstore import FastembedEmbeddingModel, FastembedVectorstore +from utils.path_helpers import get_resource_path, get_writable_path class IconFinderService: def __init__(self): - self.collection_name = "icons" - self.client = chromadb.PersistentClient( - path="chroma", settings=Settings(anonymized_telemetry=False) - ) - print("Initializing icons collection...") - self._initialize_icons_collection() - print("Icons collection initialized.") + self.model = FastembedEmbeddingModel.AllMiniLML6V2 + # Use writable path for cache since it needs to be modified + self.cache_directory = get_writable_path("fastembed_cache") + self.vectorstore = None + self._initialized = False + self._initialization_failed = False def _initialize_icons_collection(self): - self.embedding_function = ONNXMiniLM_L6_V2() - self.embedding_function.DOWNLOAD_PATH = "chroma/models" - self.embedding_function._download_model_if_not_exists() + if self._initialized or self._initialization_failed: + return + + # Mark as initialized immediately to prevent repeated attempts + self._initialized = True + + print("Initializing icons collection...") + + # Ensure cache directory exists try: - self.collection = self.client.get_collection( - self.collection_name, embedding_function=self.embedding_function + os.makedirs(self.cache_directory, exist_ok=True) + except Exception as e: + print(f"Warning: Could not create cache directory: {e}") + self._initialization_failed = True + return + + try: + # Try bundled vectorstore first (read-only location) + bundled_vectorstore_path = get_resource_path("assets/icons-vectorstore.json") + # Writable location for user-created vectorstore (directory + filename) + writable_assets_dir = get_writable_path("assets") + writable_vectorstore_path = os.path.join( + writable_assets_dir, "icons-vectorstore.json" ) - except Exception: - with open("assets/icons.json", "r") as f: - icons = json.load(f) - - documents = [] - ids = [] - - for i, each in enumerate(icons["icons"]): - if each["name"].split("-")[-1] == "bold": - doc_text = f"{each['name']} {each['tags']}" - documents.append(doc_text) - ids.append(each["name"]) - - if documents: - self.collection = self.client.create_collection( - name=self.collection_name, - embedding_function=self.embedding_function, - metadata={"hnsw:space": "cosine"}, + # Icons JSON should be in bundled assets + icons_path = get_resource_path("assets/icons.json") + + print(f"[IconFinder] Bundled vectorstore path: {bundled_vectorstore_path}") + print(f"[IconFinder] Writable vectorstore path: {writable_vectorstore_path}") + print(f"[IconFinder] Icons.json path: {icons_path}") + print(f"[IconFinder] Cache directory: {self.cache_directory}") + print(f"[IconFinder] Bundled vectorstore exists: {os.path.isfile(bundled_vectorstore_path)}") + print(f"[IconFinder] Writable vectorstore exists: {os.path.isfile(writable_vectorstore_path)}") + print(f"[IconFinder] Icons.json exists: {os.path.isfile(icons_path)}") + + # Try to load from bundled location first, then writable location + # Use os.path.isfile() instead of os.path.exists() to avoid loading directories + vectorstore_path = None + if os.path.isfile(bundled_vectorstore_path): + vectorstore_path = bundled_vectorstore_path + print(f"[IconFinder] Loading vectorstore from bundled location: {vectorstore_path}") + elif os.path.isfile(writable_vectorstore_path): + vectorstore_path = writable_vectorstore_path + print(f"[IconFinder] Loading vectorstore from writable location: {vectorstore_path}") + + if vectorstore_path: + self.vectorstore = FastembedVectorstore.load( + self.model, vectorstore_path, cache_directory=self.cache_directory ) - self.collection.add(documents=documents, ids=ids) + print("[IconFinder] Vectorstore loaded successfully") + elif os.path.isfile(icons_path): + print(f"[IconFinder] Creating new vectorstore from {icons_path}") + self.vectorstore = FastembedVectorstore( + self.model, cache_directory=self.cache_directory + ) + with open(icons_path, "r", encoding="utf-8") as f: + icons = json.load(f) + + documents = [] + + for each in icons["icons"]: + if each["name"].split("-")[-1] == "bold": + doc_text = f"{each['name']}||{each['tags']}" + documents.append(doc_text) + + if documents: + print(f"[IconFinder] Embedding {len(documents)} icons...") + success = self.vectorstore.embed_documents(documents) + if success: + print(f"[IconFinder] Successfully embedded {len(documents)} icons") + # Save to writable location + try: + os.makedirs(os.path.dirname(writable_vectorstore_path), exist_ok=True) + self.vectorstore.save(writable_vectorstore_path) + print(f"[IconFinder] Vectorstore saved to {writable_vectorstore_path}") + except Exception as e: + print(f"[IconFinder] Warning: Could not save vectorstore: {e}") + # Continue anyway - vectorstore is still usable in memory + else: + print(f"[IconFinder] Failed to embed icons") + self._initialization_failed = True + else: + print(f"[IconFinder] No icons found to embed") + self._initialization_failed = True + else: + print(f"[IconFinder] ERROR: Icons assets not found at {icons_path}") + self._initialization_failed = True + + if not self._initialization_failed: + print("[IconFinder] Icons collection initialized successfully.") + except Exception as e: + print(f"Warning: Could not initialize icon finder service: {e}") + print(f"Error type: {type(e).__name__}") + print("Icon search will be disabled.") + self._initialization_failed = True + # Keep vectorstore as None so search_icons returns empty results async def search_icons(self, query: str, k: int = 1): - result = await asyncio.to_thread( - self.collection.query, - query_texts=[query], - n_results=k, - ) - return [f"/static/icons/bold/{each}.svg" for each in result["ids"][0]] + if not self._initialized and not self._initialization_failed: + self._initialize_icons_collection() + + if not self.vectorstore or self._initialization_failed: + # Return empty list if vectorstore failed to initialize + return [] + + try: + result = await asyncio.to_thread(self.vectorstore.search, query, k) + return [ + f"/static/icons/bold/{each[0].split('||')[0]}.svg" + for each in result + ] + except Exception as e: + print(f"Icon search error: {e}") + return [] ICON_FINDER_SERVICE = IconFinderService() diff --git a/servers/fastapi/services/image_generation_service.py b/servers/fastapi/services/image_generation_service.py index 29ab85c2..00d4ecef 100644 --- a/servers/fastapi/services/image_generation_service.py +++ b/servers/fastapi/services/image_generation_service.py @@ -5,12 +5,14 @@ import os import aiohttp from fastapi import HTTPException from google import genai +from google.genai import types from openai import NOT_GIVEN, AsyncOpenAI from models.image_prompt import ImagePrompt from models.sql.image_asset import ImageAsset from utils.get_env import ( get_dall_e_3_quality_env, get_gpt_image_1_5_quality_env, + get_next_public_fast_api_env, get_pexels_api_key_env, ) from utils.get_env import get_pixabay_api_key_env @@ -58,6 +60,17 @@ class ImageGenerationService: def is_stock_provider_selected(self): return is_pixels_selected() or is_pixabay_selected() + def _to_frontend_url(self, path: str) -> str: + if path.startswith("http://") or path.startswith("https://"): + return path + + fastapi_origin = (get_next_public_fast_api_env() or "").strip() + if not fastapi_origin: + return path + + normalized_path = path if path.startswith("/") else f"/{path}" + return f"{fastapi_origin.rstrip('/')}{normalized_path}" + async def generate_image(self, prompt: ImagePrompt) -> str | ImageAsset: """ Generates an image based on the provided prompt. @@ -68,11 +81,11 @@ class ImageGenerationService: """ if self.is_image_generation_disabled: print("Image generation is disabled. Using placeholder image.") - return "/static/images/placeholder.jpg" + return self._to_frontend_url("/static/images/placeholder.jpg") if not self.image_gen_func: print("No image generation function found. Using placeholder image.") - return "/static/images/placeholder.jpg" + return self._to_frontend_url("/static/images/placeholder.jpg") image_prompt = prompt.get_image_prompt( with_theme=not self.is_stock_provider_selected() @@ -98,11 +111,15 @@ class ImageGenerationService: "theme_prompt": prompt.theme_prompt, }, ) + elif image_path.startswith("/app_data/") or image_path.startswith( + "/static/" + ): + return self._to_frontend_url(image_path) raise Exception(f"Image not found at {image_path}") except Exception as e: print(f"Error generating image: {e}") - return "/static/images/placeholder.jpg" + return self._to_frontend_url("/static/images/placeholder.jpg") async def generate_image_openai( self, prompt: str, output_directory: str, model: str, quality: str @@ -149,15 +166,45 @@ class ImageGenerationService: response = await asyncio.to_thread( client.models.generate_content, model=model, - contents=[prompt], + contents=prompt, + config=types.GenerateContentConfig( + response_modalities=["IMAGE"], + ), ) + # Latest SDK docs expose images in response.parts. + response_parts = getattr(response, "parts", None) + if not response_parts and getattr(response, "candidates", None): + first_candidate = response.candidates[0] if response.candidates else None + content = ( + getattr(first_candidate, "content", None) if first_candidate else None + ) + response_parts = getattr(content, "parts", None) if content else None + image_path = None - for part in response.candidates[0].content.parts: + for part in response_parts or []: if part.inline_data is not None: - image = part.as_image() - image_path = os.path.join(output_directory, f"{uuid.uuid4()}.jpg") - image.save(image_path) + mime_type = getattr(part.inline_data, "mime_type", "") or "" + ext = ( + mime_type.split("/")[-1] + if mime_type.startswith("image/") + else "png" + ) + image_path = os.path.join(output_directory, f"{uuid.uuid4()}.{ext}") + if hasattr(part, "as_image"): + part.as_image().save(image_path) + else: + # Backward-compatible fallback if helper method is unavailable. + image_data = getattr(part.inline_data, "data", None) + if image_data is None: + continue + image_bytes = ( + base64.b64decode(image_data) + if isinstance(image_data, str) + else image_data + ) + with open(image_path, "wb") as image_file: + image_file.write(image_bytes) if not image_path: raise HTTPException( @@ -169,9 +216,9 @@ class ImageGenerationService: async def generate_image_gemini_flash( self, prompt: str, output_directory: str ) -> str: - """Generate image using Gemini Flash (gemini-2.5-flash-image-preview).""" + """Generate image using Gemini Flash (gemini-2.5-flash-image).""" return await self._generate_image_google( - prompt, output_directory, "gemini-2.5-flash-image-preview" + prompt, output_directory, "gemini-2.5-flash-image" ) async def generate_image_nanobanana_pro( @@ -182,24 +229,92 @@ class ImageGenerationService: prompt, output_directory, "gemini-3-pro-image-preview" ) - async def get_image_from_pexels(self, prompt: str) -> str: - async with aiohttp.ClientSession(trust_env=True) as session: - response = await session.get( - f"https://api.pexels.com/v1/search?query={prompt}&per_page=1", - headers={"Authorization": f"{get_pexels_api_key_env()}"}, - ) - data = await response.json() - image_url = data["photos"][0]["src"]["large"] - return image_url + async def get_image_from_pexels( + self, prompt: str, api_key: str | None = None, limit: int = 1 + ) -> str | list[str]: + per_page = max(1, min(limit, 80)) + resolved_api_key = (api_key or get_pexels_api_key_env() or "").strip() - async def get_image_from_pixabay(self, prompt: str) -> str: async with aiohttp.ClientSession(trust_env=True) as session: response = await session.get( - f"https://pixabay.com/api/?key={get_pixabay_api_key_env()}&q={prompt}&image_type=photo&per_page=3" + "https://api.pexels.com/v1/search", + params={"query": prompt, "per_page": per_page}, + headers={"Authorization": resolved_api_key} if resolved_api_key else {}, + timeout=aiohttp.ClientTimeout(total=20), ) + + if response.status in {401, 403}: + raise HTTPException(status_code=401, detail="Invalid Pexels API key") + if response.status != 200: + error_text = await response.text() + raise HTTPException( + status_code=502, + detail=f"Pexels request failed: {error_text}", + ) + data = await response.json() - image_url = data["hits"][0]["largeImageURL"] - return image_url + photos = data.get("photos", []) + image_urls = [ + photo.get("src", {}).get("large") + for photo in photos + if photo.get("src", {}).get("large") + ] + + if limit <= 1: + return image_urls[0] if image_urls else "" + return image_urls[:limit] + + async def get_image_from_pixabay( + self, prompt: str, api_key: str | None = None, limit: int = 1 + ) -> str | list[str]: + per_page = max(3, min(limit, 200)) + resolved_api_key = (api_key or get_pixabay_api_key_env() or "").strip() + + async with aiohttp.ClientSession(trust_env=True) as session: + response = await session.get( + "https://pixabay.com/api/", + params={ + "key": resolved_api_key, + "q": prompt[:99], + "image_type": "photo", + "per_page": per_page, + }, + timeout=aiohttp.ClientTimeout(total=20), + ) + + if response.status in {401, 403}: + error_text = await response.text() + raise HTTPException( + status_code=401, + detail=f"Invalid Pixabay API key: {error_text}", + ) + if response.status == 400: + error_text = await response.text() + if "api key" in error_text.lower(): + raise HTTPException( + status_code=401, + detail=f"Invalid Pixabay API key: {error_text}", + ) + raise HTTPException( + status_code=400, + detail=f"Pixabay request invalid: {error_text}", + ) + if response.status != 200: + error_text = await response.text() + raise HTTPException( + status_code=502, + detail=f"Pixabay request failed: {error_text}", + ) + + data = await response.json() + hits = data.get("hits", []) + image_urls = [ + hit.get("largeImageURL") for hit in hits if hit.get("largeImageURL") + ] + + if limit <= 1: + return image_urls[0] if image_urls else "" + return image_urls[:limit] async def generate_image_comfyui(self, prompt: str, output_directory: str) -> str: """ @@ -342,6 +457,8 @@ class ImageGenerationService: "Found 'Input Prompt', but no writable prompt string field was found directly or through linked nodes." ) + + async def _submit_comfyui_workflow( self, session: aiohttp.ClientSession, comfyui_url: str, workflow: dict ) -> str: diff --git a/servers/fastapi/services/llm_client.py b/servers/fastapi/services/llm_client.py index 355b544b..11f29dd5 100644 --- a/servers/fastapi/services/llm_client.py +++ b/servers/fastapi/services/llm_client.py @@ -1823,7 +1823,7 @@ class LLMClient: """ client: AsyncOpenAI = self._client response_schema = response_format - # Apply strict schema once at root (includes array "items" fix in ensure_strict_json_schema). + # Apply strict schema once at root (includes array "items" fix at lines 135–155). if strict and depth == 0: response_schema = ensure_strict_json_schema( response_schema, diff --git a/servers/fastapi/services/llm_tool_calls_handler.py b/servers/fastapi/services/llm_tool_calls_handler.py index 44c2003b..63476028 100644 --- a/servers/fastapi/services/llm_tool_calls_handler.py +++ b/servers/fastapi/services/llm_tool_calls_handler.py @@ -55,7 +55,7 @@ class LLMToolCallsHandler: self.dynamic_tools.append(tool) match self.client.llm_provider: - case LLMProvider.OPENAI | LLMProvider.OLLAMA | LLMProvider.CUSTOM | LLMProvider.CODEX: + case LLMProvider.OPENAI | LLMProvider.OLLAMA | LLMProvider.CUSTOM: return self.parse_tool_openai(tool, strict) case LLMProvider.ANTHROPIC: return self.parse_tool_anthropic(tool) @@ -63,7 +63,7 @@ class LLMToolCallsHandler: return self.parse_tool_google(tool) case _: raise ValueError( - f"LLM provider must be one of: openai, anthropic, google, codex" + f"LLM provider must be either openai, anthropic, or google" ) def parse_tool_openai( diff --git a/servers/fastapi/templates/__init__.py b/servers/fastapi/templates/__init__.py new file mode 100644 index 00000000..a9a2c5b3 --- /dev/null +++ b/servers/fastapi/templates/__init__.py @@ -0,0 +1 @@ +__all__ = [] diff --git a/servers/fastapi/templates/font_utils.py b/servers/fastapi/templates/font_utils.py new file mode 100644 index 00000000..f8e118e3 --- /dev/null +++ b/servers/fastapi/templates/font_utils.py @@ -0,0 +1,167 @@ +import asyncio +import re +import xml.etree.ElementTree as ET +from typing import Iterable + +import aiohttp + +_STYLE_TOKENS = { + "italic", + "italics", + "ital", + "oblique", + "roman", + "bolditalic", + "bolditalics", + "thin", + "hairline", + "extralight", + "ultralight", + "light", + "demilight", + "semilight", + "book", + "regular", + "normal", + "medium", + "semibold", + "demibold", + "bold", + "extrabold", + "ultrabold", + "black", + "extrablack", + "ultrablack", + "heavy", + "narrow", + "condensed", + "semicondensed", + "extracondensed", + "ultracondensed", + "expanded", + "semiexpanded", + "extraexpanded", + "ultraexpanded", +} +_STYLE_MODIFIERS = {"semi", "demi", "extra", "ultra"} + + +def _insert_spaces_in_camel_case(value: str) -> str: + value = re.sub(r"(?<=[a-z0-9])([A-Z])", r" \1", value) + 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 + + name = raw_name.replace("_", " ").replace("-", " ") + name = _insert_spaces_in_camel_case(name) + name = re.sub(r"\s+", " ", name).strip() + lower_name = name.lower() + + for style in sorted(_STYLE_TOKENS, key=len, reverse=True): + suffix = " " + style + if lower_name.endswith(suffix): + name = name[: -len(suffix)] + lower_name = lower_name[: -len(suffix)] + break + + tokens_original = name.split(" ") + tokens_filtered: list[str] = [] + for index, token in enumerate(tokens_original): + lower_token = token.lower() + if index == 0: + tokens_filtered.append(token) + continue + if lower_token in _STYLE_TOKENS or lower_token in _STYLE_MODIFIERS: + continue + tokens_filtered.append(token) + + if not tokens_filtered: + tokens_filtered = tokens_original + + return re.sub(r"\s+", " ", " ".join(tokens_filtered).strip()) + + +def extract_fonts_from_oxml(xml_content: str) -> list[str]: + fonts = set() + + try: + root = ET.fromstring(xml_content) + 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", + } + + for xpath in (".//a:latin", ".//a:ea", ".//a:cs", ".//a:font"): + for font_elem in root.findall(xpath, namespaces): + typeface = font_elem.attrib.get("typeface") + if typeface: + fonts.add(typeface) + + for rpr_elem in root.findall(".//a:rPr", namespaces): + for font_elem in rpr_elem.findall(".//a:latin", namespaces): + typeface = font_elem.attrib.get("typeface") + if typeface: + fonts.add(typeface) + + for font_elem in root.findall(".//latin"): + typeface = font_elem.attrib.get("typeface") + if typeface: + fonts.add(typeface) + + fonts.update(re.findall(r'typeface="([^"]+)"', xml_content)) + + system_fonts = {"+mn-lt", "+mj-lt", "+mn-ea", "+mj-ea", "+mn-cs", "+mj-cs", ""} + return sorted(font for font in fonts if font not in system_fonts and font.strip()) + except Exception: + return [] + + +def get_google_font_css_url(font_name: str) -> str: + return f"https://fonts.googleapis.com/css2?family={font_name.replace(' ', '+')}&display=swap" + + +async def check_google_font_availability(font_name: str) -> bool: + try: + async with aiohttp.ClientSession() as session: + async with session.head( + get_google_font_css_url(font_name), + timeout=aiohttp.ClientTimeout(total=10), + ) as response: + return response.status == 200 + except Exception: + return False + + +def collect_normalized_fonts_from_xmls(slide_xmls: Iterable[str]) -> list[str]: + raw_fonts = set() + for xml_content in slide_xmls: + raw_fonts.update(extract_fonts_from_oxml(xml_content)) + + normalized_fonts = {normalize_font_family_name(font) for font in raw_fonts} + return sorted(font for font in normalized_fonts if font) + + +async def get_available_and_unavailable_fonts( + font_names: Iterable[str], +) -> tuple[list[tuple[str, str]], list[tuple[str, None]]]: + normalized_fonts = sorted({font for font in font_names if font}) + if not normalized_fonts: + return [], [] + + results = await asyncio.gather( + *[check_google_font_availability(font) for font in normalized_fonts] + ) + + available_fonts: list[tuple[str, str]] = [] + unavailable_fonts: list[tuple[str, None]] = [] + for font_name, is_available in zip(normalized_fonts, results): + if is_available: + available_fonts.append((font_name, get_google_font_css_url(font_name))) + else: + unavailable_fonts.append((font_name, None)) + return available_fonts, unavailable_fonts diff --git a/servers/fastapi/templates/preview.py b/servers/fastapi/templates/preview.py new file mode 100644 index 00000000..0a045319 --- /dev/null +++ b/servers/fastapi/templates/preview.py @@ -0,0 +1,477 @@ +import asyncio +from dataclasses import dataclass +import os +import re +import shutil +import subprocess +import tempfile +import uuid +import zipfile +from pathlib import Path +from typing import Dict, List, Optional + +from fastapi import File, HTTPException, UploadFile +from pydantic import BaseModel + +from constants.documents import PPTX_MIME_TYPES +from services.documents_loader import DocumentsLoader +from templates.font_utils import ( + collect_normalized_fonts_from_xmls, + get_available_and_unavailable_fonts, +) +from utils.get_env import get_app_data_directory_env + +try: + from fontTools.ttLib import TTFont + + FONTTOOLS_AVAILABLE = True +except ImportError: + FONTTOOLS_AVAILABLE = False + + +SUPPORTED_FONT_EXTENSIONS = { + ".ttf": "font/ttf", + ".otf": "font/otf", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".eot": "application/vnd.ms-fontobject", +} + + +class FontInfo(BaseModel): + name: str + url: str | None = None + + +class FontCheckResponse(BaseModel): + available_fonts: List[FontInfo] + unavailable_fonts: List[FontInfo] + + +class FontsUploadAndSlidesPreviewResponse(BaseModel): + slide_image_urls: List[str] + pptx_url: str + modified_pptx_url: str + fonts: dict + + +@dataclass +class StoredFont: + display_name: str + url: str + temp_path: str + + +def _get_soffice_binary() -> str: + configured = os.environ.get("SOFFICE_PATH") + if configured: + return configured + return "soffice.exe" if os.name == "nt" else "soffice" + + +def _windows_hidden_subprocess_kwargs() -> Dict[str, object]: + if os.name != "nt": + return {} + + startupinfo = subprocess.STARTUPINFO() + startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW + return { + "creationflags": getattr(subprocess, "CREATE_NO_WINDOW", 0), + "startupinfo": startupinfo, + } + + +def _app_data_directory() -> str: + app_data_dir = get_app_data_directory_env() or "/tmp/presenton" + os.makedirs(app_data_dir, exist_ok=True) + return app_data_dir + + +def _get_fonts_directory() -> str: + fonts_dir = os.path.join(_app_data_directory(), "fonts") + os.makedirs(fonts_dir, exist_ok=True) + return fonts_dir + + +def _get_images_directory() -> str: + images_dir = os.path.join(_app_data_directory(), "images") + os.makedirs(images_dir, exist_ok=True) + return images_dir + + +def _get_template_uploads_directory() -> str: + uploads_dir = os.path.join(_app_data_directory(), "uploads", "template-previews") + os.makedirs(uploads_dir, exist_ok=True) + return uploads_dir + + +def _write_bytes_to_path(path: str, data: bytes) -> None: + with open(path, "wb") as file: + file.write(data) + + +def _copy_file(source_path: str, destination_path: str) -> None: + shutil.copy2(source_path, destination_path) + + +def _extract_font_name_from_file(file_path: str) -> str: + filename = os.path.basename(file_path) + base_name = os.path.splitext(filename)[0] + if not FONTTOOLS_AVAILABLE: + return base_name + + try: + font = TTFont(file_path) + if "name" in font: + name_table = font["name"] + for name_id in (1, 4, 6): + for record in name_table.names: + if record.nameID != name_id: + continue + if record.langID in (0x409, 0): + font_name = record.toUnicode().strip() + if font_name: + font.close() + return font_name + for record in name_table.names: + if record.nameID != 1: + continue + font_name = record.toUnicode().strip() + if font_name: + font.close() + return font_name + font.close() + except Exception: + pass + + return base_name + + +def _validate_pptx_file(pptx_file: UploadFile) -> None: + filename = getattr(pptx_file, "filename", "") or "" + if not filename.lower().endswith(".pptx"): + raise HTTPException( + status_code=400, + detail="Invalid file type. Expected PPTX file", + ) + if pptx_file.content_type and pptx_file.content_type not in PPTX_MIME_TYPES: + raise HTTPException( + status_code=400, + detail=f"Invalid file type. Expected PPTX file, got {pptx_file.content_type}", + ) + + +def _ensure_valid_font_file(font_file: UploadFile) -> None: + filename = font_file.filename or "" + extension = os.path.splitext(filename)[1].lower() + if extension not in SUPPORTED_FONT_EXTENSIONS: + raise HTTPException( + status_code=400, + detail=f"Invalid font file. Supported formats: {', '.join(SUPPORTED_FONT_EXTENSIONS.keys())}", + ) + + +async def _persist_custom_fonts( + font_files: Optional[List[UploadFile]], + original_font_names: Optional[List[str]], + temp_dir: str, +) -> list[StoredFont]: + if not font_files: + return [] + + stored_fonts: list[StoredFont] = [] + fonts_dir = _get_fonts_directory() + + for index, font_file in enumerate(font_files): + _ensure_valid_font_file(font_file) + + original_name = ( + original_font_names[index] + if original_font_names and index < len(original_font_names) + else None + ) + extension = os.path.splitext(font_file.filename or "")[1].lower() + unique_name = f"{Path(font_file.filename or f'font_{index}').stem}_{uuid.uuid4().hex[:8]}{extension}" + temp_font_path = os.path.join(temp_dir, unique_name) + permanent_font_path = os.path.join(fonts_dir, unique_name) + font_bytes = await font_file.read() + + await asyncio.to_thread(_write_bytes_to_path, temp_font_path, font_bytes) + await asyncio.to_thread(_write_bytes_to_path, permanent_font_path, font_bytes) + + actual_font_name = await asyncio.to_thread( + _extract_font_name_from_file, permanent_font_path + ) + display_name = original_name or actual_font_name + stored_fonts.append( + StoredFont( + display_name=display_name, + url=f"/app_data/fonts/{unique_name}", + temp_path=temp_font_path, + ) + ) + + return stored_fonts + + +def _create_font_alias_config(raw_fonts: List[str]) -> str: + mappings: Dict[str, str] = {} + for font_name in raw_fonts: + normalized = font_name + if not normalized: + continue + mappings[font_name] = normalized + + fd, fonts_conf_path = tempfile.mkstemp(prefix="fonts_alias_", suffix=".conf") + os.close(fd) + with open(fonts_conf_path, "w", encoding="utf-8") as cfg: + cfg.write( + """ + + + /etc/fonts/fonts.conf +""" + ) + for source_family, destination_family in mappings.items(): + if source_family == destination_family: + continue + cfg.write( + f""" + + + {source_family} + + + {destination_family} + + +""" + ) + cfg.write("\n\n") + return fonts_conf_path + + +async def _install_fonts(font_paths: List[str]) -> None: + if not font_paths: + return + + for font_path in font_paths: + try: + subprocess.run( + ["cp", font_path, "/usr/share/fonts/truetype/"], + check=True, + capture_output=True, + ) + except subprocess.CalledProcessError: + continue + + try: + subprocess.run(["fc-cache", "-f", "-v"], check=True, capture_output=True) + except subprocess.CalledProcessError: + pass + + +def extract_slide_xmls(pptx_path: str, temp_dir: str) -> List[str]: + slide_xmls: list[str] = [] + extract_dir = os.path.join(temp_dir, "pptx_extract") + + with zipfile.ZipFile(pptx_path, "r") as zip_ref: + zip_ref.extractall(extract_dir) + + slides_dir = os.path.join(extract_dir, "ppt", "slides") + if not os.path.exists(slides_dir): + raise HTTPException(status_code=400, detail="No slides directory found in PPTX") + + slide_files = [ + file_name + for file_name in os.listdir(slides_dir) + if file_name.startswith("slide") and file_name.endswith(".xml") + ] + slide_files.sort(key=lambda value: int(re.sub(r"[^0-9]", "", value) or "0")) + + for slide_file in slide_files: + slide_path = os.path.join(slides_dir, slide_file) + with open(slide_path, "r", encoding="utf-8") as slide_handle: + slide_xmls.append(slide_handle.read()) + + return slide_xmls + + +async def convert_pptx_to_pdf( + pptx_path: str, + temp_dir: str, + slide_xmls: Optional[List[str]] = None, +) -> str: + screenshots_dir = os.path.join(temp_dir, "screenshots") + os.makedirs(screenshots_dir, exist_ok=True) + + slide_xmls = slide_xmls or extract_slide_xmls(pptx_path, temp_dir) + raw_fonts = collect_normalized_fonts_from_xmls(slide_xmls) + fonts_conf_path = _create_font_alias_config(raw_fonts) + env = os.environ.copy() + env["FONTCONFIG_FILE"] = fonts_conf_path + + try: + subprocess.run( + [ + _get_soffice_binary(), + "--headless", + "--convert-to", + "pdf", + "--outdir", + screenshots_dir, + pptx_path, + ], + check=True, + capture_output=True, + text=True, + timeout=500, + env=env, + **_windows_hidden_subprocess_kwargs(), + ) + except subprocess.TimeoutExpired as exc: + raise HTTPException( + status_code=500, + detail="LibreOffice PDF conversion timed out after 500 seconds", + ) from exc + except subprocess.CalledProcessError as exc: + error_message = exc.stderr if exc.stderr else str(exc) + raise HTTPException( + status_code=500, + detail=f"LibreOffice PDF conversion failed: {error_message}", + ) from exc + + pdf_files = [file_name for file_name in os.listdir(screenshots_dir) if file_name.endswith(".pdf")] + if not pdf_files: + raise HTTPException( + status_code=500, detail="LibreOffice failed to generate a PDF file" + ) + + return os.path.join(screenshots_dir, pdf_files[0]) + + +async def store_slide_images( + screenshot_paths: List[str], + session_id: uuid.UUID, +) -> List[str]: + images_dir = _get_images_directory() + target_dir = os.path.join(images_dir, str(session_id)) + os.makedirs(target_dir, exist_ok=True) + + slide_image_urls: list[str] = [] + for index, screenshot_path in enumerate(screenshot_paths, start=1): + file_name = f"slide_{index}.png" + destination_path = os.path.join(target_dir, file_name) + + if os.path.exists(screenshot_path) and os.path.getsize(screenshot_path) > 0: + await asyncio.to_thread(_copy_file, screenshot_path, destination_path) + slide_image_urls.append(f"/app_data/images/{session_id}/{file_name}") + else: + slide_image_urls.append("/static/images/placeholder.jpg") + + return slide_image_urls + + +async def store_uploaded_pptx( + pptx_path: str, + session_id: uuid.UUID, +) -> str: + uploads_dir = _get_template_uploads_directory() + target_dir = os.path.join(uploads_dir, str(session_id)) + os.makedirs(target_dir, exist_ok=True) + + destination_path = os.path.join(target_dir, "presentation.pptx") + await asyncio.to_thread(_copy_file, pptx_path, destination_path) + return f"/app_data/uploads/template-previews/{session_id}/presentation.pptx" + + +async def get_available_and_unavailable_fonts_for_pptx( + pptx_path: str, temp_dir: str +) -> tuple[list[tuple[str, str]], list[tuple[str, None]]]: + slide_xmls = extract_slide_xmls(pptx_path, temp_dir) + normalized_fonts = collect_normalized_fonts_from_xmls(slide_xmls) + return await get_available_and_unavailable_fonts(normalized_fonts) + + +async def check_fonts_in_pptx_handler( + pptx_file: UploadFile = File(..., description="PPTX file to analyze fonts from") +) -> FontCheckResponse: + _validate_pptx_file(pptx_file) + + with tempfile.TemporaryDirectory() as temp_dir: + pptx_path = os.path.join(temp_dir, "presentation.pptx") + pptx_content = await pptx_file.read() + await asyncio.to_thread(_write_bytes_to_path, pptx_path, pptx_content) + + available_fonts_data, unavailable_fonts_data = ( + await get_available_and_unavailable_fonts_for_pptx(pptx_path, temp_dir) + ) + + return FontCheckResponse( + available_fonts=[ + FontInfo(name=name, url=url) for name, url in available_fonts_data + ], + unavailable_fonts=[ + FontInfo(name=name, url=url) for name, url in unavailable_fonts_data + ], + ) + + +async def upload_fonts_and_slides_preview_handler( + pptx_file: UploadFile, + font_files: Optional[List[UploadFile]] = None, + original_font_names: Optional[List[str]] = None, + max_slides: Optional[int] = None, +) -> FontsUploadAndSlidesPreviewResponse: + if (font_files and not original_font_names) or ( + original_font_names and not font_files + ): + raise HTTPException( + status_code=400, + detail="Both font_files and original_font_names must be provided together", + ) + if font_files and original_font_names and len(font_files) != len(original_font_names): + raise HTTPException( + status_code=400, + detail="Number of font files must match number of original font names", + ) + + _validate_pptx_file(pptx_file) + + with tempfile.TemporaryDirectory() as temp_dir: + pptx_path = os.path.join(temp_dir, "presentation.pptx") + pptx_content = await pptx_file.read() + await asyncio.to_thread(_write_bytes_to_path, pptx_path, pptx_content) + + stored_fonts = await _persist_custom_fonts( + font_files=font_files, + original_font_names=original_font_names, + temp_dir=temp_dir, + ) + await _install_fonts([font.temp_path for font in stored_fonts]) + + slide_xmls = extract_slide_xmls(pptx_path, temp_dir) + pdf_path = await convert_pptx_to_pdf(pptx_path, temp_dir, slide_xmls=slide_xmls) + screenshot_paths = await DocumentsLoader.get_page_images_from_pdf_async( + pdf_path, temp_dir + ) + + if max_slides and len(screenshot_paths) > max_slides: + screenshot_paths = screenshot_paths[:max_slides] + + session_id = uuid.uuid4() + slide_image_urls = await store_slide_images(screenshot_paths, session_id) + pptx_url = await store_uploaded_pptx(pptx_path, session_id) + + available_fonts, _ = await get_available_and_unavailable_fonts( + collect_normalized_fonts_from_xmls(slide_xmls) + ) + fonts: dict[str, str] = {name: url for name, url in available_fonts} + fonts.update({font.display_name: font.url for font in stored_fonts}) + + return FontsUploadAndSlidesPreviewResponse( + slide_image_urls=slide_image_urls, + pptx_url=pptx_url, + modified_pptx_url=pptx_url, + fonts=fonts, + ) diff --git a/servers/fastapi/tests/test_image_generation.py b/servers/fastapi/tests/test_image_generation.py index bf0db108..56a602b4 100644 --- a/servers/fastapi/tests/test_image_generation.py +++ b/servers/fastapi/tests/test_image_generation.py @@ -398,3 +398,38 @@ class TestImageGenerationEndpoint: asyncio.run(run_test()) + def test_search_stock_images_defaults_to_selected_pixabay(self, client, mock_images_directory): + """ + Test stock image search defaults to IMAGE_PROVIDER when provider query param is omitted + - Sets IMAGE_PROVIDER to pixabay + - Ensures /images/search uses Pixabay instead of returning provider validation error + """ + with patch.dict(os.environ, {"IMAGE_PROVIDER": "pixabay"}): + with patch('api.v1.ppt.endpoints.images.get_images_directory', return_value=mock_images_directory): + with patch('api.v1.ppt.endpoints.images.ImageGenerationService') as mock_service_class: + mock_service_instance = Mock() + mock_service_instance.get_image_from_pixabay = AsyncMock( + return_value=["https://example.com/pixabay_image.jpg"] + ) + mock_service_instance.get_image_from_pexels = AsyncMock( + return_value=["https://example.com/pexels_image.jpg"] + ) + mock_service_class.return_value = mock_service_instance + + response = client.get("/images/search?query=business&limit=1") + + assert response.status_code == 200 + assert response.json() == ["https://example.com/pixabay_image.jpg"] + mock_service_instance.get_image_from_pixabay.assert_awaited_once() + mock_service_instance.get_image_from_pexels.assert_not_called() + + def test_search_stock_images_invalid_provider_returns_400(self, client): + """ + Test stock image search validates invalid provider values + - Ensures unsupported providers return HTTP 400 with clear guidance + """ + response = client.get("/images/search?query=business&provider=invalid-provider") + + assert response.status_code == 400 + assert response.json()["detail"] == "provider must be either 'pexels' or 'pixabay'" + diff --git a/servers/fastapi/tests/test_presentation_generation_api.py b/servers/fastapi/tests/test_presentation_generation_api.py index 3806dc61..b2dc536e 100644 --- a/servers/fastapi/tests/test_presentation_generation_api.py +++ b/servers/fastapi/tests/test_presentation_generation_api.py @@ -1,189 +1,117 @@ -from unittest.mock import patch, AsyncMock, MagicMock +import asyncio +import uuid +from unittest.mock import AsyncMock, patch + import pytest -from fastapi.testclient import TestClient -from fastapi import FastAPI -from models.presentation_layout import PresentationLayoutModel -from models.presentation_structure_model import PresentationStructureModel -from api.v1.ppt.endpoints.presentation import PRESENTATION_ROUTER +from fastapi import HTTPException +from pydantic import ValidationError -class MockAiohttpResponse: - def __init__(self, status=200, json_data=None): - self.status = status - self._json_data = json_data or {"path": "/tmp/exports/test.pdf"} +from api.v1.ppt.endpoints.presentation import generate_presentation_sync +from models.generate_presentation_request import GeneratePresentationRequest +from models.presentation_and_path import PresentationPathAndEditPath - async def __aenter__(self): - return self - async def __aexit__(self, exc_type, exc, tb): - pass +class FakeAsyncSession: + async def get(self, *_args, **_kwargs): + return None - async def json(self): - return self._json_data + def add(self, *_args, **_kwargs): + return None - async def text(self): - return str(self._json_data) + def add_all(self, *_args, **_kwargs): + return None -class MockAiohttpSession: - def __init__(self, *args, **kwargs): - pass + async def commit(self): + return None - async def __aenter__(self): - return self - - async def __aexit__(self, exc_type, exc, tb): - pass - - def post(self, *args, **kwargs): - return MockAiohttpResponse() - - def get(self, *args, **kwargs): - pptx_model_data = { - "slides": [], - "title": "Test", - "notes": [], - "layout": {}, - "structure": {}, - } - return MockAiohttpResponse(json_data=pptx_model_data) - -@pytest.fixture -def app(): - app = FastAPI() - app.include_router(PRESENTATION_ROUTER, prefix="/api/v1/ppt") - return app - -@pytest.fixture -def client(app): - return TestClient(app) - -@pytest.fixture -def mock_get_layout(): - async def _mock_get_layout_by_name(layout_name: str): - mock_slide = MagicMock() - mock_slide.name = "Mock Slide" - mock_slide.json_schema = {"title": "Mock Slide Title"} - mock_slide.description = "Mock slide description" - mock_layout = MagicMock(spec=PresentationLayoutModel) - mock_layout.name = layout_name - mock_layout.ordered = True - mock_layout.slides = [mock_slide] - mock_layout.model_dump = lambda: {} - mock_layout.to_presentation_structure = lambda: PresentationStructureModel( - slides=[index for index in range(len(mock_layout.slides))] - ) - def to_string(): - message = f"## Presentation Layout\n\n" - for index, slide in enumerate(mock_layout.slides): - message += f"### Slide Layout: {index}: \n" - message += f"- Name: {slide.name or slide.json_schema.get('title')} \n" - message += f"- Description: {slide.description} \n\n" - return message - mock_layout.to_string = to_string - return mock_layout - return _mock_get_layout_by_name - -async def mock_generate_ppt_outline(*args, **kwargs): - yield '{"title": "Test", "slides": [{"title": "Slide 1", "body": "Body 1"}], "notes": []}' - -@pytest.fixture(autouse=True) -def patch_presentation_api(monkeypatch, mock_get_layout): - # Patch all dependencies used in the API - patches = [ - patch('api.v1.ppt.endpoints.presentation.get_layout_by_name', new=AsyncMock(side_effect=mock_get_layout)), - patch('api.v1.ppt.endpoints.presentation.TEMP_FILE_SERVICE.create_temp_dir', return_value='/tmp/mockdir'), - patch('api.v1.ppt.endpoints.presentation.DocumentsLoader'), - patch('api.v1.ppt.endpoints.presentation.generate_document_summary', new_callable=AsyncMock, return_value="mock_summary"), - patch('api.v1.ppt.endpoints.presentation.generate_ppt_outline', side_effect=mock_generate_ppt_outline), - patch('api.v1.ppt.endpoints.presentation.get_sql_session'), - patch('api.v1.ppt.endpoints.presentation.get_slide_content_from_type_and_outline', new_callable=AsyncMock, return_value={"mock": "slide_content"}), - patch('api.v1.ppt.endpoints.presentation.process_slide_and_fetch_assets', new_callable=AsyncMock), - patch('api.v1.ppt.endpoints.presentation.get_exports_directory', return_value='/tmp/exports'), - patch('api.v1.ppt.endpoints.presentation.PptxPresentationCreator'), - patch('api.v1.ppt.endpoints.presentation.aiohttp.ClientSession', return_value=MockAiohttpSession()), - ] - mocks = [p.start() for p in patches] - - # Setup DocumentsLoader mock - docs_loader = mocks[2] - docs_loader.return_value.load_documents = AsyncMock() - docs_loader.return_value.documents = [] - - # Setup PptxPresentationCreator mock for pptx test - pptx_creator = mocks[9] - pptx_creator.return_value.create_ppt = AsyncMock() - pptx_creator.return_value.save = MagicMock() - - yield - - for p in patches: - p.stop() class TestPresentationGenerationAPI: - def test_generate_presentation_export_as_pdf(self, client): - response = client.post( - "/api/v1/ppt/presentation/generate", - json={ - "content": "Create a presentation about artificial intelligence and machine learning", - "n_slides": 5, - "language": "English", - "export_as": "pdf", - "layout": "general" - } + def test_generate_presentation_export_as_pdf(self): + request = GeneratePresentationRequest( + content="Create a presentation about artificial intelligence and machine learning", + n_slides=5, + language="English", + export_as="pdf", + template="general", ) - assert response.status_code == 200 - assert "presentation_id" in response.json() - assert "pdf" in response.json()["path"] - - def test_generate_presentation_export_as_pptx(self, client): - response = client.post( - "/api/v1/ppt/presentation/generate", - json={ - "content": "Create a presentation about artificial intelligence and machine learning", - "n_slides": 5, - "language": "English", - "export_as": "pptx", - "layout": "general" - } + response_payload = PresentationPathAndEditPath( + presentation_id=uuid.uuid4(), + path="/tmp/exports/test.pdf", + edit_path="/presentation?id=test", ) - assert response.status_code == 200 - assert "presentation_id" in response.json() - assert "pptx" in response.json()["path"] - def test_generate_presentation_with_no_content(self, client): - response = client.post( - "/api/v1/ppt/presentation/generate", - json={ - "n_slides": 5, - "language": "English", - "export_as": "pdf", - "layout": "general" - } + with patch( + "api.v1.ppt.endpoints.presentation.generate_presentation_handler", + new=AsyncMock(return_value=response_payload), + ) as mock_handler: + response = asyncio.run( + generate_presentation_sync(request, sql_session=FakeAsyncSession()) + ) + + assert response == response_payload + mock_handler.assert_awaited_once() + + def test_generate_presentation_export_as_pptx(self): + request = GeneratePresentationRequest( + content="Create a presentation about artificial intelligence and machine learning", + n_slides=5, + language="English", + export_as="pptx", + template="general", ) - assert response.status_code == 422 - - - def test_generate_presentation_with_n_slides_less_than_one(self, client): - response = client.post( - "/api/v1/ppt/presentation/generate", - json={ - "content": "Create a presentation about artificial intelligence and machine learning", - "n_slides": 0, - "language": "English", - "export_as": "pdf", - "layout": "general" - } + response_payload = PresentationPathAndEditPath( + presentation_id=uuid.uuid4(), + path="/tmp/exports/test.pptx", + edit_path="/presentation?id=test", ) - assert response.status_code == 422 - def test_generate_presentation_with_invalid_export_type(self, client): - response = client.post( - "/api/v1/ppt/presentation/generate", - json={ - "content": "Create a presentation about artificial intelligence and machine learning", - "n_slides": 5, - "language": "English", - "export_as": "invalid_type", - "layout": "general" - } + with patch( + "api.v1.ppt.endpoints.presentation.generate_presentation_handler", + new=AsyncMock(return_value=response_payload), + ) as mock_handler: + response = asyncio.run( + generate_presentation_sync(request, sql_session=FakeAsyncSession()) + ) + + assert response == response_payload + mock_handler.assert_awaited_once() + + def test_generate_presentation_with_no_content(self): + with pytest.raises(ValidationError): + GeneratePresentationRequest.model_validate( + { + "n_slides": 5, + "language": "English", + "export_as": "pdf", + "template": "general", + } + ) + + def test_generate_presentation_with_n_slides_less_than_one(self): + request = GeneratePresentationRequest( + content="Create a presentation about artificial intelligence and machine learning", + n_slides=0, + language="English", + export_as="pdf", + template="general", ) - assert response.status_code == 422 + + with pytest.raises(HTTPException) as exc: + asyncio.run( + generate_presentation_sync(request, sql_session=FakeAsyncSession()) + ) + + assert exc.value.status_code == 400 + assert exc.value.detail == "Number of slides must be greater than 0" + + def test_generate_presentation_with_invalid_export_type(self): + with pytest.raises(ValidationError): + GeneratePresentationRequest.model_validate( + { + "content": "Create a presentation about artificial intelligence and machine learning", + "n_slides": 5, + "language": "English", + "export_as": "invalid_type", + "template": "general", + } + ) diff --git a/servers/fastapi/utils/asset_directory_utils.py b/servers/fastapi/utils/asset_directory_utils.py index a3e44be7..5f8f13f8 100644 --- a/servers/fastapi/utils/asset_directory_utils.py +++ b/servers/fastapi/utils/asset_directory_utils.py @@ -5,12 +5,13 @@ from urllib.parse import urlparse, unquote from utils.get_env import get_app_data_directory_env -def resolve_image_path_to_filesystem(path_or_url: str) -> Optional[str]: +def resolve_app_path_to_filesystem(path_or_url: str) -> Optional[str]: """ - Resolve an image path or URL to an actual filesystem path. + Resolve an app-served path or URL to an actual filesystem path. Handles: - Path strings: /app_data/images/..., /static/..., absolute paths, relative + - file:// URLs returned by export runtimes - HTTP URLs whose path component is an absolute filesystem path (Mac/Electron): When img src is /Users/.../images/xxx.png, browser resolves to http://origin/Users/.../images/xxx.png. Next.js returns 404 for these. @@ -21,10 +22,12 @@ def resolve_image_path_to_filesystem(path_or_url: str) -> Optional[str]: return None # Extract path from HTTP URL if needed path = path_or_url - if path_or_url.startswith("http"): + if path_or_url.startswith("http") or path_or_url.startswith("file:"): try: parsed = urlparse(path_or_url) path = unquote(parsed.path) + if parsed.scheme == "file" and os.name == "nt" and path.startswith("/"): + path = path[1:] except Exception: return None # Handle /app_data/images/ @@ -63,6 +66,10 @@ def resolve_image_path_to_filesystem(path_or_url: str) -> Optional[str]: return actual if os.path.isfile(actual) else None +def resolve_image_path_to_filesystem(path_or_url: str) -> Optional[str]: + return resolve_app_path_to_filesystem(path_or_url) + + def get_images_directory(): images_directory = os.path.join(get_app_data_directory_env(), "images") os.makedirs(images_directory, exist_ok=True) diff --git a/servers/fastapi/utils/db_utils.py b/servers/fastapi/utils/db_utils.py index 368740f5..60b521fb 100644 --- a/servers/fastapi/utils/db_utils.py +++ b/servers/fastapi/utils/db_utils.py @@ -4,11 +4,31 @@ from urllib.parse import urlsplit, urlunsplit, parse_qsl import ssl +def _ensure_sqlite_parent_dir(database_url: str) -> None: + if not database_url.startswith("sqlite://"): + return + + split_result = urlsplit(database_url) + db_path = split_result.path + if not db_path: + return + + # sqlite URLs on Windows can start with /C:/..., normalize that for os.path. + if os.name == "nt" and len(db_path) >= 3 and db_path[0] == "/" and db_path[2] == ":": + db_path = db_path[1:] + + parent = os.path.dirname(db_path) + if parent: + os.makedirs(parent, exist_ok=True) + + def get_database_url_and_connect_args() -> tuple[str, dict]: database_url = get_database_url_env() or "sqlite:///" + os.path.join( get_app_data_directory_env() or "/tmp/presenton", "fastapi.db" ) + _ensure_sqlite_parent_dir(database_url) + if database_url.startswith("sqlite://"): database_url = database_url.replace("sqlite://", "sqlite+aiosqlite://", 1) elif database_url.startswith("postgresql://"): diff --git a/servers/fastapi/utils/get_env.py b/servers/fastapi/utils/get_env.py index 74cf2e1f..8a128cdd 100644 --- a/servers/fastapi/utils/get_env.py +++ b/servers/fastapi/utils/get_env.py @@ -136,9 +136,37 @@ def get_codex_account_id_env(): return os.getenv("CODEX_ACCOUNT_ID") +def get_codex_username_env(): + return os.getenv("CODEX_USERNAME") + + +def get_codex_email_env(): + return os.getenv("CODEX_EMAIL") + + +def get_codex_is_pro_env(): + return os.getenv("CODEX_IS_PRO") + + def get_codex_model_env(): return os.getenv("CODEX_MODEL") def get_migrate_database_on_startup_env(): return os.getenv("MIGRATE_DATABASE_ON_STARTUP") + + +def get_next_public_fast_api_env(): + return os.getenv("FASTAPI_PUBLIC_URL") + + +def get_sentry_dsn_env(): + return os.getenv("SENTRY_DSN") + + +def get_sentry_traces_sample_rate_env(): + return os.getenv("SENTRY_TRACES_SAMPLE_RATE") + + +def get_sentry_send_default_pii_env(): + return os.getenv("SENTRY_SEND_DEFAULT_PII") diff --git a/servers/fastapi/utils/llm_calls/edit_slide.py b/servers/fastapi/utils/llm_calls/edit_slide.py index 8664d229..6f929aa4 100644 --- a/servers/fastapi/utils/llm_calls/edit_slide.py +++ b/servers/fastapi/utils/llm_calls/edit_slide.py @@ -9,6 +9,17 @@ from utils.llm_provider import get_model from utils.schema_utils import add_field_in_schema, remove_fields_from_schema +def _resolve_prompt_language(language: Optional[str]) -> str: + if language is None: + return "auto-detect" + s = str(language).strip() + if not s: + return "auto-detect" + if s.lower() in {"auto", "auto-detect"}: + return "auto-detect" + return s + + def get_system_prompt( tone: Optional[str] = None, verbosity: Optional[str] = None, @@ -40,6 +51,7 @@ def get_system_prompt( def get_user_prompt(prompt: str, slide_data: dict, language: str): + display_language = _resolve_prompt_language(language) return f""" ## Icon Query And Image Prompt Language English @@ -48,7 +60,7 @@ def get_user_prompt(prompt: str, slide_data: dict, language: str): {datetime.now().strftime("%Y-%m-%d %H:%M:%S")} ## Slide Content Language - {language} + {display_language} ## Prompt {prompt} @@ -61,7 +73,7 @@ def get_user_prompt(prompt: str, slide_data: dict, language: str): def get_messages( prompt: str, slide_data: dict, - language: str, + language: Optional[str], tone: Optional[str] = None, verbosity: Optional[str] = None, instructions: Optional[str] = None, @@ -79,7 +91,7 @@ def get_messages( async def get_edited_slide_content( prompt: str, slide: SlideModel, - language: str, + language: Optional[str], slide_layout: SlideLayoutModel, tone: Optional[str] = None, verbosity: Optional[str] = None, diff --git a/servers/fastapi/utils/llm_calls/generate_presentation_outlines.py b/servers/fastapi/utils/llm_calls/generate_presentation_outlines.py index cb044d4c..13bf3a14 100644 --- a/servers/fastapi/utils/llm_calls/generate_presentation_outlines.py +++ b/servers/fastapi/utils/llm_calls/generate_presentation_outlines.py @@ -2,6 +2,7 @@ from datetime import datetime from typing import Optional from models.llm_message import LLMSystemMessage, LLMUserMessage +from models.presentation_outline_model import PresentationOutlineModel from models.llm_tools import SearchWebTool from services.llm_client import LLMClient from utils.get_dynamic_models import get_presentation_outline_model_with_n_slides @@ -14,77 +15,144 @@ def get_system_prompt( verbosity: Optional[str] = None, instructions: Optional[str] = None, include_title_slide: bool = True, + include_table_of_contents: bool = False, ): - return f""" - You are an expert presentation creator. Generate structured presentations based on user requirements and format them according to the specified JSON schema with markdown content. + verbosity_instruction = ( + "Slide content should be abound 20 words but detailed enough to generate a good slide." + if verbosity == "concise" + else ( + "Slide content should be abound 60 words but detailed enough to generate a good slide." + if verbosity == "text-heavy" + else "Slide content should be abound 40 words but detailed enough to generate a good slide." + ) + ) - Try to use available tools for better results. + title_slide_instruction = ( + "Include presenter name in first slide." + if include_title_slide + else "Do not include presenter name in any slides." + ) - {"# User Instruction:" if instructions else ""} - {instructions or ""} + toc_instruction = ( + "Include a table of contents slide in the outline sequence." + if include_table_of_contents + else "" + ) + toc_block = f"{toc_instruction}\n" if toc_instruction else "" - {"# Tone:" if tone else ""} - {tone or ""} + slide_outline_structure = ( + "Each slide content:\n" + " - Must have a ## title.\n" + # " - Must have content either in multiple bullet points or table or both.\n" + " - Must be in Markdown format.\n" + " - Don't use **bold** and __italic__ text." + " - First slide title must be the same as the presentation title." + ) - {"# Verbosity:" if verbosity else ""} - {verbosity or ""} + system = ( + "Generate presentation title and content for slides.\n" + "Generate flow based on user **content** and use **context** just for reference.\n" + "Presentation title should be plain text, not markdown. It should be a concise title for the presentation.\n" + "Each slide content should contain the content for that slide.\n" + f"{verbosity_instruction}\n" + "Minimize repetitive content and make sure to use different words and phrases for different slides.\n" + "Include numerical data, tables or code if required or asked by the user.\n" + "If 'auto-detect' is used, figure it out from the content/context.\n" + f"{title_slide_instruction}\n" + f"{toc_block}" + f"{slide_outline_structure}\n" + "Slide content must not contain any presentation branding/styling information.\n" + "Title slide must only contain title, presenter name, date and overview.\n" + "Only include URLs if they appear in the provided content/context.\n" + "Make sure data used is strictly from the provided content/context.\n" + "Make sure data is consistent across all slides." + ) - - Provide content for each slide in markdown format. - - Make sure that flow of the presentation is logical and consistent. - - Place greater emphasis on numerical data. - - If Additional Information is provided, divide it into slides. - - Make sure no images are provided in the content. - - Make sure that content follows language guidelines. - - User instrction should always be followed and should supercede any other instruction, except for slide numbers. **Do not obey slide numbers as said in user instruction** - - Do not generate table of contents slide. - - Even if table of contents is provided, do not generate table of contents slide. - {"- Always make first slide a title slide." if include_title_slide else "- Do not include title slide in the presentation."} + return system - **Search web to get latest information about the topic** - """ + +def _resolve_prompt_language(language: Optional[str]) -> str: + if language is None: + return "auto-detect" + s = str(language).strip() + if not s: + return "auto-detect" + if s.lower() in {"auto", "auto-detect"}: + return "auto-detect" + return s + + +def _resolve_prompt_n_slides(n_slides: Optional[int]) -> str: + if n_slides is None: + return "auto-detect" + return str(n_slides) def get_user_prompt( content: str, - n_slides: int, - language: str, + n_slides: Optional[int], + language: Optional[str], additional_context: Optional[str] = None, + tone: Optional[str] = None, + instructions: Optional[str] = None, + include_title_slide: bool = True, + include_table_of_contents: bool = False, ): - return f""" - **Input:** - - User provided content: {content or "Create presentation"} - - Output Language: {language} - - Number of Slides: {n_slides} - - Current Date and Time: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")} - - Additional Information: {additional_context or ""} - """ + display_language = _resolve_prompt_language(language) + display_slides = _resolve_prompt_n_slides(n_slides) + toc_text = f"Include Table Of Contents: {str(include_table_of_contents).lower()}\n" + return ( + f"Content: {content or ''}\n" + f"Number of Slides: {display_slides}\n" + f"Language: {display_language}\n" + f"Tone: {tone or ''}\n" + f"Today's Date: {datetime.now().strftime('%Y-%m-%d')}\n" + f"Include Title Slide: {include_title_slide}\n" + f"{toc_text if include_table_of_contents else ''}" + f"Instructions: {instructions or ''}\n" + f"Context: {additional_context or ''}" + ) def get_messages( content: str, - n_slides: int, - language: str, + n_slides: Optional[int], + language: Optional[str], additional_context: Optional[str] = None, tone: Optional[str] = None, verbosity: Optional[str] = None, instructions: Optional[str] = None, include_title_slide: bool = True, + include_table_of_contents: bool = False, ): return [ LLMSystemMessage( content=get_system_prompt( - tone, verbosity, instructions, include_title_slide + tone, + verbosity, + instructions, + include_title_slide, + include_table_of_contents, ), ), LLMUserMessage( - content=get_user_prompt(content, n_slides, language, additional_context), + content=get_user_prompt( + content, + n_slides, + language, + additional_context, + tone, + instructions, + include_title_slide, + include_table_of_contents, + ), ), ] async def generate_ppt_outline( content: str, - n_slides: int, + n_slides: Optional[int], language: Optional[str] = None, additional_context: Optional[str] = None, tone: Optional[str] = None, @@ -92,9 +160,14 @@ async def generate_ppt_outline( instructions: Optional[str] = None, include_title_slide: bool = True, web_search: bool = False, + include_table_of_contents: bool = False, ): model = get_model() - response_model = get_presentation_outline_model_with_n_slides(n_slides) + response_model = ( + get_presentation_outline_model_with_n_slides(n_slides) + if n_slides is not None + else PresentationOutlineModel + ) client = LLMClient() @@ -110,6 +183,7 @@ async def generate_ppt_outline( verbosity, instructions, include_title_slide, + include_table_of_contents, ), response_model.model_json_schema(), strict=True, diff --git a/servers/fastapi/utils/llm_calls/generate_presentation_structure.py b/servers/fastapi/utils/llm_calls/generate_presentation_structure.py index f56004be..bbe26172 100644 --- a/servers/fastapi/utils/llm_calls/generate_presentation_structure.py +++ b/servers/fastapi/utils/llm_calls/generate_presentation_structure.py @@ -1,4 +1,5 @@ -from typing import Optional +from typing import Optional, Dict + from models.llm_message import LLMSystemMessage, LLMUserMessage from models.presentation_layout import PresentationLayoutModel from models.presentation_outline_model import PresentationOutlineModel @@ -9,58 +10,106 @@ from utils.get_dynamic_models import get_presentation_structure_model_with_n_sli from models.presentation_structure_model import PresentationStructureModel +STRUCTURE_FROM_SLIDES_MARKDOWN_SYSTEM_PROMPT = """ +You will be given available slide layouts and content for each slide. +You need to select a layout for each slide based on the mentioned guidelines. + +# Steps +1. Analyze all available slide layouts. +2. Analyze content for each slide. +3. Select a layout for each slide one by one by following the selection rules. + +# Analyzing Slide Layouts +- Identify what each layout contains based on provided schema markdown. + +# Analyzing Content +- Identify how the content is structured. +- Identify if the content contains tables. + +# Selection Rules +- If content contains table, then select either table layout or graph layout. +- Don't select layout with image unless content contains image. +- Don't select table layout if content does not contain table. +- You are allowed to select same layout for multiple slides. + +# Table Layout Selection Rules +- Must select table layout if the content contains table with text data. +- Must only select a layout with table if the table only contains text data. + +# Graph Layout Selection Rules +- Must only select a layout with chart if the content contains table with numeric data. +- Identify how many columns are present in the table. +- Must select a layout that supports n-1 charts for n columns. +- Must prioritize layouts that support multiple charts. +- Don't select metrics layout for content containing table with numeric data. +- For example, if content contains table with 3 columns, then select a layout that supports 2 charts. + +{user_instructions} + +# Output Rules: +- One layout index for each slide. +- Example: [0, 1, 2, 3, 4] + +{presentation_layout} +""" + + +GET_MESSAGES_SYSTEM_PROMPT = """ +You're a professional presentation designer with creative freedom to design engaging presentations. + +# DESIGN PHILOSOPHY +- Create visually compelling and varied presentations +- Match layout to content purpose and audience needs + +# Layout Selection Guidelines +1. **Content-driven choices**: Let the slide's purpose guide layout selection +- Opening/closing → Title layouts +- Processes/workflows → Visual process layouts +- Comparisons/contrasts → Side-by-side layouts +- Data/metrics → Chart/graph layouts +- Concepts/ideas → Image + text layouts +- Key insights → Emphasis layouts + +2. **Visual variety**: Aim for diverse slide layouts across the presentation. +- Don't use same layout for multiple slides unless necessary. +- Mix text-heavy and visual-heavy slides naturally +- Use your judgment on when repetition serves the content +- Balance information density across slides +- Adjacent slide layouts should be different unless instructed/necessary otherwise. + +3. **Audience experience**: Consider how slides work together +- Create natural transitions between topics + +4. **Table of contents**: +- Must only use table of contents layout if slide content contains table of contents. + +{user_instruction_header} + +User instruction should be taken into account while creating the presentation structure, except for number of slides. + +Select layout index for each of the {n_slides} slides based on what will best serve the presentation's goals. + +""" + + def get_messages( presentation_layout: PresentationLayoutModel, n_slides: int, data: str, instructions: Optional[str] = None, ): + system_prompt = GET_MESSAGES_SYSTEM_PROMPT.format( + user_instruction_header="# User Instruction:" if instructions else "", + n_slides=n_slides, + ) + return [ - LLMSystemMessage( - content=f""" - You're a professional presentation designer with creative freedom to design engaging presentations. - - {presentation_layout.to_string()} - - # DESIGN PHILOSOPHY - - Create visually compelling and varied presentations - - Match layout to content purpose and audience needs - - Prioritize engagement over rigid formatting rules - - # Layout Selection Guidelines - 1. **Content-driven choices**: Let the slide's purpose guide layout selection - - Opening/closing → Title layouts - - Processes/workflows → Visual process layouts - - Comparisons/contrasts → Side-by-side layouts - - Data/metrics → Chart/graph layouts - - Concepts/ideas → Image + text layouts - - Key insights → Emphasis layouts - - 2. **Visual variety**: Aim for diverse, engaging presentation flow - - Mix text-heavy and visual-heavy slides naturally - - Use your judgment on when repetition serves the content - - Balance information density across slides - - 3. **Audience experience**: Consider how slides work together - - Create natural transitions between topics - - Use layouts that enhance comprehension - - Design for maximum impact and retention - - **Trust your design instincts. Focus on creating the most effective presentation for the content and audience.** - - {"# User Instruction:" if instructions else ""} - {instructions or ""} - - User intruction should be taken into account while creating the presentation structure, except for number of slides. - - Select layout index for each of the {n_slides} slides based on what will best serve the presentation's goals. - """, - ), - LLMUserMessage( - content=f""" - {data} - """, - ), + LLMSystemMessage(content=system_prompt), + LLMUserMessage(content=( + f"{presentation_layout.to_string()}\n\n" + "--------------------------------------\n\n" + f"{data}" + )), ] @@ -70,28 +119,18 @@ def get_messages_for_slides_markdown( data: str, instructions: Optional[str] = None, ): + system_prompt = STRUCTURE_FROM_SLIDES_MARKDOWN_SYSTEM_PROMPT.format( + user_instructions=instructions or "", + presentation_layout=presentation_layout.to_string(with_schema=True), + ) + return [ LLMSystemMessage( - content=f""" - You're a professional presentation designer with creative freedom to design engaging presentations. - - {"# User Instruction:" if instructions else ""} - {instructions or ""} - - {presentation_layout.to_string()} - - Select layout that best matches the content of the slides. - - User intruction should be taken into account while creating the presentation structure, except for number of slides. - - Select layout index for each of the {n_slides} slides based on what will best serve the presentation's goals. - """, + content=system_prompt ), LLMUserMessage( - content=f""" - {data} - """, - ), + content=data + ) ] diff --git a/servers/fastapi/utils/llm_calls/generate_slide_content.py b/servers/fastapi/utils/llm_calls/generate_slide_content.py index 1c80d2a9..a5010cf2 100644 --- a/servers/fastapi/utils/llm_calls/generate_slide_content.py +++ b/servers/fastapi/utils/llm_calls/generate_slide_content.py @@ -1,4 +1,5 @@ from datetime import datetime +import json from typing import Optional from models.llm_message import LLMSystemMessage, LLMUserMessage from models.presentation_layout import SlideLayoutModel @@ -9,88 +10,136 @@ from utils.llm_provider import get_model from utils.schema_utils import add_field_in_schema, remove_fields_from_schema +SLIDE_CONTENT_SYSTEM_PROMPT = """ +You will be given slide content and response schema. +You need to generate structured content json based on the schema. + +# Steps +1. Analyze the content. +2. Analyze the response schema. +3. Generate structured content json based on the schema. +4. Generate speaker note if required. +5. Provide structured content json as output. + +# General Rules +- Make sure to follow language guidelines. +- Speaker note should be normal text, not markdown. +- Never ever go over the max character limit. +- Do not add emoji in the content. +- Don't provide $schema field in content json. +{markdown_emphasis_rules} + +{user_instructions} + +{tone_instructions} + +{verbosity_instructions} + +{output_fields_instructions} +""" + + +SLIDE_CONTENT_USER_PROMPT = """ +# Current Date and Time: +{current_date_time} + +# Icon Query And Image Prompt Language: +English + +# Slide Language: +{language} + +# SLIDE CONTENT: START +{content} +# SLIDE CONTENT: END +""" + + +def _resolve_prompt_language(language: Optional[str]) -> str: + if language is None: + return "auto-detect" + s = str(language).strip() + if not s: + return "auto-detect" + if s.lower() in {"auto", "auto-detect"}: + return "auto-detect" + return s + + +def _get_schema_markdown(response_schema: Optional[dict]) -> str: + if not response_schema: + return "- Follow the provided response schema strictly." + try: + schema_text = json.dumps(response_schema, ensure_ascii=False) + except Exception: + return "- Follow the provided response schema strictly." + return f"- Follow this response schema exactly: {schema_text}" + + def get_system_prompt( tone: Optional[str] = None, verbosity: Optional[str] = None, instructions: Optional[str] = None, + response_schema: Optional[dict] = None, ): - return f""" - Generate structured slide based on provided outline, follow mentioned steps and notes and provide structured output. + markdown_emphasis_rules = ( + "- Strictly use markdown to emphasize important points, by bolding or " + "italicizing the part of text." + ) - {"# User Instructions:" if instructions else ""} - {instructions or ""} + user_instructions = f"# User Instructions:\n{instructions}" if instructions else "" + tone_instructions = ( + f"# Tone Instructions:\nMake slide as {tone} as possible." if tone else "" + ) - {"# Tone:" if tone else ""} - {tone or ""} + verbosity_instructions = "" + if verbosity: + verbosity_instructions = "# Verbosity Instructions:\n" + if verbosity == "concise": + verbosity_instructions += "Make slide as concise as possible." + elif verbosity == "standard": + verbosity_instructions += "Make slide as standard as possible." + elif verbosity == "text-heavy": + verbosity_instructions += "Make slide as text-heavy as possible." - {"# Verbosity:" if verbosity else ""} - {verbosity or ""} + output_fields_instructions = "# Output Fields:\n" + _get_schema_markdown( + response_schema + ) - # Steps - 1. Analyze the outline. - 2. Generate structured slide based on the outline. - 3. Generate speaker note that is simple, clear, concise and to the point. - - # Notes - - Slide body should not use words like "This slide", "This presentation". - - Rephrase the slide body to make it flow naturally. - - Only use markdown to highlight important points. - - Make sure to follow language guidelines. - - Speaker note should be normal text, not markdown. - - Strictly follow the max and min character limit for every property in the slide. - - Never ever go over the max character limit. Limit your narration to make sure you never go over the max character limit. - - Number of items should not be more than max number of items specified in slide schema. If you have to put multiple points then merge them to obey max numebr of items. - - Generate content as per the given tone. - - Be very careful with number of words to generate for given field. As generating more than max characters will overflow in the design. So, analyze early and never generate more characters than allowed. - - Do not add emoji in the content. - - Metrics should be in abbreviated form with least possible characters. Do not add long sequence of words for metrics. - - For verbosity: - - If verbosity is 'concise', then generate description as 1/3 or lower of the max character limit. Don't worry if you miss content or context. - - If verbosity is 'standard', then generate description as 2/3 of the max character limit. - - If verbosity is 'text-heavy', then generate description as 3/4 or higher of the max character limit. Make sure it does not exceed the max character limit. - - User instructions, tone and verbosity should always be followed and should supercede any other instruction, except for max and min character limit, slide schema and number of items. - - - Provide output in json format and **don't include tags**. - - # Image and Icon Output Format - image: {{ - __image_prompt__: string, - }} - icon: {{ - __icon_query__: string, - }} - - """ + return SLIDE_CONTENT_SYSTEM_PROMPT.format( + markdown_emphasis_rules=markdown_emphasis_rules, + user_instructions=user_instructions, + tone_instructions=tone_instructions, + verbosity_instructions=verbosity_instructions, + output_fields_instructions=output_fields_instructions, + ) -def get_user_prompt(outline: str, language: str): - return f""" - ## Current Date and Time - {datetime.now().strftime("%Y-%m-%d %H:%M:%S")} - - ## Icon Query And Image Prompt Language - English - - ## Slide Content Language - {language} - - ## Slide Outline - {outline} - """ +def get_user_prompt(outline: str, language: Optional[str]): + return SLIDE_CONTENT_USER_PROMPT.format( + current_date_time=datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + language=_resolve_prompt_language(language), + content=outline, + ) def get_messages( outline: str, - language: str, + language: Optional[str], tone: Optional[str] = None, verbosity: Optional[str] = None, instructions: Optional[str] = None, + response_schema: Optional[dict] = None, ): return [ LLMSystemMessage( - content=get_system_prompt(tone, verbosity, instructions), + content=get_system_prompt( + tone, + verbosity, + instructions, + response_schema, + ), ), LLMUserMessage( content=get_user_prompt(outline, language), @@ -101,7 +150,7 @@ def get_messages( async def get_slide_content_from_type_and_outline( slide_layout: SlideLayoutModel, outline: SlideOutlineModel, - language: str, + language: Optional[str], tone: Optional[str] = None, verbosity: Optional[str] = None, instructions: Optional[str] = None, @@ -125,28 +174,21 @@ async def get_slide_content_from_type_and_outline( True, ) - messages = get_messages( - outline.content, - language, - tone, - verbosity, - instructions, - ) - print( - f"get_slide_content_from_type_and_outline: model={model} outline_len={len(outline.content or '')} language={language}" - ) try: response = await client.generate_structured( model=model, - messages=messages, + messages=get_messages( + outline.content, + language, + tone, + verbosity, + instructions, + response_schema, + ), response_format=response_schema, strict=False, ) - print( - f"get_slide_content_from_type_and_outline: response is None={response is None} keys={list(response.keys())[:6] if isinstance(response, dict) else None}" - ) return response except Exception as e: - print(f"get_slide_content_from_type_and_outline: exception={e}") raise handle_llm_client_exceptions(e) diff --git a/servers/fastapi/utils/llm_provider.py b/servers/fastapi/utils/llm_provider.py index e20c76ae..f355d9cc 100644 --- a/servers/fastapi/utils/llm_provider.py +++ b/servers/fastapi/utils/llm_provider.py @@ -1,7 +1,10 @@ from fastapi import HTTPException +from google import genai +from openai import OpenAI from constants.llm import ( DEFAULT_ANTHROPIC_MODEL, + DEFAULT_CODEX_MODEL, DEFAULT_GOOGLE_MODEL, DEFAULT_OPENAI_MODEL, ) @@ -10,9 +13,11 @@ from utils.get_env import ( get_anthropic_model_env, get_codex_model_env, get_custom_model_env, + get_google_api_key_env, get_google_model_env, get_llm_provider_env, get_ollama_model_env, + get_openai_api_key_env, get_openai_model_env, ) @@ -64,9 +69,28 @@ def get_model(): elif selected_llm == LLMProvider.CUSTOM: return get_custom_model_env() elif selected_llm == LLMProvider.CODEX: - return get_codex_model_env() + return get_codex_model_env() or DEFAULT_CODEX_MODEL else: raise HTTPException( status_code=500, detail=f"Invalid LLM provider. Please select one of: openai, google, anthropic, ollama, custom, codex", ) + + +def get_google_llm_client() -> genai.Client: + """Google GenAI client for tests and direct API use (uses GOOGLE_API_KEY from env).""" + if not get_google_api_key_env(): + raise HTTPException(status_code=400, detail="Google API Key is not set") + return genai.Client() + + +def get_llm_client() -> OpenAI: + """OpenAI client for tests and direct API use (uses OPENAI_API_KEY from env).""" + if not get_openai_api_key_env(): + raise HTTPException(status_code=400, detail="OpenAI API Key is not set") + return OpenAI() + + +def get_large_model() -> str: + """Resolved model name for the configured LLM provider (same as runtime `get_model`).""" + return get_model() diff --git a/servers/fastapi/utils/oauth/openai_codex.py b/servers/fastapi/utils/oauth/openai_codex.py index b1b5578d..c94b75eb 100644 --- a/servers/fastapi/utils/oauth/openai_codex.py +++ b/servers/fastapi/utils/oauth/openai_codex.py @@ -28,17 +28,211 @@ JWT_CLAIM_PATH = "https://api.openai.com/auth" CALLBACK_PORT = 1455 -SUCCESS_HTML = b""" +# Simple branded success page for Presenton authentication +SUCCESS_HTML = """ - Authentication successful + Presenton – Authentication successful + -

Authentication successful. Return to your terminal / application to continue.

+
+
+ + Authentication successful +
+

You’re all set

+

You can now return to Presenton to continue.

+

This window can be safely closed.

+
-""" +""".encode("utf-8") + +STATE_MISMATCH_HTML = """ + + + + + Presenton – Authentication issue + + + + +
+
+ + We noticed something unexpected +
+

Almost there

+

We detected a small mismatch while completing authentication.

+

We’ll gently reload this page. If the issue persists, close this window and restart sign-in from Presenton.

+ +

You can also safely close this window and try again from the app.

+
+ +""".encode("utf-8") # --------------------------------------------------------------------------- @@ -50,6 +244,7 @@ class TokenSuccess: access: str refresh: str expires: int # Unix ms timestamp when the token expires + id_token: Optional[str] = None @dataclass @@ -67,6 +262,14 @@ class AuthorizationFlow: url: str +@dataclass +class CodexAccountProfile: + account_id: Optional[str] = None + username: Optional[str] = None + email: Optional[str] = None + is_pro: Optional[bool] = None + + # --------------------------------------------------------------------------- # JWT helpers # --------------------------------------------------------------------------- @@ -102,6 +305,51 @@ def get_account_id(access_token: str) -> Optional[str]: return None +def _as_non_empty_str(value) -> Optional[str]: + if isinstance(value, str): + stripped = value.strip() + return stripped or None + return None + + +def get_account_profile(access_token: str, id_token: Optional[str] = None) -> CodexAccountProfile: + """Extract profile from exact observed JWT paths in access/id tokens.""" + access_payload = _decode_jwt_payload(access_token) or {} + access_auth = access_payload.get(JWT_CLAIM_PATH) + access_auth = access_auth if isinstance(access_auth, dict) else {} + + access_profile = access_payload.get("https://api.openai.com/profile") + access_profile = access_profile if isinstance(access_profile, dict) else {} + + id_payload = _decode_jwt_payload(id_token) if id_token else None + id_payload = id_payload if isinstance(id_payload, dict) else {} + id_auth = id_payload.get(JWT_CLAIM_PATH) + id_auth = id_auth if isinstance(id_auth, dict) else {} + + account_id = _as_non_empty_str(access_auth.get("chatgpt_account_id")) or _as_non_empty_str( + id_auth.get("chatgpt_account_id") + ) + username = _as_non_empty_str(id_payload.get("name")) + email = _as_non_empty_str(access_profile.get("email")) or _as_non_empty_str( + id_payload.get("email") + ) + + plan_type = _as_non_empty_str(access_auth.get("chatgpt_plan_type")) or _as_non_empty_str( + id_auth.get("chatgpt_plan_type") + ) + if plan_type: + is_pro = plan_type.strip().lower() != "free" + else: + is_pro = None + + return CodexAccountProfile( + account_id=account_id, + username=username, + email=email, + is_pro=is_pro, + ) + + # --------------------------------------------------------------------------- # Authorization URL + PKCE # --------------------------------------------------------------------------- @@ -148,22 +396,37 @@ class _CallbackHandler(BaseHTTPRequestHandler): expected_state: str = self.server.expected_state # type: ignore[attr-defined] - if not state_vals or state_vals[0] != expected_state: - self.send_response(400) - self.end_headers() - self.wfile.write(b"State mismatch") - return - if not code_vals: self.send_response(400) self.end_headers() self.wfile.write(b"Missing authorization code") return + # In the desktop/Electron app context the redirect URI is a localhost-only + # callback, so strict CSRF protection via state comparison is less critical. + # We've seen intermittent state mismatches in the field (likely from + # overlapping auth attempts or stale callback servers), so we treat a + # mismatch as a soft warning instead of a hard failure. + state_mismatch = bool(state_vals and state_vals[0] != expected_state) + if state_mismatch: + # Best-effort warning to server logs; handler intentionally continues. + try: + print( + f"[Codex OAuth] State mismatch in callback handler: " + f"expected={expected_state} got={state_vals[0]}" + ) + except Exception: + pass + self.send_response(200) self.send_header("Content-Type", "text/html; charset=utf-8") self.end_headers() - self.wfile.write(SUCCESS_HTML) + # Show a nicer success page, and a dedicated state-mismatch page that + # gently reloads to help recover from stale callback windows. + if state_mismatch: + self.wfile.write(STATE_MISMATCH_HTML) + else: + self.wfile.write(SUCCESS_HTML) self.server.captured_code = code_vals[0] # type: ignore[attr-defined] @@ -265,6 +528,7 @@ def exchange_authorization_code( return TokenFailure(reason=f"HTTP {response.status_code}: {response.text[:200]}") body = response.json() + access = body.get("access_token") refresh = body.get("refresh_token") expires_in = body.get("expires_in") @@ -273,7 +537,9 @@ def exchange_authorization_code( return TokenFailure(reason=f"Token response missing fields: {list(body.keys())}") expires_ms = int(time.time() * 1000) + int(expires_in) * 1000 - return TokenSuccess(access=access, refresh=refresh, expires=expires_ms) + id_token = body.get("id_token") + id_token = id_token if isinstance(id_token, str) else None + return TokenSuccess(access=access, refresh=refresh, expires=expires_ms, id_token=id_token) except Exception as exc: return TokenFailure(reason=str(exc)) diff --git a/servers/fastapi/utils/outline_utils.py b/servers/fastapi/utils/outline_utils.py new file mode 100644 index 00000000..b0847643 --- /dev/null +++ b/servers/fastapi/utils/outline_utils.py @@ -0,0 +1,205 @@ +import math +import re +from typing import Iterable, List, Optional + +from models.presentation_outline_model import ( + PresentationOutlineModel, + SlideOutlineModel, +) + + +HEADING_PATTERN = re.compile(r"^\s{0,3}#+\s*(.+)$", re.MULTILINE) +FIRST_SENTENCE_PATTERN = re.compile(r"^\s*([^.?!]+?[.?!])", re.DOTALL) +IMAGE_URL_PATTERN = re.compile( + r"https?://[-\w./%~:!$&'()*+,;=]+?\.(?:jpe?g|png|webp)(?:\?[^\s\"\'\\]*)?", + re.IGNORECASE | re.UNICODE, +) + + +def get_presentation_title_from_presentation_outline( + presentation_outline: PresentationOutlineModel, +) -> str: + if not presentation_outline.slides: + return "Untitled Presentation" + + first_content = presentation_outline.slides[0].content or "" + + if re.match(r"^\s*#{1,6}\s*Page\s+\d+\b", first_content): + first_content = re.sub( + r"^\s*#{1,6}\s*Page\s+\d+\b[\s,:\-]*", + "", + first_content, + count=1, + ) + + return ( + first_content[:100] + .replace("#", "") + .replace("/", "") + .replace("\\", "") + .replace("\n", " ") + ) + + +def _get_toc_count_for_total_slides(total_slides: int, title_slide: bool) -> int: + if total_slides <= 0: + return 0 + + first_pass = math.ceil(((total_slides - 1) if title_slide else total_slides) / 10) + return math.ceil((total_slides - first_pass) / 10) + + +def get_no_of_toc_required_for_n_outlines( + *, + n_outlines: int, + title_slide: bool, + target_total_slides: Optional[int] = None, +) -> int: + if target_total_slides is not None: + adjusted_total = max(target_total_slides, n_outlines) + return _get_toc_count_for_total_slides(adjusted_total, title_slide) + + if n_outlines <= 0: + return 0 + + return math.ceil(((n_outlines - 1) if title_slide else n_outlines) / 10) + + +def get_no_of_outlines_to_generate_for_n_slides( + *, + n_slides: int, + toc: bool, + title_slide: bool, +) -> int: + if toc: + n_toc_1 = math.ceil(((n_slides - 1) if title_slide else n_slides) / 10) + n_toc_2 = math.ceil((n_slides - n_toc_1) / 10) + + return n_slides - n_toc_2 + + else: + return n_slides + + +def get_presentation_outline_model_with_toc( + *, + outline: PresentationOutlineModel, + n_toc_slides: int, + title_slide: bool, +) -> PresentationOutlineModel: + if n_toc_slides <= 0: + return outline + + outline_with_toc = outline.model_copy(deep=True) + insertion_index = 1 if title_slide else 0 + + existing_outlines = outline_with_toc.slides + outlines_for_toc = existing_outlines[insertion_index:] + if not outlines_for_toc: + return outline_with_toc + + sections = _split_outlines_evenly(outlines_for_toc, n_toc_slides) + if not sections: + return outline_with_toc + + toc_slides: List[SlideOutlineModel] = [] + outlines_before_toc = 1 if title_slide else 0 + total_toc_slides = len(sections) + global_outline_index = 0 + + for section_index, section in enumerate(sections): + section_lines = [ + "## Table of Contents", + "", + ] + + for outline in section: + outline_title = _extract_outline_title(outline.content) + page_number = ( + outlines_before_toc + total_toc_slides + global_outline_index + 1 + ) + section_lines.append( + f"- Page number: {page_number}, Title: {outline_title}" + ) + global_outline_index += 1 + + toc_slides.append( + SlideOutlineModel( + content="\n".join( + line for line in section_lines if line is not None + ).strip() + ) + ) + + for offset, toc_slide in enumerate(toc_slides): + existing_outlines.insert(insertion_index + offset, toc_slide) + + return outline_with_toc + + +def _split_outlines_evenly( + outlines: Iterable[SlideOutlineModel], n_sections: int +) -> List[List[SlideOutlineModel]]: + """Split outlines into n contiguous sections with near-equal sizes.""" + outlines_list = list(outlines) + if n_sections <= 0 or not outlines_list: + return [] + + total = len(outlines_list) + n_sections = max(1, n_sections) + base_size = total // n_sections + remainder = total % n_sections + + sections: List[List[SlideOutlineModel]] = [] + start = 0 + for section_index in range(n_sections): + current_size = base_size + (1 if section_index < remainder else 0) + end = start + current_size + sections.append(outlines_list[start:end]) + start = end + + return sections + + +def _extract_outline_title(content: str) -> str: + """Get a human-friendly title from an outline's markdown content.""" + text = content or "" + + heading_match = HEADING_PATTERN.search(text) + if heading_match: + return heading_match.group(1).strip() + + sentence_match = FIRST_SENTENCE_PATTERN.search(text.strip()) + if sentence_match: + return sentence_match.group(1).strip() + + for line in text.splitlines(): + stripped_line = line.strip() + if stripped_line: + return stripped_line + + return "Slide" + + +def get_images_for_slides_from_outline( + slides: List[SlideOutlineModel], +) -> List[List[str]]: + """ + Extract image URLs (png, jpg, jpeg, webp) from each slide's content in the outline. + + Args: + outline: PresentationOutlineModel containing slides with content + + Returns: + List of lists of image URLs, one list per slide + """ + result: List[List[str]] = [] + + for slide in slides: + content = slide.content or "" + image_urls = IMAGE_URL_PATTERN.findall(content) + # Remove duplicates while preserving order + unique_urls = list(dict.fromkeys(image_urls)) + result.append(unique_urls) + + return result diff --git a/servers/fastapi/utils/path_helpers.py b/servers/fastapi/utils/path_helpers.py new file mode 100644 index 00000000..424bceac --- /dev/null +++ b/servers/fastapi/utils/path_helpers.py @@ -0,0 +1,21 @@ +"""Paths relative to the FastAPI process working directory (Docker / local dev). + +The API is always started with cwd set to the `servers/fastapi` package root +(see start.js). No Electron, PyInstaller, or OS-specific layout handling. +""" + +from __future__ import annotations + +import os + + +def get_resource_path(relative_path: str) -> str: + """Absolute path to bundled read-only assets (e.g. ``static/``, ``assets/``).""" + return os.path.abspath(os.path.join(os.getcwd(), relative_path)) + + +def get_writable_path(relative_path: str) -> str: + """Absolute path under cwd for caches and generated files; ensures the directory exists.""" + path = os.path.abspath(os.path.join(os.getcwd(), relative_path)) + os.makedirs(path, exist_ok=True) + return path diff --git a/servers/fastapi/utils/process_slides.py b/servers/fastapi/utils/process_slides.py index b9ddb1de..a15ad59c 100644 --- a/servers/fastapi/utils/process_slides.py +++ b/servers/fastapi/utils/process_slides.py @@ -1,5 +1,6 @@ import asyncio -from typing import List, Tuple +import os +from typing import List, Optional, Tuple from models.image_prompt import ImagePrompt from models.sql.image_asset import ImageAsset from models.sql.slide import SlideModel @@ -7,20 +8,33 @@ from services.icon_finder_service import ICON_FINDER_SERVICE from services.image_generation_service import ImageGenerationService from utils.asset_directory_utils import get_images_directory from utils.dict_utils import get_dict_at_path, get_dict_paths_with_key, set_dict_at_path +from utils.path_helpers import get_resource_path async def process_slide_and_fetch_assets( image_generation_service: ImageGenerationService, slide: SlideModel, + outline_image_urls: Optional[List[str]] = None, ) -> List[ImageAsset]: async_tasks = [] + async_task_meta = [] image_paths = get_dict_paths_with_key(slide.content, "__image_prompt__") icon_paths = get_dict_paths_with_key(slide.content, "__icon_query__") - for image_path in image_paths: + for image_index, image_path in enumerate(image_paths): __image_prompt__parent = get_dict_at_path(slide.content, image_path) + + if ( + outline_image_urls + and image_index < len(outline_image_urls) + and outline_image_urls[image_index] + ): + __image_prompt__parent["__image_url__"] = outline_image_urls[image_index] + set_dict_at_path(slide.content, image_path, __image_prompt__parent) + continue + async_tasks.append( image_generation_service.generate_image( ImagePrompt( @@ -28,36 +42,37 @@ async def process_slide_and_fetch_assets( ) ) ) + async_task_meta.append(("image", image_path)) for icon_path in icon_paths: __icon_query__parent = get_dict_at_path(slide.content, icon_path) async_tasks.append( ICON_FINDER_SERVICE.search_icons(__icon_query__parent["__icon_query__"]) ) + async_task_meta.append(("icon", icon_path)) - results = await asyncio.gather(*async_tasks) - results.reverse() + results = await asyncio.gather(*async_tasks) if async_tasks else [] return_assets = [] - for image_path in image_paths: - image_dict = get_dict_at_path(slide.content, image_path) - result = results.pop() - if isinstance(result, ImageAsset): - return_assets.append(result) - image_dict["__image_url__"] = result.path - else: - image_dict["__image_url__"] = result - set_dict_at_path(slide.content, image_path, image_dict) + for (task_type, asset_path), result in zip(async_task_meta, results): + if task_type == "image": + image_dict = get_dict_at_path(slide.content, asset_path) + if isinstance(result, ImageAsset): + return_assets.append(result) + image_dict["__image_url__"] = result.file_url + else: + image_dict["__image_url__"] = result + set_dict_at_path(slide.content, asset_path, image_dict) + continue - for icon_path in icon_paths: - icon_dict = get_dict_at_path(slide.content, icon_path) - icon_result = results.pop() - if icon_result and len(icon_result) > 0: - icon_dict["__icon_url__"] = icon_result[0] + icon_dict = get_dict_at_path(slide.content, asset_path) + # ICON_FINDER_SERVICE.search_icons returns a list of URLs + if isinstance(result, list) and result: + icon_dict["__icon_url__"] = result[0] else: - # Fallback to placeholder if no icon found + # Fallback to FastAPI static placeholder if no icon found icon_dict["__icon_url__"] = "/static/icons/placeholder.svg" - set_dict_at_path(slide.content, icon_path, icon_dict) + set_dict_at_path(slide.content, asset_path, icon_dict) return return_assets @@ -152,17 +167,17 @@ async def process_old_and_new_slides_and_fetch_assets( new_assets = [] # Sets new image and icon urls for assets that were fetched - for i, new_image in enumerate(new_images): + for i, _ in enumerate(new_images): if new_images_fetch_status[i]: fetched_image = new_images[i] if isinstance(fetched_image, ImageAsset): new_assets.append(fetched_image) - image_url = fetched_image.path + image_url = fetched_image.file_url else: image_url = fetched_image new_image_dicts[i]["__image_url__"] = image_url - for i, new_icon in enumerate(new_icons): + for i, _ in enumerate(new_icons): if new_icons_fetch_status[i]: icon_result = new_icons[i] if icon_result and len(icon_result) > 0: @@ -187,10 +202,12 @@ def process_slide_add_placeholder_assets(slide: SlideModel): for image_path in image_paths: image_dict = get_dict_at_path(slide.content, image_path) + # Use FastAPI static path for placeholder image image_dict["__image_url__"] = "/static/images/placeholder.jpg" set_dict_at_path(slide.content, image_path, image_dict) for icon_path in icon_paths: icon_dict = get_dict_at_path(slide.content, icon_path) + # Use FastAPI static path for placeholder icon icon_dict["__icon_url__"] = "/static/icons/placeholder.svg" set_dict_at_path(slide.content, icon_path, icon_dict) diff --git a/servers/fastapi/utils/schema_utils.py b/servers/fastapi/utils/schema_utils.py index 66e01746..1e0241aa 100644 --- a/servers/fastapi/utils/schema_utils.py +++ b/servers/fastapi/utils/schema_utils.py @@ -1,5 +1,5 @@ from copy import deepcopy -from typing import Any, List, Mapping, Union +from typing import Any, List from openai import NOT_GIVEN @@ -22,51 +22,6 @@ supported_string_formats = [ ] -def _is_json_object(value: object) -> bool: - """True if value is a dict-like object but not a list.""" - return isinstance(value, Mapping) and not isinstance(value, list) - - -def _convert_pydantic_schema(schema: object) -> dict | None: - """Return JSON schema dict from a Pydantic model or class, else None.""" - if BaseModel is None: - return None - if isinstance(schema, BaseModel): - return schema.model_json_schema() - if isinstance(schema, type) and issubclass(schema, BaseModel): - return schema.model_json_schema() - if hasattr(schema, "model_json_schema"): - try: - return getattr(schema, "model_json_schema")() - except TypeError: - return None - return None - - -def normalize_output_schema( - schema: Union[dict, type, object] | None, -) -> dict[str, Any] | None: - """ - Normalize output schema to a plain JSON schema dict (SDK-style). - Accepts a JSON schema dict, a Pydantic model class, or a Pydantic instance. - Returns None if schema is None; otherwise returns a dict suitable for - ResponseSchema / structured output. - """ - if schema is None: - return None - - converted = _convert_pydantic_schema(schema) - if converted is not None: - return converted - - if not _is_json_object(schema): - raise ValueError( - "output_schema must be a plain JSON object (dict) or a Pydantic model" - ) - - return dict(schema) - - def remove_fields_from_schema(schema: dict, fields_to_remove: List[str]): schema = deepcopy(schema) properties_paths = get_dict_paths_with_key(schema, "properties") @@ -179,12 +134,12 @@ def ensure_strict_json_schema( # arrays # { 'type': 'array', 'items': {...} } + # OpenAI requires array schemas to have "items". Zod tuples may emit prefixItems only. items = json_schema.get("items") if isinstance(items, dict): json_schema["items"] = ensure_strict_json_schema( items, path=(*path, "items"), root=root ) - elif typ == "array": prefix_items = json_schema.get("prefixItems") if ( diff --git a/servers/fastapi/utils/set_env.py b/servers/fastapi/utils/set_env.py index 6f26b34f..f626f4df 100644 --- a/servers/fastapi/utils/set_env.py +++ b/servers/fastapi/utils/set_env.py @@ -122,5 +122,17 @@ def set_codex_account_id_env(value: str): os.environ["CODEX_ACCOUNT_ID"] = value +def set_codex_username_env(value: str): + os.environ["CODEX_USERNAME"] = value + + +def set_codex_email_env(value: str): + os.environ["CODEX_EMAIL"] = value + + +def set_codex_is_pro_env(value: str): + os.environ["CODEX_IS_PRO"] = value + + def set_codex_model_env(value: str): os.environ["CODEX_MODEL"] = value diff --git a/servers/fastapi/utils/user_config.py b/servers/fastapi/utils/user_config.py index f83d3047..ab1d91da 100644 --- a/servers/fastapi/utils/user_config.py +++ b/servers/fastapi/utils/user_config.py @@ -32,6 +32,9 @@ from utils.get_env import ( get_codex_refresh_token_env, get_codex_token_expires_env, get_codex_account_id_env, + get_codex_username_env, + get_codex_email_env, + get_codex_is_pro_env, get_codex_model_env, ) from utils.parsers import parse_bool_or_none @@ -64,6 +67,9 @@ from utils.set_env import ( 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, ) @@ -133,6 +139,13 @@ def get_user_config(): CODEX_REFRESH_TOKEN=existing_config.CODEX_REFRESH_TOKEN or get_codex_refresh_token_env(), CODEX_TOKEN_EXPIRES=existing_config.CODEX_TOKEN_EXPIRES or get_codex_token_expires_env(), CODEX_ACCOUNT_ID=existing_config.CODEX_ACCOUNT_ID or get_codex_account_id_env(), + CODEX_USERNAME=existing_config.CODEX_USERNAME or get_codex_username_env(), + CODEX_EMAIL=existing_config.CODEX_EMAIL or get_codex_email_env(), + CODEX_IS_PRO=( + existing_config.CODEX_IS_PRO + if existing_config.CODEX_IS_PRO is not None + else parse_bool_or_none(get_codex_is_pro_env()) + ), ) @@ -196,6 +209,12 @@ def update_env_with_user_config(): set_codex_token_expires_env(user_config.CODEX_TOKEN_EXPIRES) if user_config.CODEX_ACCOUNT_ID: set_codex_account_id_env(user_config.CODEX_ACCOUNT_ID) + if user_config.CODEX_USERNAME: + set_codex_username_env(user_config.CODEX_USERNAME) + if user_config.CODEX_EMAIL: + set_codex_email_env(user_config.CODEX_EMAIL) + if user_config.CODEX_IS_PRO is not None: + set_codex_is_pro_env(str(user_config.CODEX_IS_PRO)) def save_codex_tokens_to_user_config() -> None: @@ -220,6 +239,9 @@ def save_codex_tokens_to_user_config() -> None: existing["CODEX_REFRESH_TOKEN"] = get_codex_refresh_token_env() existing["CODEX_TOKEN_EXPIRES"] = get_codex_token_expires_env() existing["CODEX_ACCOUNT_ID"] = get_codex_account_id_env() + existing["CODEX_USERNAME"] = get_codex_username_env() + existing["CODEX_EMAIL"] = get_codex_email_env() + existing["CODEX_IS_PRO"] = parse_bool_or_none(get_codex_is_pro_env()) try: with open(user_config_path, "w") as f: diff --git a/servers/fastapi/utils/validators.py b/servers/fastapi/utils/validators.py index 6b405ccf..37e56209 100644 --- a/servers/fastapi/utils/validators.py +++ b/servers/fastapi/utils/validators.py @@ -1,9 +1,25 @@ +from pathlib import Path from typing import List from fastapi import HTTPException from fastapi import UploadFile +def _is_accepted_file_type(file: UploadFile, accepted_types: List[str]) -> bool: + accepted_mime_types = {t.lower() for t in accepted_types if not t.startswith(".")} + accepted_extensions = {t.lower() for t in accepted_types if t.startswith(".")} + + content_type = (file.content_type or "").strip().lower() + if content_type in accepted_mime_types: + return True + + extension = Path(file.filename or "").suffix.lower() + if extension in accepted_extensions: + return True + + return False + + def validate_files( field, nullable: bool, @@ -15,12 +31,14 @@ def validate_files( if field: files: List[UploadFile] = field if multiple else [field] for each_file in files: - if (max_size * 1024 * 1024) < each_file.size: + file_size = each_file.size or 0 + + if (max_size * 1024 * 1024) < file_size: raise HTTPException( 400, detail=f"File '{each_file.filename}' exceeded max upload size of {max_size} MB", ) - elif each_file.content_type not in accepted_types: + elif not _is_accepted_file_type(each_file, accepted_types): raise HTTPException( 400, detail=f"File '{each_file.filename}' not accepted. Accepted types: {accepted_types}", diff --git a/servers/fastapi/uv.lock b/servers/fastapi/uv.lock index f21c3a60..41c8977a 100644 --- a/servers/fastapi/uv.lock +++ b/servers/fastapi/uv.lock @@ -770,6 +770,40 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/a6/5aa862489a2918a096166fd98d9fe86b7fd53c607678b3fa9d8c432d88d5/fastapi_cloud_cli-0.1.5-py3-none-any.whl", hash = "sha256:d80525fb9c0e8af122370891f9fa83cf5d496e4ad47a8dd26c0496a6c85a012a", size = 18992, upload-time = "2025-07-28T13:30:47.427Z" }, ] +[[package]] +name = "fastembed" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub" }, + { name = "loguru" }, + { name = "mmh3" }, + { name = "numpy" }, + { name = "onnxruntime" }, + { name = "pillow" }, + { name = "py-rust-stemmers" }, + { name = "requests" }, + { name = "tokenizers" }, + { name = "tqdm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/25/58865e36b6e8a9a0d0ff905b5601aa30db97956327c0df42ec4ed6accc21/fastembed-0.8.0.tar.gz", hash = "sha256:75966edfa8b006ee78514c726bd7f6a50721dadc89305279052be9db72fd53e8", size = 75115, upload-time = "2026-03-23T16:34:41.699Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/e8/26b7d78bb8972498c467ca34cb12ee2e60d26ba5eae6d8443189a1af37a5/fastembed-0.8.0-py3-none-any.whl", hash = "sha256:40bee672657574a1009e35ec50030a55f2b426842cb011845379817641bbbbd0", size = 116572, upload-time = "2026-03-23T16:34:40.69Z" }, +] + +[[package]] +name = "fastembed-vectorstore" +version = "0.5.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fastembed" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/d9/02b99231016586081f6f1200ebf592a64ea860b1c37403a50ad0b6c7db21/fastembed_vectorstore-0.5.3.tar.gz", hash = "sha256:0c00aa9886840d77f11e637185c34edc7ec3e1aa3e319dd4c3268141d21aec8e", size = 4813, upload-time = "2026-02-18T18:49:16.338Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/33/2e28641dd141bd1002d71b8f604076f0201a675ec50ae969422ef1556230/fastembed_vectorstore-0.5.3-py3-none-any.whl", hash = "sha256:d4e3fb506fcc02c46373ad5dcac829f52a988f422f3354281622c6128bb3cc89", size = 5753, upload-time = "2026-02-18T18:49:14.868Z" }, +] + [[package]] name = "fastmcp" version = "2.11.0" @@ -1258,6 +1292,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e7/1e/fb441c07b6662ec1fc92b249225ba6e6e5221b05623cb0131d082f782edc/lazy_object_proxy-1.11.0-py3-none-any.whl", hash = "sha256:a56a5093d433341ff7da0e89f9b486031ccd222ec8e52ec84d0ec1cdc819674b", size = 16635, upload-time = "2025-04-16T16:53:47.198Z" }, ] +[[package]] +name = "loguru" +version = "0.7.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "win32-setctime", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" }, +] + [[package]] name = "lxml" version = "5.4.0" @@ -1946,6 +1993,7 @@ dependencies = [ { name = "dirtyjson" }, { name = "docling" }, { name = "fastapi", extra = ["standard"] }, + { name = "fastembed-vectorstore" }, { name = "fastmcp" }, { name = "google-genai" }, { name = "nltk" }, @@ -1970,6 +2018,7 @@ requires-dist = [ { name = "dirtyjson", specifier = ">=1.0.8" }, { name = "docling", specifier = ">=2.43.0" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.116.1" }, + { name = "fastembed-vectorstore", specifier = ">=0.5.2" }, { name = "fastmcp", specifier = ">=2.11.0" }, { name = "google-genai", specifier = ">=1.28.0" }, { name = "nltk", specifier = ">=3.9.1" }, @@ -2036,6 +2085,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/50/1b/6921afe68c74868b4c9fa424dad3be35b095e16687989ebbb50ce4fceb7c/psutil-7.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:4cf3d4eb1aa9b348dec30105c55cd9b7d4629285735a102beb4441e38db90553", size = 244885, upload-time = "2025-02-13T21:54:37.486Z" }, ] +[[package]] +name = "py-rust-stemmers" +version = "0.1.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/63/4fbc14810c32d2a884e2e94e406a7d5bf8eee53e1103f558433817230342/py_rust_stemmers-0.1.5.tar.gz", hash = "sha256:e9c310cfb5c2470d7c7c8a0484725965e7cab8b1237e106a0863d5741da3e1f7", size = 9388, upload-time = "2025-02-19T13:56:28.708Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/9b/6b11f843c01d110db58a68ec4176cb77b37f03268831742a7241f4810fe4/py_rust_stemmers-0.1.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:e644987edaf66919f5a9e4693336930f98d67b790857890623a431bb77774c84", size = 286085, upload-time = "2025-02-19T13:55:08.484Z" }, + { url = "https://files.pythonhosted.org/packages/f2/d1/e16b587dc0ebc42916b1caad994bc37fbb19ad2c7e3f5f3a586ba2630c16/py_rust_stemmers-0.1.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:910d87d39ba75da1fe3d65df88b926b4b454ada8d73893cbd36e258a8a648158", size = 272019, upload-time = "2025-02-19T13:55:10.268Z" }, + { url = "https://files.pythonhosted.org/packages/41/66/8777f125720acb896b336e6f8153e3ec39754563bc9b89523cfe06ba63da/py_rust_stemmers-0.1.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:31ff4fb9417cec35907c18a6463e3d5a4941a5aa8401f77fbb4156b3ada69e3f", size = 310547, upload-time = "2025-02-19T13:55:11.521Z" }, + { url = "https://files.pythonhosted.org/packages/f1/f5/b79249c787c59b9ce2c5d007c0a0dc0fc1ecccfcf98a546c131cca55899e/py_rust_stemmers-0.1.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07b3b8582313ef8a7f544acf2c887f27c3dd48c5ddca028fa0f498de7380e24f", size = 315238, upload-time = "2025-02-19T13:55:13.39Z" }, + { url = "https://files.pythonhosted.org/packages/62/4c/c05c266ed74c063ae31dc5633ed63c48eb3b78034afcc80fe755d0cb09e7/py_rust_stemmers-0.1.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:804944eeb5c5559443d81f30c34d6e83c6292d72423f299e42f9d71b9d240941", size = 324420, upload-time = "2025-02-19T13:55:15.292Z" }, + { url = "https://files.pythonhosted.org/packages/7f/65/feb83af28095397466e6e031989ff760cc89b01e7da169e76d4cf16a2252/py_rust_stemmers-0.1.5-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:c52c5c326de78c70cfc71813fa56818d1bd4894264820d037d2be0e805b477bd", size = 324791, upload-time = "2025-02-19T13:55:16.45Z" }, + { url = "https://files.pythonhosted.org/packages/20/3e/162be2f9c1c383e66e510218d9d4946c8a84ee92c64f6d836746540e915f/py_rust_stemmers-0.1.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8f374c0f26ef35fb87212686add8dff394bcd9a1364f14ce40fe11504e25e30", size = 488014, upload-time = "2025-02-19T13:55:18.486Z" }, + { url = "https://files.pythonhosted.org/packages/a0/ee/ed09ce6fde1eefe50aa13a8a8533aa7ebe3cc096d1a43155cc71ba28d298/py_rust_stemmers-0.1.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:0ae0540453843bc36937abb54fdbc0d5d60b51ef47aa9667afd05af9248e09eb", size = 575581, upload-time = "2025-02-19T13:55:19.669Z" }, + { url = "https://files.pythonhosted.org/packages/7b/31/2a48960a072e54d7cc244204d98854d201078e1bb5c68a7843a3f6d21ced/py_rust_stemmers-0.1.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:85944262c248ea30444155638c9e148a3adc61fe51cf9a3705b4055b564ec95d", size = 493269, upload-time = "2025-02-19T13:55:21.532Z" }, + { url = "https://files.pythonhosted.org/packages/91/33/872269c10ca35b00c5376159a2a0611a0f96372be16b616b46b3d59d09fe/py_rust_stemmers-0.1.5-cp311-none-win_amd64.whl", hash = "sha256:147234020b3eefe6e1a962173e41d8cf1dbf5d0689f3cd60e3022d1ac5c2e203", size = 209399, upload-time = "2025-02-19T13:55:22.639Z" }, +] + [[package]] name = "pyasn1" version = "0.6.1" @@ -2909,7 +2976,7 @@ dependencies = [ { name = "typing-extensions", marker = "sys_platform == 'darwin'" }, ] wheels = [ - { url = "https://download.pytorch.org/whl/cpu/torch-2.7.1-cp311-none-macosx_11_0_arm64.whl", hash = "sha256:68a352c7f435abb5cb47e2c032dcd1012772ae2bacb6fc8b83b0c1b11874ab3a" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.7.1-cp311-none-macosx_11_0_arm64.whl", hash = "sha256:68a352c7f435abb5cb47e2c032dcd1012772ae2bacb6fc8b83b0c1b11874ab3a" }, ] [[package]] @@ -2929,9 +2996,9 @@ dependencies = [ { name = "typing-extensions", marker = "sys_platform != 'darwin'" }, ] wheels = [ - { url = "https://download.pytorch.org/whl/cpu/torch-2.7.1%2Bcpu-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:5fe6045b8f426bf2d0426e4fe009f1667a954ec2aeb82f1bd0bf60c6d7a85445" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.7.1%2Bcpu-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:a1684793e352f03fa14f78857e55d65de4ada8405ded1da2bf4f452179c4b779" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.7.1%2Bcpu-cp311-cp311-win_amd64.whl", hash = "sha256:7b977eccbc85ae2bd19d6998de7b1f1f4bd3c04eaffd3015deb7934389783399" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.7.1%2Bcpu-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:5fe6045b8f426bf2d0426e4fe009f1667a954ec2aeb82f1bd0bf60c6d7a85445" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.7.1%2Bcpu-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:a1684793e352f03fa14f78857e55d65de4ada8405ded1da2bf4f452179c4b779" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.7.1%2Bcpu-cp311-cp311-win_amd64.whl", hash = "sha256:7b977eccbc85ae2bd19d6998de7b1f1f4bd3c04eaffd3015deb7934389783399" }, ] [[package]] @@ -2949,8 +3016,8 @@ dependencies = [ { name = "torch", version = "2.7.1+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "platform_machine == 'aarch64' and sys_platform == 'linux'" }, ] wheels = [ - { url = "https://download.pytorch.org/whl/cpu/torchvision-0.22.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4addf626e2b57fc22fd6d329cf1346d474497672e6af8383b7b5b636fba94a53" }, - { url = "https://download.pytorch.org/whl/cpu/torchvision-0.22.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:8b4a53a6067d63adba0c52f2b8dd2290db649d642021674ee43c0c922f0c6a69" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.22.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4addf626e2b57fc22fd6d329cf1346d474497672e6af8383b7b5b636fba94a53" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.22.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:8b4a53a6067d63adba0c52f2b8dd2290db649d642021674ee43c0c922f0c6a69" }, ] [[package]] @@ -2966,8 +3033,8 @@ dependencies = [ { name = "torch", version = "2.7.1+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" }, ] wheels = [ - { url = "https://download.pytorch.org/whl/cpu/torchvision-0.22.1%2Bcpu-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:4e0cbc165a472605d0c13da68ae22e84b17a6b815d5e600834777823e1bcb658" }, - { url = "https://download.pytorch.org/whl/cpu/torchvision-0.22.1%2Bcpu-cp311-cp311-win_amd64.whl", hash = "sha256:9482adee074f60a45fd69892f7488281aadfda7836948c94b0a9b0caf55d1d67" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.22.1%2Bcpu-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:4e0cbc165a472605d0c13da68ae22e84b17a6b815d5e600834777823e1bcb658" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.22.1%2Bcpu-cp311-cp311-win_amd64.whl", hash = "sha256:9482adee074f60a45fd69892f7488281aadfda7836948c94b0a9b0caf55d1d67" }, ] [[package]] @@ -3164,6 +3231,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/ea/c67e1dee1ba208ed22c06d1d547ae5e293374bfc43e0eb0ef5e262b68561/werkzeug-3.1.1-py3-none-any.whl", hash = "sha256:a71124d1ef06008baafa3d266c02f56e1836a5984afd6dd6c9230669d60d9fb5", size = 224371, upload-time = "2024-11-01T16:40:43.994Z" }, ] +[[package]] +name = "win32-setctime" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" }, +] + [[package]] name = "xlsxwriter" version = "3.2.5" diff --git a/servers/nextjs/app/(presentation-generator)/(dashboard)/Components/MarketOpportunitySlide.tsx b/servers/nextjs/app/(presentation-generator)/(dashboard)/Components/MarketOpportunitySlide.tsx new file mode 100755 index 00000000..55028a94 --- /dev/null +++ b/servers/nextjs/app/(presentation-generator)/(dashboard)/Components/MarketOpportunitySlide.tsx @@ -0,0 +1,133 @@ +import * as z from "zod"; + +export const slideLayoutId = "product-overview-market-opportunity-slide"; +export const slideLayoutName = "Product Overview Market Opportunity Slide"; +export const slideLayoutDescription = + "A market opportunity slide with title and intro text on the left, four bullet lines extending toward the right, and concentric value circles as the visual focal point."; + +const BulletSchema = z.object({ + text: z.string().min(12).max(46).meta({ + description: "Bullet text shown on the left side of a line.", + }), +}); + +export const Schema = z.object({ + title: z.string().min(8).max(22).default("Market Opportunity").meta({ + description: "Main heading shown at the top-left.", + }), + subtitle: z.string().min(40).max(110).default( + "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt." + ).meta({ + description: "Supporting text under the main heading.", + }), + bullets: z + .array(BulletSchema) + .min(4) + .max(4) + .default([ + { text: "Ut enim ad minim veniam, quis" }, + { text: "Ut enim ad minim veniam, quis" }, + { text: "Ut enim ad minim veniam, quis" }, + { text: "Ut enim ad minim veniam, quis" }, + ]) + .meta({ + description: "Four bullet-line entries shown on the left.", + }), + values: z + .array(z.string().min(2).max(6)) + .min(4) + .max(4) + .default(["$33", "$20", "$120", "$200"]) + .meta({ + description: "Four values shown from outer to inner circles.", + }), +}); + +export type SchemaType = z.infer; + + +const COLORS = [ + "var(--graph-0,#5f7f79)", + "var(--graph-1,#1f5a4f)", + "var(--graph-2,#0d4f43)", + "var(--graph-3,#06463d)", +]; + +const MarketOpportunitySlide = ({ data }: { data: Partial }) => { + const { title, subtitle, bullets, values } = data; + + return ( +
+
+

+ {title} +

+

+ {subtitle} +

+
+ +
+ {bullets?.map((bullet, index) => ( +
+ +

+ {bullet.text} +

+ + +
+ ))} +
+ +
+ {values?.map((value, index) => ( +
+

+ {value} +

+
+ ))} +
+
+ ); +}; + +export default MarketOpportunitySlide; diff --git a/servers/nextjs/app/(presentation-generator)/(dashboard)/settings/PrivacySettings.tsx b/servers/nextjs/app/(presentation-generator)/(dashboard)/settings/PrivacySettings.tsx new file mode 100644 index 00000000..c16e5f84 --- /dev/null +++ b/servers/nextjs/app/(presentation-generator)/(dashboard)/settings/PrivacySettings.tsx @@ -0,0 +1,104 @@ +"use client"; +import React, { useEffect, useState } from "react"; +import { Switch } from "@/components/ui/switch"; +import { setTelemetryEnabled } from "@/utils/mixpanel"; +import { Loader2 } from "lucide-react"; + +const PrivacySettings = () => { + const [trackingEnabled, setTrackingEnabled] = useState(null); + const [saving, setSaving] = useState(false); + + useEffect(() => { + async function fetchStatus() { + try { + if (window.electron?.telemetryStatus) { + const data = await window.electron.telemetryStatus(); + setTrackingEnabled(data.telemetryEnabled); + } else { + const res = await fetch("/api/telemetry-status"); + const data = await res.json(); + setTrackingEnabled(data.telemetryEnabled); + } + } catch { + setTrackingEnabled(true); + } + } + fetchStatus(); + }, []); + + const handleTrackingToggle = async (enabled: boolean) => { + const prev = trackingEnabled; + setTrackingEnabled(enabled); + setTelemetryEnabled(enabled); + setSaving(true); + try { + if (window.electron?.setUserConfig) { + await window.electron.setUserConfig({ + DISABLE_ANONYMOUS_TRACKING: enabled ? undefined : "true", + } as any); + } else { + await fetch("/api/user-config", { + method: "POST", + body: JSON.stringify({ + DISABLE_ANONYMOUS_TRACKING: enabled ? undefined : "true", + }), + }); + } + } catch { + setTrackingEnabled(prev); + setTelemetryEnabled(prev ?? true); + } finally { + setSaving(false); + } + }; + + if (trackingEnabled === null) { + return ( +
+ +
+ ); + } + + return ( +
+
+

+ Usage analytics +

+

+ Share anonymous usage data to help us improve Presenton. No personal information or presentation content is collected. +

+ +
+
+ +

+ {trackingEnabled + ? "Anonymous usage data is being shared." + : "Anonymous usage data is not being shared"} +

+
+
+ {saving && ( + + )} + +
+
+
+
+ ); +}; + +export default PrivacySettings; diff --git a/servers/nextjs/app/(presentation-generator)/(dashboard)/settings/SettingCodex.tsx b/servers/nextjs/app/(presentation-generator)/(dashboard)/settings/SettingCodex.tsx new file mode 100644 index 00000000..8b4e8b9a --- /dev/null +++ b/servers/nextjs/app/(presentation-generator)/(dashboard)/settings/SettingCodex.tsx @@ -0,0 +1,407 @@ +"use client"; +import { useEffect, useRef, useState } from "react"; +import { + Check, + ChevronUp, + Loader2, + RefreshCw, + Trash2, + Crown, + User, + UserCheck, +} from "lucide-react"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; +import { toast } from "sonner"; +import { getApiUrl } from "@/utils/api"; +import { Button } from "@/components/ui/button"; + +interface CodexConfigProps { + codexModel: string; + onInputChange: (value: string | boolean, field: string) => void; +} + +type AuthStatus = "checking" | "unauthenticated" | "polling" | "authenticated"; + +interface StatusResponse { + status: string; + account_id?: string; + username?: string; + email?: string; + is_pro?: boolean; + detail?: string; +} + +interface CodexModel { + id: string; + name: string; +} + +const CHATGPT_MODELS: CodexModel[] = [ + { id: "gpt-5.1", name: "GPT-5.1" }, + { id: "gpt-5.1-codex-max", name: "GPT-5.1 Codex Max" }, + { id: "gpt-5.2", name: "GPT-5.2" }, + { id: "gpt-5.2-codex", name: "GPT-5.2 Codex" }, + { id: "gpt-5.3-codex", name: "GPT-5.3 Codex" }, + { id: "gpt-5.4-mini", name: "GPT-5.4 Mini" }, + { id: "gpt-5.4", name: "GPT-5.4" }, +]; + +const DEFAULT_CODEX_MODEL = "gpt-5.4-mini"; + +export default function CodexConfig({ + codexModel, + onInputChange, +}: CodexConfigProps) { + const [authStatus, setAuthStatus] = useState("checking"); + const [accountId, setAccountId] = useState(null); + const [username, setUsername] = useState(null); + const [email, setEmail] = useState(null); + const [isPro, setIsPro] = useState(null); + const [sessionId, setSessionId] = useState(null); + const [manualCode, setManualCode] = useState(""); + const [isExchanging, setIsExchanging] = useState(false); + const [isLoggingOut, setIsLoggingOut] = useState(false); + const [isRefreshing, setIsRefreshing] = useState(false); + const [openModelSelect, setOpenModelSelect] = useState(false); + const pollIntervalRef = useRef | null>(null); + + const stopPolling = () => { + if (pollIntervalRef.current) { + clearInterval(pollIntervalRef.current); + pollIntervalRef.current = null; + } + }; + + useEffect(() => { + checkCurrentAuthStatus(); + return () => stopPolling(); + }, []); + + const applyProfile = (data: Partial) => { + setAccountId(data.account_id ?? null); + setUsername(data.username ?? null); + setEmail(data.email ?? null); + setIsPro(typeof data.is_pro === "boolean" ? data.is_pro : null); + }; + + const checkCurrentAuthStatus = async () => { + try { + const res = await fetch(getApiUrl("/api/v1/ppt/codex/auth/status")); + if (!res.ok) { + setAuthStatus("unauthenticated"); + applyProfile({}); + return; + } + const data: StatusResponse = await res.json(); + if (data.status === "authenticated") { + setAuthStatus("authenticated"); + applyProfile(data); + } else { + setAuthStatus("unauthenticated"); + applyProfile({}); + } + } catch { + setAuthStatus("unauthenticated"); + applyProfile({}); + } + }; + + const handleSignIn = async () => { + try { + const res = await fetch(getApiUrl("/api/v1/ppt/codex/auth/initiate"), { + method: "POST", + }); + if (!res.ok) throw new Error("Failed to initiate auth"); + const data = await res.json(); + const { session_id, url } = data; + + setSessionId(session_id); + setAuthStatus("polling"); + window.open(url, "_blank", "noopener,noreferrer"); + + pollIntervalRef.current = setInterval(async () => { + try { + const pollRes = await fetch( + getApiUrl(`/api/v1/ppt/codex/auth/status/${session_id}`) + ); + if (!pollRes.ok) return; + const pollData: StatusResponse = await pollRes.json(); + + if (pollData.status === "success") { + stopPolling(); + setAuthStatus("authenticated"); + applyProfile(pollData); + setSessionId(null); + if (!codexModel) { + onInputChange(DEFAULT_CODEX_MODEL, "codex_model"); + } + toast.success("Signed in to ChatGPT successfully"); + } else if (pollData.status === "failed") { + stopPolling(); + setAuthStatus("unauthenticated"); + applyProfile({}); + toast.error("Authentication failed. Please try again."); + } + } catch { + // keep polling on transient errors + } + }, 2000); + } catch (err) { + toast.error("Failed to start sign-in flow"); + setAuthStatus("unauthenticated"); + applyProfile({}); + } + }; + + const handleManualExchange = async () => { + if (!sessionId || !manualCode.trim()) return; + setIsExchanging(true); + try { + const res = await fetch(getApiUrl("/api/v1/ppt/codex/auth/exchange"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ session_id: sessionId, code: manualCode.trim() }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.detail || "Exchange failed"); + } + const data = await res.json(); + stopPolling(); + setAuthStatus("authenticated"); + applyProfile(data); + setSessionId(null); + setManualCode(""); + if (!codexModel) { + onInputChange(DEFAULT_CODEX_MODEL, "codex_model"); + } + toast.success("Signed in to ChatGPT successfully"); + } catch (err: any) { + toast.error(err.message || "Code exchange failed"); + } finally { + setIsExchanging(false); + } + }; + + const handleCancelPolling = () => { + stopPolling(); + setSessionId(null); + setManualCode(""); + setAuthStatus("unauthenticated"); + }; + + const handleSignOut = async () => { + setIsLoggingOut(true); + try { + await fetch(getApiUrl("/api/v1/ppt/codex/auth/logout"), { method: "POST" }); + setAuthStatus("unauthenticated"); + applyProfile({}); + onInputChange("codex", "LLM"); + onInputChange('', "codex_model"); + toast.success("Signed out from ChatGPT"); + } catch { + toast.error("Sign out failed"); + } finally { + setIsLoggingOut(false); + } + }; + + const handleRefreshToken = async () => { + setIsRefreshing(true); + try { + const res = await fetch(getApiUrl("/api/v1/ppt/codex/auth/refresh"), { + method: "POST", + }); + if (!res.ok) throw new Error("Refresh failed"); + const data = await res.json(); + applyProfile(data); + toast.success("Token refreshed successfully"); + } catch { + toast.error("Token refresh failed. Please sign in again."); + setAuthStatus("unauthenticated"); + applyProfile({}); + } finally { + setIsRefreshing(false); + } + }; + + if (authStatus === "checking") { + return ( +
+ + Checking status… +
+ ); + } + + if (authStatus === "polling") { + return ( +
+
+ + Waiting for sign-in… + +
+ +
+

+ Paste redirect URL or code if not redirected automatically +

+
+ setManualCode(e.target.value)} + /> + +
+
+
+ ); + } + + if (authStatus === "authenticated") { + const planLabel = isPro === true ? "Pro" : isPro === false ? "Free" : "Unknown"; + + return ( +
+
+ +
+
+

+ {username || email || (accountId ? `Account ${accountId}` : "ChatGPT Account")} +

+ +
+ {email && username && ( +

{email}

+ )} + {!email && accountId && ( +

ID: {accountId}

+ )} +

Signed in to ChatGPT

+
+
+ + +
+
+ +
+ + + + + + + + + + No model found. + + {CHATGPT_MODELS.map((model) => ( + { + onInputChange(value, "codex_model"); + setOpenModelSelect(false); + }} + > + + + {model.name} + + + ))} + + + + + +
+
+ ); + } + + return ( + + ); +} diff --git a/servers/nextjs/app/(presentation-generator)/(dashboard)/settings/TextProvider.tsx b/servers/nextjs/app/(presentation-generator)/(dashboard)/settings/TextProvider.tsx index d2d3ac2a..37e8010f 100644 --- a/servers/nextjs/app/(presentation-generator)/(dashboard)/settings/TextProvider.tsx +++ b/servers/nextjs/app/(presentation-generator)/(dashboard)/settings/TextProvider.tsx @@ -6,10 +6,12 @@ import { Switch } from '@/components/ui/switch'; import { cn } from '@/lib/utils'; import { LLMConfig } from '@/types/llm_config'; import { LLM_PROVIDERS } from '@/utils/providerConstants'; -import { Check, Loader2, Eye, EyeOff, ChevronUp, User, RefreshCw, LogOut } from 'lucide-react'; +import { Check, Loader2, Eye, EyeOff, ChevronUp } from 'lucide-react'; import React, { useEffect, useMemo, useRef, useState } from 'react' import { notify } from '@/components/ui/sonner'; import { toast } from 'sonner'; +import { getApiUrl } from '@/utils/api'; +import CodexConfig from '@/components/CodexConfig'; interface OpenAIConfigProps { @@ -46,6 +48,8 @@ const TextProvider = ({ return 'OLLAMA_MODEL'; case 'custom': return 'CUSTOM_MODEL'; + case 'codex': + return 'CODEX_MODEL'; default: return ''; } @@ -119,7 +123,7 @@ const TextProvider = ({ try { let response: Response; if (selectedProvider === 'google') { - response = await fetch('/api/v1/ppt/google/models/available', { + response = await fetch(getApiUrl('/api/v1/ppt/google/models/available'), { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -129,7 +133,7 @@ const TextProvider = ({ }), }); } else if (selectedProvider === 'anthropic') { - response = await fetch('/api/v1/ppt/anthropic/models/available', { + response = await fetch(getApiUrl('/api/v1/ppt/anthropic/models/available'), { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -139,9 +143,9 @@ const TextProvider = ({ }), }); } else if (selectedProvider === 'ollama') { - response = await fetch('/api/v1/ppt/ollama/models/supported'); + response = await fetch(getApiUrl('/api/v1/ppt/ollama/models/supported')); } else { - response = await fetch('/api/v1/ppt/openai/models/available', { + response = await fetch(getApiUrl('/api/v1/ppt/openai/models/available'), { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -228,33 +232,6 @@ const TextProvider = ({

- {selectedProvider === 'codex' && false &&
-
- -
-

Acc: 123-455-acghk

-

Signed in to ChatGPT

-
- -
-
- - - - - - - - -
-
} -
@@ -380,11 +357,17 @@ const TextProvider = ({ )} - ) : selectedProvider === 'codex' ? - <> - - - : ( + ) : selectedProvider === 'codex' ? ( +
+ { + const normalizedField = field === 'codex_model' ? 'CODEX_MODEL' : field; + onInputChange(value, normalizedField); + }} + /> +
+ ) : ( <>