Merge pull request #526 from presenton/feat/docker-release-electron-sync

Feat/docker release electron sync
This commit is contained in:
Sudip Parajuli 2026-04-20 20:58:40 +05:45 committed by GitHub
commit e32b958089
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
355 changed files with 644360 additions and 7990 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: |

7
.gitignore vendored
View file

@ -21,4 +21,9 @@ container.db
.cursor
.agents
skills-lock.json
.codex/
.codex/
# presentation-export runtime (downloaded via scripts/sync-presentation-export.cjs or Docker build)
presentation-export/index.js
presentation-export/py/
.cache/presentation-export/

View file

@ -1,62 +1,62 @@
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
# syntax=docker/dockerfile:1.4
FROM python:3.11-slim-trixie
WORKDIR /app
# Copy FastAPI
COPY servers/fastapi/ ./servers/fastapi/
COPY start.js LICENSE NOTICE ./
# LiteParse uses Node + @llamaindex/liteparse (same runner as Electron); OCR uses Tesseract.
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 \
PATH="/root/.local/bin:${PATH}" \
EXPORT_PACKAGE_ROOT=/app/presentation-export \
EXPORT_RUNTIME_DIR=/app/presentation-export \
BUILT_PYTHON_MODULE_PATH=/app/presentation-export/py/convert-linux-x64 \
PRESENTON_APP_ROOT=/app
# Copy nginx configuration
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl unzip \
nginx libreoffice fontconfig chromium imagemagick zstd \
tesseract-ocr tesseract-ocr-eng \
&& 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/*
COPY package.json package-lock.json /app/
RUN npm --prefix /app install --omit=dev
RUN mkdir -p /app/document-extraction-liteparse \
&& npm --prefix /app/document-extraction-liteparse init -y \
&& npm --prefix /app/document-extraction-liteparse install @llamaindex/liteparse@1.4.0 --omit=dev
COPY electron/resources/document-extraction/liteparse_runner.mjs /app/document-extraction-liteparse/liteparse_runner.mjs
COPY scripts/sync-presentation-export.cjs /app/scripts/sync-presentation-export.cjs
RUN node /app/scripts/sync-presentation-export.cjs --force \
&& chmod +x /app/presentation-export/py/convert-linux-x64
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,55 @@
FROM python:3.11-slim-bookworm
# syntax=docker/dockerfile:1.4
FROM python:3.11-slim-trixie
# 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
# LiteParse (Node + @llamaindex/liteparse) for document extraction; OCR via Tesseract.
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 \
PATH="/root/.local/bin:${PATH}" \
EXPORT_PACKAGE_ROOT=/app/presentation-export \
EXPORT_RUNTIME_DIR=/app/presentation-export \
BUILT_PYTHON_MODULE_PATH=/app/presentation-export/py/convert-linux-x64 \
PRESENTON_APP_ROOT=/app
# 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 unzip \
nginx libreoffice fontconfig chromium imagemagick zstd \
tesseract-ocr tesseract-ocr-eng \
&& 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
COPY package.json package-lock.json /app/
RUN npm --prefix /app install --omit=dev
# Copy nginx configuration
RUN mkdir -p /app/document-extraction-liteparse \
&& npm --prefix /app/document-extraction-liteparse init -y \
&& npm --prefix /app/document-extraction-liteparse install @llamaindex/liteparse@1.4.0 --omit=dev
COPY electron/resources/document-extraction/liteparse_runner.mjs /app/document-extraction-liteparse/liteparse_runner.mjs
COPY scripts/sync-presentation-export.cjs /app/scripts/sync-presentation-export.cjs
RUN node /app/scripts/sync-presentation-export.cjs --force \
&& chmod +x /app/presentation-export/py/convert-linux-x64
# 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 .
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

@ -214,6 +214,15 @@ These settings apply to both Docker and the Electron app's backend. You may want
- TOOL_CALLS=[Enable/Disable Tool Calls on Custom LLM]: If **true**, **LLM** will use Tool Call instead of Json Schema for Structured Output.
- DISABLE_THINKING=[Enable/Disable Thinking on Custom LLM]: If **true**, Thinking will be disabled.
- WEB_GROUNDING=[Enable/Disable Web Search for OpenAI, Google And Anthropic]: If **true**, LLM will be able to search web for better results.
- MEM0_ENABLED=[true/false]: Enables mem0 OSS presentation memory. Default is **true**.
- MEM0_DIR=[Path]: Directory for mem0 OSS local storage (Qdrant path + history DB). Default is **/app_data/mem0**.
- MEM0_EMBEDDER_PROVIDER=[Provider]: Embedder provider for mem0 OSS. Default is **fastembed**.
- MEM0_EMBEDDER_MODEL=[Model]: Mid-range local embedding model for memory search. Default is **BAAI/bge-small-en-v1.5**.
- MEM0_EMBEDDING_DIMS=[Number]: Embedding dimensions used by mem0 embedder and qdrant collection. Default is **384**.
- LITEPARSE_DPI=[Number]: LiteParse OCR render DPI (higher can increase memory use). Default is **120**.
- LITEPARSE_NUM_WORKERS=[Number]: LiteParse OCR worker count. Default is **1** for stable Docker parsing.
Mem0 in Docker uses OSS/self-hosted mode (not Mem0 Platform API). Memory is isolated per presentation ID. Prompt context, extracted document text, generated outline context, and subsequent edit interactions are stored and retrieved only for that same presentation (including when revisiting later).
You can also set the following environment variables to customize the image generation provider and API keys:

View file

@ -36,6 +36,13 @@ services:
- DISABLE_ANONYMOUS_TRACKING=${DISABLE_ANONYMOUS_TRACKING}
- COMFYUI_URL=${COMFYUI_URL}
- COMFYUI_WORKFLOW=${COMFYUI_WORKFLOW}
- MEM0_ENABLED=${MEM0_ENABLED:-true}
- MEM0_DIR=${MEM0_DIR:-/app_data/mem0}
- MEM0_EMBEDDER_PROVIDER=${MEM0_EMBEDDER_PROVIDER:-fastembed}
- MEM0_EMBEDDER_MODEL=${MEM0_EMBEDDER_MODEL:-BAAI/bge-small-en-v1.5}
- MEM0_EMBEDDING_DIMS=${MEM0_EMBEDDING_DIMS:-384}
- LITEPARSE_DPI=${LITEPARSE_DPI:-120}
- LITEPARSE_NUM_WORKERS=${LITEPARSE_NUM_WORKERS:-1}
- OPEN_WEBUI_IMAGE_URL=${OPEN_WEBUI_IMAGE_URL}
- OPEN_WEBUI_IMAGE_API_KEY=${OPEN_WEBUI_IMAGE_API_KEY}
@ -83,6 +90,14 @@ services:
- DISABLE_ANONYMOUS_TRACKING=${DISABLE_ANONYMOUS_TRACKING}
- COMFYUI_URL=${COMFYUI_URL}
- COMFYUI_WORKFLOW=${COMFYUI_WORKFLOW}
- MEM0_ENABLED=${MEM0_ENABLED:-true}
- MEM0_DIR=${MEM0_DIR:-/app_data/mem0}
- MEM0_EMBEDDER_PROVIDER=${MEM0_EMBEDDER_PROVIDER:-fastembed}
- MEM0_EMBEDDER_MODEL=${MEM0_EMBEDDER_MODEL:-BAAI/bge-small-en-v1.5}
- MEM0_EMBEDDING_DIMS=${MEM0_EMBEDDING_DIMS:-384}
- LITEPARSE_DPI=${LITEPARSE_DPI:-120}
- LITEPARSE_NUM_WORKERS=${LITEPARSE_NUM_WORKERS:-1}
- OPEN_WEBUI_IMAGE_URL=${OPEN_WEBUI_IMAGE_URL}
- OPEN_WEBUI_IMAGE_API_KEY=${OPEN_WEBUI_IMAGE_API_KEY}
@ -96,8 +111,12 @@ services:
- "1455:1455"
volumes:
- .:/app
- presenton_root_node_modules:/app/node_modules
- presenton_document_extraction_liteparse:/app/document-extraction-liteparse
- ./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}
@ -122,6 +141,13 @@ services:
- DISABLE_ANONYMOUS_TRACKING=${DISABLE_ANONYMOUS_TRACKING}
- COMFYUI_URL=${COMFYUI_URL}
- COMFYUI_WORKFLOW=${COMFYUI_WORKFLOW}
- MEM0_ENABLED=${MEM0_ENABLED:-true}
- MEM0_DIR=${MEM0_DIR:-/app_data/mem0}
- MEM0_EMBEDDER_PROVIDER=${MEM0_EMBEDDER_PROVIDER:-fastembed}
- MEM0_EMBEDDER_MODEL=${MEM0_EMBEDDER_MODEL:-BAAI/bge-small-en-v1.5}
- MEM0_EMBEDDING_DIMS=${MEM0_EMBEDDING_DIMS:-384}
- LITEPARSE_DPI=${LITEPARSE_DPI:-120}
- LITEPARSE_NUM_WORKERS=${LITEPARSE_NUM_WORKERS:-1}
- OPEN_WEBUI_IMAGE_URL=${OPEN_WEBUI_IMAGE_URL}
- OPEN_WEBUI_IMAGE_API_KEY=${OPEN_WEBUI_IMAGE_API_KEY}
@ -142,8 +168,11 @@ services:
- "1455:1455"
volumes:
- .:/app
- presenton_root_node_modules:/app/node_modules
- presenton_document_extraction_liteparse:/app/document-extraction-liteparse
- ./app_data:/app_data
environment:
- START_EMBEDDED_OLLAMA=false
- MIGRATE_DATABASE_ON_STARTUP=true
- CAN_CHANGE_KEYS=${CAN_CHANGE_KEYS}
- LLM=${LLM}
@ -168,5 +197,16 @@ services:
- DISABLE_ANONYMOUS_TRACKING=${DISABLE_ANONYMOUS_TRACKING}
- COMFYUI_URL=${COMFYUI_URL}
- COMFYUI_WORKFLOW=${COMFYUI_WORKFLOW}
- MEM0_ENABLED=${MEM0_ENABLED:-true}
- MEM0_DIR=${MEM0_DIR:-/app_data/mem0}
- MEM0_EMBEDDER_PROVIDER=${MEM0_EMBEDDER_PROVIDER:-fastembed}
- MEM0_EMBEDDER_MODEL=${MEM0_EMBEDDER_MODEL:-BAAI/bge-small-en-v1.5}
- MEM0_EMBEDDING_DIMS=${MEM0_EMBEDDING_DIMS:-384}
- LITEPARSE_DPI=${LITEPARSE_DPI:-120}
- LITEPARSE_NUM_WORKERS=${LITEPARSE_NUM_WORKERS:-1}
volumes:
presenton_root_node_modules:
presenton_document_extraction_liteparse:
- OPEN_WEBUI_IMAGE_URL=${OPEN_WEBUI_IMAGE_URL}
- OPEN_WEBUI_IMAGE_API_KEY=${OPEN_WEBUI_IMAGE_API_KEY}

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.1-codex-mini"
DEFAULT_CODEX_MODEL = "gpt-5.2"

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

@ -4,7 +4,6 @@ import os
import shutil
import subprocess
import tempfile
import uuid
from typing import Mapping
from fastapi import HTTPException
@ -33,7 +32,7 @@ class ExportTaskService:
self.timeout_seconds = timeout_seconds
self.node_binary = os.getenv("LITEPARSE_NODE_BINARY", "node")
self.export_dir = self._resolve_export_dir()
self.entrypoint_path = os.path.join(self.export_dir, "index.js")
self.entrypoint_path = self._resolve_entrypoint_path(self.export_dir)
self.converter_path = self._resolve_converter_path(self.export_dir)
@staticmethod
@ -42,31 +41,40 @@ class ExportTaskService:
if configured:
return configured
package_root = (os.getenv("EXPORT_PACKAGE_ROOT") or "").strip()
if package_root:
return package_root
cwd = os.path.abspath(".")
service_dir = os.path.dirname(__file__)
candidates = [
os.path.abspath(os.path.join(cwd, "..", "..", "resources", "export")),
os.path.abspath(os.path.join(cwd, "..", "export")),
os.path.abspath(
os.path.join(service_dir, "..", "..", "..", "resources", "export")
),
os.path.abspath(os.path.join(service_dir, "..", "..", "export")),
os.path.abspath(
os.path.join(cwd, "..", "..", "electron", "resources", "export")
),
os.path.abspath(
os.path.join(
service_dir, "..", "..", "..", "..", "electron", "resources", "export"
)
),
os.path.abspath(os.path.join(cwd, "..", "..", "presentation-export")),
os.path.abspath(os.path.join(cwd, "..", "presentation-export")),
os.path.abspath(os.path.join(service_dir, "..", "..", "..", "presentation-export")),
os.path.abspath(os.path.join(service_dir, "..", "..", "..", "..", "presentation-export")),
]
for candidate in candidates:
if os.path.isfile(os.path.join(candidate, "index.js")):
if os.path.isfile(os.path.join(candidate, "index.cjs")) or os.path.isfile(
os.path.join(candidate, "index.js")
):
return candidate
return candidates[0]
@staticmethod
def _resolve_entrypoint_path(export_dir: str) -> str:
index_cjs = os.path.join(export_dir, "index.cjs")
if os.path.isfile(index_cjs):
return index_cjs
index_js = os.path.join(export_dir, "index.js")
if os.path.isfile(index_js):
shutil.copyfile(index_js, index_cjs)
return index_cjs
return index_cjs
@staticmethod
def _resolve_converter_path(export_dir: str) -> str:
py_dir = os.path.join(export_dir, "py")

View file

@ -52,10 +52,9 @@ const CHATGPT_MODELS: CodexModel[] = [
{ id: "gpt-5.4-mini", name: "GPT-5.4-Mini" },
{ id: "gpt-5.3-codex", name: "GPT-5.3-Codex" },
{ id: "gpt-5.2", name: "GPT-5.2" },
{ id: "gpt-5.1-codex-mini", name: "GPT-5.1-Codex-Mini" },
];
const DEFAULT_CODEX_MODEL = "gpt-5.1-codex-mini";
const DEFAULT_CODEX_MODEL = "gpt-5.2";
export default function CodexConfig({
codexModel,

View file

@ -22,6 +22,8 @@ import ImageProvider from "./ImageProvider";
import PrivacySettings from "./PrivacySettings";
import { IMAGE_PROVIDERS, LLM_PROVIDERS } from "@/utils/providerConstants";
import { ImagesApi } from "@/app/(presentation-generator)/services/api/images";
import { getApiUrl } from "@/utils/api";
import { toast } from "sonner";
const STOCK_IMAGE_PROVIDERS = new Set(["pexels", "pixabay"]);
@ -105,7 +107,32 @@ const SettingsPage = () => {
}
};
const checkCurrentAuthStatus = async () => {
try {
const res = await fetch(getApiUrl("/api/v1/ppt/codex/auth/status"));
if (!res.ok) {
return false;
}
const data = await res.json();
if (data.status === "authenticated") {
return true;
} else {
return false;
}
} catch {
return false;
}
};
const handleSaveConfig = async () => {
if (llmConfig.LLM === 'codex') {
const isAuthenticated = await checkCurrentAuthStatus();
if (!isAuthenticated) {
toast.error("Please sign in to ChatGPT to continue");
return;
}
}
trackEvent(MixpanelEvent.Settings_SaveConfiguration_Button_Clicked, { pathname });
const validationError = getLLMConfigValidationError(llmConfig);
if (validationError) {

View file

@ -113,10 +113,10 @@ export const FileUploadSection: React.FC<FileUploadSectionProps> = ({
<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="flex gap-2 items-center justify-center h-full w-fit mx-auto">
<div className="flex gap-2 items-center justify-center mx-10 w-full">
<div className="w-[55px] h-[55px] ml-auto mr-0 rounded-[9px] bg-[#8E8F8F] flex items-center justify-center relative">
<div className="w-[55px] h-[55px] 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',
@ -132,8 +132,8 @@ export const FileUploadSection: React.FC<FileUploadSectionProps> = ({
<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>
<div className="flex-1">
<h3 className="text-[#4C4C4C] text-sm font-medium line-clamp-1"> {selectedFile.name}</h3>
<p className="text-xs font-normal text-[#808080] tracking-[-0.12px]">Presentation ( {(selectedFile.size / (1024 * 1024)).toFixed(2)} MB)</p>
</div>

View file

@ -157,7 +157,7 @@ const PresentationPage: React.FC<PresentationPageProps> = ({
background: "rgba(255, 255, 255, 0.10)",
boxShadow: "0 0 20.01px 0 rgba(122, 90, 248, 0.16) inset",
}}
className="p-6 rounded-[20px] flex flex-col items-center overflow-hidden justify-center border border-[#EDECEC] "
className="p-6 rounded-[20px] font-inter flex flex-col items-center overflow-hidden justify-center border border-[#EDECEC] "
>
<div className="w-full max-w-[1280px] h-full">

View file

@ -1,172 +1,255 @@
import ToolTip from '@/components/ToolTip'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import { SlidersHorizontal } from 'lucide-react'
import React, { useState } from 'react'
import { PresentationConfig, ToneType, VerbosityType } from '../type'
import ToolTip from '@/components/ToolTip';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import { Pencil, SlidersHorizontal, X } from 'lucide-react';
import React, { useEffect, useState } from 'react';
import { PresentationConfig, ToneType, VerbosityType } from '../type';
interface ConfigurationSelectsProps {
config: PresentationConfig;
onConfigChange: (key: keyof PresentationConfig, value: any) => void;
config: PresentationConfig;
onConfigChange: (key: keyof PresentationConfig, value: any) => void;
}
const toggleClassName =
'h-[22px] w-[36px] border-0 bg-[#D8D8DD] data-[state=checked]:bg-[#7A5AF8] ';
const AdvanceSettings = ({ config, onConfigChange }: ConfigurationSelectsProps) => {
const [openAdvanced, setOpenAdvanced] = useState(false);
const [openAdvanced, setOpenAdvanced] = useState(false);
const [advancedDraft, setAdvancedDraft] = useState({
tone: config.tone,
verbosity: config.verbosity,
instructions: config.instructions,
includeTableOfContents: config.includeTableOfContents,
includeTitleSlide: config.includeTitleSlide,
webSearch: config.webSearch,
});
const [advancedDraft, setAdvancedDraft] = useState({
tone: config.tone,
verbosity: config.verbosity,
instructions: config.instructions,
includeTableOfContents: config.includeTableOfContents,
includeTitleSlide: config.includeTitleSlide,
webSearch: config.webSearch,
const syncDraftFromConfig = () => {
setAdvancedDraft({
tone: config.tone,
verbosity: config.verbosity,
instructions: config.instructions,
includeTableOfContents: config.includeTableOfContents,
includeTitleSlide: config.includeTitleSlide,
webSearch: config.webSearch,
});
};
const handleOpenAdvancedChange = (open: boolean) => {
if (open) {
setAdvancedDraft({
tone: config.tone,
verbosity: config.verbosity,
instructions: config.instructions,
includeTableOfContents: config.includeTableOfContents,
includeTitleSlide: config.includeTitleSlide,
webSearch: config.webSearch,
});
}
setOpenAdvanced(open);
const handleOpenAdvanced = () => {
syncDraftFromConfig();
setOpenAdvanced(true);
};
const handleCloseAdvanced = () => {
setOpenAdvanced(false);
};
const handleSaveAdvanced = () => {
onConfigChange('tone', advancedDraft.tone);
onConfigChange('verbosity', advancedDraft.verbosity);
onConfigChange('instructions', advancedDraft.instructions);
onConfigChange('includeTableOfContents', advancedDraft.includeTableOfContents);
onConfigChange('includeTitleSlide', advancedDraft.includeTitleSlide);
onConfigChange('webSearch', advancedDraft.webSearch);
setOpenAdvanced(false);
};
useEffect(() => {
if (!openAdvanced) {
return;
}
const previousOverflow = document.body.style.overflow;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
handleCloseAdvanced();
}
};
const handleSaveAdvanced = () => {
onConfigChange("tone", advancedDraft.tone);
onConfigChange("verbosity", advancedDraft.verbosity);
onConfigChange("instructions", advancedDraft.instructions);
onConfigChange("includeTableOfContents", advancedDraft.includeTableOfContents);
onConfigChange("includeTitleSlide", advancedDraft.includeTitleSlide);
onConfigChange("webSearch", advancedDraft.webSearch);
setOpenAdvanced(false);
document.body.style.overflow = 'hidden';
window.addEventListener('keydown', onKeyDown);
return () => {
document.body.style.overflow = previousOverflow;
window.removeEventListener('keydown', onKeyDown);
};
return (
<div className=''>
<ToolTip content="Advanced settings" className='w-full h-full'>
<button
aria-label="Advanced settings"
title="Advanced settings"
type="button"
onClick={() => handleOpenAdvancedChange(true)}
className=" w-full h-full flex items-center px-3 py-1 text-sm bg-[#F7F6F9] hover:bg-[#F7F6F9] border-[#EDEEEF] focus-visible:ring-[#5141E5] border-none rounded-[48px] font-instrument_sans font-medium"
}, [openAdvanced]);
return (
<>
<div className="ml-auto">
<ToolTip content="Advanced settings">
<button
aria-label="Advanced settings"
title="Advanced settings"
type="button"
onClick={handleOpenAdvanced}
className="flex h-10 w-10 items-center justify-center rounded-full border border-[#E4E5E8] bg-white text-[#1C1C27] shadow-sm transition hover:bg-[#F7F7FA] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#5141E5]/25"
data-testid="advanced-settings-button"
>
<SlidersHorizontal className="h-3.5 w-3.5" aria-hidden="true" />
</button>
</ToolTip>
</div>
{openAdvanced && (
<div
className="fixed inset-0 z-[70] bg-black/35 flex items-center justify-center"
onClick={handleCloseAdvanced}
role="presentation"
>
<div
role="dialog"
aria-modal="true"
aria-label="Advanced settings"
className="relative mx-auto mt-[108px] w-[calc(100vw-2rem)] max-w-[640px] overflow-visible"
onClick={(event) => event.stopPropagation()}
>
<button
type="button"
onClick={handleCloseAdvanced}
aria-label="Close advanced settings"
className="absolute -top-[62px] right-2 flex h-[50px] w-[50px] items-center justify-center rounded-full border border-[#E7E7EC] bg-white text-[#2C2B35] shadow-sm transition hover:bg-[#F8F8FB]"
>
<X className="h-3.5 w-3.5" />
</button>
<div className="overflow-hidden rounded-[24px] border border-[#E7E9F2] bg-[#F3F3F6] shadow-[0_24px_80px_rgba(15,23,42,0.20)]">
<div className="flex items-start justify-between gap-4 bg-[#F8F8FA] px-6 py-[22px] ">
<div>
<h2 className="font-syne text-lg font-semibold leading-none text-[#191919]">
Advanced Settings
</h2>
<p className="mt-1 text-sm text-[#808080]">Adjust Presentation Behavior</p>
</div>
<Button
type="button"
onClick={handleSaveAdvanced}
style={{
background:
'linear-gradient(270deg, #D5CAFC 2.4%, #E3D2EB 27.88%, #F4DCD3 69.23%, #FDE4C2 100%)',
}}
className=" rounded-full px-[28px] py-[10px] font-syne text-xs font-semibold text-[#1E1D2B] shadow-none hover:opacity-95"
>
<SlidersHorizontal className="h-4 w-4" aria-hidden="true" />
</button>
</ToolTip>
<Dialog open={openAdvanced} onOpenChange={handleOpenAdvancedChange}>
<DialogContent className="max-w-2xl font-instrument_sans">
<DialogHeader>
<DialogTitle>Advanced settings</DialogTitle>
</DialogHeader>
Save
</Button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
{/* Tone */}
<div className="w-full flex flex-col gap-2">
<label className="text-sm font-semibold text-gray-700">Tone</label>
<p className="text-xs text-gray-500">Controls the writing style (e.g., casual, professional, funny).</p>
<Select
value={advancedDraft.tone}
onValueChange={(value) => setAdvancedDraft((prev) => ({ ...prev, tone: value as ToneType }))}
>
<SelectTrigger className="w-full font-instrument_sans capitalize font-medium bg-blue-100 border-blue-200 focus-visible:ring-blue-300">
<SelectValue placeholder="Select tone" />
</SelectTrigger>
<SelectContent className="font-instrument_sans">
{Object.values(ToneType).map((tone) => (
<SelectItem key={tone} value={tone} className="text-sm font-medium capitalize">
{tone}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="bg-[#ECE8F6] px-6 py-5">
<div className="flex items-start gap-2">
<Pencil className="mt-[3px] h-3.5 w-3.5 text-[#1C1B24]" />
<div className="w-full">
<label
htmlFor="advanced-instructions"
className="block font-syne text-sm font-semibold leading-none text-[#1F1D2A]"
>
Write instructions
</label>
<Textarea
id="advanced-instructions"
value={advancedDraft.instructions}
autoFocus={true}
rows={2}
onChange={(event) =>
setAdvancedDraft((prev) => ({ ...prev, instructions: event.target.value }))
}
placeholder="Guide the AI: define audience, tone, key points, or constraints."
className="mt-1 min-h-[64px] resize-none border-0 bg-transparent p-0 text-sm leading-[1.3] text-[#242430] shadow-none placeholder:text-[#7C7B87] focus-visible:ring-0 focus-visible:ring-offset-0"
/>
</div>
</div>
</div>
{/* Verbosity */}
<div className="w-full flex flex-col gap-2">
<label className="text-sm font-semibold text-gray-700">Verbosity</label>
<p className="text-xs text-gray-500">Controls how detailed slide descriptions are: concise, standard, or text-heavy.</p>
<Select
value={advancedDraft.verbosity}
onValueChange={(value) => setAdvancedDraft((prev) => ({ ...prev, verbosity: value as VerbosityType }))}
>
<SelectTrigger className="w-full font-instrument_sans capitalize font-medium bg-blue-100 border-blue-200 focus-visible:ring-blue-300">
<SelectValue placeholder="Select verbosity" />
</SelectTrigger>
<SelectContent className="font-instrument_sans">
{Object.values(VerbosityType).map((verbosity) => (
<SelectItem key={verbosity} value={verbosity} className="text-sm font-medium capitalize">
{verbosity}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-4 px-6 pb-5 pt-3.5 ">
<div className="flex items-center justify-between gap-3">
<label className="font-syne text-sm font-semibold leading-none text-[#1F1D2A]">Tone</label>
<Select
value={advancedDraft.tone}
onValueChange={(value) =>
setAdvancedDraft((prev) => ({ ...prev, tone: value as ToneType }))
}
>
<SelectTrigger className="p-2.5 w-[120px] rounded-xl border-[#DBDBE1] bg-white font-syne text-sm font-medium capitalize text-[#2C2B37] shadow-none focus:ring-0 focus-visible:ring-0">
<SelectValue placeholder="Select tone" />
</SelectTrigger>
<SelectContent className="z-[120] font-syne">
{Object.values(ToneType).map((tone) => (
<SelectItem key={tone} value={tone} className="text-sm font-medium capitalize">
{tone}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between gap-3">
<label className="font-syne text-sm font-semibold leading-none text-[#1F1D2A]">Verbosity</label>
<Select
value={advancedDraft.verbosity}
onValueChange={(value) =>
setAdvancedDraft((prev) => ({ ...prev, verbosity: value as VerbosityType }))
}
>
<SelectTrigger className="p-2.5 w-[120px] rounded-xl border-[#DBDBE1] bg-white font-syne text-sm font-medium capitalize text-[#2C2B37] shadow-none focus:ring-0 focus-visible:ring-0">
<SelectValue placeholder="Select verbosity" />
</SelectTrigger>
<SelectContent className="z-[120] font-syne">
{Object.values(VerbosityType).map((verbosity) => (
<SelectItem key={verbosity} value={verbosity} className="text-sm font-medium capitalize">
{verbosity}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Toggles */}
<div className="w-full flex flex-col gap-2 p-3 rounded-md bg-blue-100 border-blue-200">
<div className="flex items-center justify-between">
<label className="text-sm font-semibold text-gray-700">Include table of contents</label>
<Switch
checked={advancedDraft.includeTableOfContents}
onCheckedChange={(checked) => setAdvancedDraft((prev) => ({ ...prev, includeTableOfContents: checked }))}
/>
</div>
<p className="text-xs text-gray-600">Add an index slide summarizing sections (requires 3+ slides).</p>
</div>
<div className="w-full flex flex-col gap-2 p-3 rounded-md bg-blue-100 border-blue-200">
<div className="flex items-center justify-between">
<label className="text-sm font-semibold text-gray-700">Title slide</label>
<Switch
checked={advancedDraft.includeTitleSlide}
onCheckedChange={(checked) => setAdvancedDraft((prev) => ({ ...prev, includeTitleSlide: checked }))}
/>
</div>
<p className="text-xs text-gray-600">Include a title slide as the first slide.</p>
</div>
<div className="w-full flex flex-col gap-2 p-3 rounded-md bg-blue-100 border-blue-200">
<div className="flex items-center justify-between">
<label className="text-sm font-semibold text-gray-700">Web search</label>
<Switch
checked={advancedDraft.webSearch}
onCheckedChange={(checked) => setAdvancedDraft((prev) => ({ ...prev, webSearch: checked }))}
/>
</div>
<p className="text-xs text-gray-600">Allow the model to consult the web for fresher facts. Turn on and click Savethis toggle alone controls web search for this deck.</p>
</div>
<div className="flex items-center justify-between gap-3">
<label className="font-syne text-sm font-semibold leading-none text-[#1F1D2A]">
Include Table of Content
</label>
<Switch
checked={advancedDraft.includeTableOfContents}
onCheckedChange={(checked) =>
setAdvancedDraft((prev) => ({ ...prev, includeTableOfContents: checked }))
}
className={toggleClassName}
/>
</div>
{/* Instructions */}
<div className="w-full sm:col-span-2 flex flex-col gap-2">
<label className="text-sm font-semibold text-gray-700">Instructions</label>
<p className="text-xs text-gray-500">Optional guidance for the AI. These override defaults except format constraints.</p>
<Textarea
value={advancedDraft.instructions}
rows={4}
onChange={(e) => setAdvancedDraft((prev) => ({ ...prev, instructions: e.target.value }))}
placeholder="Example: Focus on enterprise buyers, emphasize ROI and security compliance. Keep slides data-driven, avoid jargon, and include a short call-to-action on the final slide."
className="py-2 px-3 border-2 font-medium text-sm min-h-[100px] max-h-[200px] border-blue-200 focus-visible:ring-offset-0 focus-visible:ring-blue-300"
/>
</div>
</div>
<div className="flex items-center justify-between gap-3">
<label className="font-syne text-sm font-semibold leading-none text-[#1F1D2A]">Title Slide</label>
<Switch
checked={advancedDraft.includeTitleSlide}
onCheckedChange={(checked) =>
setAdvancedDraft((prev) => ({ ...prev, includeTitleSlide: checked }))
}
className={toggleClassName}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => handleOpenAdvancedChange(false)}>Cancel</Button>
<Button onClick={handleSaveAdvanced} className="bg-[#5141e5] text-white hover:bg-[#5141e5]/90">Save</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<div className="flex items-center justify-between gap-3">
<label className="font-syne text-sm font-semibold leading-none text-[#1F1D2A]">Web Search</label>
<Switch
checked={advancedDraft.webSearch}
onCheckedChange={(checked) =>
setAdvancedDraft((prev) => ({ ...prev, webSearch: checked }))
}
className={toggleClassName}
/>
</div>
</div>
</div>
</div>
</div>
)
}
)}
</>
);
};
export default AdvanceSettings
export default AdvanceSettings;

View file

@ -5,10 +5,9 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { LanguageType, PresentationConfig, ToneType, VerbosityType } from "../type";
import { LanguageType, PresentationConfig } from "../type";
import { useEffect, useState } from "react";
import { Check, ChevronsUp, ChevronsUpDown, ChevronUp, GalleryVertical, Languages, SlidersHorizontal } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Check, ChevronUp, Languages } from "lucide-react";
import {
Command,
CommandEmpty,
@ -24,10 +23,7 @@ import {
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import ToolTip from "@/components/ToolTip";
import AdvanceSettings from "./AdvanceSettings";
// Types
interface ConfigurationSelectsProps {
@ -232,40 +228,6 @@ export function ConfigurationSelects({
}: ConfigurationSelectsProps) {
const [openSlides, setOpenSlides] = useState(false);
const [openLanguage, setOpenLanguage] = useState(false);
const [openAdvanced, setOpenAdvanced] = useState(false);
const [advancedDraft, setAdvancedDraft] = useState({
tone: config.tone,
verbosity: config.verbosity,
instructions: config.instructions,
includeTableOfContents: config.includeTableOfContents,
includeTitleSlide: config.includeTitleSlide,
webSearch: config.webSearch,
});
const handleOpenAdvancedChange = (open: boolean) => {
if (open) {
setAdvancedDraft({
tone: config.tone,
verbosity: config.verbosity,
instructions: config.instructions,
includeTableOfContents: config.includeTableOfContents,
includeTitleSlide: config.includeTitleSlide,
webSearch: config.webSearch,
});
}
setOpenAdvanced(open);
};
const handleSaveAdvanced = () => {
onConfigChange("tone", advancedDraft.tone);
onConfigChange("verbosity", advancedDraft.verbosity);
onConfigChange("instructions", advancedDraft.instructions);
onConfigChange("includeTableOfContents", advancedDraft.includeTableOfContents);
onConfigChange("includeTitleSlide", advancedDraft.includeTitleSlide);
onConfigChange("webSearch", advancedDraft.webSearch);
setOpenAdvanced(false);
};
return (
<div className="flex flex-wrap order-1 gap-4 items-center">
@ -281,123 +243,7 @@ export function ConfigurationSelects({
open={openLanguage}
onOpenChange={setOpenLanguage}
/>
<ToolTip content="Advanced settings">
<button
aria-label="Advanced settings"
title="Advanced settings"
type="button"
onClick={() => handleOpenAdvancedChange(true)}
className="ml-auto flex items-center gap-2 text-sm text-slate-700 hover:bg-slate-50 focus-visible:ring-[#5146E5]/30 h-10 rounded-xl px-3 ring-1 ring-inset ring-slate-200 shadow-sm font-instrument_sans font-medium"
data-testid="advanced-settings-button"
>
<SlidersHorizontal className="h-4 w-4" aria-hidden="true" />
</button>
</ToolTip>
<Dialog open={openAdvanced} onOpenChange={handleOpenAdvancedChange}>
<DialogContent className="max-w-2xl font-syne">
<DialogHeader>
<DialogTitle>Advanced settings</DialogTitle>
</DialogHeader>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
{/* Tone */}
<div className="w-full flex flex-col gap-2">
<label className="text-sm font-semibold text-gray-700">Tone</label>
<p className="text-xs text-gray-500">Controls the writing style (e.g., casual, professional, funny).</p>
<Select
value={advancedDraft.tone}
onValueChange={(value) => setAdvancedDraft((prev) => ({ ...prev, tone: value as ToneType }))}
>
<SelectTrigger className="w-full font-instrument_sans capitalize font-medium bg-white border-slate-300 hover:bg-slate-50 focus-visible:ring-slate-300">
<SelectValue placeholder="Select tone" />
</SelectTrigger>
<SelectContent className="font-instrument_sans">
{Object.values(ToneType).map((tone) => (
<SelectItem key={tone} value={tone} className="text-sm font-medium capitalize">
{tone}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Verbosity */}
<div className="w-full flex flex-col gap-2">
<label className="text-sm font-semibold text-gray-700">Verbosity</label>
<p className="text-xs text-gray-500">Controls how detailed slide descriptions are: concise, standard, or text-heavy.</p>
<Select
value={advancedDraft.verbosity}
onValueChange={(value) => setAdvancedDraft((prev) => ({ ...prev, verbosity: value as VerbosityType }))}
>
<SelectTrigger className="w-full font-instrument_sans capitalize font-medium bg-white border-slate-300 hover:bg-slate-50 focus-visible:ring-slate-300">
<SelectValue placeholder="Select verbosity" />
</SelectTrigger>
<SelectContent className="font-instrument_sans">
{Object.values(VerbosityType).map((verbosity) => (
<SelectItem key={verbosity} value={verbosity} className="text-sm font-medium capitalize">
{verbosity}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Toggles */}
<div className="w-full flex flex-col gap-2 p-3 rounded-md bg-slate-50 border-slate-200">
<div className="flex items-center justify-between">
<label className="text-sm font-semibold text-gray-700">Include table of contents</label>
<Switch
checked={advancedDraft.includeTableOfContents}
onCheckedChange={(checked) => setAdvancedDraft((prev) => ({ ...prev, includeTableOfContents: checked }))}
/>
</div>
<p className="text-xs text-gray-600">Add an index slide summarizing sections (requires 3+ slides).</p>
</div>
<div className="w-full flex flex-col gap-2 p-3 rounded-md bg-slate-50 border-slate-200">
<div className="flex items-center justify-between">
<label className="text-sm font-semibold text-gray-700">Title slide</label>
<Switch
checked={advancedDraft.includeTitleSlide}
onCheckedChange={(checked) => setAdvancedDraft((prev) => ({ ...prev, includeTitleSlide: checked }))}
/>
</div>
<p className="text-xs text-gray-600">Include a title slide as the first slide.</p>
</div>
<div className="w-full flex flex-col gap-2 p-3 rounded-md bg-slate-50 border-slate-200">
<div className="flex items-center justify-between">
<label className="text-sm font-semibold text-gray-700">Web search</label>
<Switch
checked={advancedDraft.webSearch}
onCheckedChange={(checked) => setAdvancedDraft((prev) => ({ ...prev, webSearch: checked }))}
/>
</div>
<p className="text-xs text-gray-600">Allow the model to consult the web for fresher facts. Turn on and click Savethis toggle alone controls web search for this deck.</p>
</div>
{/* Instructions */}
<div className="w-full sm:col-span-2 flex flex-col gap-2">
<label className="text-sm font-semibold text-gray-700">Instructions</label>
<p className="text-xs text-gray-500">Optional guidance for the AI. These override defaults except format constraints.</p>
<Textarea
value={advancedDraft.instructions}
rows={4}
onChange={(e) => setAdvancedDraft((prev) => ({ ...prev, instructions: e.target.value }))}
placeholder="Example: Focus on enterprise buyers, emphasize ROI and security compliance. Keep slides data-driven, avoid jargon, and include a short call-to-action on the final slide."
className="py-2 px-3 border-2 font-medium text-sm min-h-[100px] max-h-[200px] border-blue-200 focus-visible:ring-offset-0 focus-visible:ring-blue-300"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => handleOpenAdvancedChange(false)}>Cancel</Button>
<Button onClick={handleSaveAdvanced} className="bg-[#5141e5] text-white hover:bg-[#5141e5]/90">Save</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AdvanceSettings config={config} onConfigChange={onConfigChange} />
</div>
);
}
}

View file

@ -347,7 +347,7 @@ const UploadPage = () => {
};
return (
<Wrapper className="pb-10 lg:max-w-[70%] xl:max-w-[65%]">
<Wrapper className="pb-10 lg:max-w-[65%] xl:max-w-[60%]">
<OverlayLoader
show={loadingState.isLoading}
text={loadingState.message}

View file

@ -43,7 +43,7 @@ export const metadata: Metadata = {
const page = () => {
return (
<div className="relative">
<div className="relative min-h-screen">
<Header />
<div className="flex flex-col items-center justify-center mb-8">
<h1 className="text-[64px] relative leading-[112%] font-semibold font-syne text-[#101323] ">
@ -62,7 +62,14 @@ const page = () => {
</h1>
<p className="text-xl font-syne text-[#101323CC]">Turn prompts or documents into presentations with AI</p>
</div>
{/* stars */}
<div
className='fixed z-0 -bottom-[14.5rem] 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%)',
}}
/>
<UploadPage />

View file

@ -39,10 +39,9 @@ export const CHATGPT_MODELS: CodexModel[] = [
{ id: "gpt-5.4-mini", name: "GPT-5.4-Mini" },
{ id: "gpt-5.3-codex", name: "GPT-5.3-Codex" },
{ id: "gpt-5.2", name: "GPT-5.2" },
{ id: "gpt-5.1-codex-mini", name: "GPT-5.1-Codex-Mini" },
];
export const DEFAULT_CODEX_MODEL = "gpt-5.1-codex-mini";
export const DEFAULT_CODEX_MODEL = "gpt-5.2";
export default function CodexConfig({
codexModel,
@ -90,6 +89,8 @@ export default function CodexConfig({
}
const data: StatusResponse = await res.json();
if (data.status === "authenticated") {
onInputChange('chatgpt', 'LLM');
onInputChange(DEFAULT_CODEX_MODEL, 'codex_model');
setAuthStatus("authenticated");
applyProfile(data);
} else {
@ -106,7 +107,7 @@ export default function CodexConfig({
try {
trackEvent(MixpanelEvent.Codex_SignIn_API_Call);
onInputChange('codex', 'LLM');
onInputChange('chatgpt', 'LLM');
const res = await fetch(getApiUrl("/api/v1/ppt/codex/auth/initiate"), {
method: "POST",
@ -199,6 +200,7 @@ export default function CodexConfig({
setUsername(null);
setEmail(null);
setIsPro(null);
onInputChange("openai", "LLM");
onInputChange("", "codex_model");
toast.success("Signed out from ChatGPT");
} catch {
@ -229,13 +231,13 @@ export default function CodexConfig({
if (authStatus === "checking") {
return (
<div className="mb-5 w-full p-3 bg-[#010100] font-syne rounded-[8px] flex items-center gap-6">
<div className="mb-5 w-full p-3 border border-[#EDEEEF] font-syne rounded-[8px] flex items-center gap-6">
<div className="w-[74px] h-[74px] bg-[#333333] rounded-full flex items-center justify-center shrink-0">
<Loader2 className="w-10 h-10 text-white animate-spin" />
<Loader2 className="w-10 h-10 text-[#191919] animate-spin" />
</div>
<div className="text-start flex-1 min-w-0">
<h4 className="text-white text-lg font-medium">Checking status</h4>
<p className="text-[#808080] text-sm font-normal">
<h4 className="text-[#191919] text-lg font-medium">Checking status</h4>
<p className="text-[#B3B3B3] text-sm font-normal">
Verifying your ChatGPT connection
</p>
</div>
@ -246,14 +248,14 @@ export default function CodexConfig({
if (authStatus === "polling") {
return (
<div className="mb-5 space-y-4 font-syne">
<div className="w-full p-3 bg-[#010100] rounded-[8px] flex items-center justify-between gap-4">
<div className="w-full p-3 border border-[#EDEEEF] rounded-[8px] flex items-center justify-between gap-4">
<div className="flex items-center gap-6 min-w-0 flex-1">
<div className="w-[74px] h-[74px] bg-[#333333] rounded-full flex items-center justify-center shrink-0">
<Loader2 className="w-10 h-10 text-white animate-spin" />
<div className="w-[40px] h-[40px] bg-[#EDEEEF] rounded-full flex items-center justify-center shrink-0">
<Loader2 className="w-5 h-5 text-[#191919] animate-spin" />
</div>
<div className="text-start min-w-0">
<h4 className="text-white text-lg font-medium">Waiting for sign-in</h4>
<p className="text-[#808080] text-sm font-normal">
<h4 className="text-[#191919] text-lg font-medium">Waiting for sign-in</h4>
<p className="text-[#B3B3B3] text-sm font-normal">
Complete sign-in in the browser tab we opened.
</p>
</div>
@ -261,21 +263,21 @@ export default function CodexConfig({
<button
type="button"
onClick={handleCancelPolling}
className="shrink-0 text-sm text-[#808080] hover:text-white underline underline-offset-2 transition-colors"
className="shrink-0 text-sm text-[#B3B3B3] hover:text-[#191919] underline underline-offset-2 transition-colors"
>
Cancel
</button>
</div>
<div className="space-y-2 rounded-[8px] border border-[#333333] bg-[#010100] p-3">
<p className="text-white text-xs font-normal">
<div className="space-y-2 rounded-[8px] border border-[#EDEEEF] p-3">
<p className="text-[#191919] text-xs font-normal">
Paste redirect URL or code if you were not redirected automatically
</p>
<div className="flex gap-2">
<input
type="text"
placeholder="Paste URL or code…"
className="flex-1 min-w-0 px-3 py-2.5 outline-none border border-[#333333] rounded-[8px] bg-[#1a1a1a] text-sm text-white placeholder:text-[#666666] focus:border-[#555555] transition-colors"
className="flex-1 min-w-0 px-3 py-2.5 outline-none border border-[#EDEEEF] rounded-[8px] text-sm text-[#191919] placeholder:text-[#666666] focus:border-[#555555] transition-colors"
value={manualCode}
onChange={(e) => setManualCode(e.target.value)}
/>
@ -283,7 +285,7 @@ export default function CodexConfig({
type="button"
onClick={handleManualExchange}
disabled={isExchanging || !manualCode.trim()}
className="shrink-0 px-4 py-2.5 bg-[#333333] hover:bg-[#444444] disabled:opacity-40 disabled:hover:bg-[#333333] rounded-[8px] text-sm font-medium text-white transition-colors flex items-center justify-center min-w-[88px]"
className="shrink-0 px-4 py-2.5 bg-[#EDEEEF] hover:bg-[#E4E5E6] disabled:opacity-40 disabled:hover:bg-[#EDEEEF] rounded-[8px] text-sm font-medium text-[#191919] transition-colors flex items-center justify-center min-w-[88px]"
>
{isExchanging ? (
<Loader2 className="w-5 h-5 animate-spin" />
@ -298,28 +300,27 @@ export default function CodexConfig({
}
if (authStatus === "authenticated") {
const planLabel = isPro === true ? "Pro" : isPro === false ? "Free" : "Unknown";
return (
<div className=" mb-5">
<div className="flex items-center justify-between gap-3 p-5 border border-[#EDEEEF] rounded-[8px]">
<div className="flex items-center gap-3">
<UserCheck className="w-6 h-6 text-black shrink-0" />
<UserCheck className="w-6 h-6 text-[#191919] 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">
<p className="text-sm font-medium text-[#191919] truncate">
{username || email || (accountId ? `Account ${accountId}` : "ChatGPT Account")}
</p>
</div>
{email && username && (
<p className="text-xs text-gray-500 truncate">{email}</p>
<p className="text-xs text-[#B3B3B3] truncate">{email}</p>
)}
{!email && accountId && (
<p className="text-xs text-gray-500 truncate">ID: {accountId}</p>
<p className="text-xs text-[#B3B3B3] truncate">ID: {accountId}</p>
)}
<p className="text-xs text-gray-400">Signed in to ChatGPT</p>
<p className="text-xs text-[#B3B3B3]">Signed in to ChatGPT</p>
</div>
</div>
<div className="flex gap-1.5 shrink-0">
@ -330,9 +331,9 @@ export default function CodexConfig({
className="flex items-center justify-center px-3.5 py-2.5 bg-[#EDEEEF] rounded-[58px] minid:opacity-40 transition-colors"
>
{isRefreshing ? (
<Loader2 className="w-3.5 h-3.5 animate-spin text-black" />
<Loader2 className="w-3.5 h-3.5 animate-spin text-[#191919]" />
) : (
<RefreshCw className="w-3.5 h-3.5 text-black" />
<RefreshCw className="w-3.5 h-3.5 text-[#191919]" />
)}
</button>
<button
@ -342,9 +343,9 @@ export default function CodexConfig({
className="flex items-center justify-center px-3.5 py-2.5 bg-[#EDEEEF] rounded-[58px] hover:bg-[#E4E5E6] disabled:opacity-40 transition-colors"
>
{isLoggingOut ? (
<Loader2 className="w-3.5 h-3.5 animate-spin text-black" />
<Loader2 className="w-3.5 h-3.5 animate-spin text-[#191919]" />
) : (
<Trash2 className="w-3.5 h-3.5 text-black" />
<Trash2 className="w-3.5 h-3.5 text-[#191919]" />
)}
</button>
</div>
@ -358,19 +359,19 @@ export default function CodexConfig({
return (
<button
onClick={handleSignIn}
className="mb-5 w-full p-3 bg-[#010100] font-syne rounded-[8px] flex items-center justify-between "
className=" w-full p-5 border border-[#EDEEEF] hover:bg-[#F7F6F9] transition-colors duration-300 font-syne rounded-[12px] flex items-center justify-between "
>
<div className="flex items-center gap-6">
<div className="w-[74px] h-[74px] bg-[#333333] rounded-full flex items-center justify-center" >
<div className="flex items-center gap-2 flex-1">
<div className="w-[40px] h-[40px] bg-[#333333] rounded-full flex items-center justify-center" >
<img src="/providers/OpenAI-white.png" alt="openai Logo" className="w-[52px] h-[52px]" />
<img src="/providers/OpenAI-white.png" alt="openai Logo" className="w-[27px] h-[27px]" />
</div>
<div className="text-start">
<h4 className="text-white text-lg font-medium">Sign in with ChatGPT</h4>
<p className="text-[#808080] text-sm font-normal">Use your ChatGPT account no API <br /> key required</p>
<div className="text-start flex-1">
<h4 className="text-[#191919] text-sm font-medium">Sign in with ChatGPT</h4>
<p className="text-[#B3B3B3] text-xs font-normal">Use your ChatGPT account no API key required</p>
</div>
</div>
<ArrowRight className="w-[22px] h-[22px] text-white" />
<ArrowRight className="w-[22px] h-[22px] text-[#4C4C4C]" />
</button>
);
}

View file

@ -3,7 +3,7 @@ import React from 'react'
const OnBoardingHeader = ({ currentStep, setStep }: { currentStep: number, setStep: (step: number) => void }) => {
return (
<div className='relative z-20 flex items-center font-syne justify-end gap-1 mt-7 mb-[52px]'>
<div className='sticky top-8 z-20 flex items-center font-syne justify-end gap-1 mt-7 mb-[52px]'>
<div className='flex items-center gap-1 cursor-pointer'
onClick={() => {

View file

@ -3,7 +3,7 @@ import React from 'react'
const OnBoardingSlidebar = ({ step }: { step: number }) => {
return (
<div className={`${step === 3 ? "bg-white" : "bg-[#F6F6F9]"} w-[300px] relative`}>
<img src="/Logo.png" alt="Presenton logo" className="absolute top-0 left-0 w-[128px] m-6" />
<img src="/Logo.png" alt="Presenton logo" className="sticky top-6 left-0 w-[128px] m-6" />
{step !== 3 && <svg xmlns="http://www.w3.org/2000/svg" width="296" height="591" viewBox="0 0 296 591" fill="none">
<path d="M291.5 183.5C311.916 183.5 328.5 200.271 328.5 221C328.5 241.729 311.916 258.5 291.5 258.5C271.084 258.5 254.5 241.729 254.5 221C254.5 200.271 271.084 183.5 291.5 183.5Z" stroke="#EDEEEF" strokeWidth="3" />
<path d="M291.5 131.238C340.408 131.238 380.089 171.407 380.09 220.998C380.09 270.589 340.408 310.758 291.5 310.758C242.591 310.758 202.91 270.589 202.91 220.998C202.91 171.407 242.591 131.238 291.5 131.238Z" stroke="#EDEEEF" strokeWidth="3" />

View file

@ -1,7 +1,7 @@
import React, { useEffect, useMemo, useState } from 'react'
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
import { Button } from '../ui/button';
import { Check, CheckCircle, ChevronLeft, ChevronUp, Download, Eye, EyeOff, Loader2 } from 'lucide-react';
import { ArrowUpRight, Check, CheckCircle, ChevronLeft, ChevronUp, Download, Eye, EyeOff, Info, Loader2 } from 'lucide-react';
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '../ui/command';
import { DALLE_3_QUALITY_OPTIONS, GPT_IMAGE_1_5_QUALITY_OPTIONS, IMAGE_PROVIDERS, LLM_PROVIDERS } from '@/utils/providerConstants';
import { cn } from '@/lib/utils';
@ -231,7 +231,7 @@ const PresentonMode = ({ currentStep, setStep }: { currentStep: number, setStep:
DALL·E 3 Image Quality
</label>
<div className="">
<Select value={llmConfig.DALL_E_3_QUALITY} onValueChange={(value) => setLlmConfig((prev) => ({
<Select value={llmConfig.DALL_E_3_QUALITY || 'standard'} onValueChange={(value) => setLlmConfig((prev) => ({
...prev,
DALL_E_3_QUALITY: value
}))}>
@ -258,7 +258,7 @@ const PresentonMode = ({ currentStep, setStep }: { currentStep: number, setStep:
</label>
<div className="">
<Select
value={llmConfig.GPT_IMAGE_1_5_QUALITY}
value={llmConfig.GPT_IMAGE_1_5_QUALITY || 'low'}
onValueChange={(value) => setLlmConfig((prev) => ({
...prev,
GPT_IMAGE_1_5_QUALITY: value
@ -292,8 +292,31 @@ const PresentonMode = ({ currentStep, setStep }: { currentStep: number, setStep:
setShowDownloadModal(false);
}
};
const checkCurrentAuthStatus = async () => {
try {
const res = await fetch(getApiUrl("/api/v1/ppt/codex/auth/status"));
if (!res.ok) {
return false;
}
const data = await res.json();
if (data.status === "authenticated") {
return true;
} else {
return false;
}
} catch {
return false;
}
};
const handleSaveConfig = async () => {
try {
if (llmConfig.LLM === 'codex') {
const isAuthenticated = await checkCurrentAuthStatus();
if (!isAuthenticated) {
toast.error("Please sign in to ChatGPT to continue");
return;
}
}
setSavingConfig(true);
await handleSaveLLMConfig(llmConfig);
@ -350,25 +373,20 @@ const PresentonMode = ({ currentStep, setStep }: { currentStep: number, setStep:
}, [llmConfig.LLM, modelsChecked, modelsLoading]);
return (
<div className='w-full max-w-[640px] font-syne'>
<div className='w-full max-w-[660px] font-syne pb-10'>
<p className='px-2.5 py-0.5 w-fit text-[#7A5AF8] rounded-[50px] border border-[#EDEEEF] text-[10px] font-medium mb-5 font-syne'>PRESENTON</p>
<div className='mb-[54px]'>
<div className=''>
<h2 className='mb-4 text-black text-[26px] font-normal font-unbounded '>Choose your content providers</h2>
<p className='text-[#000000CC] text-xl font-normal font-syne'>Select the AI engines that will generate your slide text and visuals.</p>
</div>
<CodexConfig
codexModel={llmConfig.CODEX_MODEL || ''}
onInputChange={(value, field) => {
const normalizedField = field === 'codex_model' ? 'CODEX_MODEL' : field;
setLlmConfig(prev => ({
...prev,
[normalizedField]: value
}));
}}
/>
<div className='flex items-center gap-2 bg-[#F0F3F9B2] rounded-[8px] px-6 py-2.5 my-[54px]'>
<Info className='w-4 h-4 fill-[#003399] stroke-white' />
<p className='text-sm text-[#5F6062] font-medium'>Runs locally on your device. Your API keys and generation setup stay on your machine.</p>
</div>
{/* Text Provider */}
<div className='p-3 border border-[#EDEEEF] rounded-[11px] '>
<div className='p-3 border border-[#EDEEEF] rounded-[11px] bg-white '>
<div className="flex items-center gap-[24.3px] mb-[42px]">
<div className='w-[74px] h-[74px] rounded-[4px] pt-[16.8px] pr-[17.15px] pb-[17.2px] pl-[16.85px] flex items-center justify-center'
style={{ backgroundColor: '#4C55541A' }}
@ -387,7 +405,22 @@ const PresentonMode = ({ currentStep, setStep }: { currentStep: number, setStep:
</p>
</div>
</div>
<div className='flex items-start gap-4 '>
<CodexConfig
codexModel={llmConfig.CODEX_MODEL || ''}
onInputChange={(value, field) => {
const normalizedField = field === 'codex_model' ? 'CODEX_MODEL' : field;
setLlmConfig(prev => ({
...prev,
[normalizedField]: value
}));
}}
/>
<div className='flex items-center gap-2.5 my-[30px]'>
<div className='w-full h-[1px] bg-[#E1E1E5]' />
<p className='text-xs font-normal text-[#999999]'>OR</p>
<div className='w-full h-[1px] bg-[#E1E1E5]' />
</div>
<div className='flex flex-col items-start gap-4 '>
<div className="flex flex-col justify-start w-full ">
<label className="block text-sm font-medium text-gray-700 mb-2">
@ -416,8 +449,8 @@ const PresentonMode = ({ currentStep, setStep }: { currentStep: number, setStep:
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0 w-[215px] "
align="start"
className="p-0 w-full "
align="end"
>
<Command>
@ -473,7 +506,7 @@ const PresentonMode = ({ currentStep, setStep }: { currentStep: number, setStep:
USE_CUSTOM_URL: true,
OLLAMA_URL: prev.OLLAMA_URL || 'http://localhost:11434'
}))}
className="mt-8 py-2.5 bg-[#EDEEEF] px-3.5 w-fit rounded-[48px] text-xs font-semibold text-[#101323] transition-all duration-200 border border-[#EDEEEF] hover:bg-[#E8F0FF]/90 focus:ring-2 focus:ring-blue-500/20"
className="py-2.5 bg-[#EDEEEF] px-3.5 w-fit rounded-[48px] text-xs font-semibold text-[#101323] transition-all duration-200 border border-[#EDEEEF] hover:bg-[#E8F0FF]/90 focus:ring-2 focus:ring-blue-500/20"
>
Use Ollama URL
</button>
@ -508,7 +541,7 @@ const PresentonMode = ({ currentStep, setStep }: { currentStep: number, setStep:
</>
)}
</>
) : llmConfig.LLM === 'codex' ? (
) : llmConfig.LLM === 'codex' || llmConfig.LLM === 'chatgpt' ? (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Select GPT Model
@ -570,9 +603,14 @@ const PresentonMode = ({ currentStep, setStep }: { currentStep: number, setStep:
</div>
) : (
<>
<label className="block text-sm font-medium capitalize text-gray-700 mb-2">
{llmConfig.LLM === 'custom' ? 'Custom LLM API Key' : `${llmConfig.LLM} API Key`}
</label>
<div className='flex items-center justify-between mb-2'>
<label className="block text-sm font-medium capitalize text-gray-700 ">
{llmConfig.LLM === 'custom' ? 'Custom LLM API Key' : `${llmConfig.LLM} API Key`}
</label>
{llmConfig.LLM && LLM_PROVIDERS[llmConfig.LLM!]?.getApiKeyUrl && <a href={LLM_PROVIDERS[llmConfig.LLM!]?.getApiKeyUrl || ""} target='_blank' className='text-[#666666] text-xs font-normal flex items-center gap-1'>Get API Key <ArrowUpRight className='w-3.5 h-3.5' /></a>}
</div>
<div className="relative">
<input
type={showApiKey ? 'text' : 'password'}
@ -611,7 +649,7 @@ const PresentonMode = ({ currentStep, setStep }: { currentStep: number, setStep:
</div>
{llmConfig.LLM !== 'ollama' && llmConfig.LLM !== 'codex' && (!modelsChecked || (modelsChecked && availableModels.length === 0)) && (
{llmConfig.LLM !== 'ollama' && llmConfig.LLM !== 'codex' && llmConfig.LLM !== 'chatgpt' && (!modelsChecked || (modelsChecked && availableModels.length === 0)) && (
<button
onClick={fetchAvailableModels}
@ -622,9 +660,9 @@ const PresentonMode = ({ currentStep, setStep }: { currentStep: number, setStep:
(llmConfig.LLM === 'anthropic' && !currentApiKey) ||
(llmConfig.LLM === 'custom' && !llmConfig.CUSTOM_LLM_URL)
}
className={`mt-4 py-2.5 bg-[#EDEEEF] disabled:opacity-50 disabled:cursor-not-allowed px-3.5 w-fit rounded-[48px] text-xs font-semibold text-[#101323] transition-all duration-200 border ${modelsLoading
className={`mt-4 py-2.5 bg-[#EDEEEF] disabled:opacity-50 disabled:cursor-not-allowed px-3.5 w-full rounded-[48px] text-xs font-semibold text-[#101323] transition-all duration-200 border ${modelsLoading
? " border-gray-300 cursor-not-allowed text-gray-500"
: " border-[#EDEEEF] text-[#101323] hover:bg-[#E8F0FF]/90 focus:ring-2 focus:ring-blue-500/20"
: " border-[#EDEEEF] text-[#101323] hover:bg-[#EDEEEF]/90 focus:ring-2 focus:ring-blue-500/20"
}`}
>
{modelsLoading ? (
@ -633,7 +671,7 @@ const PresentonMode = ({ currentStep, setStep }: { currentStep: number, setStep:
Checking for models...
</span>
) : (
"Check models"
"Validate & Load Models"
)}
</button>
)}
@ -641,7 +679,7 @@ const PresentonMode = ({ currentStep, setStep }: { currentStep: number, setStep:
</div>
<div className='flex items-start gap-4 mt-4'>
<p className='text-sm font-medium text-gray-700 mb-2 w-full'></p>
{/* Model Selection - only show if models are available */}
{llmConfig.LLM !== 'codex' && modelsChecked && availableModels.length > 0 && (
@ -729,7 +767,7 @@ const PresentonMode = ({ currentStep, setStep }: { currentStep: number, setStep:
</div>
</div>
{/* Image Provider */}
<div className={`p-3 border border-[#EDEEEF] rounded-[11px] relative mt-5 ${llmConfig.DISABLE_IMAGE_GENERATION ? "bg-[#F9FAFB]" : ""}`}>
<div className={`p-3 border border-[#EDEEEF] rounded-[11px] relative mt-5 bg-white ${llmConfig.DISABLE_IMAGE_GENERATION ? "bg-[#F9FAFB]" : ""}`}>
<ToolTip content="Enable/Disable Image Generation" className='flex justify-end items-center absolute top-3 right-3'>
<div className='flex justify-end items-center'>
<Switch
@ -758,7 +796,7 @@ const PresentonMode = ({ currentStep, setStep }: { currentStep: number, setStep:
</div>
</div>
{!llmConfig.DISABLE_IMAGE_GENERATION && (
<div className='flex gap-4'>
<div className='flex flex-col gap-4'>
{/* Image Provider Selection */}
<div className="w-full">
<label className="block text-sm font-medium text-gray-700 mb-2">
@ -884,9 +922,13 @@ const PresentonMode = ({ currentStep, setStep }: { currentStep: number, setStep:
// Show API key input for other providers
return (
<div className="w-full ">
<label className="block text-sm font-medium text-gray-700 mb-2">
{provider.apiKeyFieldLabel}
</label>
<div className='flex items-center justify-between mb-2'>
<label className="block text-sm font-medium text-gray-700">
{provider.apiKeyFieldLabel}
</label>
{provider.getApiKeyUrl && <a href={provider.getApiKeyUrl || ""} target='_blank' className='text-[#666666] text-xs font-normal flex items-center gap-1'>Get API Key <ArrowUpRight className='w-3.5 h-3.5' /></a>}
</div>
<div className="relative">
<input
type={showApiKey ? 'text' : 'password'}
@ -917,9 +959,9 @@ const PresentonMode = ({ currentStep, setStep }: { currentStep: number, setStep:
</div>
)}
{!llmConfig.DISABLE_IMAGE_GENERATION && <div className='flex justify-end items-center mt-[18px]'>
{!llmConfig.DISABLE_IMAGE_GENERATION && <div className='flex flex-col justify-end items-center mt-[18px]'>
<div className='w-full flex items-center gap-4'>
<p className='w-full'></p>
{renderQualitySelector(llmConfig)}
</div>
{llmConfig.IMAGE_PROVIDER === "comfyui" && <div className='w-full'>

View file

@ -14,6 +14,7 @@ export interface ImageProviderOption {
requiresApiKey?: boolean;
apiKeyField?: string;
apiKeyFieldLabel?: string;
getApiKeyUrl?: string;
}
export interface LLMProviderOption {
@ -24,8 +25,10 @@ export interface LLMProviderOption {
model_label?: string;
url?: string;
icon?: string;
getApiKeyUrl?: string;
}
export const IMAGE_PROVIDERS: Record<string, ImageProviderOption> = {
pexels: {
value: "pexels",
@ -35,6 +38,7 @@ export const IMAGE_PROVIDERS: Record<string, ImageProviderOption> = {
requiresApiKey: true,
apiKeyField: "PEXELS_API_KEY",
apiKeyFieldLabel: "Pexels API Key",
getApiKeyUrl: "https://docs.presenton.ai/help/get-api-keys/get-pexels-api-key",
},
pixabay: {
value: "pixabay",
@ -44,6 +48,7 @@ export const IMAGE_PROVIDERS: Record<string, ImageProviderOption> = {
requiresApiKey: true,
apiKeyField: "PIXABAY_API_KEY",
apiKeyFieldLabel: "Pixabay API Key",
getApiKeyUrl: "https://docs.presenton.ai/help/get-api-keys/get-pixabay-api-keyhttps://www.google.com/search?q=how+to+get+openai+api+key&ie=UTF-8",
},
"dall-e-3": {
value: "dall-e-3",
@ -53,6 +58,7 @@ export const IMAGE_PROVIDERS: Record<string, ImageProviderOption> = {
requiresApiKey: true,
apiKeyField: "OPENAI_API_KEY",
apiKeyFieldLabel: "OpenAI API Key",
getApiKeyUrl: "https://www.google.com/search?q=how+to+get+openai+api+key&ie=UTF-8",
},
"gpt-image-1.5": {
value: "gpt-image-1.5",
@ -62,6 +68,7 @@ export const IMAGE_PROVIDERS: Record<string, ImageProviderOption> = {
requiresApiKey: true,
apiKeyField: "OPENAI_API_KEY",
apiKeyFieldLabel: "OpenAI API Key",
getApiKeyUrl: "https://www.google.com/search?q=how+to+get+openai+api+key&ie=UTF-8",
},
gemini_flash: {
value: "gemini_flash",
@ -71,6 +78,7 @@ export const IMAGE_PROVIDERS: Record<string, ImageProviderOption> = {
requiresApiKey: true,
apiKeyField: "GOOGLE_API_KEY",
apiKeyFieldLabel: "Google API Key",
getApiKeyUrl: "https://www.google.com/search?q=how+to+get+google+AI+studio+api+key&sxsrf=ANbL-n5_hUGaEiG9v6k9VxZWyv0mqO0Jew%3A1776339625724",
},
nanobanana_pro: {
value: "nanobanana_pro",
@ -80,6 +88,7 @@ export const IMAGE_PROVIDERS: Record<string, ImageProviderOption> = {
requiresApiKey: true,
apiKeyField: "GOOGLE_API_KEY",
apiKeyFieldLabel: "Google API Key",
getApiKeyUrl: "https://www.google.com/search?q=how+to+get+google+AI+studio+api+key&sxsrf=ANbL-n5_hUGaEiG9v6k9VxZWyv0mqO0Jew%3A1776339625724",
},
comfyui: {
value: "comfyui",
@ -98,6 +107,7 @@ export const LLM_PROVIDERS: Record<string, LLMProviderOption> = {
label: "ChatGPT",
description: "ChatGPT Plus/Pro via OAuth",
icon: "/providers/openai.png",
getApiKeyUrl: "https://www.google.com/search?q=how+to+get+openai+api+key&ie=UTF-8",
},
openai: {
value: "openai",
@ -105,6 +115,7 @@ export const LLM_PROVIDERS: Record<string, LLMProviderOption> = {
description: "OpenAI's latest text generation model",
url: "https://api.openai.com/v1",
icon: "/providers/openai.png",
getApiKeyUrl: "https://www.google.com/search?q=how+to+get+openai+api+key&ie=UTF-8",
},
google: {
value: "google",
@ -112,6 +123,7 @@ export const LLM_PROVIDERS: Record<string, LLMProviderOption> = {
description: "Google's primary text generation model",
url: "https://api.google.com/v1",
icon: "/providers/gemini-color.svg",
getApiKeyUrl: "https://www.google.com/search?q=how+to+get+google+AI+studio+api+key&sxsrf=ANbL-n5_hUGaEiG9v6k9VxZWyv0mqO0Jew%3A1776339625724",
},
anthropic: {
value: "anthropic",
@ -119,6 +131,7 @@ export const LLM_PROVIDERS: Record<string, LLMProviderOption> = {
description: "Anthropic's Claude models",
url: "https://api.anthropic.com/v1",
icon: "/providers/claude-color.svg",
getApiKeyUrl: "https://www.google.com/search?q=how+to+get+anthropic+api+key&sxsrf=ANbL-n7lsueZQ88L56HhqC1ch2PGD0rbNQ%3A1776339632265",
},
ollama: {
value: "ollama",

View file

@ -57,7 +57,7 @@ export const getLLMConfigValidationError = (
if (!isProvided(llmConfig.CUSTOM_MODEL)) {
return 'No model selected for your custom endpoint. Use "Check models" after entering the URL, then choose a model.';
}
} else if (llm === "codex") {
} else if (llm === "codex" || llm === "chatgpt") {
if (!isProvided(llmConfig.CODEX_MODEL)) {
return "Select a Codex model.";
}
@ -151,7 +151,7 @@ export const handleSaveLLMConfig = async (llmConfig: LLMConfig) => {
if (validationError) {
throw new Error(validationError);
}
// Check if running in Electron environment
if (typeof window !== 'undefined' && window.electron?.setUserConfig) {
// Use Electron IPC handler

View file

@ -96,5 +96,11 @@ http {
expires 1y;
add_header Cache-Control "public, immutable";
}
location /app_data/pptx-to-html/ {
alias /app_data/pptx-to-html/;
expires 1y;
add_header Cache-Control "public, immutable";
}
}
}

577
package-lock.json generated
View file

@ -8,48 +8,555 @@
"name": "presenton",
"version": "1.0.0",
"dependencies": {
"react-colorful": "^5.6.1"
"sharp": "^0.34.5"
}
},
"node_modules/react": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"node_modules/@emnapi/runtime": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-colorful": {
"version": "5.6.1",
"resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.6.1.tgz",
"integrity": "sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw==",
"license": "MIT",
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/react-dom": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT",
"peer": true,
"optional": true,
"dependencies": {
"scheduler": "^0.27.0"
},
"peerDependencies": {
"react": "^19.2.4"
"tslib": "^2.4.0"
}
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"license": "MIT",
"peer": true
"engines": {
"node": ">=18"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.2.4"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
"cpu": [
"arm"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
"cpu": [
"ppc64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
"cpu": [
"riscv64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
"cpu": [
"s390x"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
"cpu": [
"arm"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.2.4"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
"cpu": [
"ppc64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-riscv64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
"cpu": [
"riscv64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
"cpu": [
"s390x"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.2.4"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.7.0"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
"cpu": [
"ia32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/sharp": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@img/colour": "^1.0.0",
"detect-libc": "^2.1.2",
"semver": "^7.7.3"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.34.5",
"@img/sharp-darwin-x64": "0.34.5",
"@img/sharp-libvips-darwin-arm64": "1.2.4",
"@img/sharp-libvips-darwin-x64": "1.2.4",
"@img/sharp-libvips-linux-arm": "1.2.4",
"@img/sharp-libvips-linux-arm64": "1.2.4",
"@img/sharp-libvips-linux-ppc64": "1.2.4",
"@img/sharp-libvips-linux-riscv64": "1.2.4",
"@img/sharp-libvips-linux-s390x": "1.2.4",
"@img/sharp-libvips-linux-x64": "1.2.4",
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
"@img/sharp-linux-arm": "0.34.5",
"@img/sharp-linux-arm64": "0.34.5",
"@img/sharp-linux-ppc64": "0.34.5",
"@img/sharp-linux-riscv64": "0.34.5",
"@img/sharp-linux-s390x": "0.34.5",
"@img/sharp-linux-x64": "0.34.5",
"@img/sharp-linuxmusl-arm64": "0.34.5",
"@img/sharp-linuxmusl-x64": "0.34.5",
"@img/sharp-wasm32": "0.34.5",
"@img/sharp-win32-arm64": "0.34.5",
"@img/sharp-win32-ia32": "0.34.5",
"@img/sharp-win32-x64": "0.34.5"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD",
"optional": true
}
}
}

View file

@ -1,6 +1,15 @@
{
"name": "presenton",
"version": "1.0.0",
"presentationExportVersion": "v0.2.0",
"type": "module",
"description": "Open-source AI presentation generator"
"description": "Open-source AI presentation generator",
"scripts": {
"sync:presentation-export": "node scripts/sync-presentation-export.cjs",
"sync:presentation-export:force": "node scripts/sync-presentation-export.cjs --force",
"check:presentation-export": "node scripts/sync-presentation-export.cjs --check-only"
},
"dependencies": {
"sharp": "^0.34.5"
}
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,291 @@
/**
* Download presenton-export release (Linux x64) into repo-root `presentation-export/`.
* Same release host as Electron (`electron/sync_export_runtime.js`); Docker uses this at build time.
*
* Version resolution (first match):
* 1. EXPORT_RUNTIME_VERSION env
* 2. package.json presentationExportVersion
*
* CLI: --force re-download even if valid runtime already exists
* --check-only verify index.cjs + converter exist and exit 0/1
*/
const fs = require("fs");
const path = require("path");
const https = require("https");
const http = require("http");
const { execFileSync } = require("child_process");
const repoRoot = path.join(__dirname, "..");
const targetRoot = path.join(repoRoot, "presentation-export");
const targetPyDir = path.join(targetRoot, "py");
const targetIndexJs = path.join(targetRoot, "index.js");
const targetIndexCjs = path.join(targetRoot, "index.cjs");
const packageJsonFile = path.join(repoRoot, "package.json");
const cacheDir = path.join(repoRoot, ".cache", "presentation-export");
const exportRepoBase =
"https://github.com/presenton/presenton-export/releases/download";
const linuxAssetName = "export-Linux-X64.zip";
const cliArgs = new Set(process.argv.slice(2));
const forceDownload = cliArgs.has("--force");
const checkOnly = cliArgs.has("--check-only");
function ensureDir(dirPath) {
fs.mkdirSync(dirPath, { recursive: true });
}
function readPinnedVersion() {
if (!fs.existsSync(packageJsonFile)) {
throw new Error(
`Missing ${path.relative(repoRoot, packageJsonFile)}. Add \"presentationExportVersion\": \"vX.Y.Z\".`
);
}
const raw = JSON.parse(fs.readFileSync(packageJsonFile, "utf8"));
const v = (raw.presentationExportVersion || "").trim();
if (!v) {
throw new Error(
`${path.relative(repoRoot, packageJsonFile)} must set \"presentationExportVersion\" (e.g. \"v0.2.0\").`
);
}
return v;
}
async function getTargetVersion() {
const fromEnv = (process.env.EXPORT_RUNTIME_VERSION || "").trim();
if (fromEnv) {
return fromEnv === "latest" ? await resolveLatestTag() : fromEnv;
}
const pinned = readPinnedVersion();
if (pinned === "latest") {
return await resolveLatestTag();
}
return pinned;
}
function requestJson(url, redirects = 5) {
return new Promise((resolve, reject) => {
const client = url.startsWith("https:") ? https : http;
const req = client.get(
url,
{
headers: {
"User-Agent": "presenton-presentation-export-sync",
Accept: "application/vnd.github+json",
},
},
(res) => {
if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location) {
if (redirects <= 0) {
reject(new Error(`Too many redirects for JSON request: ${url}`));
return;
}
requestJson(res.headers.location, redirects - 1).then(resolve).catch(reject);
return;
}
if (res.statusCode < 200 || res.statusCode >= 300) {
reject(new Error(`Failed to fetch ${url}. HTTP ${res.statusCode}`));
return;
}
let payload = "";
res.setEncoding("utf8");
res.on("data", (chunk) => {
payload += chunk;
});
res.on("end", () => {
try {
resolve(JSON.parse(payload));
} catch (e) {
reject(new Error(`Invalid JSON from ${url}: ${e.message}`));
}
});
}
);
req.on("error", reject);
});
}
async function resolveLatestTag() {
const apiUrl =
"https://api.github.com/repos/presenton/presenton-export/releases/latest";
const latest = await requestJson(apiUrl);
if (!latest.tag_name) {
throw new Error(`Could not resolve latest tag from ${apiUrl}`);
}
return latest.tag_name;
}
function chmodIfPossible(filePath) {
if (process.platform !== "win32") {
fs.chmodSync(filePath, 0o755);
}
}
function getConverterCandidates() {
return [
path.join(targetPyDir, "convert-linux-x64"),
path.join(targetPyDir, "convert-linux-amd64"),
];
}
function ensureCommonJsEntrypoint() {
if (!fs.existsSync(targetIndexJs)) {
return { ok: false, reason: `Missing runtime bundle: ${targetIndexJs}` };
}
if (fs.existsSync(targetIndexCjs)) {
return { ok: true, entrypointPath: targetIndexCjs };
}
try {
fs.copyFileSync(targetIndexJs, targetIndexCjs);
return { ok: true, entrypointPath: targetIndexCjs };
} catch (err) {
return {
ok: false,
reason: `Failed to create CommonJS entrypoint ${targetIndexCjs}: ${err.message}`,
};
}
}
function validateExistingRuntime() {
const entrypoint = ensureCommonJsEntrypoint();
if (!entrypoint.ok) {
return { ok: false, reason: entrypoint.reason };
}
const candidates = getConverterCandidates();
const converterPath = candidates.find((c) => fs.existsSync(c));
if (!converterPath) {
return {
ok: false,
reason: `No Linux converter binary under ${targetPyDir}.`,
};
}
chmodIfPossible(converterPath);
return { ok: true, entrypointPath: entrypoint.entrypointPath, converterPath };
}
function downloadFile(url, outputPath, redirects = 5) {
return new Promise((resolve, reject) => {
const client = url.startsWith("https:") ? https : http;
const req = client.get(
url,
{
headers: {
"User-Agent": "presenton-presentation-export-sync",
Accept: "application/octet-stream",
},
},
(res) => {
if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location) {
if (redirects <= 0) {
reject(new Error(`Too many redirects while downloading ${url}`));
return;
}
downloadFile(res.headers.location, outputPath, redirects - 1)
.then(resolve)
.catch(reject);
return;
}
if (res.statusCode < 200 || res.statusCode >= 300) {
reject(new Error(`Failed to download ${url}. HTTP ${res.statusCode}`));
return;
}
ensureDir(path.dirname(outputPath));
const fileStream = fs.createWriteStream(outputPath);
res.pipe(fileStream);
fileStream.on("finish", () => {
fileStream.close(resolve);
});
fileStream.on("error", reject);
}
);
req.on("error", reject);
});
}
function unzipArchive(zipPath, destDir) {
ensureDir(destDir);
execFileSync("unzip", ["-o", zipPath, "-d", destDir], { stdio: "inherit" });
}
function resolveExtractedRoot(extractDir) {
const directIndex = path.join(extractDir, "index.js");
const directPy = path.join(extractDir, "py");
if (fs.existsSync(directIndex) && fs.existsSync(directPy)) {
return extractDir;
}
const children = fs.readdirSync(extractDir, { withFileTypes: true });
for (const entry of children) {
if (!entry.isDirectory()) continue;
const candidate = path.join(extractDir, entry.name);
const candidateIndex = path.join(candidate, "index.js");
const candidatePy = path.join(candidate, "py");
if (fs.existsSync(candidateIndex) && fs.existsSync(candidatePy)) {
return candidate;
}
}
throw new Error(`Unable to locate export runtime root under ${extractDir}`);
}
async function downloadAndInstallRuntime() {
const tag = await getTargetVersion();
const downloadUrl = `${exportRepoBase}/${tag}/${linuxAssetName}`;
ensureDir(cacheDir);
const zipPath = path.join(cacheDir, linuxAssetName);
const extractDir = path.join(cacheDir, `extract-${Date.now()}`);
console.log(`[presentation-export] Downloading ${downloadUrl}`);
await downloadFile(downloadUrl, zipPath);
console.log(`[presentation-export] Extracting ${zipPath}`);
unzipArchive(zipPath, extractDir);
const sourceRoot = resolveExtractedRoot(extractDir);
fs.rmSync(targetRoot, { recursive: true, force: true });
ensureDir(targetRoot);
fs.cpSync(sourceRoot, targetRoot, { recursive: true, force: true });
fs.rmSync(extractDir, { recursive: true, force: true });
return { tag, downloadUrl };
}
async function main() {
const existing = validateExistingRuntime();
if (checkOnly) {
if (!existing.ok) {
throw new Error(existing.reason);
}
console.log("[presentation-export] OK");
console.log(` - ${existing.entrypointPath}`);
console.log(` - ${existing.converterPath}`);
return;
}
if (existing.ok && !forceDownload) {
console.log("[presentation-export] Using existing runtime:");
console.log(` - ${existing.entrypointPath}`);
console.log(` - ${existing.converterPath}`);
return;
}
const { tag, downloadUrl } = await downloadAndInstallRuntime();
const installed = validateExistingRuntime();
if (!installed.ok) {
throw new Error(installed.reason);
}
console.log("[presentation-export] Synced successfully:");
console.log(` - release: ${tag}`);
console.log(` - url: ${downloadUrl}`);
console.log(` - ${installed.entrypointPath}`);
console.log(` - ${installed.converterPath}`);
}
main().catch((err) => {
console.error(`[presentation-export] ${err.message}`);
process.exit(1);
});

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)
@ -36,12 +108,12 @@ async def generate_image(
@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())
)
return images
return list(images_result)
except Exception as e:
raise HTTPException(
status_code=500, detail=f"Failed to retrieve generated images: {str(e)}"
@ -65,6 +137,8 @@ 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)
return image_asset
except Exception as e:
@ -74,12 +148,12 @@ 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())
)
return images
return list(images_result)
except Exception as e:
raise HTTPException(
status_code=500, detail=f"Failed to retrieve uploaded images: {str(e)}"

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,17 @@ 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.llm_calls.generate_presentation_outlines import generate_ppt_outline
from utils.ppt_utils import get_presentation_title_from_outlines
from services.mem0_presentation_memory_service import (
MEM0_PRESENTATION_MEMORY_SERVICE,
)
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,
get_messages as get_outline_messages,
)
OUTLINES_ROUTER = APIRouter(prefix="/outlines", tags=["Outlines"])
@ -43,7 +51,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 +62,42 @@ 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
outline_messages = get_outline_messages(
presentation.content,
n_slides_to_generate,
presentation.language,
additional_context,
presentation.tone,
presentation.verbosity,
presentation.instructions,
presentation.include_title_slide,
presentation.include_table_of_contents,
)
await MEM0_PRESENTATION_MEMORY_SERVICE.store_generation_context(
presentation_id=presentation.id,
system_prompt=(
outline_messages[0].content
if len(outline_messages) > 0
else None
),
user_prompt=(
outline_messages[1].content
if len(outline_messages) > 1
else None
),
extracted_document_text=additional_context,
source_content=presentation.content,
instructions=presentation.instructions,
)
async for chunk in generate_ppt_outline(
presentation.content,
@ -68,6 +109,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,16 +138,39 @@ 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()
await MEM0_PRESENTATION_MEMORY_SERVICE.store_generated_outlines(
presentation.id,
presentation.outlines,
)
yield SSECompleteResponse(
key="presentation", value=presentation.model_dump(mode="json")
).to_string()

View file

@ -99,7 +99,7 @@ async def process_pdf_slides(
)
else:
# Fallback if screenshot generation failed or file is empty placeholder
screenshot_url = "/static/images/placeholder.jpg"
screenshot_url = "/static/images/replaceable_template_image.png"
slides_data.append(
PdfSlideData(slide_number=i, screenshot_url=screenshot_url)

View file

@ -378,7 +378,7 @@ async def process_pptx_slides(
)
else:
# Fallback if screenshot generation failed or file is empty placeholder
screenshot_url = "/static/images/placeholder.jpg"
screenshot_url = "/static/images/replaceable_template_image.png"
# Compute normalized fonts for this slide
raw_slide_fonts = extract_fonts_from_oxml(xml_content)

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,25 @@ 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 services.mem0_presentation_memory_service import (
MEM0_PRESENTATION_MEMORY_SERVICE,
)
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 utils.llm_calls.generate_presentation_outlines import (
generate_ppt_outline,
get_messages as get_outline_messages,
)
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 +61,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 +154,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 +175,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 +204,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 +213,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 +251,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 +300,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")
@ -254,6 +326,11 @@ async def prepare_presentation(
presentation.set_structure(presentation_structure)
await sql_session.commit()
await MEM0_PRESENTATION_MEMORY_SERVICE.store_generated_outlines(
presentation.id,
presentation.outlines,
)
return presentation
@ -281,6 +358,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 +399,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 +441,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 +454,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 +466,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 +488,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 +528,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 +564,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,14 +616,14 @@ async def generate_presentation_handler(
):
try:
using_slides_markdown = False
language_to_use = (request.language or "").strip() or None
additional_context = ""
if request.slides_markdown:
using_slides_markdown = True
request.n_slides = len(request.slides_markdown)
if not using_slides_markdown:
additional_context = ""
# Updating async status
if async_status:
async_status.message = "Generating presentation outlines"
@ -519,7 +632,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 +643,55 @@ 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
)
outline_messages = get_outline_messages(
request.content,
n_slides_to_generate,
language_to_use,
additional_context,
request.tone.value,
request.verbosity.value,
request.instructions,
request.include_title_slide,
request.include_table_of_contents,
)
await MEM0_PRESENTATION_MEMORY_SERVICE.store_generation_context(
presentation_id=presentation_id,
system_prompt=(
outline_messages[0].content
if len(outline_messages) > 0
else None
),
user_prompt=(
outline_messages[1].content
if len(outline_messages) > 1
else None
),
extracted_document_text=additional_context,
source_content=request.content,
instructions=request.instructions,
)
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 +712,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
@ -583,6 +737,20 @@ async def generate_presentation_handler(
)
total_outlines = len(request.slides_markdown)
await MEM0_PRESENTATION_MEMORY_SERVICE.store_generation_context(
presentation_id=presentation_id,
system_prompt=None,
user_prompt=None,
extracted_document_text=None,
source_content=request.content,
instructions=request.instructions,
)
await MEM0_PRESENTATION_MEMORY_SERVICE.store_generated_outlines(
presentation_id,
presentation_outlines.model_dump(mode="json"),
)
# Updating async status
if async_status:
async_status.message = "Selecting layout for each slide"
@ -619,50 +787,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 +859,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 +884,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 +992,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

@ -7,12 +7,14 @@ from models.sql.presentation import PresentationModel
from models.sql.slide import SlideModel
from services.database import get_async_session
from services.image_generation_service import ImageGenerationService
from services.mem0_presentation_memory_service import (
MEM0_PRESENTATION_MEMORY_SERVICE,
)
from utils.asset_directory_utils import get_images_directory
from utils.llm_calls.edit_slide import get_edited_slide_content
from utils.llm_calls.edit_slide_html import get_edited_slide_html
from utils.llm_calls.select_slide_type_on_edit import get_slide_layout_from_prompt
from utils.process_slides import process_old_and_new_slides_and_fetch_assets
import uuid
SLIDE_ROUTER = APIRouter(prefix="/slide", tags=["Slide"])
@ -31,13 +33,28 @@ async def edit_slide(
if not presentation:
raise HTTPException(status_code=404, detail="Presentation not found")
memory_context = await MEM0_PRESENTATION_MEMORY_SERVICE.retrieve_context(
presentation.id,
prompt,
)
presentation_layout = presentation.get_layout()
slide_layout = await get_slide_layout_from_prompt(
prompt, presentation_layout, slide
prompt,
presentation_layout,
slide,
memory_context,
)
edited_slide_content = await get_edited_slide_content(
prompt, slide, presentation.language, slide_layout
prompt,
slide,
presentation.language,
slide_layout,
presentation.tone,
presentation.verbosity,
presentation.instructions,
memory_context,
)
image_generation_service = ImageGenerationService(get_images_directory())
@ -59,6 +76,13 @@ async def edit_slide(
sql_session.add_all(new_assets)
await sql_session.commit()
await MEM0_PRESENTATION_MEMORY_SERVICE.store_slide_edit(
presentation_id=presentation.id,
slide_index=slide.index,
edit_prompt=prompt,
edited_slide_content=edited_slide_content,
)
return slide
@ -73,11 +97,24 @@ async def edit_slide_html(
if not slide:
raise HTTPException(status_code=404, detail="Slide not found")
presentation = await sql_session.get(PresentationModel, slide.presentation)
if not presentation:
raise HTTPException(status_code=404, detail="Presentation not found")
html_to_edit = html or slide.html_content
if not html_to_edit:
raise HTTPException(status_code=400, detail="No HTML to edit")
edited_slide_html = await get_edited_slide_html(prompt, html_to_edit)
memory_context = await MEM0_PRESENTATION_MEMORY_SERVICE.retrieve_context(
presentation.id,
prompt,
)
edited_slide_html = await get_edited_slide_html(
prompt,
html_to_edit,
memory_context,
)
# Always assign a new unique id to the slide
# This is to ensure that the nextjs can track slide updates
@ -87,4 +124,11 @@ async def edit_slide_html(
slide.html_content = edited_slide_html
await sql_session.commit()
await MEM0_PRESENTATION_MEMORY_SERVICE.store_slide_edit(
presentation_id=presentation.id,
slide_index=slide.index,
edit_prompt=prompt,
edited_slide_content=edited_slide_html,
)
return slide

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

@ -18,6 +18,7 @@ from api.v1.ppt.endpoints.slide import SLIDE_ROUTER
from api.v1.ppt.endpoints.pptx_slides import PPTX_FONTS_ROUTER
from api.v1.ppt.endpoints.theme import THEMES_ROUTER
from api.v1.ppt.endpoints.theme_generate import THEME_ROUTER
from templates.router import TEMPLATE_ROUTER
API_V1_PPT_ROUTER = APIRouter(prefix="/api/v1/ppt")
@ -43,3 +44,4 @@ API_V1_PPT_ROUTER.include_router(CODEX_AUTH_ROUTER)
API_V1_PPT_ROUTER.include_router(PPTX_FONTS_ROUTER)
API_V1_PPT_ROUTER.include_router(THEMES_ROUTER)
API_V1_PPT_ROUTER.include_router(THEME_ROUTER)
API_V1_PPT_ROUTER.include_router(TEMPLATE_ROUTER)

File diff suppressed because it is too large Load diff

View file

@ -1,20 +1,90 @@
PDF_EXTENSIONS = [".pdf"]
TEXT_EXTENSIONS = [".txt"]
WORD_EXTENSIONS = [".doc", ".docx", ".docm", ".odt", ".rtf"]
POWERPOINT_EXTENSIONS = [".ppt", ".pptx", ".pptm", ".odp"]
SPREADSHEET_EXTENSIONS = [".xls", ".xlsx", ".xlsm", ".ods", ".csv", ".tsv"]
JPEG_EXTENSIONS = [".jpg", ".jpeg"]
PNG_EXTENSIONS = [".png"]
GIF_EXTENSIONS = [".gif"]
BMP_EXTENSIONS = [".bmp"]
TIFF_EXTENSIONS = [".tiff", ".tif"]
WEBP_EXTENSIONS = [".webp"]
SVG_EXTENSIONS = [".svg"]
IMAGE_EXTENSIONS = (
JPEG_EXTENSIONS
+ PNG_EXTENSIONS
+ GIF_EXTENSIONS
+ BMP_EXTENSIONS
+ TIFF_EXTENSIONS
+ WEBP_EXTENSIONS
+ SVG_EXTENSIONS
)
OFFICE_EXTENSIONS = WORD_EXTENSIONS + POWERPOINT_EXTENSIONS + SPREADSHEET_EXTENSIONS
PDF_MIME_TYPES = ["application/pdf"]
TEXT_MIME_TYPES = ["text/plain"]
POWERPOINT_TYPES = [
"application/vnd.openxmlformats-officedocument.presentationml.presentation"
]
WORD_TYPES = [
TEXT_MIME_TYPES = ["text/plain", "text/markdown"]
WORD_MIME_TYPES = [
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.ms-word.document.macroenabled.12",
"application/vnd.oasis.opendocument.text",
"application/rtf",
"text/rtf",
]
SPREADSHEET_TYPES = ["text/csv", "application/csv"]
POWERPOINT_MIME_TYPES = [
"application/vnd.ms-powerpoint",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"application/vnd.ms-powerpoint.presentation.macroenabled.12",
"application/vnd.oasis.opendocument.presentation",
]
PNG_MIME_TYPES = ["image/png"]
JPEG_MIME_TYPES = ["image/jpeg"]
WEBP_MIME_TYPES = ["image/webp"]
SPREADSHEET_MIME_TYPES = [
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.ms-excel.sheet.macroenabled.12",
"application/vnd.oasis.opendocument.spreadsheet",
"text/csv",
"application/csv",
"text/tab-separated-values",
"text/tsv",
]
IMAGE_MIME_TYPES = [
"image/jpeg",
"image/png",
"image/gif",
"image/bmp",
"image/tiff",
"image/webp",
"image/svg+xml",
]
UPLOAD_ACCEPTED_FILE_TYPES = (
PDF_MIME_TYPES + TEXT_MIME_TYPES + POWERPOINT_TYPES + WORD_TYPES
UPLOAD_ACCEPTED_MIME_TYPES = (
PDF_MIME_TYPES
+ TEXT_MIME_TYPES
+ WORD_MIME_TYPES
+ POWERPOINT_MIME_TYPES
+ SPREADSHEET_MIME_TYPES
+ IMAGE_MIME_TYPES
)
UPLOAD_ACCEPTED_EXTENSIONS = (
PDF_EXTENSIONS + TEXT_EXTENSIONS + OFFICE_EXTENSIONS + IMAGE_EXTENSIONS
)
# Includes both MIME types and extensions because some clients upload legacy
# office files with generic content-type values.
UPLOAD_ACCEPTED_FILE_TYPES = UPLOAD_ACCEPTED_MIME_TYPES + UPLOAD_ACCEPTED_EXTENSIONS
# Kept for endpoints that strictly require modern .pptx files.
PPTX_MIME_TYPES = ["application/vnd.openxmlformats-officedocument.presentationml.presentation"]
# Backward compatibility aliases used across existing modules.
POWERPOINT_TYPES = PPTX_MIME_TYPES
WORD_TYPES = WORD_MIME_TYPES
SPREADSHEET_TYPES = SPREADSHEET_MIME_TYPES

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"

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

@ -0,0 +1,25 @@
{
"_name_or_path": "sentence-transformers/all-MiniLM-L6-v2",
"architectures": [
"BertModel"
],
"attention_probs_dropout_prob": 0.1,
"classifier_dropout": null,
"gradient_checkpointing": false,
"hidden_act": "gelu",
"hidden_dropout_prob": 0.1,
"hidden_size": 384,
"initializer_range": 0.02,
"intermediate_size": 1536,
"layer_norm_eps": 1e-12,
"max_position_embeddings": 512,
"model_type": "bert",
"num_attention_heads": 12,
"num_hidden_layers": 6,
"pad_token_id": 0,
"position_embedding_type": "absolute",
"transformers_version": "4.36.2",
"type_vocab_size": 2,
"use_cache": true,
"vocab_size": 30522
}

View file

@ -0,0 +1,64 @@
{
"added_tokens_decoder": {
"0": {
"content": "[PAD]",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": true
},
"100": {
"content": "[UNK]",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": true
},
"101": {
"content": "[CLS]",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": true
},
"102": {
"content": "[SEP]",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": true
},
"103": {
"content": "[MASK]",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": true
}
},
"clean_up_tokenization_spaces": true,
"cls_token": "[CLS]",
"do_basic_tokenize": true,
"do_lower_case": true,
"mask_token": "[MASK]",
"max_length": 128,
"model_max_length": 512,
"never_split": null,
"pad_to_multiple_of": null,
"pad_token": "[PAD]",
"pad_token_type_id": 0,
"padding_side": "right",
"sep_token": "[SEP]",
"stride": 0,
"strip_accents": null,
"tokenize_chinese_chars": true,
"tokenizer_class": "BertTokenizer",
"truncation_side": "right",
"truncation_strategy": "longest_first",
"unk_token": "[UNK]"
}

View file

@ -0,0 +1,37 @@
{
"cls_token": {
"content": "[CLS]",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false
},
"mask_token": {
"content": "[MASK]",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false
},
"pad_token": {
"content": "[PAD]",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false
},
"sep_token": {
"content": "[SEP]",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false
},
"unk_token": {
"content": "[UNK]",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false
}
}

View file

@ -0,0 +1 @@
{"snapshots/5f1b8cd78bc4fb444dd171e59b18f3a3af89a079/special_tokens_map.json": {"size": 695, "blob_id": "9bbecc17cabbcbd3112c14d6982b51403b264bfa"}, "snapshots/5f1b8cd78bc4fb444dd171e59b18f3a3af89a079/model.onnx": {"size": 90387630, "blob_id": "672677e74ea0ea5bc37e3698bd1c7f5f1550ac8a"}, "snapshots/5f1b8cd78bc4fb444dd171e59b18f3a3af89a079/config.json": {"size": 650, "blob_id": "56c8c186de9040d4fea8daac2ca110f9d412bf04"}, "snapshots/5f1b8cd78bc4fb444dd171e59b18f3a3af89a079/tokenizer.json": {"size": 711661, "blob_id": "c17ed520ed8438736732a54957a69306b8822215"}, "snapshots/5f1b8cd78bc4fb444dd171e59b18f3a3af89a079/tokenizer_config.json": {"size": 1433, "blob_id": "61e23f16c75ff9995b1d2f251d720c6146d21338"}}

View file

@ -0,0 +1 @@
5f1b8cd78bc4fb444dd171e59b18f3a3af89a079

View file

@ -0,0 +1 @@
../../blobs/56c8c186de9040d4fea8daac2ca110f9d412bf04

View file

@ -0,0 +1 @@
../../blobs/bbd7b466f6d58e646fdc2bd5fd67b2f5e93c0b687011bd4548c420f7bd46f0c5

View file

@ -0,0 +1 @@
../../blobs/c17ed520ed8438736732a54957a69306b8822215

View file

@ -0,0 +1 @@
../../blobs/61e23f16c75ff9995b1d2f251d720c6146d21338

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,39 +1,5 @@
from typing import List, Optional
from fastapi import HTTPException
from pydantic import BaseModel, Field
"""Re-export layout models defined in `templates.presentation_layout`."""
from models.presentation_structure_model import PresentationStructureModel
from templates.presentation_layout import PresentationLayoutModel, SlideLayoutModel
class SlideLayoutModel(BaseModel):
id: str
name: Optional[str] = None
description: Optional[str] = None
json_schema: dict
class PresentationLayoutModel(BaseModel):
name: str
ordered: bool = Field(default=False)
slides: List[SlideLayoutModel]
def get_slide_layout_index(self, slide_layout_id: str) -> int:
for index, slide in enumerate(self.slides):
if slide.id == slide_layout_id:
return index
raise HTTPException(
status_code=404, detail=f"Slide layout {slide_layout_id} not found"
)
def to_presentation_structure(self):
return PresentationStructureModel(
slides=[index for index in range(len(self.slides))]
)
def to_string(self):
message = f"## Presentation Layout\n\n"
for index, slide in enumerate(self.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
__all__ = ["PresentationLayoutModel", "SlideLayoutModel"]

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

@ -18,12 +18,3 @@ 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.
"""
return self.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

@ -59,3 +59,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

@ -0,0 +1,25 @@
Metadata-Version: 2.4
Name: presenton-backend
Version: 0.1.0
Summary: Add your description here
Requires-Python: <3.12,>=3.11
Requires-Dist: alembic>=1.14.0
Requires-Dist: aiohttp>=3.12.15
Requires-Dist: aiomysql>=0.2.0
Requires-Dist: aiosqlite>=0.21.0
Requires-Dist: anthropic>=0.60.0
Requires-Dist: asyncpg>=0.30.0
Requires-Dist: chromadb>=1.0.15
Requires-Dist: dirtyjson>=1.0.8
Requires-Dist: fastapi[standard]>=0.116.1
Requires-Dist: fastembed-vectorstore>=0.5.2
Requires-Dist: fastmcp>=2.11.0
Requires-Dist: google-genai>=1.28.0
Requires-Dist: nltk>=3.9.1
Requires-Dist: openai>=1.98.0
Requires-Dist: pathvalidate>=3.3.1
Requires-Dist: pdfplumber>=0.11.7
Requires-Dist: pytest>=8.4.1
Requires-Dist: python-pptx>=1.0.2
Requires-Dist: redis>=6.2.0
Requires-Dist: sqlmodel>=0.0.24

View file

@ -0,0 +1,155 @@
pyproject.toml
api/__init__.py
api/lifespan.py
api/main.py
api/middlewares.py
api/v1/mock/router.py
api/v1/ppt/background_tasks.py
api/v1/ppt/router.py
api/v1/ppt/endpoints/__init__.py
api/v1/ppt/endpoints/anthropic.py
api/v1/ppt/endpoints/codex_auth.py
api/v1/ppt/endpoints/files.py
api/v1/ppt/endpoints/fonts.py
api/v1/ppt/endpoints/google.py
api/v1/ppt/endpoints/icons.py
api/v1/ppt/endpoints/images.py
api/v1/ppt/endpoints/layouts.py
api/v1/ppt/endpoints/ollama.py
api/v1/ppt/endpoints/openai.py
api/v1/ppt/endpoints/outlines.py
api/v1/ppt/endpoints/pdf_slides.py
api/v1/ppt/endpoints/pptx_slides.py
api/v1/ppt/endpoints/presentation.py
api/v1/ppt/endpoints/prompts.py
api/v1/ppt/endpoints/slide.py
api/v1/ppt/endpoints/slide_to_html.py
api/v1/ppt/endpoints/theme.py
api/v1/ppt/endpoints/theme_generate.py
api/v1/webhook/router.py
constants/__init__.py
constants/documents.py
constants/llm.py
constants/presentation.py
constants/supported_ollama_models.py
enums/__init__.py
enums/image_provider.py
enums/llm_call_type.py
enums/llm_provider.py
enums/tone.py
enums/verbosity.py
enums/webhook_event.py
models/__init__.py
models/api_error_model.py
models/decomposed_file_info.py
models/document_chunk.py
models/generate_presentation_request.py
models/image_prompt.py
models/json_path_guide.py
models/llm_message.py
models/llm_tool_call.py
models/llm_tools.py
models/ollama_model_metadata.py
models/ollama_model_status.py
models/pptx_models.py
models/presentation_and_path.py
models/presentation_from_template.py
models/presentation_layout.py
models/presentation_outline_model.py
models/presentation_structure_model.py
models/presentation_with_slides.py
models/slide_layout_index.py
models/sse_response.py
models/theme_data.py
models/user_config.py
models/sql/async_presentation_generation_status.py
models/sql/image_asset.py
models/sql/key_value.py
models/sql/ollama_pull_status.py
models/sql/presentation.py
models/sql/presentation_layout_code.py
models/sql/slide.py
models/sql/template.py
models/sql/template_create_info.py
models/sql/webhook_subscription.py
presenton_backend.egg-info/PKG-INFO
presenton_backend.egg-info/SOURCES.txt
presenton_backend.egg-info/dependency_links.txt
presenton_backend.egg-info/requires.txt
presenton_backend.egg-info/top_level.txt
services/__init__.py
services/codex_llm.py
services/concurrent_service.py
services/database.py
services/document_conversion_service.py
services/documents_loader.py
services/export_task_service.py
services/html_to_text_runs_service.py
services/icon_finder_service.py
services/image_generation_service.py
services/liteparse_service.py
services/llm_client.py
services/llm_tool_calls_handler.py
services/pptx_presentation_creator.py
services/score_based_chunker.py
services/temp_file_service.py
services/webhook_service.py
templates/__init__.py
templates/example.py
templates/font_utils.py
templates/get_layout_by_name.py
templates/handler.py
templates/presentation_layout.py
templates/preview.py
templates/prompts.py
templates/providers.py
templates/router.py
tests/test_gemini_schema_support.py
tests/test_image_generation.py
tests/test_mcp_server.py
tests/test_openai_schema_support.py
tests/test_pptx_creator.py
tests/test_pptx_slides_processing.py
tests/test_presentation_generation_api.py
tests/test_slide_to_html.py
utils/__init__.py
utils/asset_directory_utils.py
utils/async_iterator.py
utils/available_models.py
utils/datetime_utils.py
utils/db_utils.py
utils/dict_utils.py
utils/download_helpers.py
utils/dummy_functions.py
utils/error_handling.py
utils/export_utils.py
utils/file_utils.py
utils/get_dynamic_models.py
utils/get_env.py
utils/get_layout_by_name.py
utils/image_provider.py
utils/image_utils.py
utils/llm_client_error_handler.py
utils/llm_provider.py
utils/model_availability.py
utils/ocr_language.py
utils/ollama.py
utils/outline_utils.py
utils/parsers.py
utils/path_helpers.py
utils/ppt_utils.py
utils/process_slides.py
utils/schema_utils.py
utils/set_env.py
utils/theme_utils.py
utils/user_config.py
utils/validators.py
utils/llm_calls/edit_slide.py
utils/llm_calls/edit_slide_html.py
utils/llm_calls/generate_presentation_outlines.py
utils/llm_calls/generate_presentation_structure.py
utils/llm_calls/generate_slide_content.py
utils/llm_calls/select_slide_type_on_edit.py
utils/oauth/__init__.py
utils/oauth/openai_codex.py
utils/oauth/pkce.py

View file

@ -0,0 +1,20 @@
alembic>=1.14.0
aiohttp>=3.12.15
aiomysql>=0.2.0
aiosqlite>=0.21.0
anthropic>=0.60.0
asyncpg>=0.30.0
chromadb>=1.0.15
dirtyjson>=1.0.8
fastapi[standard]>=0.116.1
fastembed-vectorstore>=0.5.2
fastmcp>=2.11.0
google-genai>=1.28.0
nltk>=3.9.1
openai>=1.98.0
pathvalidate>=3.3.1
pdfplumber>=0.11.7
pytest>=8.4.1
python-pptx>=1.0.2
redis>=6.2.0
sqlmodel>=0.0.24

View file

@ -0,0 +1,7 @@
api
constants
enums
models
services
templates
utils

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"
@ -12,10 +16,11 @@ dependencies = [
"asyncpg>=0.30.0",
"chromadb>=1.0.15",
"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",
"mem0ai[nlp]>=0.1.115",
"nltk>=3.9.1",
"openai>=1.98.0",
"pathvalidate>=3.3.1",
@ -29,9 +34,6 @@ dependencies = [
[tool.uv]
index-strategy = "unsafe-best-match"
[[tool.uv.index]]
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,10 +13,14 @@ if __name__ == "__main__":
)
args = parser.parse_args()
reload = args.reload == "true"
host = "127.0.0.1"
# PPTX-to-HTML export and other in-process callers resolve `/app_data` assets here.
os.environ.setdefault("FASTAPI_PUBLIC_URL", f"http://{host}:{args.port}")
uvicorn.run(
"api.main:app",
host="127.0.0.1",
host=host,
port=args.port,
log_level="info",
reload=reload,

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,11 +14,14 @@ 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.get_env import get_app_data_directory_env
from utils.get_env import get_migrate_database_on_startup_env
from utils.db_utils import get_database_url_and_connect_args, get_pool_kwargs
@ -40,8 +42,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}
)
@ -57,28 +60,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,33 +0,0 @@
from docling.document_converter import (
DocumentConverter,
PdfFormatOption,
PowerpointFormatOption,
WordFormatOption,
)
from docling.datamodel.pipeline_options import PdfPipelineOptions
from docling.datamodel.base_models import InputFormat
class DoclingService:
def __init__(self):
self.pipeline_options = PdfPipelineOptions()
self.pipeline_options.do_ocr = False
self.converter = DocumentConverter(
allowed_formats=[InputFormat.PPTX, InputFormat.PDF, InputFormat.DOCX],
format_options={
InputFormat.DOCX: WordFormatOption(
pipeline_options=self.pipeline_options,
),
InputFormat.PPTX: PowerpointFormatOption(
pipeline_options=self.pipeline_options,
),
InputFormat.PDF: PdfFormatOption(
pipeline_options=self.pipeline_options,
),
},
)
def parse_to_markdown(self, file_path: str) -> str:
result = self.converter.convert(file_path)
return result.document.export_to_markdown()

View file

@ -0,0 +1,235 @@
import os
import subprocess
import logging
from pathlib import Path
from typing import Dict, List
class DocumentConversionError(Exception):
pass
LOGGER = logging.getLogger(__name__)
_LOG_SNIPPET_LIMIT = 600
def _snippet(value: str, limit: int = _LOG_SNIPPET_LIMIT) -> str:
text = (value or "").strip()
if not text:
return "<empty>"
if len(text) <= limit:
return text
return f"{text[:limit]}... [truncated {len(text) - limit} chars]"
def _command_str(parts: list[str]) -> str:
return " ".join(repr(part) for part in parts)
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,
}
class DocumentConversionService:
def __init__(self):
self.soffice_binary = self._resolve_soffice_binary()
self.imagemagick_binary = self._resolve_imagemagick_binary()
@staticmethod
def _resolve_soffice_binary() -> str:
configured = (os.getenv("SOFFICE_PATH") or "").strip()
if configured:
return configured
return "soffice.exe" if os.name == "nt" else "soffice"
@staticmethod
def _can_execute(command: str, args: List[str]) -> bool:
try:
result = subprocess.run(
[command, *args],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=10,
check=False,
**_windows_hidden_subprocess_kwargs(),
)
return result.returncode == 0
except Exception:
return False
def _resolve_imagemagick_binary(self) -> str:
configured = (os.getenv("IMAGEMAGICK_BINARY") or "").strip()
if configured:
return configured
for candidate in ["magick", "convert"]:
if self._can_execute(candidate, ["-version"]):
return candidate
return "magick" if os.name == "nt" else "convert"
def convert_office_to_pdf(
self,
file_path: str,
output_dir: str,
timeout_seconds: int = 180,
) -> str:
Path(output_dir).mkdir(parents=True, exist_ok=True)
existing_pdfs = {
p.name for p in Path(output_dir).glob("*.pdf") if p.is_file()
}
try:
command = [
self.soffice_binary,
"--headless",
"--convert-to",
"pdf",
"--outdir",
output_dir,
file_path,
]
LOGGER.info(
"[DocumentConversion] LibreOffice conversion start input=%s output_dir=%s",
file_path,
output_dir,
)
subprocess.run(
command,
check=True,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=timeout_seconds,
**_windows_hidden_subprocess_kwargs(),
)
LOGGER.info(
"[DocumentConversion] LibreOffice conversion complete input=%s",
file_path,
)
except subprocess.TimeoutExpired as exc:
LOGGER.error(
"[DocumentConversion] LibreOffice timed out command=%s",
_command_str(exc.cmd if isinstance(exc.cmd, list) else [str(exc.cmd)]),
)
raise DocumentConversionError(
f"LibreOffice conversion timed out for {os.path.basename(file_path)}"
) from exc
except subprocess.CalledProcessError as exc:
stderr = (exc.stderr or "").strip()
stdout = (exc.stdout or "").strip()
details = stderr or stdout or str(exc)
LOGGER.error(
"[DocumentConversion] LibreOffice failed code=%s command=%s stderr=%s stdout=%s",
exc.returncode,
_command_str(exc.cmd if isinstance(exc.cmd, list) else [str(exc.cmd)]),
_snippet(stderr),
_snippet(stdout),
)
raise DocumentConversionError(
f"LibreOffice conversion failed for {os.path.basename(file_path)}: {details} "
f"(stderr={_snippet(stderr)}; stdout={_snippet(stdout)})"
) from exc
except Exception as exc:
LOGGER.exception("[DocumentConversion] LibreOffice conversion unexpected error")
raise DocumentConversionError(
f"LibreOffice conversion failed for {os.path.basename(file_path)}: {exc}"
) from exc
expected_pdf = Path(output_dir) / f"{Path(file_path).stem}.pdf"
if expected_pdf.is_file():
return str(expected_pdf)
generated_pdfs = [
p
for p in Path(output_dir).glob("*.pdf")
if p.is_file() and p.name not in existing_pdfs
]
if generated_pdfs:
newest = max(generated_pdfs, key=lambda p: p.stat().st_mtime)
return str(newest)
raise DocumentConversionError(
f"LibreOffice did not create a PDF for {os.path.basename(file_path)}"
)
def convert_image_to_png(
self,
file_path: str,
output_dir: str,
timeout_seconds: int = 120,
) -> str:
Path(output_dir).mkdir(parents=True, exist_ok=True)
output_path = Path(output_dir) / f"{Path(file_path).stem}_converted.png"
command = [self.imagemagick_binary, file_path, str(output_path)]
try:
LOGGER.info(
"[DocumentConversion] ImageMagick conversion start input=%s output=%s command=%s",
file_path,
output_path,
_command_str(command),
)
subprocess.run(
command,
check=True,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=timeout_seconds,
**_windows_hidden_subprocess_kwargs(),
)
LOGGER.info(
"[DocumentConversion] ImageMagick conversion complete output=%s",
output_path,
)
except subprocess.TimeoutExpired as exc:
LOGGER.error(
"[DocumentConversion] ImageMagick timed out command=%s",
_command_str(exc.cmd if isinstance(exc.cmd, list) else [str(exc.cmd)]),
)
raise DocumentConversionError(
f"ImageMagick conversion timed out for {os.path.basename(file_path)}"
) from exc
except subprocess.CalledProcessError as exc:
stderr = (exc.stderr or "").strip()
stdout = (exc.stdout or "").strip()
details = stderr or stdout or str(exc)
LOGGER.error(
"[DocumentConversion] ImageMagick failed code=%s command=%s stderr=%s stdout=%s",
exc.returncode,
_command_str(exc.cmd if isinstance(exc.cmd, list) else [str(exc.cmd)]),
_snippet(stderr),
_snippet(stdout),
)
raise DocumentConversionError(
f"ImageMagick conversion failed for {os.path.basename(file_path)}: {details} "
f"(stderr={_snippet(stderr)}; stdout={_snippet(stdout)})"
) from exc
except Exception as exc:
LOGGER.exception("[DocumentConversion] ImageMagick conversion unexpected error")
raise DocumentConversionError(
f"ImageMagick conversion failed for {os.path.basename(file_path)}: {exc}"
) from exc
if not output_path.is_file():
raise DocumentConversionError(
f"ImageMagick did not create a PNG for {os.path.basename(file_path)}"
)
return str(output_path)

View file

@ -1,24 +1,52 @@
import mimetypes
from fastapi import HTTPException
import os, asyncio
from typing import List, Optional, Tuple
import asyncio
import logging
import os
import tempfile
from pathlib import Path
from typing import Any, List, Optional, Tuple
import pdfplumber
from fastapi import HTTPException
from constants.documents import (
PDF_MIME_TYPES,
POWERPOINT_TYPES,
TEXT_MIME_TYPES,
WORD_TYPES,
IMAGE_EXTENSIONS,
OFFICE_EXTENSIONS,
PDF_EXTENSIONS,
TEXT_EXTENSIONS,
)
from services.docling_service import DoclingService
from services.document_conversion_service import (
DocumentConversionError,
DocumentConversionService,
)
from services.liteparse_service import LiteParseError, LiteParseService
from utils.ocr_language import presentation_language_to_ocr_code
# Optional fallback converter (primarily useful on Windows)
try:
from services.lightweight_document_service import DocumentService as DocumentServiceCls
except Exception:
DocumentServiceCls = None
LOGGER = logging.getLogger(__name__)
class DocumentsLoader:
DECOMPOSE_TIMEOUT_SECONDS = 600
def __init__(self, file_paths: List[str]):
def __init__(
self,
file_paths: List[str],
presentation_language: Optional[str] = None,
):
self._file_paths = file_paths
self.docling_service = DoclingService()
self._ocr_language = presentation_language_to_ocr_code(presentation_language)
self.liteparse_service = LiteParseService(
timeout_seconds=self.DECOMPOSE_TIMEOUT_SECONDS
)
self.document_conversion_service = DocumentConversionService()
self.document_service: Any = (
DocumentServiceCls() if DocumentServiceCls is not None else None
)
self._documents: List[str] = []
self._images: List[List[str]] = []
@ -40,7 +68,7 @@ class DocumentsLoader:
"""If load_images is True, temp_dir must be provided"""
documents: List[str] = []
images: List[str] = []
images: List[List[str]] = []
for file_path in self._file_paths:
if not os.path.exists(file_path):
@ -49,19 +77,35 @@ class DocumentsLoader:
)
document = ""
imgs = []
imgs: List[str] = []
mime_type = mimetypes.guess_type(file_path)[0]
if mime_type in PDF_MIME_TYPES:
extension = Path(file_path).suffix.lower()
LOGGER.info(
"[DocumentsLoader] Processing file=%s extension=%s",
file_path,
extension,
)
if extension in PDF_EXTENSIONS:
document, imgs = await self.load_pdf(
file_path, load_text, load_images, temp_dir
)
elif mime_type in TEXT_MIME_TYPES:
elif extension in TEXT_EXTENSIONS:
document = await self.load_text(file_path)
elif mime_type in POWERPOINT_TYPES:
document = self.load_powerpoint(file_path)
elif mime_type in WORD_TYPES:
document = self.load_msword(file_path)
elif extension in OFFICE_EXTENSIONS:
document = await asyncio.to_thread(
self.load_office_document,
file_path,
temp_dir,
)
elif extension in IMAGE_EXTENSIONS:
document = await asyncio.to_thread(
self.load_image,
file_path,
temp_dir,
)
else:
document = await asyncio.to_thread(self._parse_with_liteparse, file_path)
documents.append(document)
images.append(imgs)
@ -76,26 +120,88 @@ class DocumentsLoader:
load_images: bool,
temp_dir: Optional[str] = None,
) -> Tuple[str, List[str]]:
image_paths = []
image_paths: List[str] = []
document: str = ""
if load_text:
document = self.docling_service.parse_to_markdown(file_path)
document = await asyncio.to_thread(self._parse_with_liteparse, file_path)
if load_images:
if temp_dir is None:
raise HTTPException(
status_code=400,
detail="temp_dir is required when load_images is true",
)
image_paths = await self.get_page_images_from_pdf_async(file_path, temp_dir)
return document, image_paths
async def load_text(self, file_path: str) -> str:
with open(file_path, "r") as file:
with open(file_path, "r", encoding="utf-8") as file:
return await asyncio.to_thread(file.read)
def load_msword(self, file_path: str) -> str:
return self.docling_service.parse_to_markdown(file_path)
def load_office_document(self, file_path: str, temp_dir: Optional[str] = None) -> str:
if temp_dir:
converted_path = self.document_conversion_service.convert_office_to_pdf(
file_path,
temp_dir,
timeout_seconds=self.DECOMPOSE_TIMEOUT_SECONDS,
)
return self._parse_with_liteparse(converted_path)
def load_powerpoint(self, file_path: str) -> str:
return self.docling_service.parse_to_markdown(file_path)
with tempfile.TemporaryDirectory(prefix="office-convert-") as conversion_dir:
converted_path = self.document_conversion_service.convert_office_to_pdf(
file_path,
conversion_dir,
timeout_seconds=self.DECOMPOSE_TIMEOUT_SECONDS,
)
return self._parse_with_liteparse(converted_path)
def load_image(self, file_path: str, temp_dir: Optional[str] = None) -> str:
if temp_dir:
converted_path = self.document_conversion_service.convert_image_to_png(
file_path,
temp_dir,
timeout_seconds=self.DECOMPOSE_TIMEOUT_SECONDS,
)
return self._parse_with_liteparse(converted_path)
with tempfile.TemporaryDirectory(prefix="image-convert-") as conversion_dir:
converted_path = self.document_conversion_service.convert_image_to_png(
file_path,
conversion_dir,
timeout_seconds=self.DECOMPOSE_TIMEOUT_SECONDS,
)
return self._parse_with_liteparse(converted_path)
def _parse_with_liteparse(self, file_path: str) -> str:
try:
LOGGER.info("[DocumentsLoader] LiteParse start file=%s", file_path)
return self.liteparse_service.parse_to_markdown(
file_path,
ocr_enabled=True,
ocr_language=self._ocr_language,
)
except (LiteParseError, DocumentConversionError) as exc:
LOGGER.warning(
"[DocumentsLoader] Primary parse failed file=%s error=%s",
file_path,
exc,
)
if self.document_service is not None:
try:
LOGGER.info("[DocumentsLoader] Trying fallback parser file=%s", file_path)
return self.document_service.parse_to_markdown(file_path)
except Exception:
LOGGER.exception(
"[DocumentsLoader] Fallback parser failed file=%s",
file_path,
)
pass
raise HTTPException(
status_code=500,
detail=f"Failed to parse document {os.path.basename(file_path)}: {exc}",
) from exc
@classmethod
def get_page_images_from_pdf(cls, file_path: str, temp_dir: str) -> List[str]:

View file

@ -0,0 +1,247 @@
import asyncio
import json
import os
import shutil
import subprocess
import tempfile
from typing import Mapping
from fastapi import HTTPException
from pydantic import BaseModel
from services.liteparse_service import _snippet, _subprocess_text_kwargs
from utils.asset_directory_utils import resolve_app_path_to_filesystem
from utils.get_env import get_app_data_directory_env, get_temp_directory_env
class PptxToHtmlDocument(BaseModel):
slides: list[str]
font_css: str = ""
width: float
height: float
images_dir: str
fonts_dir: str
class ExportTaskService:
def __init__(self, timeout_seconds: int = 300):
self.timeout_seconds = timeout_seconds
self.node_binary = os.getenv("LITEPARSE_NODE_BINARY", "node")
self.export_dir = self._resolve_export_dir()
self.entrypoint_path = self._resolve_entrypoint_path(self.export_dir)
self.converter_path = self._resolve_converter_path(self.export_dir)
@staticmethod
def _resolve_export_dir() -> str:
configured = (os.getenv("EXPORT_RUNTIME_DIR") or "").strip()
if configured:
return configured
package_root = (os.getenv("EXPORT_PACKAGE_ROOT") or "").strip()
if package_root:
return package_root
cwd = os.path.abspath(".")
service_dir = os.path.dirname(__file__)
candidates = [
os.path.abspath(os.path.join(cwd, "..", "..", "presentation-export")),
os.path.abspath(os.path.join(cwd, "..", "presentation-export")),
os.path.abspath(os.path.join(service_dir, "..", "..", "..", "presentation-export")),
os.path.abspath(os.path.join(service_dir, "..", "..", "..", "..", "presentation-export")),
]
for candidate in candidates:
if os.path.isfile(os.path.join(candidate, "index.cjs")) or os.path.isfile(
os.path.join(candidate, "index.js")
):
return candidate
return candidates[0]
@staticmethod
def _resolve_entrypoint_path(export_dir: str) -> str:
index_cjs = os.path.join(export_dir, "index.cjs")
if os.path.isfile(index_cjs):
return index_cjs
index_js = os.path.join(export_dir, "index.js")
if os.path.isfile(index_js):
shutil.copyfile(index_js, index_cjs)
return index_cjs
return index_cjs
@staticmethod
def _resolve_converter_path(export_dir: str) -> str:
py_dir = os.path.join(export_dir, "py")
extension = ".exe" if os.name == "nt" else ""
platform_name = sys_platform()
arch_name = sys_arch()
candidates = [
os.path.join(py_dir, f"convert-{platform_name}-{arch_name}{extension}"),
os.path.join(py_dir, f"convert-{platform_name}{extension}"),
os.path.join(py_dir, f"convert{extension}"),
os.path.join(py_dir, "convert"),
]
for candidate in candidates:
if candidate and os.path.isfile(candidate):
return candidate
return candidates[1]
def _build_node_env(self) -> Mapping[str, str]:
env = os.environ.copy()
app_data_directory = get_app_data_directory_env()
if not app_data_directory:
raise HTTPException(
status_code=500,
detail="APP_DATA_DIRECTORY must be set for PPTX-to-HTML export",
)
env["APP_DATA_DIRECTORY"] = app_data_directory
temp_directory = get_temp_directory_env() or os.path.join(
tempfile.gettempdir(), "presenton"
)
os.makedirs(temp_directory, exist_ok=True)
env["TEMP_DIRECTORY"] = temp_directory
fastapi_public_url = (os.getenv("FASTAPI_PUBLIC_URL") or "").strip()
if not fastapi_public_url:
raise HTTPException(
status_code=500,
detail="FASTAPI_PUBLIC_URL must be set for PPTX-to-HTML export",
)
env["ASSETS_BASE_URL"] = f"{fastapi_public_url.rstrip('/')}/app_data"
env["BUILT_PYTHON_MODULE_PATH"] = self.converter_path
return env
def _ensure_runtime_ready(self) -> None:
if not os.path.isfile(self.entrypoint_path):
raise HTTPException(
status_code=500,
detail=f"Export runtime not found at {self.entrypoint_path}",
)
if not os.path.isfile(self.converter_path):
raise HTTPException(
status_code=500,
detail=f"Export converter binary not found at {self.converter_path}",
)
@staticmethod
def _resolve_output_path(response_data: dict) -> str:
path_value = response_data.get("path")
if isinstance(path_value, str):
resolved = resolve_app_path_to_filesystem(path_value) or path_value
if os.path.isfile(resolved):
return resolved
url_value = response_data.get("url")
if isinstance(url_value, str):
resolved = resolve_app_path_to_filesystem(url_value)
if resolved and os.path.isfile(resolved):
return resolved
raise HTTPException(
status_code=500,
detail="PPTX-to-HTML task completed without a valid output path",
)
async def convert_pptx_to_html(
self, pptx_path: str, get_fonts: bool = False
) -> PptxToHtmlDocument:
self._ensure_runtime_ready()
if not os.path.isfile(pptx_path):
raise HTTPException(status_code=400, detail=f"PPTX not found: {pptx_path}")
temp_root = get_temp_directory_env() or os.path.join(tempfile.gettempdir(), "presenton")
os.makedirs(temp_root, exist_ok=True)
temp_dir = tempfile.mkdtemp(prefix="export-task-", dir=temp_root)
task_path = os.path.join(temp_dir, "export_task.json")
response_path = os.path.join(temp_dir, "export_task.response.json")
try:
with open(task_path, "w", encoding="utf-8") as task_file:
json.dump(
{
"type": "pptx-to-html",
"pptx_path": pptx_path,
"get_fonts": get_fonts,
},
task_file,
)
result = await asyncio.to_thread(
subprocess.run,
[self.node_binary, self.entrypoint_path, task_path],
cwd=self.export_dir,
capture_output=True,
timeout=self.timeout_seconds,
env=dict(self._build_node_env()),
**_subprocess_text_kwargs(),
)
if result.returncode != 0:
raise HTTPException(
status_code=500,
detail=(
"PPTX-to-HTML export task failed. "
f"stderr={_snippet(result.stderr)} stdout={_snippet(result.stdout)}"
),
)
if not os.path.isfile(response_path):
raise HTTPException(
status_code=500,
detail="PPTX-to-HTML export task did not produce a response file",
)
with open(response_path, "r", encoding="utf-8") as response_file:
response_data = json.load(response_file)
output_path = self._resolve_output_path(response_data)
with open(output_path, "r", encoding="utf-8") as output_file:
output_data = json.load(output_file)
return PptxToHtmlDocument(**output_data)
except subprocess.TimeoutExpired as exc:
raise HTTPException(
status_code=500,
detail=f"PPTX-to-HTML export timed out after {self.timeout_seconds} seconds",
) from exc
except json.JSONDecodeError as exc:
raise HTTPException(
status_code=500,
detail="PPTX-to-HTML export produced invalid JSON output",
) from exc
except OSError as exc:
raise HTTPException(
status_code=500,
detail=f"Failed to run PPTX-to-HTML export task: {exc}",
) from exc
finally:
shutil.rmtree(temp_dir, ignore_errors=True)
def sys_platform() -> str:
if os.name == "nt":
return "win32"
return os.sys.platform
def sys_arch() -> str:
machine = (os.environ.get("PROCESSOR_ARCHITECTURE") or "").lower()
if not machine and hasattr(os, "uname"):
machine = os.uname().machine.lower()
arch_map = {
"x86_64": "x64",
"amd64": "x64",
"x64": "x64",
"aarch64": "arm64",
"arm64": "arm64",
}
return arch_map.get(machine, machine or "x64")
EXPORT_TASK_SERVICE = ExportTaskService()

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,6 +5,7 @@ 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
@ -73,11 +74,11 @@ class ImageGenerationService:
"""
if self.is_image_generation_disabled:
print("Image generation is disabled. Using placeholder image.")
return "/static/images/placeholder.jpg"
return "/static/images/replaceable_template_image.png"
if not self.image_gen_func:
print("No image generation function found. Using placeholder image.")
return "/static/images/placeholder.jpg"
return "/static/images/replaceable_template_image.png"
image_prompt = prompt.get_image_prompt(
with_theme=not self.is_stock_provider_selected()
@ -103,11 +104,15 @@ class ImageGenerationService:
"theme_prompt": prompt.theme_prompt,
},
)
elif image_path.startswith("/app_data/") or image_path.startswith(
"/static/"
):
return 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 "/static/images/replaceable_template_image.png"
async def generate_image_openai(
self, prompt: str, output_directory: str, model: str, quality: str
@ -236,15 +241,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(
@ -256,9 +291,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(
@ -269,24 +304,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:
"""
@ -429,6 +532,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

@ -0,0 +1,349 @@
import json
import logging
import os
import subprocess
from typing import Any, Dict, Mapping, Tuple
class LiteParseError(Exception):
pass
LOGGER = logging.getLogger(__name__)
_LOG_SNIPPET_LIMIT = 600
_DEFAULT_DPI = 120
_DEFAULT_NUM_WORKERS = 1
def _snippet(value: str, limit: int = _LOG_SNIPPET_LIMIT) -> str:
text = (value or "").strip()
if not text:
return "<empty>"
if len(text) <= limit:
return text
return f"{text[:limit]}... [truncated {len(text) - limit} chars]"
def _command_str(parts: list[str]) -> str:
return " ".join(json.dumps(part) for part in parts)
def _subprocess_text_kwargs() -> Mapping[str, object]:
"""Decode subprocess output consistently across platforms.
Windows defaults to a locale-dependent code page (often cp1252), which can
crash while decoding UTF-8 output from Node tools. Use UTF-8 and replace
undecodable bytes to keep parsing resilient.
"""
return {"text": True, "encoding": "utf-8", "errors": "replace"}
def _env_int(name: str, default: int, minimum: int, maximum: int) -> int:
raw = (os.getenv(name) or "").strip()
if not raw:
return default
try:
parsed = int(raw)
except Exception:
LOGGER.warning(
"[LiteParse] Invalid %s=%r, using default=%s",
name,
raw,
default,
)
return default
return min(max(parsed, minimum), maximum)
class LiteParseService:
def __init__(self, timeout_seconds: int = 180):
self.timeout_seconds = timeout_seconds
self.node_binary = os.getenv("LITEPARSE_NODE_BINARY", "node")
self.dpi = _env_int("LITEPARSE_DPI", _DEFAULT_DPI, minimum=72, maximum=600)
self.num_workers = _env_int(
"LITEPARSE_NUM_WORKERS",
_DEFAULT_NUM_WORKERS,
minimum=1,
maximum=64,
)
self.runner_path = os.getenv("LITEPARSE_RUNNER_PATH", self._resolve_runner_path())
self.runner_dir = os.path.dirname(self.runner_path)
self._npm_project_root = self._resolve_npm_project_root()
def _build_node_env(self) -> Dict[str, str]:
"""Build environment for Node subprocesses."""
env = os.environ.copy()
# LiteParse checks ImageMagick availability with `which magick`.
# On macOS app launches, PATH often excludes Homebrew bins, even when
# IMAGEMAGICK_BINARY is configured to an absolute executable path.
path_entries = [p for p in (env.get("PATH") or "").split(os.pathsep) if p]
additional_entries = []
imagemagick_binary = (env.get("IMAGEMAGICK_BINARY") or "").strip()
if imagemagick_binary:
magick_dir = os.path.dirname(imagemagick_binary)
if magick_dir:
additional_entries.append(magick_dir)
soffice_binary = (env.get("SOFFICE_PATH") or "").strip()
if soffice_binary:
soffice_dir = os.path.dirname(soffice_binary)
if soffice_dir:
additional_entries.append(soffice_dir)
if os.name != "nt":
additional_entries.extend([
"/opt/homebrew/bin",
"/usr/local/bin",
"/opt/local/bin",
"/usr/bin",
"/bin",
])
deduped_additional_entries = []
for entry in additional_entries:
normalized = entry.strip()
if not normalized or not os.path.isdir(normalized):
continue
if normalized in path_entries or normalized in deduped_additional_entries:
continue
deduped_additional_entries.append(normalized)
if deduped_additional_entries:
env["PATH"] = os.pathsep.join(deduped_additional_entries + path_entries)
return env
def _resolve_npm_project_root(self) -> str:
"""Directory whose node_modules contains @llamaindex/liteparse."""
candidates = [
self.runner_dir,
os.path.abspath(os.path.join(self.runner_dir, "..")),
os.path.abspath(os.path.join(os.getcwd(), "..", "..", "document-extraction-liteparse")),
os.path.abspath(os.path.join(os.getcwd(), "..", "..")),
"/app/document-extraction-liteparse",
"/app",
]
fallback = candidates[0]
for candidate in candidates:
if os.path.isdir(candidate):
fallback = candidate
local_nm = os.path.join(candidate, "node_modules", "@llamaindex", "liteparse")
if os.path.isdir(local_nm):
return candidate
return fallback
@staticmethod
def _resolve_runner_path() -> str:
cwd = os.path.abspath(".")
service_dir = os.path.dirname(__file__)
candidates = [
# Dedicated Docker runtime path
"/app/document-extraction-liteparse/liteparse_runner.mjs",
# servers/fastapi (repo root layout) → resources/...
os.path.abspath(
os.path.join(
cwd,
"..",
"..",
"resources",
"document-extraction",
"liteparse_runner.mjs",
)
),
# services/liteparse_service.py → resources/...
os.path.abspath(
os.path.join(
service_dir,
"..",
"..",
"..",
"resources",
"document-extraction",
"liteparse_runner.mjs",
)
),
# PyInstaller bundle layout
os.path.abspath(
os.path.join(
cwd, "..", "..", "app", "resources", "document-extraction", "liteparse_runner.mjs"
)
),
]
for path in candidates:
if os.path.isfile(path):
return path
return candidates[0]
def check_runtime_ready(self) -> Tuple[bool, str]:
if not os.path.isfile(self.runner_path):
return False, f"LiteParse runner not found at: {self.runner_path}"
try:
subprocess.run(
[self.node_binary, "--version"],
cwd=self.runner_dir,
check=True,
capture_output=True,
timeout=10,
env=self._build_node_env(),
**_subprocess_text_kwargs(),
)
except Exception as exc:
return False, f"Node.js runtime is unavailable: {exc}"
liteparse_dir = os.path.join(
self._npm_project_root, "node_modules", "@llamaindex", "liteparse"
)
if not os.path.isdir(liteparse_dir):
return (
False,
f"LiteParse npm package missing at {liteparse_dir}. Install @llamaindex/liteparse in the runtime project root.",
)
# @llamaindex/liteparse is ESM-only; require.resolve() fails. Use dynamic import.
try:
subprocess.run(
[
self.node_binary,
"--input-type=module",
"-e",
"import '@llamaindex/liteparse'",
],
cwd=self._npm_project_root,
check=True,
capture_output=True,
timeout=20,
env=self._build_node_env(),
**_subprocess_text_kwargs(),
)
except Exception as exc:
return False, f"LiteParse dependency is unavailable: {exc}"
return True, "ok"
def parse_to_markdown(
self,
file_path: str,
ocr_enabled: bool = True,
ocr_language: str = "eng",
) -> str:
result = self.parse(
file_path=file_path,
ocr_enabled=ocr_enabled,
ocr_language=ocr_language,
)
return str(result.get("text") or "")
def parse(
self,
file_path: str,
ocr_enabled: bool = True,
ocr_language: str = "eng",
) -> Dict[str, Any]:
is_ready, reason = self.check_runtime_ready()
if not is_ready:
raise LiteParseError(reason)
command = [
self.node_binary,
self.runner_path,
"--file",
file_path,
"--ocr-enabled",
"true" if ocr_enabled else "false",
"--ocr-language",
ocr_language,
"--dpi",
str(self.dpi),
"--num-workers",
str(self.num_workers),
]
ocr_server = (os.getenv("LITEPARSE_OCR_SERVER_URL") or "").strip()
if ocr_server:
command.extend(["--ocr-server-url", ocr_server])
tessdata = (os.getenv("LITEPARSE_TESSDATA_PATH") or "").strip()
if tessdata:
command.extend(["--tessdata-path", tessdata])
LOGGER.info(
"[LiteParse] Parsing file=%s ocr_enabled=%s ocr_language=%s dpi=%s num_workers=%s",
file_path,
ocr_enabled,
ocr_language,
self.dpi,
self.num_workers,
)
process = subprocess.run(
command,
cwd=self._npm_project_root,
capture_output=True,
timeout=self.timeout_seconds,
env=self._build_node_env(),
**_subprocess_text_kwargs(),
)
LOGGER.info(
"[LiteParse] Command finished returncode=%s command=%s",
process.returncode,
_command_str(command),
)
payload: Dict[str, Any]
try:
payload = self._decode_runner_output(process.stdout)
except LiteParseError as exc:
raise LiteParseError(
f"{exc}; returncode={process.returncode}; "
f"stderr={_snippet(process.stderr)}; stdout={_snippet(process.stdout)}"
) from exc
if process.returncode != 0:
message = payload.get("error") or process.stderr.strip() or "Unknown error"
LOGGER.error(
"[LiteParse] Parse failed returncode=%s stderr=%s stdout=%s",
process.returncode,
_snippet(process.stderr),
_snippet(process.stdout),
)
raise LiteParseError(message)
if not payload.get("ok"):
LOGGER.error(
"[LiteParse] Runner returned not-ok payload=%s",
_snippet(json.dumps(payload)),
)
raise LiteParseError(payload.get("error") or "LiteParse parse failed")
return payload
@staticmethod
def _decode_runner_output(stdout: str) -> Dict[str, Any]:
raw = (stdout or "").lstrip("\ufeff").strip()
if not raw:
raise LiteParseError("LiteParse runner returned empty output")
# Prefer the last line that parses as JSON (handles stray log lines before our payload).
lines = [line.strip() for line in raw.splitlines() if line.strip()]
for line in reversed(lines):
try:
parsed = json.loads(line)
if isinstance(parsed, dict):
return parsed
except json.JSONDecodeError:
continue
# Single blob without newlines (entire stdout is one JSON object).
try:
parsed = json.loads(raw)
if isinstance(parsed, dict):
return parsed
except json.JSONDecodeError:
pass
raise LiteParseError("LiteParse runner returned invalid JSON output")

View file

@ -1824,7 +1824,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,291 @@
import asyncio
import json
import logging
import os
from importlib import import_module
from typing import Any, Optional
from uuid import UUID
LOGGER = logging.getLogger(__name__)
class Mem0PresentationMemoryService:
def __init__(self):
self._enabled = self._to_bool(os.getenv("MEM0_ENABLED"), default=True)
self._top_k = self._to_int(os.getenv("MEM0_TOP_K"), default=8)
self._max_context_chars = self._to_int(
os.getenv("MEM0_MAX_CONTEXT_CHARS"), default=6000
)
self._namespace_prefix = (
os.getenv("MEM0_PRESENTATION_NAMESPACE_PREFIX") or "presentation"
).strip() or "presentation"
self._embedder_provider = (
os.getenv("MEM0_EMBEDDER_PROVIDER") or "fastembed"
).strip() or "fastembed"
self._embedder_model = (
os.getenv("MEM0_EMBEDDER_MODEL") or "BAAI/bge-small-en-v1.5"
).strip() or "BAAI/bge-small-en-v1.5"
self._embedding_dims = self._to_int(
os.getenv("MEM0_EMBEDDING_DIMS"),
default=384,
)
app_data_dir = (os.getenv("APP_DATA_DIRECTORY") or "/tmp/presenton").strip()
self._mem0_dir = (os.getenv("MEM0_DIR") or os.path.join(app_data_dir, "mem0")).strip()
self._qdrant_path = (os.getenv("MEM0_QDRANT_PATH") or os.path.join(self._mem0_dir, "qdrant")).strip()
self._history_db_path = (
os.getenv("MEM0_HISTORY_DB_PATH")
or os.path.join(self._mem0_dir, "history.db")
).strip()
self._collection_name = (
os.getenv("MEM0_COLLECTION_NAME") or "presenton_memories"
).strip() or "presenton_memories"
self._client: Any = None
self._attempted_client_init = False
@staticmethod
def _to_bool(value: Optional[str], default: bool = False) -> bool:
if value is None:
return default
return str(value).strip().lower() in {"1", "true", "yes", "on"}
@staticmethod
def _to_int(value: Optional[str], default: int) -> int:
try:
parsed = int(value) if value is not None else default
return max(1, parsed)
except Exception:
return default
def _scope_user_id(self, presentation_id: UUID) -> str:
return f"{self._namespace_prefix}:{presentation_id}"
def _truncate(self, text: str, limit: int = 20000) -> str:
if len(text) <= limit:
return text
return f"{text[:limit]}\n\n[TRUNCATED]"
def _get_oss_config(self) -> dict:
return {
"vector_store": {
"provider": "qdrant",
"config": {
"collection_name": self._collection_name,
"path": self._qdrant_path,
"on_disk": True,
"embedding_model_dims": self._embedding_dims,
},
},
"embedder": {
"provider": self._embedder_provider,
"config": {
"model": self._embedder_model,
"embedding_dims": self._embedding_dims,
},
},
"history_db_path": self._history_db_path,
}
async def _get_client(self):
if not self._enabled:
return None
if self._client is not None:
return self._client
if self._attempted_client_init:
return None
self._attempted_client_init = True
try:
module = import_module("mem0")
memory_cls = getattr(module, "Memory")
os.makedirs(self._mem0_dir, exist_ok=True)
os.makedirs(self._qdrant_path, exist_ok=True)
config = self._get_oss_config()
try:
self._client = memory_cls.from_config(config)
except Exception:
# Backward compatibility across mem0 OSS versions.
self._client = memory_cls(config)
LOGGER.info(
"Mem0 OSS presentation memory service initialized (qdrant_path=%s, history_db_path=%s)",
self._qdrant_path,
self._history_db_path,
)
except Exception:
LOGGER.exception("Failed to initialize Mem0 OSS Memory")
self._client = None
return self._client
async def _add_message(self, presentation_id: UUID, message: str):
client = await self._get_client()
if client is None or not message.strip():
return
scoped_user_id = self._scope_user_id(presentation_id)
payload = [{"role": "user", "content": self._truncate(message)}]
def _add():
try:
return client.add(payload, user_id=scoped_user_id, infer=False)
except TypeError:
return client.add(
messages=payload,
user_id=scoped_user_id,
infer=False,
)
try:
await asyncio.to_thread(_add)
except Exception:
LOGGER.exception(
"Failed to add mem0 memory for presentation_id=%s", presentation_id
)
async def store_generation_context(
self,
presentation_id: UUID,
system_prompt: Optional[str],
user_prompt: Optional[str],
extracted_document_text: Optional[str],
source_content: Optional[str],
instructions: Optional[str],
):
if source_content:
await self._add_message(
presentation_id,
"[presentation_source_prompt]\n" + source_content,
)
if instructions:
await self._add_message(
presentation_id,
"[presentation_generation_instructions]\n" + instructions,
)
if system_prompt:
await self._add_message(
presentation_id,
"[outline_system_prompt]\n" + system_prompt,
)
if user_prompt:
await self._add_message(
presentation_id,
"[outline_user_prompt]\n" + user_prompt,
)
if extracted_document_text:
await self._add_message(
presentation_id,
"[document_extracted_text]\n" + extracted_document_text,
)
async def store_generated_outlines(self, presentation_id: UUID, outlines: Any):
if outlines is None:
return
try:
outlines_text = (
outlines
if isinstance(outlines, str)
else json.dumps(outlines, ensure_ascii=False)
)
except Exception:
outlines_text = str(outlines)
await self._add_message(
presentation_id,
"[generated_outlines]\n" + outlines_text,
)
async def store_slide_edit(
self,
presentation_id: UUID,
slide_index: Optional[int],
edit_prompt: str,
edited_slide_content: Any,
):
try:
edited_text = (
edited_slide_content
if isinstance(edited_slide_content, str)
else json.dumps(edited_slide_content, ensure_ascii=False)
)
except Exception:
edited_text = str(edited_slide_content)
index_text = f"{slide_index}" if slide_index is not None else "unknown"
message = (
f"[slide_edit]\n"
f"slide_index={index_text}\n"
f"user_edit_prompt={edit_prompt}\n"
f"edited_slide_content={edited_text}"
)
await self._add_message(presentation_id, message)
async def retrieve_context(self, presentation_id: UUID, query: str) -> str:
client = await self._get_client()
if client is None:
return ""
scoped_user_id = self._scope_user_id(presentation_id)
def _search():
try:
return client.search(
query,
filters={"user_id": scoped_user_id},
top_k=self._top_k,
)
except TypeError:
return client.search(
query,
user_id=scoped_user_id,
top_k=self._top_k,
)
try:
response = await asyncio.to_thread(_search)
except Exception:
LOGGER.exception(
"Failed to search mem0 context for presentation_id=%s", presentation_id
)
return ""
results = []
if isinstance(response, dict):
results = response.get("results") or []
elif isinstance(response, list):
results = response
memories: list[str] = []
for item in results:
if not isinstance(item, dict):
continue
memory_text = item.get("memory") or item.get("text") or item.get("data")
if not memory_text:
continue
normalized = str(memory_text).strip()
if normalized:
memories.append(normalized)
if not memories:
return ""
deduped_memories = list(dict.fromkeys(memories))
context = "\n\n".join(deduped_memories)
return self._truncate(context, self._max_context_chars)
MEM0_PRESENTATION_MEMORY_SERVICE = Mem0PresentationMemoryService()

View file

@ -220,7 +220,7 @@ class PptxPresentationCreator:
each_shape.picture.path = os.path.join("/app_data", relative_path)
each_shape.picture.is_network = False
return
# Resolve HTTP URLs that contain absolute filesystem paths (Mac/Electron)
# Resolve HTTP URLs that contain absolute filesystem paths.
local_path = resolve_image_path_to_filesystem(image_path)
if local_path:
each_shape.picture.path = local_path
@ -315,7 +315,7 @@ class PptxPresentationCreator:
def add_picture(self, slide: Slide, picture_model: PptxPictureBoxModel):
image_path = picture_model.picture.path
# Resolve /app_data/... to actual filesystem path (Electron)
# Resolve /app_data/... to actual filesystem path.
if image_path.startswith("/app_data/"):
app_data_dir = get_app_data_directory_env()
if app_data_dir:

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View file

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

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