feat: add support for optional embedded Ollama and enhance database migration handling

- Updated docker-compose.yml to allow disabling embedded Ollama via environment variable.
- Refactored Dockerfile and Dockerfile.dev for improved dependency management and installation process.
- Enhanced FastAPI migration scripts to handle orphaned Alembic revisions and added new database migration logic.
- Improved error handling in background tasks and Codex authentication endpoints.
- Added support for font file uploads with better validation and extraction of font names.
- Introduced new image search functionality with support for Pexels and Pixabay APIs.
This commit is contained in:
sudipnext 2026-04-15 15:39:35 +05:45
parent 98cc548984
commit c7860127f2
215 changed files with 20009 additions and 3494 deletions

View file

@ -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: |

View file

@ -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"]
CMD ["node", "/app/start.js"]

View file

@ -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"]

View file

@ -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}

View file

@ -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,

View file

@ -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:

View file

@ -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 ###

View file

@ -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(

View file

@ -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()

View file

@ -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"}

View file

@ -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("<br>", "\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(

View file

@ -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()
# 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)}"
)

View file

@ -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(

View file

@ -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

View file

@ -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()

View file

@ -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")

View file

@ -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(

View file

@ -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",

View file

@ -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"

View file

@ -1 +1,2 @@
DEFAULT_TEMPLATES = ["general", "modern", "standard", "swift"]
MAX_NUMBER_OF_SLIDES = 50

View file

@ -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,
}

View file

@ -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,

View file

@ -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"

View file

@ -8,3 +8,4 @@ class OllamaModelStatus(BaseModel):
downloaded: Optional[int] = None
status: str
done: bool
error: Optional[str] = None

View file

@ -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):

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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(

View file

@ -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))

View file

@ -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

View file

@ -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
)
)

View file

@ -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

View file

@ -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*"]

View file

@ -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",

View file

@ -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(

View file

@ -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()

View file

@ -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:

View file

@ -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 135155).
if strict and depth == 0:
response_schema = ensure_strict_json_schema(
response_schema,

View file

@ -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(

View file

@ -0,0 +1 @@
__all__ = []

View file

@ -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

View file

@ -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(
"""<?xml version='1.0'?>
<!DOCTYPE fontconfig SYSTEM "urn:fontconfig:fonts.dtd">
<fontconfig>
<include>/etc/fonts/fonts.conf</include>
"""
)
for source_family, destination_family in mappings.items():
if source_family == destination_family:
continue
cfg.write(
f"""
<match target="pattern">
<test name="family" compare="eq">
<string>{source_family}</string>
</test>
<edit name="family" mode="assign" binding="strong">
<string>{destination_family}</string>
</edit>
</match>
"""
)
cfg.write("\n</fontconfig>\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,
)

View file

@ -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'"

View file

@ -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",
}
)

View file

@ -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)

View file

@ -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://"):

View file

@ -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")

View file

@ -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,

View file

@ -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,

View file

@ -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
)
]

View file

@ -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 <parameters> 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)

View file

@ -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()

View file

@ -28,17 +28,211 @@ JWT_CLAIM_PATH = "https://api.openai.com/auth"
CALLBACK_PORT = 1455
SUCCESS_HTML = b"""<!doctype html>
# Simple branded success page for Presenton authentication
SUCCESS_HTML = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Authentication successful</title>
<title>Presenton Authentication successful</title>
<style>
:root {
color-scheme: light dark;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Text",
"Segoe UI", sans-serif;
background: radial-gradient(circle at top, #eef2ff 0, #0f172a 55%, #020617 100%);
color: #e5e7eb;
}
.card {
background: rgba(15, 23, 42, 0.9);
border-radius: 18px;
padding: 28px 32px 26px;
box-shadow:
0 18px 45px rgba(15, 23, 42, 0.75),
0 0 0 1px rgba(148, 163, 184, 0.2);
max-width: 440px;
width: 92vw;
text-align: center;
backdrop-filter: blur(18px);
}
h1 {
font-size: 20px;
margin: 4px 0 10px;
color: #e5e7eb;
}
p {
margin: 4px 0;
font-size: 14px;
color: #94a3b8;
}
.pill {
display: inline-flex;
align-items: center;
gap: 6px;
border-radius: 999px;
padding: 4px 10px;
background: rgba(22, 163, 74, 0.12);
color: #bbf7d0;
font-size: 11px;
font-weight: 500;
margin-bottom: 8px;
}
.pill-dot {
width: 8px;
height: 8px;
border-radius: 999px;
background: #22c55e;
box-shadow: 0 0 0 4px rgba(34, 197, 94, 0.25);
}
.hint {
margin-top: 14px;
font-size: 12px;
color: #64748b;
}
</style>
</head>
<body>
<p>Authentication successful. Return to your terminal / application to continue.</p>
<main class="card">
<div class="pill">
<span class="pill-dot"></span>
<span>Authentication successful</span>
</div>
<h1>Youre all set</h1>
<p>You can now return to Presenton to continue.</p>
<p class="hint">This window can be safely closed.</p>
</main>
</body>
</html>"""
</html>""".encode("utf-8")
STATE_MISMATCH_HTML = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Presenton Authentication issue</title>
<style>
:root { color-scheme: light dark; }
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Text",
"Segoe UI", sans-serif;
background: radial-gradient(circle at top, #fef3c7 0, #0f172a 55%, #020617 100%);
color: #e5e7eb;
}
.card {
background: rgba(15, 23, 42, 0.94);
border-radius: 18px;
padding: 26px 30px 24px;
box-shadow:
0 18px 45px rgba(15, 23, 42, 0.78),
0 0 0 1px rgba(248, 250, 252, 0.09);
max-width: 440px;
width: 92vw;
text-align: center;
backdrop-filter: blur(18px);
}
h1 {
font-size: 18px;
margin: 4px 0 8px;
color: #fee2e2;
}
p {
margin: 4px 0;
font-size: 13px;
color: #cbd5f5;
}
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
border-radius: 999px;
padding: 4px 10px;
background: rgba(239, 68, 68, 0.14);
color: #fecaca;
font-size: 11px;
font-weight: 500;
margin-bottom: 10px;
}
.badge-dot {
width: 7px;
height: 7px;
border-radius: 999px;
background: #f97316;
box-shadow: 0 0 0 4px rgba(248, 171, 85, 0.32);
}
button {
margin-top: 14px;
border-radius: 999px;
padding: 7px 16px;
border: 0;
background: linear-gradient(135deg, #4f46e5, #22c55e);
color: #f9fafb;
font-size: 13px;
font-weight: 500;
cursor: pointer;
box-shadow:
0 10px 25px rgba(59, 130, 246, 0.55),
0 0 0 1px rgba(15, 23, 42, 0.85);
}
button:active {
transform: translateY(1px);
box-shadow:
0 4px 16px rgba(59, 130, 246, 0.55),
0 0 0 1px rgba(15, 23, 42, 0.85);
}
.hint {
margin-top: 10px;
font-size: 11px;
color: #9ca3af;
}
</style>
<script>
// Gentle auto-reload after a short delay to recover from stale callback windows.
setTimeout(function () {
try {
window.location.reload();
} catch (e) {
/* ignore */
}
}, 2500);
function reloadNow() {
try {
window.location.reload();
} catch (e) {
/* ignore */
}
}
</script>
</head>
<body>
<main class="card">
<div class="badge">
<span class="badge-dot"></span>
<span>We noticed something unexpected</span>
</div>
<h1>Almost there</h1>
<p>We detected a small mismatch while completing authentication.</p>
<p>Well gently reload this page. If the issue persists, close this window and restart sign-in from Presenton.</p>
<button type="button" onclick="reloadNow()">Reload this page</button>
<p class="hint">You can also safely close this window and try again from the app.</p>
</main>
</body>
</html>""".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))

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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 (

View file

@ -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

View file

@ -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:

View file

@ -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}",

View file

@ -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"

View file

@ -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<typeof Schema>;
const COLORS = [
"var(--graph-0,#5f7f79)",
"var(--graph-1,#1f5a4f)",
"var(--graph-2,#0d4f43)",
"var(--graph-3,#06463d)",
];
const MarketOpportunitySlide = ({ data }: { data: Partial<SchemaType> }) => {
const { title, subtitle, bullets, values } = data;
return (
<div
className="relative h-[720px] w-[1280px] overflow-hidden rounded-[24px]"
style={{
backgroundColor: "var(--background-color,#DAE1DE)",
fontFamily: "var(--body-font-family,'Bricolage Grotesque')",
}}
>
<div className="px-[56px] pt-[72px]">
<h2
className="text-[80px] font-semibold leading-[108.4%] tracking-[-2.419px] text-[#15342D]"
style={{ color: "var(--primary-color,#15342D)" }}
>
{title}
</h2>
<p
className="mt-[20px] w-[730px] text-[24px] font-normal text-[#15342DCC]"
style={{ color: "var(--background-text,#15342DCC)" }}
>
{subtitle}
</p>
</div>
<div className="absolute left-[56px] top-[368px] space-y-[42px]">
{bullets?.map((bullet, index) => (
<div key={index} className="relative flex items-center">
<span
className="mr-[14px] h-[14px] w-[14px] rounded-full bg-[#0a4a3f]"
style={{ backgroundColor: "var(--graph-0,#0a4a3f)" }}
/>
<p
className="w-[640px] text-[24px] font-normal text-[#15342DCC]"
style={{ color: "var(--background-text,#15342DCC)" }}
>
{bullet.text}
</p>
<span
className="ml-[8px] h-[2px] w-[80px] bg-[#8ea8a5]"
style={{ backgroundColor: "var(--stroke,#8ea8a5)" }}
/>
<span
className="h-[6px] w-[6px] rounded-full bg-[#edf2f1]"
style={{ backgroundColor: "var(--primary-text,#edf2f1)" }}
/>
</div>
))}
</div>
<div className="absolute bottom-[58px] right-[48px] h-[474px] w-[474px]">
{values?.map((value, index) => (
<div
key={index}
className="absolute rounded-full"
style={{
width: 237 + (index * 50),
height: 237 + (index * 50),
bottom: 0,
right: 0,
backgroundColor: COLORS[index],
}}
>
<p
className="pt-[24px] text-center text-[24px] font-normal text-white"
style={{ color: "var(--primary-text,#ffffff)" }}
>
{value}
</p>
</div>
))}
</div>
</div>
);
};
export default MarketOpportunitySlide;

View file

@ -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<boolean | null>(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 (
<div className="w-full bg-[#F9F8F8] p-7 rounded-[20px] flex items-center justify-center min-h-[200px]">
<Loader2 className="w-5 h-5 animate-spin text-[#5146E5]" />
</div>
);
}
return (
<div className="w-full space-y-6">
<div className="bg-[#F9F8F8] p-7 rounded-[20px]">
<h4 className="text-sm font-semibold text-[#191919] mb-1">
Usage analytics
</h4>
<p className="text-xs text-[#6B7280] mb-6 leading-relaxed max-w-lg">
Share anonymous usage data to help us improve Presenton. No personal information or presentation content is collected.
</p>
<div className="flex items-center justify-between gap-4 rounded-[10px] bg-white border border-[#EDEEEF] p-4">
<div>
<label
htmlFor="tracking-toggle"
className="text-sm font-medium text-[#191919] cursor-pointer select-none block"
>
Share anonymous usage data
</label>
<p className="text-xs text-[#9CA3AF] mt-0.5">
{trackingEnabled
? "Anonymous usage data is being shared."
: "Anonymous usage data is not being shared"}
</p>
</div>
<div className="flex items-center gap-2">
{saving && (
<Loader2 className="w-3.5 h-3.5 animate-spin text-[#9CA3AF]" />
)}
<Switch
id="tracking-toggle"
checked={trackingEnabled}
onCheckedChange={handleTrackingToggle}
disabled={saving}
/>
</div>
</div>
</div>
</div>
);
};
export default PrivacySettings;

View file

@ -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<AuthStatus>("checking");
const [accountId, setAccountId] = useState<string | null>(null);
const [username, setUsername] = useState<string | null>(null);
const [email, setEmail] = useState<string | null>(null);
const [isPro, setIsPro] = useState<boolean | null>(null);
const [sessionId, setSessionId] = useState<string | null>(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<ReturnType<typeof setInterval> | null>(null);
const stopPolling = () => {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
};
useEffect(() => {
checkCurrentAuthStatus();
return () => stopPolling();
}, []);
const applyProfile = (data: Partial<StatusResponse>) => {
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 (
<div className="flex items-center gap-2 py-3 text-gray-400">
<Loader2 className="w-4 h-4 animate-spin" />
<span className="text-xs">Checking status</span>
</div>
);
}
if (authStatus === "polling") {
return (
<div className="space-y-4">
<div className="flex items-center gap-3 py-2">
<Loader2 className="w-4 h-4 text-gray-500 animate-spin" />
<span className="text-sm text-gray-600">Waiting for sign-in</span>
<button
onClick={handleCancelPolling}
className="text-xs text-gray-400 hover:text-gray-600 underline underline-offset-2 ml-auto"
>
Cancel
</button>
</div>
<div className="space-y-2">
<p className="text-xs text-gray-400">
Paste redirect URL or code if not redirected automatically
</p>
<div className="flex gap-2">
<input
type="text"
placeholder="Paste URL or code…"
className="flex-1 px-2 py-2 outline-none border border-gray-300 rounded-lg text-xs focus:border-gray-400 transition-colors"
value={manualCode}
onChange={(e) => setManualCode(e.target.value)}
/>
<button
onClick={handleManualExchange}
disabled={isExchanging || !manualCode.trim()}
className="px-3 py-2 bg-[#EDEEEF] hover:bg-[#E4E5E6] disabled:opacity-40 rounded-lg text-xs font-medium text-[#101323] transition-colors"
>
{isExchanging ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
"Submit"
)}
</button>
</div>
</div>
</div>
);
}
if (authStatus === "authenticated") {
const planLabel = isPro === true ? "Pro" : isPro === false ? "Free" : "Unknown";
return (
<div className="space-y-4">
<div className="flex items-center gap-3 p-3 border border-[#EDEEEF] rounded-lg">
<UserCheck className="w-5 h-5 text-black shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 min-w-0">
<p className="text-sm font-medium text-gray-800 truncate">
{username || email || (accountId ? `Account ${accountId}` : "ChatGPT Account")}
</p>
</div>
{email && username && (
<p className="text-xs text-gray-500 truncate">{email}</p>
)}
{!email && accountId && (
<p className="text-xs text-gray-500 truncate">ID: {accountId}</p>
)}
<p className="text-xs text-gray-400">Signed in to ChatGPT</p>
</div>
<div className="flex gap-1.5 shrink-0">
<button
onClick={handleRefreshToken}
disabled={isRefreshing}
title="Refresh token"
className="w-8 h-8 flex items-center justify-center rounded-full bg-[#EDEEEF] hover:bg-[#E4E5E6] disabled:opacity-40 transition-colors"
>
{isRefreshing ? (
<Loader2 className="w-3.5 h-3.5 animate-spin text-gray-500" />
) : (
<RefreshCw className="w-3.5 h-3.5 text-gray-500" />
)}
</button>
<button
onClick={handleSignOut}
disabled={isLoggingOut}
title="Sign out"
className="w-8 h-8 flex items-center justify-center rounded-full bg-[#EDEEEF] hover:bg-[#E4E5E6] disabled:opacity-40 transition-colors"
>
{isLoggingOut ? (
<Loader2 className="w-3.5 h-3.5 animate-spin text-gray-500" />
) : (
<Trash2 className="w-3.5 h-3.5 text-gray-500" />
)}
</button>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Select GPT Model
</label>
<Popover open={openModelSelect} onOpenChange={setOpenModelSelect}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={openModelSelect}
className="w-full h-10 px-3 outline-none border border-gray-300 rounded-lg hover:border-gray-400 justify-between"
>
<span className="text-sm text-gray-900">
{codexModel
? (CHATGPT_MODELS.find((m) => m.id === codexModel)?.name ?? codexModel)
: "Select a model"}
</span>
<ChevronUp className="w-4 h-4 text-gray-400" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
align="start"
style={{ width: "var(--radix-popover-trigger-width)" }}
>
<Command>
<CommandInput placeholder="Search models…" />
<CommandList>
<CommandEmpty>No model found.</CommandEmpty>
<CommandGroup>
{CHATGPT_MODELS.map((model) => (
<CommandItem
key={model.id}
value={model.id}
onSelect={(value) => {
onInputChange(value, "codex_model");
setOpenModelSelect(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
codexModel === model.id ? "opacity-100" : "opacity-0"
)}
/>
<span className="text-sm text-gray-900">
{model.name}
</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
</div>
);
}
return (
<button
onClick={handleSignIn}
className="mt-8 py-2.5 px-3.5 bg-[#EDEEEF] hover:bg-[#E4E5E6] rounded-[48px] text-xs font-semibold text-[#101323] transition-colors"
>
Sign in with ChatGPT
</button>
);
}

View file

@ -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 = ({
</p>
</div>
<div>
{selectedProvider === 'codex' && false && <div className='border border-[#EDEEEF] mb-4 rounded-[8px] p-5 flex justify-between items-center'>
<div className='flex items-center gap-2.5'>
<User className='w-4 h-4 text-gray-500' />
<div>
<h4 className='text-[#19001F] text-sm font-medium'>Acc: 123-455-acghk</h4>
<p className='text-xs text-[#B3B3B3]'>Signed in to ChatGPT</p>
</div>
</div>
<div className='flex items-center gap-2.5'>
<ToolTip content='Refresh ChatGPT account'>
<button className='px-3.5 py-2.5 rounded-full bg-[#EDEEEF]'>
<RefreshCw className='w-4 h-4 text-black' />
</button>
</ToolTip>
<ToolTip content='Logout from ChatGPT'>
<button className='px-3.5 py-2.5 rounded-full bg-[#EDEEEF]'>
<LogOut className='w-4 h-4 text-black' />
</button>
</ToolTip>
</div>
</div>}
<div className={`flex gap-4 justify-end ${selectedProvider === 'codex' ? 'items-end' : 'items-start'}`}>
<div className="relative w-[205px] ">
<div className="flex flex-col justify-start ">
@ -380,11 +357,17 @@ const TextProvider = ({
</>
)}
</>
) : selectedProvider === 'codex' ?
<>
<button className='px-3.5 py-2.5 bg-[#EDEEEF] mt-auto rounded-[58px] w-full text-xs font-medium text-[#101323]'>Sign in with ChatGPT</button>
</>
: (
) : selectedProvider === 'codex' ? (
<div className="w-full mt-0 rounded-[12px]">
<CodexConfig
codexModel={llmConfig.CODEX_MODEL || ''}
onInputChange={(value, field) => {
const normalizedField = field === 'codex_model' ? 'CODEX_MODEL' : field;
onInputChange(value, normalizedField);
}}
/>
</div>
) : (
<>
<label className="block text-sm font-medium capitalize text-gray-700 mb-2">
{selectedProvider === 'custom' ? 'Custom LLM API Key' : `${llmConfig.LLM} API Key`}
@ -452,7 +435,7 @@ const TextProvider = ({
{/* Model Selection - only show if models are available */}
{modelsChecked && availableModels.length > 0 ? (
{selectedProvider !== 'codex' && modelsChecked && availableModels.length > 0 ? (
<div className="w-[205px]">
<div>
<label className="block text-sm font-medium text-gray-700 mb-3">
@ -532,7 +515,7 @@ const TextProvider = ({
</div>
</div>
{/* Show message if no models found */}
{modelsChecked && availableModels.length === 0 && (
{selectedProvider !== 'codex' && modelsChecked && availableModels.length === 0 && (
<div className="mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
<p className="text-sm text-yellow-800">
No models found. Please make sure your provider credentials are valid and the selected provider is reachable.

View file

@ -0,0 +1,136 @@
'use client'
import React, { useEffect, useState, memo, useCallback } from "react";
import { useDispatch } from "react-redux";
import { addNewSlide } from "@/store/slices/presentationGeneration";
import { Loader2 } from "lucide-react";
import { v4 as uuidv4 } from "uuid";
import { Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { getCustomTemplateDetails } from "@/app/hooks/useCustomTemplates";
import { getTemplatesByTemplateName } from "@/app/presentation-templates";
interface LayoutItemProps {
layout: any;
onSelect: (sampleData: any, layoutId: string) => void;
}
const LayoutItem = memo(({ layout, onSelect }: LayoutItemProps) => {
const { component: LayoutComponent, sampleData, layoutId } = layout;
return (
<div
onClick={() => onSelect(sampleData, layoutId)}
className="relative cursor-pointer overflow-hidden aspect-video"
>
<div className="absolute cursor-pointer bg-transparent z-40 top-0 left-0 w-full h-full" />
<div className="transform scale-[0.2] flex justify-center items-center origin-top-left w-[500%] h-[500%]">
<LayoutComponent data={sampleData} />
</div>
</div>
);
});
LayoutItem.displayName = 'LayoutItem';
interface NewSlideV1Props {
setShowNewSlideSelection: (show: boolean) => void;
templateID: string;
index: number;
presentationId: string;
}
const NewSlideV1 = ({
setShowNewSlideSelection,
templateID,
index,
presentationId,
}: NewSlideV1Props) => {
const dispatch = useDispatch();
const [layouts, setLayouts] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const isCustomTemplate = templateID.startsWith("custom-");
const handleNewSlide = useCallback((sampleData: any, id: string) => {
try {
const newSlide = {
id: uuidv4(),
index: index,
content: sampleData,
layout_group: templateID,
layout: isCustomTemplate ? `${templateID}:${id}` : id,
presentation: presentationId,
};
dispatch(addNewSlide({ slideData: newSlide, index }));
setShowNewSlideSelection(false);
} catch (error: any) {
console.error(error);
toast.error("Error adding new slide");
}
}, [index, templateID, presentationId, dispatch, setShowNewSlideSelection]);
useEffect(() => {
if (layouts.length > 0 || loading) return;
const fetchLayouts = async () => {
if (isCustomTemplate) {
setLoading(true);
const customTemplateId = templateID.split("custom-")[1];
const templateDetails = await getCustomTemplateDetails(customTemplateId, "Custom Template", "User-created template");
setLayouts(templateDetails?.layouts || []);
setLoading(false);
} else {
setLoading(true);
const templateDetails = getTemplatesByTemplateName(templateID);
setLayouts(templateDetails || []);
setLoading(false);
}
}
fetchLayouts();
}, []);
if (loading) {
return (
<div className="my-6 w-full bg-gray-50 p-8 max-w-[1280px]">
<div className="flex justify-between items-center mb-8">
<h2 className="text-2xl font-semibold">Select a Slide Layout</h2>
<Trash2
onClick={() => setShowNewSlideSelection(false)}
className="text-gray-500 text-2xl cursor-pointer"
/>
</div>
<div className="flex items-center justify-center h-32">
<Loader2 className="w-8 h-8 animate-spin text-gray-500" />
</div>
</div>
);
}
return (
<div className="my-6 w-full bg-gray-50 p-8 max-w-[1280px]">
<div className="flex justify-between items-center mb-8">
<h2 className="text-2xl font-semibold">Select a Slide Layout</h2>
<Trash2
onClick={() => setShowNewSlideSelection(false)}
className="text-gray-500 text-2xl cursor-pointer"
/>
</div>
<div className="grid grid-cols-4 gap-4">
{layouts.map((layout: any) => (
<LayoutItem
key={layout.layoutId}
layout={layout}
onSelect={handleNewSlide}
/>
))}
</div>
</div>
);
};
export default NewSlideV1;

View file

@ -0,0 +1,248 @@
"use client";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import {
ChevronLeft,
ChevronRight,
X,
Minimize2,
Maximize2,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Slide } from "../types/slide";
import { V1ContentRender } from "./V1ContentRender";
interface PresentationModeProps {
slides: Slide[];
currentSlide: number;
isFullscreen: boolean;
onFullscreenToggle: () => void;
onExit: () => void;
onSlideChange: (slideNumber: number) => void;
}
const PresentationMode: React.FC<PresentationModeProps> = ({
slides,
currentSlide,
isFullscreen,
onFullscreenToggle,
onExit,
onSlideChange,
}) => {
if (slides === undefined || slides === null || slides.length === 0) {
return null;
}
const recomputeScale = useCallback(() => {
if (typeof window === "undefined") return;
const padding = isFullscreen ? 0 : 64; // match p-8 when not fullscreen
const fullscreenMargin = isFullscreen ? 16 : 0; // small safety margin to prevent clipping
const availableWidth = Math.max(window.innerWidth - padding - fullscreenMargin, 0);
const availableHeight = Math.max(window.innerHeight - padding - fullscreenMargin, 0);
const baseW = 1280;
const baseH = 720;
const s = Math.min(availableWidth / baseW, availableHeight / baseH);
}, [isFullscreen]);
useEffect(() => {
recomputeScale();
window.addEventListener("resize", recomputeScale);
return () => window.removeEventListener("resize", recomputeScale);
}, [recomputeScale]);
// Modify the handleKeyPress to prevent default behavior
const handleKeyPress = useCallback(
(event: KeyboardEvent) => {
event.preventDefault(); // Prevent default scroll behavior
switch (event.key) {
case "ArrowRight":
case "ArrowDown":
case " ": // Space key
if (currentSlide < slides.length - 1) {
onSlideChange(currentSlide + 1);
}
break;
case "ArrowLeft":
case "ArrowUp":
if (currentSlide > 0) {
onSlideChange(currentSlide - 1);
}
break;
case "Escape":
// If fullscreen is active, only exit fullscreen on first ESC. Second ESC exits present mode.
if (document.fullscreenElement) {
try { document.exitFullscreen(); } catch (_) { }
return;
}
onExit();
break;
case "f":
case "F":
onFullscreenToggle();
break;
}
},
[currentSlide, slides.length, onSlideChange, onExit, onFullscreenToggle, isFullscreen]
);
// Add both keydown and keyup listeners
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Prevent default behavior for arrow keys and space
if (
["ArrowRight", "ArrowLeft", "ArrowUp", "ArrowDown", " "].includes(e.key)
) {
e.preventDefault();
}
handleKeyPress(e);
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [handleKeyPress]);
// Add click handlers for the slide area
const handleSlideClick = (e: React.MouseEvent) => {
// Don't trigger navigation if clicking on controls
if ((e.target as HTMLElement).closest(".presentation-controls")) {
return;
}
const clickX = e.clientX;
const windowWidth = window.innerWidth;
if (clickX < windowWidth / 3) {
if (currentSlide > 0) {
onSlideChange(currentSlide - 1);
}
} else if (clickX > (windowWidth * 2) / 3) {
if (currentSlide < slides.length - 1) {
onSlideChange(currentSlide + 1);
}
}
};
// Handle Escape key separately
useEffect(() => {
const handleEscKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && isFullscreen) {
onFullscreenToggle(); // Just toggle fullscreen, don't exit presentation
}
};
document.addEventListener("keydown", handleEscKey);
return () => document.removeEventListener("keydown", handleEscKey);
}, [isFullscreen, onFullscreenToggle]);
return (
<div
className="fixed inset-0 flex flex-col"
style={{ backgroundColor: "var(--page-background-color,#c8c7c9)" }}
tabIndex={0}
onClick={handleSlideClick}
>
{/* Controls - Only show when not in fullscreen */}
{!isFullscreen && (
<>
<div className="presentation-controls absolute top-4 right-4 flex items-center gap-2 z-50">
<Button
variant="ghost"
style={{ color: "var(--text-body-color,#000000)" }}
size="icon"
onClick={(e) => {
e.stopPropagation();
onFullscreenToggle();
}}
className="text-white hover:bg-white/20"
>
{isFullscreen ? (
<Minimize2 className="h-5 w-5" />
) : (
<Maximize2 className="h-5 w-5" />
)}
</Button>
<Button
variant="ghost"
style={{ color: "var(--text-body-color,#000000)" }}
size="icon"
onClick={(e) => {
e.stopPropagation();
onExit();
}}
className="text-white hover:bg-white/20"
>
<X className="h-5 w-5" />
</Button>
</div>
<div className="presentation-controls absolute bottom-4 left-1/2 -translate-x-1/2 flex items-center gap-4 z-50">
<Button
variant="ghost"
style={{ color: "var(--text-body-color,#000000)" }}
size="icon"
onClick={(e) => {
e.stopPropagation();
onSlideChange(currentSlide - 1);
}}
disabled={currentSlide === 0}
className="text-white hover:bg-white/20"
>
<ChevronLeft className="h-5 w-5" style={{ color: "var(--text-body-color,#000000)" }} />
</Button>
<span className="text-white"
style={{ color: "var(--text-body-color,#000000)" }}
>
{currentSlide + 1} / {slides.length}
</span>
<Button
variant="ghost"
style={{ color: "var(--text-body-color,#000000)" }}
size="icon"
onClick={(e) => {
e.stopPropagation();
onSlideChange(currentSlide + 1);
}}
disabled={currentSlide === slides.length - 1}
className="text-white hover:bg-white/20"
>
<ChevronRight className="h-5 w-5" style={{ color: "var(--text-body-color,#000000)" }} />
</Button>
</div>
</>
)}
{/* Slides (all mounted, only current visible) */}
<div className={`flex-1 flex items-center justify-center ${isFullscreen ? "p-0" : "p-8"}`}>
<div className="w-full h-full flex items-center justify-center relative" >
<div
className={` rounded-sm font-inter relative w-full h-full flex items-center justify-center`}
>
{slides.length > 0 && slides.map((slide, index) => (
<div
key={slide.id}
className={index === currentSlide ? " w-full h-full flex items-center justify-center" : "hidden w-full h-full"}
>
<V1ContentRender slide={slide} isEditMode={true} />
</div>
))}
</div>
</div>
</div>
</div>
);
};
export default PresentationMode;

View file

@ -0,0 +1,125 @@
"use client";
import React, { memo, useMemo } from "react";
import { Loader2 } from "lucide-react";
import { TemplateWithData } from "@/app/presentation-templates/utils";
import { CompiledLayout } from "@/app/hooks/compileLayout";
export function TemplatePreviewStage({ children }: { children: React.ReactNode }) {
return (
<div className="relative overflow-hidden px-5 pb-5 pt-5 h-[230px]">
<img
src="/card_bg.svg"
alt=""
className="absolute top-0 left-0 w-full h-full object-cover"
/>
{children}
</div>
);
}
export const LayoutsBadge = memo(function LayoutsBadge({ count }: { count: number }) {
return (
<span className="text-xs font-syne absolute top-3.5 left-4 z-40 inline-flex items-center rounded-full bg-[#333333] px-3 py-1 font-semibold text-white">
Layouts-{count}
</span>
);
});
export const ScaledSlidePreview = memo(function ScaledSlidePreview({
children,
id,
index,
isOutline = false,
}: {
children: React.ReactNode;
id: string;
index: number;
isOutline?: boolean;
}) {
const PREVIEW_SCALE = isOutline ? 0.2 : 0.24;
const SLIDE_HEIGHT = 720 * PREVIEW_SCALE;
const SLIDE_WIDTH = 1280;
const SLIDE_NATIVE_HEIGHT = 720;
return (
<div
key={`${id}-preview-${index}`}
className="relative"
style={{ height: `${SLIDE_HEIGHT}px`, overflow: "hidden" }}
>
<div
className={`absolute top-0 ${isOutline ? "left-0" : "left-8"} pointer-events-none`}
style={{
width: SLIDE_WIDTH,
height: SLIDE_NATIVE_HEIGHT,
transformOrigin: "top left",
transform: `scale(${PREVIEW_SCALE})`,
}}
>
{children}
</div>
</div>
);
});
export const InbuiltTemplatePreview = memo(function InbuiltTemplatePreview({
layouts,
templateId,
isOutline = false,
}: {
layouts: TemplateWithData[];
templateId: string;
isOutline?: boolean;
}) {
const previewLayouts = useMemo(() => layouts.slice(0, 2), [layouts]);
return (
<div className="relative z-10 flex flex-col gap-3 overflow-hidden">
{previewLayouts.map((layout, index) => {
const LayoutComponent = layout.component;
return (
<ScaledSlidePreview key={`${templateId}-preview-${index}`} id={templateId} index={index} isOutline={isOutline}>
<LayoutComponent data={layout.sampleData} />
</ScaledSlidePreview>
);
})}
</div>
);
});
export const CustomTemplatePreview = memo(function CustomTemplatePreview({
previewLayouts,
loading,
templateId,
isOutline = false,
}: {
previewLayouts: CompiledLayout[];
loading: boolean;
templateId: string;
isOutline?: boolean;
}) {
return (
<div className="relative z-10 flex flex-col gap-3">
{loading ? (
[...Array(2)].map((_, index) => (
<div
key={`${templateId}-loading-${index}`}
className="relative w-full aspect-video flex items-center justify-center"
>
<Loader2 className="h-4 w-4 animate-spin text-slate-300" />
</div>
))
) : (
previewLayouts.slice(0, 2).map((layout, index) => {
const LayoutComponent = layout.component;
return (
<ScaledSlidePreview key={`${templateId}-preview-${index}`} id={templateId} index={index} isOutline={isOutline}>
<LayoutComponent data={layout.sampleData} />
</ScaledSlidePreview>
);
})
)}
</div>
);
});

View file

@ -0,0 +1,276 @@
"use client";
import React, { useEffect, useCallback, useState } from "react";
import { useRouter } from "next/navigation";
import { useFileUpload } from "./hooks/useFileUpload";
import { useTemplateCreation } from "./hooks/useTemplateCreation";
import { useLayoutSaving } from "./hooks/useLayoutSaving";
import { ProcessedSlide } from "./types";
import { TAILWIND_CDN_URL } from "./constants";
import { TemplateStudioHeader } from "./components/TemplateStudioHeader";
import { TemplateCreationProgress } from "./components/TemplateCreationProgress";
import { Step2FontManagement } from "./components/steps/Step2FontManagement";
import { Step3SlidePreview } from "./components/steps/Step3SlidePreview";
import { Step4TemplateCreation } from "./components/steps/Step4TemplateCreation";
import { SaveLayoutButton } from "./components/SaveLayoutButton";
import { SaveLayoutModal } from "./components/SaveLayoutModal";
import { FileUploadSection } from "./components/FileUploadSection";
import { useFontLoader } from "../hooks/useFontLoad";
import Header from "@/app/(presentation-generator)/(dashboard)/dashboard/components/Header";
const CustomTemplatePage = () => {
const router = useRouter();
const [schemaEditorSlideIndex, setSchemaEditorSlideIndex] = useState<number | null>(null);
const [schemaPreviewData, setSchemaPreviewData] = useState<Record<number, Record<string, any>>>({});
const { selectedFile, handleFileSelect, removeFile } = useFileUpload();
const {
state,
uploadedFonts,
slides,
setSlides,
completedSlides,
checkFonts,
uploadFont,
removeFont,
fontUploadAndPreview,
initTemplateCreation,
retrySlide,
} = useTemplateCreation();
// Layout saving hook
const {
isSavingLayout,
isModalOpen,
openSaveModal,
closeSaveModal,
saveLayout,
} = useLayoutSaving(slides);
useEffect(() => {
const existingScript = document.querySelector('script[src*="tailwindcss.com"]');
if (!existingScript) {
const script = document.createElement("script");
script.src = TAILWIND_CDN_URL;
script.async = true;
document.head.appendChild(script);
}
}, []);
/**
* Step 1: Check fonts in uploaded PPTX
*/
const handleCheckFonts = useCallback(async () => {
if (selectedFile) {
await checkFonts(selectedFile);
}
}, [selectedFile, checkFonts]);
/**
* Step 2: Upload fonts and generate preview
*/
const handleFontUploadAndPreview = useCallback(async () => {
if (selectedFile) {
const data = await fontUploadAndPreview(selectedFile);
if (data) {
useFontLoader(data.fonts);
}
}
}, [selectedFile, fontUploadAndPreview]);
/**
* Step 5: Save template with metadata
*/
const handleSaveTemplate = useCallback(async (
layoutName: string,
description: string,
template_info_id: string
): Promise<string | null> => {
const id = await saveLayout(layoutName, description, template_info_id);
if (id) {
router.push(`/template-preview?slug=custom-${id}`);
}
return id;
}, [saveLayout, router]);
/**
* Update a specific slide's data
*/
const handleSlideUpdate = useCallback((index: number, updatedSlideData: Partial<ProcessedSlide>) => {
setSlides((prevSlides) =>
prevSlides.map((s, i) =>
i === index
? { ...s, ...updatedSlideData, modified: true }
: s
)
);
}, [setSlides]);
/**
* Open schema editor for a specific slide
*/
const handleOpenSchemaEditor = useCallback((index: number | null) => {
setSchemaEditorSlideIndex(index);
}, []);
/**
* Close schema editor
*/
const handleCloseSchemaEditor = useCallback(() => {
setSchemaEditorSlideIndex(null);
}, []);
/**
* Save changes from schema editor
*/
const handleSchemaEditorSave = useCallback((updatedReact: string) => {
if (schemaEditorSlideIndex !== null) {
setSlides(prev => prev.map((s, i) =>
i === schemaEditorSlideIndex ? { ...s, react: updatedReact } : s
));
}
setSchemaEditorSlideIndex(null);
}, [schemaEditorSlideIndex, setSlides]);
/**
* Update schema preview content (for AI fill)
*/
const handleSchemaPreviewContent = useCallback((content: Record<string, any>) => {
if (schemaEditorSlideIndex !== null) {
setSchemaPreviewData(prev => ({
...prev,
[schemaEditorSlideIndex]: content
}));
}
}, [schemaEditorSlideIndex]);
/**
* Clear schema preview data for a specific slide
*/
const handleClearSchemaPreview = useCallback((slideIndex: number) => {
setSchemaPreviewData(prev => {
const newData = { ...prev };
delete newData[slideIndex];
return newData;
});
}, []);
const showFileUpload = state.step === 'file-upload';
const showFontManager = state.step === 'font-check' || state.step === 'font-upload';
const showPreview = state.step === 'slides-preview';
const showSlides = state.step === 'template-creation' || state.step === 'completed';
const isProcessingCompleted = state.step === 'completed';
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100">
<Header />
<TemplateStudioHeader />
{showFileUpload ? (
<div className="pb-24">
<FileUploadSection
selectedFile={selectedFile}
handleFileSelect={handleFileSelect}
removeFile={removeFile}
CheckFonts={handleCheckFonts}
isProcessingPptx={state.isLoading}
slides={[]}
completedSlides={0}
/>
</div>
) : (
<div className="mx-auto min-h-[600px] px-6 pb-24">
<TemplateCreationProgress
currentStep={state.step}
totalSlides={state.totalSlides}
processedSlides={completedSlides}
/>
{/* Step 2: Font Management */}
{showFontManager && (
<Step2FontManagement
fontsData={state.fontsData}
uploadedFonts={uploadedFonts}
uploadFont={uploadFont}
removeFont={removeFont}
onContinue={handleFontUploadAndPreview}
isUploading={state.isLoading}
/>
)}
{/* Step 3: Slide Preview */}
{showPreview && (
<Step3SlidePreview
previewData={state.previewData}
onInitTemplate={initTemplateCreation}
isLoading={state.isLoading}
/>
)}
{/* Step 4: Template Creation & Editing */}
{showSlides && slides.length > 0 && (
<Step4TemplateCreation
slides={slides}
setSlides={setSlides}
retrySlide={retrySlide}
onSlideUpdate={handleSlideUpdate}
schemaEditorSlideIndex={schemaEditorSlideIndex}
onOpenSchemaEditor={handleOpenSchemaEditor}
onCloseSchemaEditor={handleCloseSchemaEditor}
onSchemaEditorSave={handleSchemaEditorSave}
schemaPreviewData={schemaPreviewData}
onSchemaPreviewContent={handleSchemaPreviewContent}
onClearSchemaPreview={handleClearSchemaPreview}
/>
)}
{/* Floating Save Template Button */}
{isProcessingCompleted && slides.some((s) => s.processed) && (
<SaveLayoutButton
onSave={openSaveModal}
isSaving={isSavingLayout}
isProcessing={slides.some((s) => s.processing)}
/>
)}
{/* Save Template Modal */}
<SaveLayoutModal
isOpen={isModalOpen}
onClose={closeSaveModal}
onSave={handleSaveTemplate}
isSaving={isSavingLayout}
template_info_id={state.templateId || ''}
/>
</div>
)}
</div>
);
};
export default CustomTemplatePage;

View file

@ -1,21 +0,0 @@
import React from "react";
import Header from "@/app/(presentation-generator)/(dashboard)/dashboard/components/Header";
export const APIKeyWarning: React.FC = () => {
return (
<div className="min-h-screen font-roboto bg-gradient-to-br from-slate-50 to-slate-100">
<Header />
<div className="flex items-center justify-center aspect-video mx-auto px-6">
<div className="text-center space-y-2 my-6 bg-white p-10 rounded-lg shadow-lg">
<h1 className="text-xl font-bold text-gray-900">
Please add "GOOGLE_API_KEY" to enable template creation via AI.
</h1>
<h1 className="text-xl font-bold text-gray-900">Please add your OpenAI API Key to process the layout</h1>
<p className="text-lg text-gray-600 max-w-2xl mx-auto">
This feature requires an OpenAI model GPT-5. Configure your key in settings or via environment variables.
</p>
</div>
</div>
</div>
);
};

View file

@ -1,167 +0,0 @@
'use client'
import React from "react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Pencil, Eraser, RotateCcw, SendHorizontal, X } from "lucide-react";
import { EditControlsProps } from "../../types";
export const EditControls: React.FC<EditControlsProps> = ({
isEditMode,
prompt,
isUpdating,
strokeWidth,
strokeColor,
eraserMode,
onPromptChange,
onSave,
onCancel,
onStrokeWidthChange,
onStrokeColorChange,
onEraserModeChange,
onClearCanvas,
}) => {
const colors = [
"#000000",
"#FF0000",
"#00FF00",
"#0000FF",
"#FFFF00",
"#FF00FF",
"#00FFFF",
"#FFA500",
];
const strokeWidths = [1, 3, 5, 8, 12];
if (!isEditMode) return null;
return (
<div className="border-2 max-w-[1280px] mx-auto border-blue-200 rounded-lg p-4 bg-blue-50 space-y-4">
{/* Drawing Tools */}
<div className="flex items-center justify-between flex-wrap gap-4">
<div className="flex items-center gap-4 flex-wrap">
{/* Drawing Tools */}
<div className="flex items-center gap-2">
<Button
variant={!eraserMode ? "default" : "outline"}
size="sm"
onClick={() => onEraserModeChange(false)}
className="flex items-center gap-1"
>
<Pencil size={14} />
Draw
</Button>
<Button
variant={eraserMode ? "default" : "outline"}
size="sm"
onClick={() => onEraserModeChange(true)}
className="flex items-center gap-1"
>
<Eraser size={14} />
Erase
</Button>
</div>
{/* Color Picker */}
{!eraserMode && (
<div className="flex items-center gap-1">
{colors.map((color) => (
<button
key={color}
className={`w-5 h-5 rounded-full border-2 ${
strokeColor === color
? "border-gray-800"
: "border-gray-300"
}`}
style={{ backgroundColor: color }}
onClick={() => onStrokeColorChange(color)}
/>
))}
</div>
)}
{/* Stroke Width */}
<div className="flex items-center gap-1">
{strokeWidths.map((width) => (
<button
key={width}
className={`w-7 h-7 rounded border flex items-center justify-center ${
strokeWidth === width
? "bg-blue-100 border-blue-500"
: "border-gray-300"
}`}
onClick={() => onStrokeWidthChange(width)}
>
<div
className="rounded-full bg-gray-800"
style={{
width: `${width + 1}px`,
height: `${width + 1}px`,
}}
/>
</button>
))}
</div>
<Button
variant="outline"
size="sm"
onClick={onClearCanvas}
className="flex items-center gap-1"
>
<RotateCcw size={14} />
Clear
</Button>
</div>
<Button
variant="outline"
size="sm"
onClick={onCancel}
className="flex items-center gap-1"
>
<X size={14} />
Cancel
</Button>
</div>
{/* Prompt Section */}
<div className="space-y-2 mt-2">
<label
htmlFor="edit-prompt"
className="text-sm font-medium font-inter text-gray-700"
>
Describe the changes you want to make:
</label>
<div className="flex gap-2">
<Textarea
id="edit-prompt"
placeholder="Enter your prompt here... (e.g., 'Change the title color to blue', 'Add a border to the image', etc.)"
value={prompt}
onChange={(e) => onPromptChange(e.target.value)}
className="flex-1 font-inter duration-300 h-[70px] border-blue-200 border-2 rounded-lg outline-none focus:border-blue-500 focus:ring-0 max-h-[70px] resize-none"
disabled={isUpdating}
/>
<div>
<Button
onClick={onSave}
disabled={isUpdating || !prompt.trim()}
className="flex flex-col w-28 font-inter font-semibold items-center gap-1 h-full bg-green-600 hover:bg-green-700 px-4"
>
{isUpdating ? (
"Updating..."
) : (
<>
<SendHorizontal size={14} />
Update
</>
)}
</Button>
</div>
</div>
</div>
</div>
);
};

View file

@ -1,97 +0,0 @@
'use client'
import React, { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Save, X, Code } from "lucide-react";
import { ProcessedSlide } from "../../types";
import Editor from 'react-simple-code-editor';
import { highlight, languages } from 'prismjs';
import 'prismjs/components/prism-clike';
import 'prismjs/components/prism-javascript';
import 'prismjs/components/prism-markup';
import 'prismjs/components/prism-jsx';
import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet";
interface HtmlEditorProps {
slide: ProcessedSlide;
isHtmlEditMode: boolean;
onSave: (html: string) => void;
onCancel: () => void;
}
export const HtmlEditor: React.FC<HtmlEditorProps> = ({
slide,
isHtmlEditMode,
onSave,
onCancel,
}) => {
const [htmlContent, setHtmlContent] = useState(slide.html || "");
useEffect(() => {
setHtmlContent(slide.html || "");
}, [slide.html]);
if (!isHtmlEditMode) return null;
const handleSave = () => {
onSave(htmlContent);
};
const handleCancel = () => {
setHtmlContent(slide.html || "");
onCancel();
};
return (
<Sheet open={isHtmlEditMode} onOpenChange={(open) => { if (!open) handleCancel(); }}>
<SheetContent side="right" className="w-full sm:max-w-[860px] p-0">
<SheetHeader className="px-6 py-4 border-b">
<SheetTitle className="flex items-center justify-between w-full">
<span className="flex items-center gap-2 text-purple-800">
<Code className="w-5 h-5 text-purple-600" />
HTML Editor
</span>
</SheetTitle>
</SheetHeader>
<div className="space-y-4 px-2 overflow-y-auto h-[85%]">
<div className="container__content_area">
<Editor
value={htmlContent}
onValueChange={html => setHtmlContent(html)}
highlight={code => highlight(code, languages.jsx!, 'jsx')}
padding={10}
id="html-editor"
name="html-editor"
className="container__editor"
/>
</div>
</div>
<SheetFooter className="px-6 py-4 border-b">
<SheetTitle className="flex items-center justify-between w-full">
<div></div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={handleCancel}
className="flex items-center gap-1"
>
<X size={14} />
Cancel
</Button>
<Button
onClick={handleSave}
className="flex items-center gap-1 bg-purple-600 hover:bg-purple-700"
size="sm"
>
<Save size={14} />
Save HTML
</Button>
</div>
</SheetTitle>
</SheetFooter>
</SheetContent>
</Sheet>
);
};

View file

@ -1,16 +1,32 @@
'use client'
import React from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useDrawingCanvas } from "../../hooks/useDrawingCanvas";
import React, { useRef, useState, useMemo, useEffect } from "react";
import { useCompiledLayout } from "../../hooks/useCompiledLayout";
import { useSlideUndoRedo } from "../../hooks/useSlideUndoRedo";
import { EachSlideProps } from "../../types";
import { SlideContentDisplay } from "./SlideContentDisplay";
import { useHtmlEdit } from "../../hooks/useHtmlEdit";
import { SlideActions } from "./SlideActions";
import { HtmlEditor } from "./HtmlEditor";
import { EditControls } from "./EditControls";
import { useSlideEdit } from "../../hooks/useSlideEdit";
import {
Trash2,
X,
Check,
Loader2,
RotateCcw,
Sparkles,
Edit,
Code,
MousePointer2,
Undo,
Redo
} from "lucide-react";
import Timer from "../Timer";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import ToolTip from "@/components/ToolTip";
// import { CodeEditor } from "./CodeEditor";
// import SlideSelectionEditor from "./SlideSelectionEditor";
import SchemaElementHighlighter from "../SchemaElementHighlighter";
const EachSlide: React.FC<EachSlideProps> = ({
slide,
@ -19,145 +35,451 @@ const EachSlide: React.FC<EachSlideProps> = ({
setSlides,
onSlideUpdate,
isProcessing,
onOpenSchemaEditor,
isSchemaEditorOpen = false,
schemaPreviewData,
onClearSchemaPreview,
}) => {
// Custom hooks
const [localPreviewData, setLocalPreviewData] = useState<Record<string, any> | null>(null);
// Use schema preview data from parent if available, otherwise use local
const previewData = schemaPreviewData ?? localPreviewData;
const setPreviewData = setLocalPreviewData;
const [isEditPromptOpen, setIsEditPromptOpen] = useState(false);
const slideDisplayRef = useRef<HTMLDivElement>(null);
const [showCodeEditor, setShowCodeEditor] = useState(false);
const [isSelectionEditMode, setIsSelectionEditMode] = useState(false);
// Compile layout once and share with child components
const compiledLayout = useCompiledLayout(slide.react);
// Auto-retry once if compilation fails
const hasAutoRetriedCompile = useRef(false);
useEffect(() => {
// Reset the flag when compilation succeeds
if (compiledLayout) {
hasAutoRetriedCompile.current = false;
}
}, [compiledLayout]);
useEffect(() => {
if (
slide.react &&
slide.processed &&
!slide.processing &&
!compiledLayout &&
!hasAutoRetriedCompile.current
) {
hasAutoRetriedCompile.current = true;
console.log(`Auto-retrying slide ${index + 1} after compile failure...`);
retrySlide(index);
}
}, [slide.react, slide.processed, slide.processing, compiledLayout, index, retrySlide]);
// Get sample data for schema-element highlighting
const sampleData = useMemo(() => {
if (previewData) return previewData;
if (compiledLayout?.sampleData && Object.keys(compiledLayout.sampleData).length > 0) {
return compiledLayout.sampleData;
}
try {
return compiledLayout?.schema?.parse({}) ?? null;
} catch {
return null;
}
}, [compiledLayout, previewData]);
// Undo/Redo functionality for this slide
const {
canvasRef,
slideDisplayRef,
strokeWidth,
strokeColor,
eraserMode,
isDrawing,
canvasDimensions,
setCanvasDimensions,
didYourDraw,
handleMouseDown,
handleMouseMove,
handleMouseUp,
handleTouchStart,
handleTouchMove,
handleTouchEnd,
handleClearCanvas,
handleEraserModeChange,
handleStrokeColorChange,
handleStrokeWidthChange,
} = useDrawingCanvas();
undo,
redo,
canUndo,
canRedo,
} = useSlideUndoRedo(slide, setSlides, index);
const {
isEditMode,
isUpdating,
prompt,
slideContentRef,
setPrompt,
handleSave,
handleEditClick,
handleCancelEdit,
} = useSlideEdit(slide, index, onSlideUpdate, setSlides);
const {
isHtmlEditMode,
handleHtmlEditClick,
handleHtmlEditCancel,
handleHtmlSave,
} = useHtmlEdit(slide, index, onSlideUpdate, setSlides);
// Set canvas dimensions when entering edit mode
React.useEffect(() => {
if (isEditMode && slideContentRef.current && slide.html) {
const rect = slideContentRef.current.getBoundingClientRect();
setCanvasDimensions({
width: Math.max(rect.width, 800),
height: Math.max(rect.height, 600),
});
}
}, [isEditMode, slide.html, slideContentRef, setCanvasDimensions]);
// Handle save with drawing data
const handleSaveWithDrawing = () => {
handleSave(slideDisplayRef!, didYourDraw);
};
// Handle delete slide
const handleDeleteSlide = () => {
setSlides((prevSlides) => prevSlides.filter((_, i) => i !== index));
};
// Handle retry slide
const handleRetrySlide = () => {
retrySlide(index);
};
const closeEditPrompt = () => {
setIsEditPromptOpen(false);
handleCancelEdit();
};
const submitEditPrompt = async () => {
if (isUpdating) return;
await handleSave();
setIsEditPromptOpen(false);
setPrompt("");
};
// Clear preview data - clears both local and parent state
const handleClearPreview = () => {
setPreviewData(null);
onClearSchemaPreview?.();
};
// Handle delete slide
const handleDeleteSlide = () => {
// warmin
const confirmed = window.confirm(
`Are you sure you want to delete slide ${index + 1}? This action cannot be undone.`
);
if (!confirmed) return;
setSlides(prev => prev.filter((_, i) => i !== index));
};
// Handle selection edit update
const handleSelectionUpdate = (updatedHtml: string) => {
// Update the slide's html content via parent callback or directly
setSlides(prev => prev.map((s, i) => i === index ? { ...s, react: updatedHtml } : s));
};
const isSlideReady = slide.processed && !slide.processing;
const isSlideProcessing = slide.processing;
const hasError = !!slide.error;
return (
<Card
key={slide.slide_number}
className="border-2 font-instrument_sans w-full relative"
>
<CardHeader className="max-w-[1280px] mx-auto px-0 py-6">
<CardTitle className="text-xl">
<SlideActions
<div className="group max-w-[1440px] mx-auto relative bg-white rounded-2xl border border-[#E5E7EB] overflow-hidden transition-all duration-300 hover:shadow-lg hover:border-[#D1D5DB]">
{/* Slide Header */}
<div className="px-5 py-4 border-b border-[#F3F4F6] bg-gradient-to-r from-[#FAFAFA] to-white">
<div className="flex items-center justify-between">
{/* Left: Slide Info */}
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-[#EBE9FE] text-[#7A5AF8] font-semibold text-sm">
{index + 1}
</div>
<div>
<h3 className="text-base font-semibold text-[#111827] tracking-tight">
{compiledLayout?.layoutId || `Slide ${index + 1}`}
</h3>
{compiledLayout?.layoutDescription && (
<p className="text-sm text-[#6B7280] mt-0.5 line-clamp-1 max-w-[300px]">
{compiledLayout.layoutDescription}
</p>
)}
</div>
</div>
{/* Right: Actions */}
<div className="flex items-center gap-1.5">
{/* Primary Actions Group */}
<div className="flex items-center bg-gray-50/80 rounded-lg p-1 gap-0.5">
{/* AI Edit Button */}
<Popover
open={isEditPromptOpen}
onOpenChange={(open) => {
setIsEditPromptOpen(open);
if (open) handleEditClick();
else handleCancelEdit();
}}
>
<PopoverTrigger asChild>
<button
disabled={!isSlideReady}
className={`
inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium
rounded-md transition-all duration-150
${!isSlideReady
? "opacity-40 cursor-not-allowed text-gray-400"
: "text-gray-600 hover:bg-white hover:text-violet-600 hover:shadow-sm"
}
`}
>
<Sparkles className="w-3.5 h-3.5" />
<span>AI Edit</span>
</button>
</PopoverTrigger>
<PopoverContent
align="end"
side="bottom"
sideOffset={8}
className="w-[380px] p-0 rounded-xl border border-gray-200 shadow-2xl bg-white"
>
<div className="p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-lg bg-gradient-to-br from-violet-500 to-purple-600 flex items-center justify-center shadow-sm">
<Sparkles className="w-3.5 h-3.5 text-white" />
</div>
<div>
<span className="text-sm font-semibold text-gray-800">AI Edit</span>
<p className="text-[10px] text-gray-400">Apply AI edits & tweaks</p>
</div>
</div>
<button
type="button"
onClick={closeEditPrompt}
disabled={isUpdating}
className="p-1 rounded-md hover:bg-gray-100 text-gray-400 hover:text-gray-600 transition-colors disabled:opacity-50"
>
<X className="w-4 h-4" />
</button>
</div>
<textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
rows={3}
autoFocus
placeholder="What changes would you like? e.g., 'Make the title larger' or 'Change colors to blue theme'"
disabled={isUpdating}
className="w-full px-3 py-2.5 rounded-lg border border-gray-200 bg-gray-50 text-sm text-gray-800 placeholder:text-gray-400 resize-none focus:outline-none focus:ring-2 focus:ring-violet-500/20 focus:border-violet-400 focus:bg-white transition-all"
/>
<div className="flex justify-end mt-3">
<button
type="button"
onClick={submitEditPrompt}
disabled={isUpdating || !prompt.trim()}
className={`
inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium rounded-lg transition-all
${isUpdating || !prompt.trim()
? "bg-gray-100 text-gray-400 cursor-not-allowed"
: "bg-gradient-to-r from-violet-500 to-purple-600 text-white hover:from-violet-600 hover:to-purple-700 shadow-sm hover:shadow-md"
}
`}
>
{isUpdating ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin" />
Applying...
</>
) : (
<>
<Check className="w-3.5 h-3.5" />
Apply
</>
)}
</button>
</div>
</div>
</PopoverContent>
</Popover>
{/* Schema Button */}
<ToolTip content="Edit content schema">
<button
onClick={() => {
if (isSchemaEditorOpen) {
onOpenSchemaEditor?.(null);
} else {
onOpenSchemaEditor?.(index);
}
}}
disabled={!isSlideReady}
className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md transition-all duration-150 disabled:opacity-40 disabled:cursor-not-allowed ${isSchemaEditorOpen
? "bg-emerald-100 text-emerald-700"
: "text-gray-600 hover:bg-white hover:text-emerald-600 hover:shadow-sm"
}`}
>
<Edit className="w-3.5 h-3.5" />
<span>Schema</span>
</button>
</ToolTip>
{/* Code Button */}
{/* <ToolTip content="Edit source code">
<button
onClick={() => setShowCodeEditor(true)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md text-gray-600 hover:bg-white hover:text-blue-600 hover:shadow-sm transition-all duration-150"
>
<Code className="w-3.5 h-3.5" />
<span>Code</span>
</button>
</ToolTip> */}
{/* Select Edit Button */}
{/* <ToolTip content={isSelectionEditMode ? "Exit selection mode" : "Click elements to edit"}>
<button
onClick={() => setIsSelectionEditMode(!isSelectionEditMode)}
disabled={!isSlideReady}
className={`
inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium
rounded-md transition-all duration-150
${isSelectionEditMode
? "bg-indigo-100 text-indigo-700"
: "text-gray-600 hover:bg-white hover:text-indigo-600 hover:shadow-sm"
}
disabled:opacity-40 disabled:cursor-not-allowed
`}
>
<MousePointer2 className="w-3.5 h-3.5" />
<span>{isSelectionEditMode ? "Exit" : "Select"}</span>
</button>
</ToolTip> */}
</div>
{/* Separator */}
<div className="w-px h-6 bg-gray-200 mx-1" />
{/* Undo/Redo Group */}
<div className="flex items-center bg-gray-50/80 rounded-lg p-1 gap-0.5">
<ToolTip content={canUndo ? "Undo (Ctrl+Z)" : "Nothing to undo"}>
<button
onClick={undo}
disabled={!canUndo || !isSlideReady}
className={`
inline-flex items-center justify-center w-8 h-8
rounded-md transition-all duration-150
${!canUndo || !isSlideReady
? "opacity-40 cursor-not-allowed text-gray-400"
: "text-gray-600 hover:bg-white hover:text-amber-600 hover:shadow-sm"
}
`}
>
<Undo className="w-4 h-4" />
</button>
</ToolTip>
<ToolTip content={canRedo ? "Redo (Ctrl+Shift+Z)" : "Nothing to redo"}>
<button
onClick={redo}
disabled={!canRedo || !isSlideReady}
className={`
inline-flex items-center justify-center w-8 h-8
rounded-md transition-all duration-150
${!canRedo || !isSlideReady
? "opacity-40 cursor-not-allowed text-gray-400"
: "text-gray-600 hover:bg-white hover:text-amber-600 hover:shadow-sm"
}
`}
>
<Redo className="w-4 h-4" />
</button>
</ToolTip>
</div>
{/* Separator */}
<div className="w-px h-6 bg-gray-200 mx-1" />
{/* Re-Construct Button */}
<ToolTip content="Re-Design this slide">
<button
onClick={handleRetrySlide}
disabled={!isSlideReady}
className={`
inline-flex items-center gap-2 px-4 py-2 text-sm font-medium
rounded-full transition-all duration-200
${!isSlideReady
? "opacity-40 cursor-not-allowed bg-gradient-to-r from-[#F3F4F6] to-[#E5E7EB] text-[#9CA3AF]"
: "text-[#111827] shadow-sm hover:shadow-md"
}
`}
style={isSlideReady ? {
background: 'linear-gradient(135deg, #D5CAFC 0%, #E3D2EB 35%, #F4DCD3 70%, #FDE4C2 100%)',
} : undefined}
>
<RotateCcw className="w-3.5 h-3.5" />
Re-Construct
</button>
</ToolTip>
{/* Delete Button */}
<ToolTip content="Delete slide">
<button
onClick={handleDeleteSlide}
disabled={!isSlideReady}
className={`
p-1.5 rounded-lg border transition-all duration-150
${!isSlideReady
? "opacity-40 cursor-not-allowed bg-gray-50 border-gray-200 text-gray-400"
: "bg-white border-gray-200 text-gray-400 hover:bg-red-50 hover:border-red-200 hover:text-red-500"
}
`}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</ToolTip>
</div>
</div>
{/* Processing Timer - Only show here, not in SlideContentDisplay */}
{isSlideProcessing && (
<div className="mt-4">
<div className="flex items-center gap-2 mb-2">
<Loader2 className="w-4 h-4 animate-spin text-[#7A5AF8]" />
<span className="text-sm font-medium text-[#7A5AF8]">Generating slide layout...</span>
</div>
<Timer duration={120} />
</div>
)}
</div>
{/* Slide Content */}
<div className="p-4">
{/* Selection Edit Mode Banner */}
{isSelectionEditMode && slide.processed && !slide.processing && (
<div className="mb-4 flex items-center justify-between bg-indigo-50 border border-indigo-200 rounded-xl px-4 py-3">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-full bg-indigo-500 flex items-center justify-center">
<MousePointer2 className="w-3.5 h-3.5 text-white" />
</div>
<span className="text-sm font-medium text-indigo-700">
Selection Edit Mode Click on any element to edit with AI
</span>
</div>
<button
onClick={() => setIsSelectionEditMode(false)}
className="h-8 px-3 text-sm font-medium text-indigo-600 hover:text-indigo-800 hover:bg-indigo-100 rounded-md transition-colors"
>
Exit
</button>
</div>
)}
<div className="relative">
<SlideContentDisplay
slide={slide}
index={index}
isProcessing={isProcessing}
isEditMode={isEditMode}
isHtmlEditMode={isHtmlEditMode}
onEditClick={handleEditClick}
onHtmlEditClick={handleHtmlEditClick}
onRetry={handleRetrySlide}
onDelete={handleDeleteSlide}
compiledLayout={compiledLayout}
previewData={previewData}
retrySlide={handleRetrySlide}
onClearPreview={handleClearPreview}
slideDisplayRef={slideDisplayRef}
/>
</CardTitle>
</CardHeader>
{/* Schema-Element Highlighting Overlay - active when schema editor is open */}
{isSchemaEditorOpen && slide.processed && !slide.processing && (
<SchemaElementHighlighter
containerRef={slideDisplayRef}
sampleData={sampleData}
isActive={isSchemaEditorOpen}
/>
)}
{/* Selection Editor Overlay */}
{/* {isSelectionEditMode && slide.processed && !slide.processing && (
<SlideSelectionEditor
containerRef={slideDisplayRef}
slide={slide}
onSlideUpdate={handleSelectionUpdate}
/>
)} */}
</div>
</div>
<CardContent className="space-y-4">
{/* HTML Editor */}
<HtmlEditor
slide={slide}
isHtmlEditMode={isHtmlEditMode}
onSave={handleHtmlSave}
onCancel={handleHtmlEditCancel}
/>
{/* Edit Controls */}
<EditControls
isEditMode={isEditMode}
prompt={prompt}
isUpdating={isUpdating}
strokeWidth={strokeWidth}
strokeColor={strokeColor}
eraserMode={eraserMode}
onPromptChange={setPrompt}
onSave={handleSaveWithDrawing}
onCancel={handleCancelEdit}
onStrokeWidthChange={handleStrokeWidthChange}
onStrokeColorChange={handleStrokeColorChange}
onEraserModeChange={handleEraserModeChange}
onClearCanvas={handleClearCanvas}
/>
{/* Slide Content Display */}
<SlideContentDisplay
slide={slide}
isEditMode={isEditMode}
isHtmlEditMode={isHtmlEditMode}
slideContentRef={slideContentRef}
slideDisplayRef={slideDisplayRef}
canvasRef={canvasRef}
canvasDimensions={canvasDimensions}
strokeWidth={strokeWidth}
strokeColor={strokeColor}
eraserMode={eraserMode}
isDrawing={isDrawing}
didYourDraw={didYourDraw}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
retrySlide={handleRetrySlide}
onTouchEnd={handleTouchEnd}
/>
</CardContent>
</Card>
{/* Status Indicator */}
{hasError && (
<div className="absolute top-3 right-3">
<div className="w-3 h-3 rounded-full bg-[#EF4444] animate-pulse" />
</div>
)}
</div>
);
};
export default EachSlide;
export default EachSlide;

View file

@ -1,103 +0,0 @@
'use client'
import React from "react";
import { AlertCircle, CheckCircle, Edit, Loader2, Repeat2, Trash, Code } from "lucide-react";
import ToolTip from "@/components/ToolTip";
import { SlideActionsProps } from "../../types";
export const SlideActions: React.FC<SlideActionsProps> = ({
slide,
index,
isProcessing,
isEditMode,
isHtmlEditMode,
onEditClick,
onHtmlEditClick,
onRetry,
onDelete,
}) => {
return (
<div className="flex items-center w-full justify-between gap-2">
<div>
{slide.processing ? (
<Loader2 className="w-6 h-6 text-blue-600 animate-spin" />
) : slide.processed ? (
<CheckCircle className="w-6 h-6 text-green-600" />
) : slide.error ? (
<AlertCircle className="w-6 h-6 text-red-600" />
) : (
<div className="w-6 h-6 border-2 border-gray-300 rounded-full" />
)}
</div>
{slide.processed && (
<div className="flex gap-6">
{slide.processed && slide.html && !isEditMode && !isHtmlEditMode && (
<>
<div>
<ToolTip content="Edit slide with AI">
<button
onClick={onEditClick}
disabled={isProcessing || !slide.processed}
className={`px-6 py-2 flex gap-2 text-sm items-center group-hover:scale-105 rounded-lg bg-[#5141e5] hover:shadow-md transition-all duration-300 cursor-pointer shadow-md ${
isProcessing || !slide.processed
? "opacity-50 cursor-not-allowed"
: ""
}`}
>
<Edit className="w-4 sm:w-5 h-4 sm:h-5 text-white" />
<span className="text-white">Edit Slide</span>
</button>
</ToolTip>
</div>
<div>
<ToolTip content="Edit HTML directly">
<button
onClick={onHtmlEditClick}
disabled={isProcessing || !slide.processed}
className={`px-6 py-2 flex gap-2 text-sm items-center group-hover:scale-105 rounded-lg bg-purple-600 hover:bg-purple-700 hover:shadow-md transition-all duration-300 cursor-pointer shadow-md ${
isProcessing || !slide.processed
? "opacity-50 cursor-not-allowed"
: ""
}`}
>
<Code className="w-4 sm:w-5 h-4 sm:h-5 text-white" />
<span className="text-white">Edit HTML</span>
</button>
</ToolTip>
</div>
</>
)}
<div>
<ToolTip content="Re-Design this slide">
<button
onClick={onRetry}
disabled={isProcessing || !slide.processed}
className={`px-6 py-2 flex gap-2 text-sm items-center group-hover:scale-105 rounded-lg bg-[#5141e5] hover:shadow-md transition-all duration-300 cursor-pointer shadow-md ${
isProcessing || !slide.processed
? "opacity-50 cursor-not-allowed"
: ""
}`}
>
<Repeat2 className="w-4 sm:w-5 h-4 sm:h-5 text-white" />
<span className="text-white">Re-Construct</span>
</button>
</ToolTip>
</div>
<div>
<ToolTip content="Delete Slide">
<button
disabled={isProcessing}
onClick={onDelete}
className={`px-4 py-2 flex gap-2 text-sm items-center group-hover:scale-105 rounded-lg hover:shadow-md transition-all duration-300 cursor-pointer shadow-md ${
isProcessing ? "opacity-50 cursor-not-allowed" : ""
}`}
>
<Trash className="w-4 sm:w-5 h-4 sm:h-5 text-red-500" />
</button>
</ToolTip>
</div>
</div>
)}
</div>
);
};

View file

@ -1,127 +1,122 @@
'use client'
import React from "react";
import SlideContent from "../SlideContent";
import { SlideContentDisplayProps } from "../../types";
import { Repeat2 } from "lucide-react";
import Timer from "../Timer";
import { ProcessedSlide } from "../../types";
import { RotateCcw, X, AlertCircle, ImageOff } from "lucide-react";
import { Button } from "@/components/ui/button";
import { CompiledLayout } from "@/app/hooks/compileLayout";
export interface SlideContentDisplayProps {
slide: ProcessedSlide;
compiledLayout: CompiledLayout | null;
previewData?: Record<string, any> | null;
retrySlide: (slideNumber: number) => void;
onClearPreview?: () => void;
slideDisplayRef?: React.RefObject<HTMLDivElement>;
}
export const SlideContentDisplay: React.FC<SlideContentDisplayProps> = ({
slide,
isEditMode,
isHtmlEditMode,
slideContentRef,
slideDisplayRef,
canvasRef,
canvasDimensions,
eraserMode,
strokeWidth,
strokeColor,
isDrawing,
didYourDraw,
onMouseDown,
onMouseMove,
onMouseUp,
onTouchStart,
onTouchMove,
onTouchEnd,
compiledLayout,
previewData,
retrySlide,
onClearPreview,
slideDisplayRef,
}) => {
// Don't show slide content when in HTML edit mode
if (isHtmlEditMode) {
return null;
}
if (slide.processing) {
// Successfully processed slide
if (slide.processed && slide.react && !slide.processing) {
return (
<div className="space-y-4">
<p className="text-base text-blue-600 font-medium">🔄 Converting to HTML...</p>
<div className="space-y-3">
<Timer duration={160} />
</div>
<div className="animate-pulse space-y-3">
<div className="h-6 bg-gray-200 rounded w-2/3"></div>
<div className="h-6 bg-gray-200 rounded w-1/2"></div>
<div className="h-64 bg-gray-200 rounded"></div>
</div>
</div>
);
}
if (slide.processed && slide.html) {
return (
<div className="relative">
{slide.convertingToReact && (
<div className="mb-4">
<p className="text-sm text-purple-700 font-medium mb-1"> Converting HTML to React...</p>
<Timer duration={90} />
<div className="relative flex-1">
{/* Preview Mode Banner */}
{previewData && (
<div className="mb-4 flex items-center justify-between bg-[#EDE9FE] border border-[#C4B5FD] rounded-xl px-4 py-3">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-full bg-[#7A5AF8] flex items-center justify-center">
<span className="text-white text-xs"></span>
</div>
<span className="text-sm font-medium text-[#5B21B6]">
Showing AI-generated preview
</span>
</div>
{onClearPreview && (
<Button
variant="ghost"
size="sm"
onClick={onClearPreview}
className="h-8 text-[#7A5AF8] hover:text-[#5B21B6] hover:bg-[#DDD6FE]"
>
<X className="w-4 h-4 mr-1.5" />
Clear
</Button>
)}
</div>
)}
<div ref={slideDisplayRef} className="relative mx-auto w-full">
<div ref={slideContentRef}>
<SlideContent slide={slide} />
</div>
{isEditMode && (
<canvas
ref={canvasRef}
width={canvasDimensions.width}
height={canvasDimensions.height}
style={{
position: "absolute",
top: 0,
left: 0,
zIndex: 30,
cursor: eraserMode ? "grab" : "crosshair",
pointerEvents: "auto",
touchAction: "none",
}}
onMouseDown={onMouseDown}
onMouseMove={onMouseMove}
onMouseUp={onMouseUp}
onMouseLeave={onMouseUp}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
onContextMenu={(e) => e.preventDefault()}
{/* Slide Content */}
<div className="relative rounded-xl overflow-hidden border border-[#E5E7EB] bg-white shadow-sm">
<div ref={slideDisplayRef}>
<SlideContent
slide={slide}
compiledLayout={compiledLayout}
data={previewData}
retrySlide={retrySlide}
/>
)}
</div>
</div>
</div>
);
}
// Error state
if (slide.error) {
const isImageTooLarge = slide.error.includes("image exceeds 5 MB maximum");
return (
<div className="space-y-4">
<p className="text-base text-red-600 font-medium"> Conversion failed</p>
<div className="text-sm text-gray-700 p-4 bg-red-50 rounded border border-red-200">
{slide.error.includes("image exceeds 5 MB maximum") ? (
<div>
<p className="font-medium text-red-700 mb-2">Image too large for processing</p>
<p>This slide's image exceeds the 5MB limit. Try using a smaller resolution PPTX file.</p>
</div>
) : (
slide.error
)}
</div>
<div className="flex justify-center">
<button className="bg-red-50 flex gap-2 items-center rounded border border-red-200 px-4 py-2 " onClick={() => retrySlide(slide.slide_number)}>
<Repeat2 className="w-4 h-4" />Retry
</button>
<div className="rounded-xl border border-[#FECACA] bg-[#FEF2F2] p-6">
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-full bg-[#FEE2E2] flex items-center justify-center flex-shrink-0">
{isImageTooLarge ? (
<ImageOff className="w-5 h-5 text-[#DC2626]" />
) : (
<AlertCircle className="w-5 h-5 text-[#DC2626]" />
)}
</div>
<div className="flex-1">
<h4 className="text-base font-semibold text-[#991B1B] mb-1">
{isImageTooLarge ? "Image Too Large" : "Conversion Failed"}
</h4>
<p className="text-sm text-[#B91C1C] mb-4">
{isImageTooLarge
? "This slide's image exceeds the 5MB limit. Try using a smaller resolution PPTX file or compressing the images."
: slide.error
}
</p>
<button
onClick={() => retrySlide(slide.slide_number)}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-full bg-white border border-[#FECACA] text-[#DC2626] hover:bg-[#FEE2E2] transition-all"
>
<RotateCcw className="w-4 h-4" />
Retry
</button>
</div>
</div>
</div>
);
}
// Loading/Processing state - Timer is now shown in parent component (NewEachSlide)
// This just shows a skeleton placeholder
return (
<div className="space-y-4">
<p className="text-base text-gray-500"> Waiting in queue to process...</p>
<div className="animate-pulse space-y-3">
<div className="h-6 bg-gray-200 rounded w-2/3"></div>
<div className="h-6 bg-gray-200 rounded w-1/2"></div>
<div className="h-64 bg-gray-200 rounded"></div>
<div className="rounded-xl border border-[#E5E7EB] bg-[#F9FAFB] p-6 mx-auto max-w-[1280px] w-full aspect-video h-[720px]">
<div className="animate-pulse space-y-4 w-full h-full">
{/* Content skeleton */}
<div className="aspect-video bg-[#E5E7EB] rounded-xl mt-4 w-full h-full" />
</div>
</div>
);
};
};

View file

@ -1,117 +1,251 @@
import React from "react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Upload, FileText, X, Loader2 } from "lucide-react";
import React, { useState, useRef, useEffect } from "react";
import { UploadIcon, ChevronRight, Plus, FileText, X, Coins, Edit3, Info } from "lucide-react";
import { ProcessedSlide } from "../types";
import Timer from "./Timer";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { useSelector } from "react-redux";
import { RootState } from "@/store/store";
interface FileUploadSectionProps {
selectedFile: File | null;
handleFileSelect: (event: React.ChangeEvent<HTMLInputElement>) => void;
removeFile: () => void;
processFile: () => void;
CheckFonts: () => void;
isProcessingPptx: boolean;
slides: ProcessedSlide[];
completedSlides: number;
}
// Credit costs constants
const COST_PER_SLIDE = 3;
const COST_EDIT = 1;
export const FileUploadSection: React.FC<FileUploadSectionProps> = ({
selectedFile,
handleFileSelect,
removeFile,
processFile,
CheckFonts,
isProcessingPptx,
slides,
completedSlides,
}) => {
const isProcessing = isProcessingPptx || slides.some((s) => s.processing);
const [isAllowed, setIsAllowed] = useState(false);
const { llm_config } = useSelector((state: RootState) => state.userConfig);
const handleCheckFonts = () => {
CheckFonts();
}
useEffect(() => {
if (llm_config?.LLM === 'custom' || llm_config?.LLM === 'ollama') {
setIsAllowed(false);
} else {
setIsAllowed(true);
}
}, [llm_config]);
return (
<Card className="w-full">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Upload className="w-5 h-5" />
Upload PDF or PPTX File
</CardTitle>
<CardDescription>
Select a PDF or PowerPoint file (.pdf or .pptx) to process. Maximum file size: 100MB
</CardDescription>
{slides.length > 0 && (
<div className="flex items-center justify-end gap-2">
{slides.some((s) => s.processing) && (
<Loader2 className="w-6 h-6 animate-spin text-blue-600" />
)}
{completedSlides}/{slides.length} slides completed
<div className="md:h-[calc(100vh-310px)] h-[calc(100vh-450px)] relative overflow-hidden">
<div className=" max-w-[650px] w-full mx-auto px-2 md:px-0 ">
<div
className='absolute z-0 md:-bottom-[36%] -bottom-[40%] left-0 w-full h-full'
style={{
height: "341px",
borderRadius: '1440px',
background: 'radial-gradient(5.92% 104.69% at 50% 100%, rgba(122, 90, 248, 0.00) 0%, rgba(255, 255, 255, 0.00) 100%), radial-gradient(50% 50% at 50% 50%, rgba(122, 90, 248, 0.80) 0%, rgba(122, 90, 248, 0.00) 100%)',
}}
/>
<div className=' w-max ml-9 rounded-tl-[28px] rounded-tr-[28px] flex items-center bg-[#FAFAFF] px-2.5 pt-2.5 pb-1'
style={{
boxShadow: '0 0 16px 0 rgba(80, 71, 230, 0.12)',
}}
>
<div className={`flex justify-center gap-1 py-2.5 pl-2 pr-3 cursor-pointer bg-white rounded-[80px] `}
style={{
boxShadow: '0 0 4px 0 rgba(0, 0, 0, 0.06)',
}}
>
<UploadIcon className={`w-4 h-4 text-black`} />
<p className='text-xs font-medium text-black'>Upload PPTX File</p>
</div>
)}
</CardHeader>
<CardContent className="space-y-4">
{!selectedFile ? (
<div className="border-2 relative border-dashed border-gray-300 rounded-lg p-8 text-center hover:border-gray-400 transition-colors">
<Upload className="w-12 h-12 text-gray-400 mx-auto mb-4" />
<Label htmlFor="file-upload" className="cursor-pointer">
<span className="text-lg font-medium text-gray-700">
Click to upload a PDF or PPTX file
</span>
<input
id="file-upload"
type="file"
accept=".pdf,.pptx"
onChange={handleFileSelect}
className="opacity-0 w-full h-full cursor-pointer absolute top-0 left-0 z-10"
/>
</Label>
<p className="text-sm text-gray-500 mt-2">
Drag and drop your file here or click to browse
</p>
</div>
) : (
<div className="flex items-center justify-between p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-center gap-3">
<FileText className="w-8 h-8 text-blue-600" />
<div>
<p className="font-medium text-gray-900">
{selectedFile.name}
</p>
<p className="text-sm text-gray-500">
{(selectedFile.size / (1024 * 1024)).toFixed(2)} MB
</p>
</div>
<div className=" w-full bg-[#FAFAFF] rounded-[28px] p-2.5 "
style={{
boxShadow: '0 0 16px 0 rgba(80, 71, 230, 0.12)',
clipPath: 'inset(0px -28px -28px -28px)',
}}
>
<div className="bg-[#FEFEFF] rounded-[18px] p-2 border border-[#EDEEEF] ">
<div className="h-[120px] w-full bg-[#F6F6F9] rounded-[12px] p-1.5">
<div className="border border-[#B8B8C1] border-dashed rounded-[12px ] p-1.5 h-full relative">
{!selectedFile ? <>
<input
id="file-upload"
type="file"
accept=".pptx"
disabled={!isAllowed}
onChange={handleFileSelect}
className={`opacity-0 w-full h-full ${!isAllowed ? 'cursor-not-allowed' : 'cursor-pointer'} absolute top-0 left-0 z-10`}
/>
<div className='absolute inset-0 flex flex-col items-center justify-center'>
<div className='w-[42px] h-[42px] flex justify-center items-center rounded-full bg-[#EBE9FE]' >
<div className='w-[22px] h-[22px] rounded-full bg-[#7A5AF8] flex items-center justify-center text-white'>
<Plus className='w-3 h-3' />
</div>
</div>
<p className='pt-3 text-xs font-normal text-[#808080] tracking-[-0.12px] text-center'>
<span className='text-[#808080] underline underline-offset-4'>Click to Upload</span> or drag &amp; drop.
</p>
</div>
</> : <div className="flex gap-2 items-center justify-center h-full">
<div className="flex gap-2 items-center">
<div className="w-[55px] h-[55px] ml-auto mr-0 rounded-[9px] bg-[#8E8F8F] flex items-center justify-center relative">
<button className="absolute w-[16px] h-[16px] flex items-center justify-center -top-1.5 -right-1.5"
style={{
borderRadius: '54.545px',
border: '0.682px solid #EDEEEF',
background: '#FFF',
boxShadow: '0 4px 8px 0 rgba(0, 0, 0, 0.25)',
}}
disabled={isProcessing}
onClick={removeFile}
>
<X className="w-3 h-3 text-black " />
</button>
<FileText className="w-5 h-5 text-white" />
</div>
<div className="w-4/5">
<h3 className="text-[#4C4C4C] text-sm font-medium w-full truncate"> {selectedFile.name}</h3>
<p className="text-xs font-normal text-[#808080] tracking-[-0.12px]">Presentation ( {(selectedFile.size / (1024 * 1024)).toFixed(2)} MB)</p>
</div>
</div>
</div>
}
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={removeFile}
disabled={
isProcessingPptx || slides.some((s) => s.processing)
}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
<X className="w-4 h-4" />
</Button>
</div>
)}
<div className="mt-2">
<div className="flex items-center justify-between gap-2.5">
<div className="min-w-[140px] w-full">
{isProcessing ? (
<div className="flex items-center justify-end gap-3" aria-live="polite" aria-label="Processing">
<div
className="h-[14px] w-[74px] rounded-full bg-[#EFEDFF] overflow-hidden ring-1 ring-[#E4E0FF]"
aria-hidden="true"
>
<div className="h-full w-full rounded-full processing-stripes" />
</div>
<p className="text-sm font-medium text-[#9A9AA6] tracking-[-0.1px]">Processing</p>
{slides.length > 0 ? (
<p className="text-sm font-medium text-[#9A9AA6] tracking-[-0.1px]">
{completedSlides}/{slides.length} Slides
</p>
) : null}
<style jsx>{`
@keyframes stripes {
from {
background-position: 0 0;
}
to {
background-position: 24px 0;
}
}
.processing-stripes {
background: repeating-linear-gradient(
135deg,
rgba(122, 90, 248, 0.9) 0px,
rgba(122, 90, 248, 0.9) 9px,
rgba(122, 90, 248, 0.18) 9px,
rgba(122, 90, 248, 0.18) 18px
);
filter: saturate(1.05);
background-size: 24px 24px;
will-change: background-position;
animation: stripes 0.7s linear infinite;
}
`}</style>
</div>
) : (
<div className="flex items-center justify-end gap-2.5">
<button className="px-4 py-2.5 text-xs font-semibold text-[#101323] font-syne tracking-[-0.12px] flex gap-1"
style={{
borderRadius: '48px',
background: 'linear-gradient(270deg, #D5CAFC 2.4%, #E3D2EB 27.88%, #F4DCD3 69.23%, #FDE4C2 100%)',
cursor: !isAllowed ? 'not-allowed' : 'pointer',
}}
onClick={handleCheckFonts}
disabled={isProcessing || !isAllowed}
>
{isProcessingPptx
? "Checking Fonts..."
: !selectedFile
? "Select a PPTX file"
: "Check Fonts"}
<ChevronRight className="w-3.5 h-3.5 text-black" />
</button>
</div>
)}
</div>
</div>
</div>
</div>
<div className="flex flex-col gap-1 ">
<Button
onClick={processFile}
disabled={isProcessingPptx || slides.some((s) => s.processing)}
className="flex-1 bg-blue-600 hover:bg-blue-700 text-white"
>
{isProcessingPptx
? "Extracting Slides..."
: !selectedFile
? "Select a PDF or PPTX file"
: "Process File"}
</Button>
{isProcessingPptx && <Timer duration={90} />}
</div>
</CardContent>
</Card>
<ul className="flex items-center max-w-[85%] md:max-w-[70%] mx-auto mt-5 justify-between gap-2.5">
<li className="flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<circle cx="8.5" cy="8.17041" r="4.5" fill="#EBE9FE" />
</svg>
<p className="md:text-sm text-[10px] font-normal text-[#3A3A3A] ">PPTX. Only</p>
</li>
<li className="flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<circle cx="8.5" cy="8.17041" r="4.5" fill="#EBE9FE" />
</svg>
<p className="md:text-sm text-[10px] font-normal text-[#3A3A3A] ">Max 100MB</p>
</li>
<li className="flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<circle cx="8.5" cy="8.17041" r="4.5" fill="#EBE9FE" />
</svg>
<p className="md:text-sm text-[10px] font-normal text-[#3A3A3A] ">5min Generation</p>
</li>
</ul>
<div className="mt-4 px-4 py-3 rounded-lg border border-[#EBE9FE] flex items-start gap-2 shadow-md">
<svg className="mt-0.5 shrink-0" xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 20 20" fill="none">
<circle cx="10" cy="10" r="10" fill="#EBE9FE" />
<path d="M10 6V10M10 14H10.0088" stroke="#5B49A1" strokeWidth="1.5" strokeLinecap="round" />
</svg>
<p className="text-sm md:text-base font-medium text-[#20165C] tracking-[-0.13px]">
<span className="font-bold text-[#5B49A1]">Note:</span> Template generation relies on <span className="font-semibold">vision-capable models</span> and is currently supported only by providers: <span className="font-medium text-[#5246C3]">Google</span>, <span className="font-medium text-[#5246C3]">OpenAI</span>, and <span className="font-medium text-[#5246C3]">Anthropic</span>.
For optimal results, use state-of-the-art models from these providers, as performance may degrade with smaller models.
</p>
</div>
</div>
</div>
);
};

View file

@ -1,77 +1,44 @@
import React, { useState, useRef } from "react";
import React, { useRef } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Upload,
CheckCircle,
AlertCircle,
CheckCircle2,
AlertTriangle,
X,
Loader2,
Type,
ChevronRight,
FileType,
Info,
} from "lucide-react";
interface UploadedFont {
fontName: string;
fontUrl: string;
fontPath: string;
}
interface FontData {
internally_supported_fonts: {
name: string;
google_fonts_url: string;
}[];
not_supported_fonts: string[];
}
interface FontManagerProps {
fontsData: FontData;
UploadedFonts: UploadedFont[];
uploadFont: (fontName: string, file: File) => Promise<string | null>;
removeFont: (fontUrl: string) => void;
getAllUnsupportedFonts: () => string[];
processSlideToHtml: () => void;
}
import { FontManagerProps, FontItem } from "../types";
const FontManager: React.FC<FontManagerProps> = ({
fontsData,
UploadedFonts,
uploadedFonts,
uploadFont,
removeFont,
getAllUnsupportedFonts,
processSlideToHtml,
onContinue,
isUploading = false,
}) => {
const [uploadingFonts, setUploadingFonts] = useState<Set<string>>(new Set());
const fileInputRefs = useRef<{ [key: string]: HTMLInputElement | null }>({});
const allUnsupportedFonts = getAllUnsupportedFonts();
// Filter out fonts that are already uploaded
const fontsNeedingUpload = allUnsupportedFonts.filter(
(fontName) =>
!UploadedFonts.some((uploadedFont) => uploadedFont.fontName === fontName)
// Get fonts that still need to be uploaded (unavailable fonts not yet uploaded)
const fontsNeedingUpload = fontsData.unavailable_fonts.filter(
(font) => !uploadedFonts.some((uploaded) => uploaded.fontName === font.name)
);
const handleFontUpload = async (fontName: string, file: File) => {
const allFontsUploaded = fontsNeedingUpload.length === 0;
const hasAvailableFonts = fontsData.available_fonts.length > 0;
const hasUploadedFonts = uploadedFonts.length > 0;
const handleFontUpload = (fontName: string, file: File) => {
if (!file) return;
setUploadingFonts((prev) => new Set(prev).add(fontName));
const result = uploadFont(fontName, file);
try {
const fontUrl = await uploadFont(fontName, file);
if (fontUrl) {
// Clear the file input
if (fileInputRefs.current[fontName]) {
fileInputRefs.current[fontName]!.value = "";
}
}
} finally {
setUploadingFonts((prev) => {
const newSet = new Set(prev);
newSet.delete(fontName);
return newSet;
});
if (result && fileInputRefs.current[fontName]) {
fileInputRefs.current[fontName]!.value = "";
}
};
@ -85,149 +52,188 @@ const FontManager: React.FC<FontManagerProps> = ({
}
};
if (allUnsupportedFonts.length === 0 && UploadedFonts.length === 0) {
return null;
}
return (
<Card className="my-6">
<CardHeader>
<CardTitle className="text-xl flex items-center gap-2">
<Type className="w-6 h-6" />
Font Management
</CardTitle>
<p className="text-sm text-gray-600">
We couldn't load these fonts automatically. Please upload them manually. Make sure naem of the font should be exactly as shown.
</p>
</CardHeader>
<CardContent className="space-y-6">
{/* Supported Fonts */}
{fontsData.internally_supported_fonts.length > 0 && (
<div>
<h4 className="text-sm font-medium text-green-700 mb-3 flex items-center gap-1">
<CheckCircle className="w-4 h-4" />
Supported Fonts ({fontsData.internally_supported_fonts.length})
</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
{fontsData.internally_supported_fonts.map((font, index) => (
<div
key={index}
className="p-2 bg-green-50 border border-green-200 rounded text-sm text-green-800"
>
{font.name}
</div>
))}
<div className="my-8 max-w-[900px] mx-auto">
<div className="bg-white rounded-2xl border border-[#E5E7EB] shadow-sm overflow-hidden">
{/* Header */}
<div className="px-6 py-5 border-b border-[#F3F4F6] bg-[#FAFAFA]">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-[#EBE9FE] flex items-center justify-center">
<Type className="w-6 h-6 text-[#7A5AF8]" />
</div>
<div>
<h2 className="text-xl font-semibold text-[#111827]">Font Management</h2>
<p className="text-sm text-[#6B7280] mt-0.5">
{allFontsUploaded
? "All fonts are ready! You can proceed to preview."
: "Upload missing fonts to ensure your presentation displays correctly."}
</p>
</div>
</div>
)}
</div>
{/* Fonts Needing Upload */}
{fontsNeedingUpload.length > 0 && (
<div>
<h4 className="text-sm font-medium text-orange-700 mb-3 flex items-center gap-1">
<AlertCircle className="w-4 h-4" />
Fonts Needing Upload ({fontsNeedingUpload.length})
</h4>
<div className="space-y-3">
{fontsNeedingUpload.map((fontName: string, index: number) => (
<div
key={index}
className="p-4 bg-orange-50 border border-orange-200 rounded-lg"
>
<div className="flex items-center justify-between">
<div>
<span className="text-sm font-medium text-orange-800">
{fontName}
</span>
<p className="text-xs text-orange-600 mt-1">
Required for presentation
</p>
<div className="p-6 space-y-6">
{/* Available Fonts */}
{hasAvailableFonts && (
<div className="p-4 bg-[#F0FDF4] rounded-xl border border-[#BBF7D0]">
<div className="flex items-center gap-2 mb-3">
<CheckCircle2 className="w-5 h-5 text-[#16A34A]" />
<h4 className="text-sm font-semibold text-[#166534]">
Available Fonts ({fontsData.available_fonts.length})
</h4>
</div>
<div className="flex flex-wrap gap-2">
{fontsData.available_fonts.map((font, index) => (
<span
key={index}
className="px-3 py-1.5 bg-white border border-[#D1FAE5] rounded-full text-xs font-medium text-[#166534] shadow-sm"
>
{font.name}
</span>
))}
</div>
</div>
)}
{/* Fonts Needing Upload */}
{fontsNeedingUpload.length > 0 && (
<div className="p-4 bg-[#FFFBEB] rounded-xl border border-[#FDE68A]">
<div className="flex items-center gap-2 mb-4">
<AlertTriangle className="w-5 h-5 text-[#D97706]" />
<h4 className="text-sm font-semibold text-[#92400E]">
Missing Fonts ({fontsNeedingUpload.length})
</h4>
</div>
<div className="space-y-3">
{fontsNeedingUpload.map((font, index) => (
<div
key={index}
className="flex items-center justify-between p-4 bg-white rounded-xl border border-[#FDE68A] shadow-sm"
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-[#FEF3C7] flex items-center justify-center">
<FileType className="w-5 h-5 text-[#D97706]" />
</div>
<div>
<span className="text-sm font-semibold text-[#111827] block">
{font.name}
</span>
<span className="text-xs text-[#6B7280]">
.ttf, .otf, .woff, .woff2
</span>
</div>
</div>
<div className="flex items-center gap-2">
<div>
<input
ref={(el) => {
fileInputRefs.current[fontName] = el;
fileInputRefs.current[font.name] = el;
}}
type="file"
accept=".ttf,.otf,.woff,.woff2,.eot"
onChange={(e) => handleFileInputChange(fontName, e)}
onChange={(e) => handleFileInputChange(font.name, e)}
className="hidden"
id={`global-font-upload-${index}`}
id={`font-upload-${index}`}
/>
<Button
size="sm"
variant="outline"
disabled={uploadingFonts.has(fontName)}
onClick={() => fileInputRefs.current[fontName]?.click()}
className="text-xs bg-blue-600 text-white hover:text-white hover:bg-blue-700 border-blue-600"
onClick={() => fileInputRefs.current[font.name]?.click()}
className="rounded-full px-4 h-9 text-sm font-medium transition-all text-[#D97706] border-[#D97706] hover:bg-[#FFFBEB] hover:border-[#D97706]"
>
{uploadingFonts.has(fontName) ? (
<>
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
Uploading...
</>
) : (
<>
<Upload className="w-3 h-3 mr-1" />
Upload Font
</>
)}
<Upload className="w-4 h-4 mr-1" />
Upload
</Button>
</div>
</div>
</div>
))}
))}
</div>
</div>
</div>
)}
)}
{/* Successfully Uploaded Fonts */}
{UploadedFonts.length > 0 && (
<div>
<h4 className="text-sm font-medium text-green-700 mb-3 flex items-center gap-1">
<CheckCircle className="w-4 h-4" />
Uploaded Fonts ({UploadedFonts.length})
</h4>
<div className="space-y-2">
{UploadedFonts.map((font, index) => (
<div
key={index}
className="p-3 bg-green-50 border border-green-200 rounded-lg flex items-center justify-between"
>
<div>
<span className="text-sm font-medium text-green-800">
{font.fontName}
</span>
<p className="text-xs text-green-600 mt-1">
Available for all slides
</p>
</div>
<Button
size="sm"
variant="ghost"
onClick={() => removeFont(font.fontUrl)}
className="text-red-600 hover:text-red-700 hover:bg-red-50 p-1"
{/* Uploaded Fonts */}
{hasUploadedFonts && (
<div className="p-4 bg-[#F0FDF4] rounded-xl border border-[#BBF7D0]">
<div className="flex items-center gap-2 mb-4">
<CheckCircle2 className="w-5 h-5 text-[#16A34A]" />
<h4 className="text-sm font-semibold text-[#166534]">
Uploaded Fonts ({uploadedFonts.length})
</h4>
</div>
<div className="space-y-2">
{uploadedFonts.map((font, index) => (
<div
key={index}
className="flex items-center justify-between p-3 bg-white rounded-xl border border-[#D1FAE5] shadow-sm"
>
<X className="w-3 h-3" />
</Button>
</div>
))}
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-[#DCFCE7] flex items-center justify-center">
<CheckCircle2 className="w-4 h-4 text-[#16A34A]" />
</div>
<span className="text-sm font-medium text-[#166534]">
{font.fontName}
</span>
</div>
<button
onClick={() => removeFont(font.fontName)}
className="p-2 rounded-full text-[#6B7280] hover:text-[#DC2626] hover:bg-[#FEE2E2] transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
))}
</div>
</div>
</div>
)}
<div className="flex justify-center mt-4">
)}
</div>
<Button
size="sm"
variant="outline"
onClick={processSlideToHtml}
className="text-xs px-8 py-2 font-semibold bg-blue-600 text-white hover:text-white hover:bg-blue-700 border-blue-600"
>
Extract Template
</Button>
{/* Action Footer */}
<div className={`px-6 py-5 border-t transition-colors duration-300 ${allFontsUploaded
? 'bg-[#F0FDF4] border-[#BBF7D0]'
: 'bg-[#FAFAFA] border-[#F3F4F6]'
}`}>
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
{!allFontsUploaded && (
<div className="flex items-start gap-2 text-sm text-[#6B7280]">
<Info className="w-4 h-4 mt-0.5 flex-shrink-0" />
<p>You can continue without all fonts, but some text may not display correctly.</p>
</div>
)}
{allFontsUploaded && (
<p className="text-sm text-[#16A34A] font-medium">
All fonts are ready
</p>
)}
<Button
size="lg"
onClick={onContinue}
disabled={isUploading}
className={`
px-5 py-2 h-auto text-sm font-semibold rounded-full transition-all duration-300
${isUploading
? 'bg-[#E5E7EB] text-[#9CA3AF]'
: allFontsUploaded
? 'bg-[#16A34A] text-white hover:bg-[#15803D] shadow-sm'
: 'bg-white text-[#374151] border border-[#E5E7EB] hover:bg-[#F9FAFB]'
}
`}
>
{isUploading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Processing...
</>
) : (
<>
{allFontsUploaded ? 'Continue to Preview' : 'Continue'}
<ChevronRight className="w-4 h-4 ml-1" />
</>
)}
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
);
};

View file

@ -1,3 +1,4 @@
'use client'
import React from "react";
import { Button } from "@/components/ui/button";
import { FileText, Loader2 } from "lucide-react";
@ -13,13 +14,18 @@ export const SaveLayoutButton: React.FC<SaveLayoutButtonProps> = ({
isSaving,
isProcessing,
}) => {
return (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50">
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 p-2"
style={{
borderRadius: '36px',
background: 'rgba(0, 0, 0, 0.28)',
}}
>
<Button
onClick={onSave}
disabled={isSaving || isProcessing}
className="bg-green-600 hover:bg-green-700 text-white shadow-lg hover:shadow-xl transition-all duration-200 px-10 py-3 text-lg"
className="bg-[#6938EF] hover:bg-[#6938EF]/90 rounded-[24px] text-white shadow-lg hover:shadow-xl transition-all duration-200 p-3.5 text-base font-semibold"
size="lg"
>
{isSaving ? (
@ -29,7 +35,7 @@ export const SaveLayoutButton: React.FC<SaveLayoutButtonProps> = ({
</>
) : (
<>
<FileText className="w-5 h-5 mr-2" />
Save as Template
</>
)}

View file

@ -1,3 +1,5 @@
'use client'
import React, { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@ -11,14 +13,16 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Loader2, Save } from "lucide-react";
import { Loader2, Save, Info } from "lucide-react";
import { useRouter } from "next/navigation";
import ToolTip from "@/components/ToolTip";
interface SaveLayoutModalProps {
isOpen: boolean;
onClose: () => void;
onSave: (layoutName: string, description: string) => Promise<string | null>;
onSave: (layoutName: string, description: string, template_info_id: string) => Promise<string | null>;
isSaving: boolean;
template_info_id: string;
}
export const SaveLayoutModal: React.FC<SaveLayoutModalProps> = ({
@ -26,23 +30,20 @@ export const SaveLayoutModal: React.FC<SaveLayoutModalProps> = ({
onClose,
onSave,
isSaving,
template_info_id,
}) => {
const router = useRouter();
const [layoutName, setLayoutName] = useState("");
const [description, setDescription] = useState("");
const handleSave = async () => {
if (!layoutName.trim()) {
return; // Don't save if name is empty
}
const id = await onSave(layoutName.trim(), description.trim());
if (id) {
// Redirect to the new template preview page
router.push(`/template-preview/custom-${id}`);
}
// Reset form after navigation decision
setLayoutName("");
setDescription("");
await onSave(layoutName.trim(), description.trim(), template_info_id);
};
const handleClose = () => {
@ -55,44 +56,57 @@ export const SaveLayoutModal: React.FC<SaveLayoutModalProps> = ({
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[425px]">
<DialogContent className="sm:max-w-[480px] " style={{ zIndex: 1000 }}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Save className="w-5 h-5 text-green-600" />
Save Template
<DialogTitle className="flex items-center justify-between gap-2">
<span className="flex items-center gap-2">
<Save className="w-5 h-5 text-primary" />
Save Template
</span>
</DialogTitle>
<DialogDescription>
Enter a name and description for your template. This will help you identify it later.
Give your template a clear name and an optional description to find it later.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-5 py-4">
<div className="grid gap-2">
<Label htmlFor="layout-name" className="text-sm font-medium">
Template Name *
Template Name <span className="text-red-500">*</span>
</Label>
<Input
id="layout-name"
value={layoutName}
onChange={(e) => setLayoutName(e.target.value)}
placeholder="Enter template name..."
placeholder="e.g., Modern Tech Pitch"
disabled={isSaving}
className="w-full"
aria-required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="description" className="text-sm font-medium">
Description
Description <span className="text-gray-400">(optional)</span>
</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Enter a description for your template..."
placeholder="Add a short summary of what this template is best for..."
disabled={isSaving}
className="w-full resize-none"
rows={3}
/>
</div>
{isSaving && (
<div className="flex items-center gap-2 text-sm text-gray-600">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="lucide lucide-clock-icon lucide-clock"><path d="M12 6v6l4 2"><animateTransform attributeName="transform" type="rotate" from="0 12 12" to="360 12 12" dur="10s" repeatCount="indefinite" /></path><circle cx="12" cy="12" r="10" /></svg>
<span>Saving your template. This may take a moment</span>
</div>
)}
</div>
<DialogFooter>
<Button
@ -106,6 +120,7 @@ export const SaveLayoutModal: React.FC<SaveLayoutModalProps> = ({
onClick={handleSave}
disabled={isSaving || !layoutName.trim()}
className="bg-green-600 hover:bg-green-700"
aria-busy={isSaving}
>
{isSaving ? (
<>
@ -121,6 +136,7 @@ export const SaveLayoutModal: React.FC<SaveLayoutModalProps> = ({
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View file

@ -0,0 +1,39 @@
/**
* Schema Editor Panel Component
* Wraps the SchemaEditor with compiled layout functionality
*/
import React from "react";
import { ProcessedSlide } from "../types";
import { SchemaEditor } from "./SchemaEditor";
import { useCompiledLayout } from "../hooks/useCompiledLayout";
interface SchemaEditorPanelProps {
slide: ProcessedSlide;
slideIndex: number;
onSave: (updatedReact: string) => void;
onCancel: () => void;
onFillContent?: (content: Record<string, any>) => void;
}
export const SchemaEditorPanel: React.FC<SchemaEditorPanelProps> = ({
slide,
slideIndex,
onSave,
onCancel,
onFillContent,
}) => {
const compiledLayout = useCompiledLayout(slide.react);
return (
<SchemaEditor
slide={slide}
compiledLayout={compiledLayout}
isOpen={true}
onSave={onSave}
onCancel={onCancel}
onFillContent={onFillContent}
/>
);
};

View file

@ -0,0 +1,307 @@
'use client'
import React, { useCallback, useEffect, useState, useMemo } from 'react'
import { useSchemaHighlight, getAllValuesAtPath } from './SchemaHighlightContext'
interface HighlightRect {
left: number
top: number
width: number
height: number
path: string
}
interface SchemaElementHighlighterProps {
containerRef: React.RefObject<HTMLDivElement | null>
sampleData: Record<string, any> | null
isActive: boolean
}
const SchemaElementHighlighter: React.FC<SchemaElementHighlighterProps> = ({
containerRef,
sampleData,
isActive,
}) => {
const {
highlightedSchemaPath,
setHighlightedElementPath,
} = useSchemaHighlight()
const [highlightRects, setHighlightRects] = useState<HighlightRect[]>([])
const [hoverRect, setHoverRect] = useState<HighlightRect | null>(null)
// Build a map of text content to schema paths
const textToPathMap = useMemo(() => {
if (!sampleData) return new Map<string, string>()
const map = new Map<string, string>()
const buildMap = (obj: any, parentPath: string = '') => {
if (!obj || typeof obj !== 'object') return
for (const [key, value] of Object.entries(obj)) {
// Skip internal fields
if (key.startsWith('__') || key.startsWith('_')) continue
const path = parentPath ? `${parentPath}.${key}` : key
if (typeof value === 'string' && value.trim().length > 0) {
// Store the text content mapped to its path
map.set(value.trim(), path)
} else if (typeof value === 'number') {
map.set(String(value), path)
} else if (Array.isArray(value)) {
// For arrays, map each item's content with array notation
value.forEach((item, index) => {
if (typeof item === 'object' && item !== null) {
buildMap(item, `${path}[]`)
} else if (typeof item === 'string' && item.trim().length > 0) {
map.set(item.trim(), `${path}[]`)
}
})
} else if (typeof value === 'object' && value !== null) {
buildMap(value, path)
}
}
}
buildMap(sampleData)
return map
}, [sampleData])
// Find elements that contain the highlighted schema path's value
const findElementsForPath = useCallback((path: string): HTMLElement[] => {
const container = containerRef.current
if (!container || !sampleData) return []
// Get all values for this path (handles arrays)
const values = getAllValuesAtPath(sampleData, path)
if (values.length === 0) return []
const elements: HTMLElement[] = []
// Walk through all text nodes and find matches
const walker = document.createTreeWalker(
container,
NodeFilter.SHOW_TEXT,
null
)
const valueSet = new Set(values.map(v => String(v).trim()))
let node: Text | null
while ((node = walker.nextNode() as Text | null)) {
const text = node.textContent?.trim()
if (text && valueSet.has(text)) {
const parent = node.parentElement
if (parent && !parent.closest('[data-inspector-overlay="1"]')) {
elements.push(parent)
}
}
}
return elements
}, [containerRef, sampleData])
// Find the schema path for an element based on its text content
const findPathForElement = useCallback((element: HTMLElement): string | null => {
const text = element.textContent?.trim()
if (!text) return null
// Look up the text in our map
return textToPathMap.get(text) || null
}, [textToPathMap])
// Calculate highlight rectangles for the highlighted schema path
useEffect(() => {
if (!isActive || !highlightedSchemaPath) {
setHighlightRects([])
return
}
const container = containerRef.current
if (!container) return
const elements = findElementsForPath(highlightedSchemaPath)
const rects: HighlightRect[] = elements.map(el => {
const rect = el.getBoundingClientRect()
return {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
path: highlightedSchemaPath,
}
}).filter(r => r.width > 0 && r.height > 0)
setHighlightRects(rects)
}, [highlightedSchemaPath, isActive, containerRef, findElementsForPath])
// Handle hover on elements to show which schema field they map to
useEffect(() => {
if (!isActive) return
const container = containerRef.current
if (!container) return
const handleMouseOver = (e: MouseEvent) => {
const target = e.target as HTMLElement
if (!target || target.closest('[data-inspector-overlay="1"]')) {
return
}
const path = findPathForElement(target)
if (path) {
const rect = target.getBoundingClientRect()
setHoverRect({
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
path,
})
} else {
setHoverRect(null)
}
}
const handleMouseLeave = () => {
setHoverRect(null)
}
const handleClick = (e: MouseEvent) => {
const target = e.target as HTMLElement
if (!target || target.closest('[data-inspector-overlay="1"]')) {
return
}
const path = findPathForElement(target)
if (path) {
e.preventDefault()
e.stopPropagation()
setHighlightedElementPath(path)
}
}
container.addEventListener('mouseover', handleMouseOver, true)
container.addEventListener('mouseleave', handleMouseLeave, true)
container.addEventListener('click', handleClick, true)
return () => {
container.removeEventListener('mouseover', handleMouseOver, true)
container.removeEventListener('mouseleave', handleMouseLeave, true)
container.removeEventListener('click', handleClick, true)
}
}, [isActive, containerRef, findPathForElement, setHighlightedElementPath])
// Recalculate on scroll/resize
useEffect(() => {
if (!isActive) return
const handleUpdate = () => {
if (highlightedSchemaPath) {
const container = containerRef.current
if (!container) return
const elements = findElementsForPath(highlightedSchemaPath)
const rects: HighlightRect[] = elements.map(el => {
const rect = el.getBoundingClientRect()
return {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
path: highlightedSchemaPath,
}
}).filter(r => r.width > 0 && r.height > 0)
setHighlightRects(rects)
}
}
window.addEventListener('scroll', handleUpdate, true)
window.addEventListener('resize', handleUpdate)
return () => {
window.removeEventListener('scroll', handleUpdate, true)
window.removeEventListener('resize', handleUpdate)
}
}, [isActive, highlightedSchemaPath, containerRef, findElementsForPath])
if (!isActive) return null
return (
<>
{/* Hover highlight - shows path label */}
{hoverRect && !highlightedSchemaPath && (
<>
<div
data-inspector-overlay="1"
style={{
position: 'fixed',
left: hoverRect.left - 2,
top: hoverRect.top - 2,
width: hoverRect.width + 4,
height: hoverRect.height + 4,
border: '2px dashed #10b981',
backgroundColor: 'rgba(16, 185, 129, 0.1)',
pointerEvents: 'none',
zIndex: 40,
borderRadius: '4px',
}}
/>
{/* Path label */}
<div
data-inspector-overlay="1"
style={{
position: 'fixed',
left: hoverRect.left,
top: hoverRect.top - 24,
backgroundColor: '#10b981',
color: 'white',
fontSize: '10px',
fontWeight: 600,
padding: '2px 6px',
borderRadius: '4px',
pointerEvents: 'none',
zIndex: 41,
whiteSpace: 'nowrap',
}}
>
{hoverRect.path}
</div>
</>
)}
{/* Schema path highlights */}
{highlightRects.map((rect, idx) => (
<div
key={`schema-highlight-${idx}`}
data-inspector-overlay="1"
className="animate-pulse"
style={{
position: 'fixed',
left: rect.left - 3,
top: rect.top - 3,
width: rect.width + 6,
height: rect.height + 6,
border: '3px solid #8b5cf6',
backgroundColor: 'rgba(139, 92, 246, 0.15)',
pointerEvents: 'none',
zIndex: 40,
borderRadius: '6px',
boxShadow: '0 0 0 2px rgba(139, 92, 246, 0.3), 0 4px 12px rgba(139, 92, 246, 0.2)',
}}
/>
))}
</>
)
}
export default SchemaElementHighlighter

View file

@ -0,0 +1,129 @@
'use client'
import React, { createContext, useContext, useState, RefObject } from 'react'
interface SchemaHighlightContextType {
// Currently highlighted schema path (from schema editor hover)
highlightedSchemaPath: string | null
setHighlightedSchemaPath: (path: string | null) => void
// Currently highlighted element path (from element click)
highlightedElementPath: string | null
setHighlightedElementPath: (path: string | null) => void
// Sample data for matching elements to schema paths
sampleData: Record<string, any> | null
setSampleData: (data: Record<string, any> | null) => void
// Container ref for the slide preview
slideContainerRef: RefObject<HTMLDivElement | null> | null
setSlideContainerRef: (ref: RefObject<HTMLDivElement | null> | null) => void
}
const SchemaHighlightContext = createContext<SchemaHighlightContextType | null>(null)
export const SchemaHighlightProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [highlightedSchemaPath, setHighlightedSchemaPath] = useState<string | null>(null)
const [highlightedElementPath, setHighlightedElementPath] = useState<string | null>(null)
const [sampleData, setSampleData] = useState<Record<string, any> | null>(null)
const [slideContainerRef, setSlideContainerRef] = useState<RefObject<HTMLDivElement | null> | null>(null)
return (
<SchemaHighlightContext.Provider value={{
highlightedSchemaPath,
setHighlightedSchemaPath,
highlightedElementPath,
setHighlightedElementPath,
sampleData,
setSampleData,
slideContainerRef,
setSlideContainerRef,
}}>
{children}
</SchemaHighlightContext.Provider>
)
}
export const useSchemaHighlight = () => {
const context = useContext(SchemaHighlightContext)
if (!context) {
// Return a no-op version if not in provider
return {
highlightedSchemaPath: null,
setHighlightedSchemaPath: () => { },
highlightedElementPath: null,
setHighlightedElementPath: () => { },
sampleData: null,
setSampleData: () => { },
slideContainerRef: null,
setSlideContainerRef: () => { },
}
}
return context
}
// Helper to get value from nested path in data object
export function getValueAtPath(data: Record<string, any> | null | undefined, path: string): any {
if (!data || !path) return undefined
// Handle array item paths like "items[].title" - get from first item
const parts = path.split('.')
let current: any = data
for (const part of parts) {
if (current === undefined || current === null) return undefined
// Check if this part has array notation
if (part.endsWith('[]')) {
const arrayKey = part.slice(0, -2)
current = current[arrayKey]
if (Array.isArray(current) && current.length > 0) {
current = current[0] // Get first item for matching
} else {
return undefined
}
} else {
current = current[part]
}
}
return current
}
// Helper to find all values for a schema path (for arrays, returns all item values)
export function getAllValuesAtPath(data: Record<string, any> | null | undefined, path: string): any[] {
if (!data || !path) return []
const parts = path.split('.')
let currentItems: any[] = [data]
for (const part of parts) {
const nextItems: any[] = []
for (const item of currentItems) {
if (item === undefined || item === null) continue
if (part.endsWith('[]')) {
const arrayKey = part.slice(0, -2)
const arr = item[arrayKey]
if (Array.isArray(arr)) {
nextItems.push(...arr)
}
} else {
if (item[part] !== undefined) {
nextItems.push(item[part])
}
}
}
currentItems = nextItems
}
return currentItems.filter(v => v !== undefined && v !== null)
}

View file

@ -1,21 +1,65 @@
'use client'
import React, { memo } from "react";
import { CompiledLayout, compileCustomLayout } from "@/app/hooks/compileLayout";
import { RotateCcw } from "lucide-react";
import React, { memo, useMemo } from "react";
interface SlideContentProps {
slide: any;
data?: Record<string, any> | null;
compiledLayout?: CompiledLayout | null;
retrySlide: (slideNumber: number) => void;
}
const SlideContent = memo(({ slide, data, compiledLayout, retrySlide }: SlideContentProps) => {
// Use provided compiled layout or compile (fallback for other usages)
const module = useMemo(() => {
if (compiledLayout) return compiledLayout;
if (!slide.react) return null;
return compileCustomLayout(slide.react);
}, [slide.react, compiledLayout]);
const sampleData = useMemo(() => {
// If custom data is provided, use it
if (data) {
return data;
}
// Otherwise use sampleData from compiled layout or generate from schema defaults
if (module?.sampleData && Object.keys(module.sampleData).length > 0) {
return module.sampleData;
}
try {
return module?.schema?.parse({}) ?? {};
} catch {
return {};
}
}, [module, data]);
const Component = useMemo(() => {
return module?.component;
}, [module]);
if (!slide?.react) return null;
if (!module) {
return (
<div className="w-full aspect-[16/9] h-[720px] bg-red-50 text-red-700 p-4 rounded border border-red-200 text-sm whitespace-pre-wrap break-words flex flex-col items-center justify-center">
<p className="text-center"> Failed to render slide component. Check console for details.</p>
<button onClick={() => retrySlide(slide.slide_number)} className="bg-blue-500 text-white px-4 py-2 rounded-md hover:bg-blue-600 flex items-center justify-center mt-6"> <RotateCcw className="w-4 h-4 mr-1" /> Re-Construct</button>
</div>
);
}
const SlideContent = memo(({ slide }: { slide: any }) => {
const cleanHtml = slide.html
.replace(/```html/g, "")
.replace(/```/g, "")
.replace(/<html>/g, "")
.replace(/<\/html>/g, "")
.replace(/html/g, "");
return (
<div
dangerouslySetInnerHTML={{
__html: cleanHtml,
}}
/>
<>
{Component && <Component data={sampleData} />}
</>
);
});
SlideContent.displayName = 'SlideContent';
export default SlideContent;

View file

@ -0,0 +1,105 @@
'use client'
import React from "react";
import { Button } from "@/components/ui/button";
import {
Loader2,
Images,
ChevronRight,
Sparkles
} from "lucide-react";
import { SlidePreviewSectionProps } from "../types";
import { resolveBackendAssetUrl } from '@/utils/api'
export const SlidePreviewSection: React.FC<SlidePreviewSectionProps> = ({
previewData,
onInitTemplate,
isLoading,
}) => {
const slideCount = previewData.slide_image_urls?.length || 0;
return (
<div className="my-8 max-w-[1440px] mx-auto">
{/* Header Card */}
<div className="bg-white rounded-2xl border border-[#E5E7EB] shadow-sm overflow-hidden">
{/* Header */}
<div className="px-6 py-5 border-b border-[#F3F4F6] bg-gradient-to-r from-[#FAFAFA] to-white">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-[#EBE9FE] to-[#DDD6FE] flex items-center justify-center shadow-sm">
<Images className="w-6 h-6 text-[#7A5AF8]" />
</div>
<div>
<h2 className="text-xl font-semibold text-[#111827]">Slide Preview</h2>
<p className="text-sm text-[#6B7280] mt-0.5">
{slideCount} slide{slideCount !== 1 ? 's' : ''} ready for template generation
</p>
</div>
</div>
</div>
</div>
<div className="grid grid-cols-1 gap-4 py-4 max-h-[900px] overflow-y-auto">
{previewData.slide_image_urls?.map((url, index) => (
<div
key={index}
className="group relative aspect-video w-full max-w-[1280px] mx-auto rounded-xl overflow-hidden "
>
<img
src={resolveBackendAssetUrl(url)}
alt={`Slide ${index + 1}`}
className="w-full h-full object-cover"
/>
{/* Slide number badge */}
<div className="absolute top-2 left-2 px-2.5 py-1 bg-black/70 backdrop-blur-sm rounded-lg text-xs font-semibold text-white shadow-lg">
{index + 1}
</div>
</div>
))}
</div>
{/* Action Footer */}
<div className="px-6 py-5 border-t border-[#F3F4F6] bg-gradient-to-r from-[#FAFAFA] to-white">
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
<p className="text-sm text-[#6B7280] max-w-md text-center sm:text-left">
Ready to generate your template. Each slide will be converted to a reusable React component.
</p>
<Button
size="lg"
onClick={onInitTemplate}
disabled={isLoading}
className="px-4 py-2 h-auto text-xs font-syne font-medium rounded-full shadow-lg hover:shadow-xl transition-all duration-300 "
style={{
background: isLoading
? '#E5E7EB'
: 'linear-gradient(135deg, #D5CAFC 0%, #E3D2EB 35%, #F4DCD3 70%, #FDE4C2 100%)',
color: isLoading ? '#9CA3AF' : '#111827',
}}
>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
Starting...
</>
) : (
<>
Generate Template
<ChevronRight className="w-4 h-4 ml-1" />
</>
)}
</Button>
</div>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,160 @@
import React from "react";
import {
Upload,
Type,
Images,
Sparkles,
Check,
Loader2,
} from "lucide-react";
import { TemplateCreationStep } from "../types";
interface TemplateCreationProgressProps {
currentStep: TemplateCreationStep;
totalSlides?: number;
processedSlides?: number;
}
interface StepConfig {
id: TemplateCreationStep;
label: string;
icon: React.ReactNode;
}
const steps: StepConfig[] = [
{
id: 'file-upload',
label: 'Upload',
icon: <Upload className="w-4 h-4" />
},
{
id: 'font-check',
label: 'Fonts',
icon: <Type className="w-4 h-4" />
},
{
id: 'slides-preview',
label: 'Preview',
icon: <Images className="w-4 h-4" />
},
{
id: 'template-creation',
label: 'Generate',
icon: <Sparkles className="w-4 h-4" />
},
{
id: 'completed',
label: 'Done',
icon: <Check className="w-4 h-4" />
},
];
export const TemplateCreationProgress: React.FC<TemplateCreationProgressProps> = ({
currentStep,
totalSlides = 0,
processedSlides = 0,
}) => {
const getCurrentStepIndex = () => {
if (currentStep === 'font-upload') return 1;
const stepIndex = steps.findIndex(s => s.id === currentStep);
return stepIndex >= 0 ? stepIndex : 0;
};
const currentStepIndex = getCurrentStepIndex();
const getStepStatus = (stepIndex: number): 'completed' | 'current' | 'pending' => {
if (currentStep === 'completed') return 'completed';
if (stepIndex < currentStepIndex) return 'completed';
if (stepIndex === currentStepIndex) return 'current';
return 'pending';
};
const progressPercentage = totalSlides > 0
? Math.round((processedSlides / totalSlides) * 100)
: 0;
return (
<div className="w-full max-w-[700px] mx-auto mb-8">
{/* Steps */}
<div className="flex items-center justify-between">
{steps.map((step, index) => {
const status = getStepStatus(index);
const isLast = index === steps.length - 1;
return (
<React.Fragment key={step.id}>
<div className="flex flex-col items-center">
{/* Circle */}
<div
className={`
w-10 h-10 rounded-full flex items-center justify-center border-2 transition-all duration-200
${status === 'completed'
? 'bg-[#7A5AF8] border-[#7A5AF8] text-white'
: status === 'current'
? 'bg-white border-[#7A5AF8] text-[#7A5AF8]'
: 'bg-white border-[#E5E7EB] text-[#9CA3AF]'
}
`}
>
{status === 'completed' ? (
<Check className="w-4 h-4" />
) : status === 'current' && currentStep === 'template-creation' ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
step.icon
)}
</div>
{/* Label */}
<span
className={`
mt-2 text-xs font-medium
${status === 'completed' || status === 'current'
? 'text-[#374151]'
: 'text-[#9CA3AF]'
}
`}
>
{step.label}
</span>
</div>
{/* Connector */}
{!isLast && (
<div className="flex-1 h-px mx-3 -mt-5">
<div
className={`h-full transition-colors duration-200 ${getStepStatus(index + 1) !== 'pending'
? 'bg-[#7A5AF8]'
: 'bg-[#E5E7EB]'
}`}
/>
</div>
)}
</React.Fragment>
);
})}
</div>
{/* Processing Progress */}
{currentStep === 'template-creation' && totalSlides > 0 && (
<div className="mt-6 p-4 bg-[#F9FAFB] rounded-xl border border-[#E5E7EB]">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-[#374151]">
Processing slides
</span>
<span className="text-sm font-medium text-[#374151]">
{processedSlides} / {totalSlides}
</span>
</div>
<div className="w-full h-2 bg-[#E5E7EB] rounded-full overflow-hidden">
<div
className="h-full bg-[#7A5AF8] rounded-full transition-all duration-300"
style={{ width: `${progressPercentage}%` }}
/>
</div>
</div>
)}
</div>
);
};

View file

@ -0,0 +1,17 @@
import React from "react";
export const TemplateStudioHeader: React.FC = () => {
return (
<div className="text-center my-[52px] px-2 md:px-0">
<h1 className="font-unbounded text-[36px] sm:text-[38px] md:text-[64px] text-[#101323] font-normal tracking-[-1.92px] pb-2">
Template Studio
</h1>
<p className="text-[#101323CC] text-base md:text-xl font-syne font-normal max-w-[600px] mx-auto">
Upload your PPTX file to extract slides and convert them to a template which you can use to generate AI presentations.
</p>
</div>
);
};

View file

@ -14,34 +14,18 @@ const Timer = ({ duration }: TimerProps) => {
// Guard against invalid durations
const totalMs = Math.max(0, duration * 1000)
const easeOutCubic = (x: number) => 1 - Math.pow(1 - x, 3)
const easeOutSine = (x: number) => Math.sin((x * Math.PI) / 2)
const tick = (now: number) => {
if (startTimeRef.current === null) startTimeRef.current = now
const elapsed = now - startTimeRef.current
const t = totalMs === 0 ? 1 : Math.min(elapsed / totalMs, 1)
// Piecewise progression:
// - Reach ~75% around 60% of the total duration (faster start)
// - Then ease slowly towards 99% for the remainder
let nextProgress: number
if (t <= 0.6) {
nextProgress = 75 * easeOutCubic(t / 0.6)
} else {
nextProgress = 75 + 24 * easeOutSine((t - 0.6) / 0.4)
}
setProgress(prev => (t <= prev ? prev : t))
// Clamp and ensure we never hit 100
nextProgress = Math.min(99, nextProgress)
setProgress(prev => (nextProgress < prev ? prev : nextProgress))
if (t < 1 && nextProgress < 99) {
if (t < 1) {
rafIdRef.current = requestAnimationFrame(tick)
} else {
// End at 99 and stop
setProgress(99)
// Ensure we finish at 100%
setProgress(1)
if (rafIdRef.current) cancelAnimationFrame(rafIdRef.current)
rafIdRef.current = null
}
@ -59,41 +43,55 @@ const Timer = ({ duration }: TimerProps) => {
}
}, [duration])
const progressValue = Math.min(1, Number(progress.toFixed(4)))
const displayedProgress = Math.round(progressValue * 100)
return (
<div className="w-full space-y-2">
<div className="flex justify-end items-center text-gray-800 text-sm">
<span className="font-inter text-end font-semibold text-xs">{Math.round(progress)}%</span>
</div>
<div className="w-full">
{/* Progress bar container */}
<div
className="w-full rounded-full h-3 overflow-hidden shadow-inner"
className="relative w-full h-2 rounded-full bg-[#E5E7EB] overflow-hidden"
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(progress)}
aria-valuenow={displayedProgress}
>
<div className="relative h-full rounded-full" style={{
width: `${progress}%`,
backgroundImage: 'linear-gradient(90deg, #9034EA, #5146E5, #9034EA)',
backgroundSize: '200% 100%',
animation: 'gradient 2s linear infinite'
}}>
<div className="absolute inset-0 opacity-25" style={{
backgroundImage:
'linear-gradient(45deg, rgba(255,255,255,.8) 25%, transparent 25%, transparent 50%, rgba(255,255,255,.8) 50%, rgba(255,255,255,.8) 75%, transparent 75%, transparent)',
backgroundSize: '16px 16px',
animation: 'stripes 1s linear infinite'
}} />
{/* Progress fill */}
<div
className="absolute inset-y-0 left-0 rounded-full transition-[width] duration-100 ease-out"
style={{
width: `${progressValue * 100}%`,
background: 'linear-gradient(90deg, #7A5AF8, #9B8AFB, #7A5AF8)',
backgroundSize: '200% 100%',
animation: 'shimmer 2s linear infinite',
}}
>
{/* Animated stripes overlay */}
<div
className="absolute inset-0 opacity-30"
style={{
backgroundImage: 'linear-gradient(45deg, rgba(255,255,255,0.4) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.4) 50%, rgba(255,255,255,0.4) 75%, transparent 75%, transparent)',
backgroundSize: '12px 12px',
animation: 'stripes 0.8s linear infinite',
}}
/>
</div>
<div className="absolute inset-0" />
</div>
{/* Percentage text */}
<div className="flex justify-end mt-1.5">
<span className="text-xs font-medium text-[#6B7280] tabular-nums">
{displayedProgress}%
</span>
</div>
<style jsx>{`
@keyframes gradient {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
@keyframes shimmer {
0% { background-position: 200% 50%; }
100% { background-position: 0% 50%; }
}
@keyframes stripes {
to { background-position: 16px 0; }
to { background-position: 12px 0; }
}
`}</style>
</div>

View file

@ -0,0 +1,63 @@
/**
* Slides List Component
* Renders the grid of slides being edited
*/
import React from "react";
import { ProcessedSlide } from "../../types";
import SlideErrorBoundary from "@/app/(presentation-generator)/components/SlideErrorBoundary";
import EachSlide from "../EachSlide/NewEachSlide";
interface SlidesListProps {
slides: ProcessedSlide[];
setSlides: React.Dispatch<React.SetStateAction<ProcessedSlide[]>>;
retrySlide: (index: number) => void;
onSlideUpdate: (index: number, updatedSlideData: Partial<ProcessedSlide>) => void;
onOpenSchemaEditor: (index: number | null) => void;
schemaEditorSlideIndex: number | null;
schemaPreviewData: Record<number, Record<string, any>>;
onClearSchemaPreview: (slideIndex: number) => void;
isSchemaEditorOpen: boolean;
}
export const SlidesList: React.FC<SlidesListProps> = ({
slides,
setSlides,
retrySlide,
onSlideUpdate,
onOpenSchemaEditor,
schemaEditorSlideIndex,
schemaPreviewData,
onClearSchemaPreview,
isSchemaEditorOpen,
}) => {
const containerWidth = isSchemaEditorOpen ? 'w-[calc(100%-540px)]' : 'w-full';
return (
<div className={`space-y-5 w-full p-5 rounded-2xl ${containerWidth}`}>
{slides.map((slide, index) => (
<SlideErrorBoundary
key={index}
label={`Slide ${index + 1}`}
>
<EachSlide
key={index}
slide={slide}
index={index}
isProcessing={slides.some((s) => s.processing)}
retrySlide={retrySlide}
setSlides={setSlides}
onSlideUpdate={(updatedSlideData) =>
onSlideUpdate(index, updatedSlideData)
}
onOpenSchemaEditor={onOpenSchemaEditor}
isSchemaEditorOpen={schemaEditorSlideIndex === index}
schemaPreviewData={schemaPreviewData[index] ?? null}
onClearSchemaPreview={() => onClearSchemaPreview(index)}
/>
</SlideErrorBoundary>
))}
</div>
);
};

View file

@ -0,0 +1,40 @@
/**
* Step 2: Font Management
* Handles font checking and uploading
*/
import React from "react";
import FontManager from "../FontManager";
import { FontData, UploadedFont } from "../../types";
interface Step2FontManagementProps {
fontsData: FontData | null;
uploadedFonts: UploadedFont[];
uploadFont: (fontName: string, file: File) => string | null;
removeFont: (fontName: string) => void;
onContinue: () => Promise<void>;
isUploading: boolean;
}
export const Step2FontManagement: React.FC<Step2FontManagementProps> = ({
fontsData,
uploadedFonts,
uploadFont,
removeFont,
onContinue,
isUploading,
}) => {
if (!fontsData) return null;
return (
<FontManager
fontsData={fontsData}
uploadedFonts={uploadedFonts}
uploadFont={uploadFont}
removeFont={removeFont}
onContinue={onContinue}
isUploading={isUploading}
/>
);
};

View file

@ -0,0 +1,31 @@
/**
* Step 3: Slide Preview
* Displays preview of slides with uploaded fonts
*/
import React from "react";
import { SlidePreviewSection } from "../SlidePreviewSection";
import { FontUploadPreviewResponse } from "../../types";
interface Step3SlidePreviewProps {
previewData: FontUploadPreviewResponse | null;
onInitTemplate: () => void;
isLoading: boolean;
}
export const Step3SlidePreview: React.FC<Step3SlidePreviewProps> = ({
previewData,
onInitTemplate,
isLoading,
}) => {
if (!previewData) return null;
return (
<SlidePreviewSection
previewData={previewData}
onInitTemplate={onInitTemplate}
isLoading={isLoading}
/>
);
};

View file

@ -0,0 +1,81 @@
import React from "react";
import { ProcessedSlide } from "../../types";
import { SchemaHighlightProvider } from "../SchemaHighlightContext";
import { SlidesList } from "./SlidesList";
import { SchemaEditorPanel } from "../SchemaEditorPanel";
interface Step4TemplateCreationProps {
slides: ProcessedSlide[];
setSlides: React.Dispatch<React.SetStateAction<ProcessedSlide[]>>;
retrySlide: (index: number) => void;
onSlideUpdate: (index: number, updatedSlideData: Partial<ProcessedSlide>) => void;
// Schema editor state
schemaEditorSlideIndex: number | null;
onOpenSchemaEditor: (index: number | null) => void;
onCloseSchemaEditor: () => void;
onSchemaEditorSave: (updatedReact: string) => void;
// Schema preview state
schemaPreviewData: Record<number, Record<string, any>>;
onSchemaPreviewContent: (content: Record<string, any>) => void;
onClearSchemaPreview: (slideIndex: number) => void;
}
export const Step4TemplateCreation: React.FC<Step4TemplateCreationProps> = ({
slides,
setSlides,
retrySlide,
onSlideUpdate,
schemaEditorSlideIndex,
onOpenSchemaEditor,
onCloseSchemaEditor,
onSchemaEditorSave,
schemaPreviewData,
onSchemaPreviewContent,
onClearSchemaPreview,
}) => {
const schemaEditorSlide = schemaEditorSlideIndex !== null ? slides[schemaEditorSlideIndex] : null;
const isSchemaEditorOpen = schemaEditorSlideIndex !== null;
return (
<SchemaHighlightProvider>
<div className="mt-8 mx-auto">
<div className="transition-all duration-300 flex-1">
<div className="flex items-stretch gap-2">
{/* Slides List */}
<SlidesList
slides={slides}
setSlides={setSlides}
retrySlide={retrySlide}
onSlideUpdate={onSlideUpdate}
onOpenSchemaEditor={onOpenSchemaEditor}
schemaEditorSlideIndex={schemaEditorSlideIndex}
schemaPreviewData={schemaPreviewData}
onClearSchemaPreview={onClearSchemaPreview}
isSchemaEditorOpen={isSchemaEditorOpen}
/>
{/* Schema Editor Panel (Right Sidebar) */}
{isSchemaEditorOpen && schemaEditorSlide && (
<div className="w-[520px] sticky top-20 self-start">
<div className="bg-white rounded-2xl border border-gray-200 shadow-lg overflow-hidden">
<SchemaEditorPanel
slide={schemaEditorSlide}
slideIndex={schemaEditorSlideIndex}
onSave={onSchemaEditorSave}
onCancel={onCloseSchemaEditor}
onFillContent={onSchemaPreviewContent}
/>
</div>
</div>
)}
</div>
</div>
</div>
</SchemaHighlightProvider>
);
};

View file

@ -0,0 +1,94 @@
/**
* Constants for Custom Template Creation Flow
*/
import { TemplateCreationStep } from "../types";
// Step configuration
export const TEMPLATE_STEPS: Record<TemplateCreationStep, { title: string; description: string }> = {
'file-upload': {
title: 'Upload Template',
description: 'Upload your PPTX file to begin',
},
'font-check': {
title: 'Font Check',
description: 'Checking fonts in your presentation',
},
'font-upload': {
title: 'Upload Fonts',
description: 'Upload missing fonts for accurate rendering',
},
'slides-preview': {
title: 'Preview Slides',
description: 'Review your slides before processing',
},
'template-creation': {
title: 'Template Creation',
description: 'Converting slides to reusable templates',
},
'completed': {
title: 'Completed',
description: 'Your template is ready to save',
},
};
// UI Configuration
export const UI_CONFIG = {
schemaEditorWidth: '520px',
slideGridGap: '20px',
maxContentWidth: '1400px',
}
// Highlights for benefits section
export const HIGHLIGHTS_ITEMS = [
{
number: "1",
title: "Time-consume",
description: "Manual formatting and slide copying wastes hours every week",
},
{
number: "2",
title: "Expensive",
description: "Design resources spent on repetitive tasks instead of innovation",
},
{
number: "3",
title: "Inconsistent",
description: "AI generates unpredictable layouts that require constant cleanup",
},
]
// External scripts
export const TAILWIND_CDN_URL = "https://cdn.tailwindcss.com";
export const FAQS = [
{
question: "What is Custom Template Creation?",
answer: "Custom Template Creation is a feature that allows you to create custom templates for your presentations.",
},
{
question: "How do I create a custom template?",
answer: "You can create a custom template by uploading a PPTX file and then editing the template to your liking.",
},
{
question: "How do I edit a custom template?",
answer: "You can edit a custom template by uploading a PPTX file and then editing the template to your liking.",
},
{
question: "How do I delete a custom template?",
answer: "You can delete a custom template by uploading a PPTX file and then editing the template to your liking.",
},
{
question: "How do I create a custom template?",
answer: "You can create a custom template by uploading a PPTX file and then editing the template to your liking.",
},
{
question: "How do I edit a custom template?",
answer: "You can edit a custom template by uploading a PPTX file and then editing the template to your liking.",
},
{
question: "How do I delete a custom template?",
answer: "You can delete a custom template by uploading a PPTX file and then editing the template to your liking.",
},
]

View file

@ -0,0 +1,12 @@
/**
* Hooks Index
* Central export point for all custom template hooks
*/
export { useFileUpload } from "./useFileUpload";
export { useTemplateCreation } from "./useTemplateCreation";
export { useLayoutSaving } from "./useLayoutSaving";
export { useCompiledLayout } from "./useCompiledLayout";
export { useSlideEdit } from "./useSlideEdit";
export { useSlideUndoRedo } from "./useSlideUndoRedo";

View file

@ -1,18 +0,0 @@
import { useState, useEffect } from "react";
export const useAPIKeyCheck = () => {
const [hasRequiredKey, setHasRequiredKey] = useState(false);
const [isRequiredKeyLoading, setIsRequiredKeyLoading] = useState(true);
useEffect(() => {
fetch("/api/has-required-key")
.then((res) => res.json())
.then((data) => {
setHasRequiredKey(Boolean(data.hasKey));
setIsRequiredKeyLoading(false);
})
.catch(() => setIsRequiredKeyLoading(false));
}, []);
return { hasRequiredKey, isRequiredKeyLoading };
};

View file

@ -0,0 +1,19 @@
import { useMemo } from "react";
import { compileCustomLayout, CompiledLayout } from "@/app/hooks/compileLayout";
/**
* Hook to compile layout code once and memoize the result.
* This prevents double compilation when both SlideContent and SchemaEditor need the compiled layout.
*/
export function useCompiledLayout(code: string | undefined): CompiledLayout | null {
return useMemo(() => {
if (!code) return null;
try {
return compileCustomLayout(code);
} catch (error) {
console.error("Error compiling layout:", error);
return null;
}
}, [code]);
}

View file

@ -1,32 +0,0 @@
import { useState, useEffect } from "react";
import { ProcessedSlide } from "../types";
export const useCustomLayout = () => {
const [slides, setSlides] = useState<ProcessedSlide[]>([]);
const [isLayoutSaved, setIsLayoutSaved] = useState(false);
// Warning before page unload
useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
e.preventDefault();
return "You have unsaved changes. Are you sure you want to leave?";
};
if (slides.length > 0 && !isLayoutSaved) {
window.addEventListener("beforeunload", handleBeforeUnload);
}
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
}, [slides, isLayoutSaved]);
// Calculate progress
const completedSlides = slides.filter(
(slide) => slide.processed || slide.error
).length;
return {
slides,
setSlides,
completedSlides,
isLayoutSaved,
setIsLayoutSaved,
};
};

View file

@ -1,167 +0,0 @@
import { useState, useCallback, useRef } from "react";
export const useDrawingCanvas = () => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const slideDisplayRef = useRef<HTMLDivElement>(null);
const [strokeWidth, setStrokeWidth] = useState(3);
const [strokeColor, setStrokeColor] = useState("#000000");
const [eraserMode, setEraserMode] = useState(false);
const [isDrawing, setIsDrawing] = useState(false);
const [canvasDimensions, setCanvasDimensions] = useState({
width: 1280,
height: 720,
});
const [didYourDraw, setDidYourDraw] = useState(false);
const getCanvasContext = () => {
const canvas = canvasRef.current;
if (!canvas) return null;
return canvas.getContext("2d");
};
const getMousePos = (e: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
if (!canvas) return { x: 0, y: 0 };
const rect = canvas.getBoundingClientRect();
return {
x: e.clientX - rect.left,
y: e.clientY - rect.top,
};
};
const getTouchPos = (e: React.TouchEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
if (!canvas) return { x: 0, y: 0 };
const rect = canvas.getBoundingClientRect();
const touch = e.touches[0];
return {
x: touch.clientX - rect.left,
y: touch.clientY - rect.top,
};
};
const startDrawing = useCallback(
(pos: { x: number; y: number }) => {
const ctx = getCanvasContext();
if (!ctx) return;
setIsDrawing(true);
ctx.beginPath();
ctx.moveTo(pos.x, pos.y);
if (eraserMode) {
ctx.globalCompositeOperation = "destination-out";
ctx.lineWidth = strokeWidth * 2;
} else {
ctx.globalCompositeOperation = "source-over";
ctx.strokeStyle = strokeColor;
ctx.lineWidth = strokeWidth;
}
ctx.lineCap = "round";
ctx.lineJoin = "round";
},
[eraserMode, strokeColor, strokeWidth]
);
const draw = useCallback(
(pos: { x: number; y: number }) => {
if (!isDrawing) return;
setDidYourDraw(true);
const ctx = getCanvasContext();
if (!ctx) return;
ctx.lineTo(pos.x, pos.y);
ctx.stroke();
},
[isDrawing]
);
const stopDrawing = useCallback(() => {
setIsDrawing(false);
}, []);
// Mouse events
const handleMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
e.preventDefault();
const pos = getMousePos(e);
startDrawing(pos);
};
const handleMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
e.preventDefault();
const pos = getMousePos(e);
draw(pos);
};
const handleMouseUp = (e: React.MouseEvent<HTMLCanvasElement>) => {
e.preventDefault();
stopDrawing();
};
// Touch events
const handleTouchStart = (e: React.TouchEvent<HTMLCanvasElement>) => {
e.preventDefault();
const pos = getTouchPos(e);
startDrawing(pos);
};
const handleTouchMove = (e: React.TouchEvent<HTMLCanvasElement>) => {
e.preventDefault();
const pos = getTouchPos(e);
draw(pos);
};
const handleTouchEnd = (e: React.TouchEvent<HTMLCanvasElement>) => {
e.preventDefault();
stopDrawing();
};
const handleClearCanvas = () => {
const canvas = canvasRef.current;
setDidYourDraw(false);
const ctx = getCanvasContext();
if (!canvas || !ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
};
const handleEraserModeChange = (isEraser: boolean) => {
setEraserMode(isEraser);
};
const handleStrokeColorChange = (color: string) => {
setStrokeColor(color);
setEraserMode(false);
};
const handleStrokeWidthChange = (width: number) => {
setStrokeWidth(width);
};
return {
canvasRef,
slideDisplayRef,
strokeWidth,
strokeColor,
eraserMode,
isDrawing,
canvasDimensions,
setCanvasDimensions,
didYourDraw,
setDidYourDraw,
handleMouseDown,
handleMouseMove,
handleMouseUp,
handleTouchStart,
handleTouchMove,
handleTouchEnd,
handleClearCanvas,
handleEraserModeChange,
handleStrokeColorChange,
handleStrokeWidthChange,
};
};

View file

@ -12,9 +12,8 @@ export const useFileUpload = () => {
// Validate file type
const lowerName = file.name.toLowerCase();
const isPptx = lowerName.endsWith(".pptx");
const isPdf = lowerName.endsWith(".pdf");
if (!isPptx && !isPdf) {
toast.error("Please select a valid PDF or PPTX file");
if (!isPptx) {
toast.error("Please select a valid PPTX file");
return;
}

Some files were not shown because too many files have changed in this diff Show more