Refactor code structure and remove redundant changes in multiple sections

This commit is contained in:
sudipnext 2026-04-16 13:33:21 +05:45
parent cfc7233447
commit a3a6a1acd2
51 changed files with 3928 additions and 1303 deletions

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

@ -3,20 +3,22 @@ FROM python:3.11-slim-bookworm
WORKDIR /app
# Docling + CPU torch: declared in pyproject.toml; lockfile uses PyTorch CPU index.
# UV_EXTRA_INDEX_URL mirrors the old `pip install docling --extra-index-url .../cpu`.
# 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 \
UV_EXTRA_INDEX_URL=https://download.pytorch.org/whl/cpu \
PATH="/root/.local/bin:${PATH}"
PATH="/root/.local/bin:${PATH}" \
EXPORT_PACKAGE_ROOT=/app/presentation-export \
BUILT_PYTHON_MODULE_PATH=/app/presentation-export/py/convert-linux-x64 \
PRESENTON_APP_ROOT=/app
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl \
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/*
@ -24,6 +26,19 @@ RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y nodejs \
&& rm -rf /var/lib/apt/lists/*
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
# PDF/PPTX export runtime: version pin in presentation-export/export-version.json (or build-arg).
COPY presentation-export/export-version.json /app/presentation-export/export-version.json
COPY scripts/sync-presentation-export.cjs /app/scripts/sync-presentation-export.cjs
ARG EXPORT_RUNTIME_VERSION
RUN export EXPORT_RUNTIME_VERSION="${EXPORT_RUNTIME_VERSION:-}" \
&& 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

View file

@ -3,21 +3,22 @@ FROM python:3.11-slim-bookworm
WORKDIR /app
# Docling is in pyproject.toml; uv.lock pins torch to this index (same as former:
# pip install docling --extra-index-url https://download.pytorch.org/whl/cpu
# UV_EXTRA_INDEX_URL keeps CPU wheels available if the lock is refreshed in Docker.)
# 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 \
UV_EXTRA_INDEX_URL=https://download.pytorch.org/whl/cpu \
PATH="/root/.local/bin:${PATH}"
PATH="/root/.local/bin:${PATH}" \
EXPORT_PACKAGE_ROOT=/app/presentation-export \
BUILT_PYTHON_MODULE_PATH=/app/presentation-export/py/convert-linux-x64 \
PRESENTON_APP_ROOT=/app
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl \
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/*
@ -25,6 +26,18 @@ RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y nodejs \
&& rm -rf /var/lib/apt/lists/*
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 presentation-export/export-version.json /app/presentation-export/export-version.json
COPY scripts/sync-presentation-export.cjs /app/scripts/sync-presentation-export.cjs
ARG EXPORT_RUNTIME_VERSION
RUN export EXPORT_RUNTIME_VERSION="${EXPORT_RUNTIME_VERSION:-}" \
&& 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

View file

@ -4,6 +4,9 @@ services:
build:
context: .
dockerfile: Dockerfile
args:
# Optional: override presentation-export release (else presentation-export/export-version.json)
EXPORT_RUNTIME_VERSION: ${EXPORT_RUNTIME_VERSION:-}
ports:
# You can replace 5000 with any other port number of your choice to run Presenton on a different port number.
- "5000:80"
@ -42,6 +45,8 @@ services:
build:
context: .
dockerfile: Dockerfile
args:
EXPORT_RUNTIME_VERSION: ${EXPORT_RUNTIME_VERSION:-}
deploy:
resources:
reservations:
@ -86,6 +91,8 @@ services:
build:
context: .
dockerfile: Dockerfile.dev
args:
EXPORT_RUNTIME_VERSION: ${EXPORT_RUNTIME_VERSION:-}
ports:
- "5000:80"
# Required for Codex OAuth callback (OpenAI redirects browser directly to localhost:1455)
@ -125,6 +132,8 @@ services:
build:
context: .
dockerfile: Dockerfile.dev
args:
EXPORT_RUNTIME_VERSION: ${EXPORT_RUNTIME_VERSION:-}
deploy:
resources:
reservations:

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.2-codex"
DEFAULT_CODEX_MODEL = "gpt-5.1-codex-mini"

View file

@ -46,16 +46,16 @@ interface CodexModel {
}
const CHATGPT_MODELS: CodexModel[] = [
{ id: "gpt-5.1", name: "GPT-5.1" },
{ id: "gpt-5.1-codex-max", name: "GPT-5.1 Codex Max" },
{ id: "gpt-5.2", name: "GPT-5.2" },
{ id: "gpt-5.2-codex", name: "GPT-5.2 Codex" },
{ id: "gpt-5.3-codex", name: "GPT-5.3 Codex" },
{ id: "gpt-5.4-mini", name: "GPT-5.4 Mini" },
{ id: "gpt-5.4", name: "GPT-5.4" },
{ id: "gpt-5.2-codex", name: "GPT-5.2-Codex" },
{ id: "gpt-5.1-codex-max", name: "GPT-5.1-Codex-Max" },
{ 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.4-mini";
const DEFAULT_CODEX_MODEL = "gpt-5.1-codex-mini";
export default function CodexConfig({
codexModel,

View file

@ -33,16 +33,16 @@ interface CodexModel {
}
export const CHATGPT_MODELS: CodexModel[] = [
{ id: "gpt-5.1", name: "GPT-5.1" },
{ id: "gpt-5.1-codex-max", name: "GPT-5.1 Codex Max" },
{ id: "gpt-5.2", name: "GPT-5.2" },
{ id: "gpt-5.2-codex", name: "GPT-5.2 Codex" },
{ id: "gpt-5.3-codex", name: "GPT-5.3 Codex" },
{ id: "gpt-5.4 mini", name: "GPT-5.4 Mini" },
{ id: "gpt-5.4", name: "GPT-5.4" },
{ id: "gpt-5.2-codex", name: "GPT-5.2-Codex" },
{ id: "gpt-5.1-codex-max", name: "GPT-5.1-Codex-Max" },
{ 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.4-mini";
export const DEFAULT_CODEX_MODEL = "gpt-5.1-codex-mini";
export default function CodexConfig({
codexModel,

View file

@ -2,5 +2,10 @@
"name": "presenton",
"version": "1.0.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"
}
}

View file

@ -0,0 +1,3 @@
{
"exportVersion": "v0.2.0"
}

View file

@ -0,0 +1,273 @@
/**
* 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. presentation-export/export-version.json exportVersion
*
* CLI: --force re-download even if valid runtime already exists
* --check-only verify index.js + 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 targetIndex = path.join(targetRoot, "index.js");
const versionFile = path.join(targetRoot, "export-version.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(versionFile)) {
throw new Error(
`Missing ${path.relative(repoRoot, versionFile)}. Create it with { "exportVersion": "vX.Y.Z" }.`
);
}
const raw = JSON.parse(fs.readFileSync(versionFile, "utf8"));
const v = (raw.exportVersion || "").trim();
if (!v) {
throw new Error(`${versionFile} must set "exportVersion" (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 validateExistingRuntime() {
if (!fs.existsSync(targetIndex)) {
return { ok: false, reason: `Missing runtime bundle: ${targetIndex}` };
}
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, 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}`;
const versionPinBackup = fs.existsSync(versionFile)
? fs.readFileSync(versionFile, "utf8")
: JSON.stringify({ exportVersion: tag }, null, 2) + "\n";
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 });
ensureDir(path.dirname(versionFile));
fs.writeFileSync(versionFile, versionPinBackup, "utf8");
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(` - ${targetIndex}`);
console.log(` - ${existing.converterPath}`);
return;
}
if (existing.ok && !forceDownload) {
console.log("[presentation-export] Using existing runtime:");
console.log(` - ${targetIndex}`);
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(` - ${targetIndex}`);
console.log(` - ${installed.converterPath}`);
}
main().catch((err) => {
console.error(`[presentation-export] ${err.message}`);
process.exit(1);
});

View file

@ -102,7 +102,7 @@ async def generate_image(
sql_session.add(image)
await sql_session.commit()
return image.file_url
return image.path
@IMAGES_ROUTER.get("/generated", response_model=List[ImageAsset])
@ -113,12 +113,7 @@ async def get_generated_images(sql_session: AsyncSession = Depends(get_async_ses
.where(ImageAsset.is_uploaded == False)
.order_by(ImageAsset.created_at.desc())
)
images = list(images_result)
for image in images:
# Ensure path exposed to the frontend is a web-safe URL
if hasattr(image, "file_url"):
image.path = image.file_url # type: ignore[attr-defined]
return images
return list(images_result)
except Exception as e:
raise HTTPException(
status_code=500, detail=f"Failed to retrieve generated images: {str(e)}"
@ -145,10 +140,6 @@ async def upload_image(
# Refresh to ensure all defaults are loaded
await sql_session.refresh(image_asset)
# Expose a web-safe URL in the path field for the frontend
if hasattr(image_asset, "file_url"):
image_asset.path = image_asset.file_url # type: ignore[attr-defined]
return image_asset
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to upload image: {str(e)}")
@ -162,12 +153,7 @@ async def get_uploaded_images(sql_session: AsyncSession = Depends(get_async_sess
.where(ImageAsset.is_uploaded == True)
.order_by(ImageAsset.created_at.desc())
)
images = list(images_result)
for image in images:
# Ensure path exposed to the frontend is a web-safe URL
if hasattr(image, "file_url"):
image.path = image.file_url # type: ignore[attr-defined]
return images
return list(images_result)
except Exception as e:
raise HTTPException(
status_code=500, detail=f"Failed to retrieve uploaded images: {str(e)}"

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)

View file

@ -1,22 +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"
]
# Alias used by font/PPTX validation helpers shared with the Electron server tree.
PPTX_MIME_TYPES = POWERPOINT_TYPES
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.2-codex"
DEFAULT_CODEX_MODEL = "gpt-5.1-codex-mini"

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,27 +1,11 @@
from datetime import datetime
from typing import Optional
import os
import uuid
from sqlalchemy import JSON, Column, DateTime
from sqlmodel import Field, SQLModel
from utils.datetime_utils import get_current_utc_datetime
from utils.get_env import get_app_data_directory_env, get_next_public_fast_api_env
from utils.path_helpers import get_resource_path
def _with_fastapi_origin(path: str) -> str:
"""Prefix relative web paths with FastAPI origin when available."""
if path.startswith("http://") or path.startswith("https://"):
return path
fastapi_origin = (get_next_public_fast_api_env() or "").strip()
if not fastapi_origin:
return path
normalized_path = path if path.startswith("/") else f"/{path}"
return f"{fastapi_origin.rstrip('/')}{normalized_path}"
class ImageAsset(SQLModel, table=True):
@ -34,45 +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:
"""
Returns a web path suitable for FastAPI static serving.
- HTTP(S) URLs are returned as-is.
- Files under APP_DATA are exposed under /app_data.
- Files under the packaged static directory are exposed under /static.
"""
path = self.path
# Already an absolute web URL
if path.startswith("http://") or path.startswith("https://"):
return path
# Already a web path under known mounts
if path.startswith("/app_data/") or path.startswith("/static/"):
return _with_fastapi_origin(path)
# Normalize filesystem path
real_path = os.path.realpath(path)
# Map APP_DATA files to /app_data/...
app_data_dir = get_app_data_directory_env()
if app_data_dir:
app_data_dir_real = os.path.realpath(app_data_dir)
if real_path.startswith(app_data_dir_real):
rel = os.path.relpath(real_path, app_data_dir_real)
rel_web = rel.replace(os.sep, "/")
return _with_fastapi_origin(f"/app_data/{rel_web}")
# Map packaged static assets to /static/...
static_root = get_resource_path("static")
static_root_real = os.path.realpath(static_root)
if real_path.startswith(static_root_real):
rel = os.path.relpath(real_path, static_root_real)
rel_web = rel.replace(os.sep, "/")
return _with_fastapi_origin(f"/static/{rel_web}")
# Fallback: return the original path (may be absolute or relative);
# frontend can decide how to handle unusual cases.
return path

View file

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

@ -16,7 +16,6 @@ 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",
@ -34,9 +33,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*", "templates*"]

View file

@ -14,11 +14,13 @@ if __name__ == "__main__":
args = parser.parse_args()
reload = args.reload == "true"
host = "127.0.0.1"
os.environ["FASTAPI_PUBLIC_URL"] = f"http://{host}:{args.port}"
# 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

@ -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,242 @@
import asyncio
import json
import os
import shutil
import subprocess
import tempfile
import uuid
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 = os.path.join(self.export_dir, "index.js")
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
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"
)
),
]
for candidate in candidates:
if os.path.isfile(os.path.join(candidate, "index.js")):
return candidate
return candidates[0]
@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()
binary_name = os.path.basename(self.node_binary).lower()
if binary_name not in {"node", "node.exe"}:
env.setdefault("ELECTRON_RUN_AS_NODE", "1")
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

@ -12,7 +12,6 @@ from models.sql.image_asset import ImageAsset
from utils.get_env import (
get_dall_e_3_quality_env,
get_gpt_image_1_5_quality_env,
get_next_public_fast_api_env,
get_pexels_api_key_env,
)
from utils.get_env import get_pixabay_api_key_env
@ -60,17 +59,6 @@ class ImageGenerationService:
def is_stock_provider_selected(self):
return is_pixels_selected() or is_pixabay_selected()
def _to_frontend_url(self, path: str) -> str:
if path.startswith("http://") or path.startswith("https://"):
return path
fastapi_origin = (get_next_public_fast_api_env() or "").strip()
if not fastapi_origin:
return path
normalized_path = path if path.startswith("/") else f"/{path}"
return f"{fastapi_origin.rstrip('/')}{normalized_path}"
async def generate_image(self, prompt: ImagePrompt) -> str | ImageAsset:
"""
Generates an image based on the provided prompt.
@ -81,11 +69,11 @@ class ImageGenerationService:
"""
if self.is_image_generation_disabled:
print("Image generation is disabled. Using placeholder image.")
return self._to_frontend_url("/static/images/placeholder.jpg")
return "/static/images/placeholder.jpg"
if not self.image_gen_func:
print("No image generation function found. Using placeholder image.")
return self._to_frontend_url("/static/images/placeholder.jpg")
return "/static/images/placeholder.jpg"
image_prompt = prompt.get_image_prompt(
with_theme=not self.is_stock_provider_selected()
@ -114,12 +102,12 @@ class ImageGenerationService:
elif image_path.startswith("/app_data/") or image_path.startswith(
"/static/"
):
return self._to_frontend_url(image_path)
return image_path
raise Exception(f"Image not found at {image_path}")
except Exception as e:
print(f"Error generating image: {e}")
return self._to_frontend_url("/static/images/placeholder.jpg")
return "/static/images/placeholder.jpg"
async def generate_image_openai(
self, prompt: str, output_directory: str, model: str, quality: str

View file

@ -0,0 +1,309 @@
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
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"}
class LiteParseService:
def __init__(self, timeout_seconds: int = 180):
self.timeout_seconds = timeout_seconds
self.node_binary = os.getenv("LITEPARSE_NODE_BINARY", "node")
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.
When the configured runtime binary is not the canonical `node` executable
(for example Electron's app binary), force Node-compatible mode.
"""
env = os.environ.copy()
binary_name = os.path.basename(self.node_binary).lower()
if binary_name not in {"node", "node.exe"}:
env.setdefault("ELECTRON_RUN_AS_NODE", "1")
# 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 (runner dir or Electron app root)."""
local_nm = os.path.join(
self.runner_dir, "node_modules", "@llamaindex", "liteparse"
)
if os.path.isdir(local_nm):
return self.runner_dir
electron_nm = os.path.abspath(
os.path.join(self.runner_dir, "..", "..", "node_modules", "@llamaindex", "liteparse")
)
if os.path.isdir(electron_nm):
return os.path.abspath(os.path.join(self.runner_dir, "..", ".."))
return os.path.abspath(os.path.join(self.runner_dir, "..", ".."))
@staticmethod
def _resolve_runner_path() -> str:
cwd = os.path.abspath(".")
candidates = [
# electron/servers/fastapi → electron/resources/...
os.path.abspath(
os.path.join(
cwd, "..", "..", "resources", "document-extraction", "liteparse_runner.mjs"
)
),
# servers/fastapi (repo root layout) → electron/resources/...
os.path.abspath(
os.path.join(
cwd,
"..",
"..",
"electron",
"resources",
"document-extraction",
"liteparse_runner.mjs",
)
),
# PyInstaller bundle layout
os.path.abspath(
os.path.join(
cwd, "..", "..", "app", "resources", "document-extraction", "liteparse_runner.mjs"
)
),
# Docker / explicit layout
"/app/document-extraction-liteparse/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}. Run npm install in the Electron app directory.",
)
# @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,
]
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",
file_path,
ocr_enabled,
ocr_language,
)
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

@ -0,0 +1,98 @@
from typing import Any
from templates.presentation_layout import PresentationLayoutModel
PLACEHOLDER_IMAGE_URL = "/static/images/replaceable_template_image.png"
PLACEHOLDER_ICON_URL = "/static/icons/placeholder.svg"
def build_schema_example(schema: dict) -> Any:
if not isinstance(schema, dict):
return None
if "default" in schema:
return schema["default"]
for key in ("anyOf", "oneOf", "allOf"):
options = schema.get(key)
if isinstance(options, list):
for option in options:
example = build_schema_example(option)
if example is not None:
return example
enum_values = schema.get("enum")
if enum_values:
return enum_values[0]
schema_type = schema.get("type")
if schema_type == "object":
properties = schema.get("properties", {})
result = {}
for field_name, field_schema in properties.items():
result[field_name] = build_schema_example(field_schema)
return result
if schema_type == "array":
items_schema = schema.get("items", {})
if "default" in schema:
return schema["default"]
item_example = build_schema_example(items_schema)
return [] if item_example is None else [item_example]
if schema_type == "string":
schema_description = (schema.get("description") or "").lower()
if "icon" in schema_description:
return PLACEHOLDER_ICON_URL
if "image" in schema_description or "url" in schema_description:
return PLACEHOLDER_IMAGE_URL
return "Sample text"
if schema_type == "integer":
return schema.get("minimum", 1)
if schema_type == "number":
return schema.get("minimum", 1)
if schema_type == "boolean":
return False
return None
def replace_special_placeholders(value: Any) -> Any:
if isinstance(value, dict):
result = {}
for key, child in value.items():
if key == "__image_url__":
result[key] = PLACEHOLDER_IMAGE_URL
elif key == "__icon_url__":
result[key] = PLACEHOLDER_ICON_URL
else:
result[key] = replace_special_placeholders(child)
return result
if isinstance(value, list):
return [replace_special_placeholders(item) for item in value]
if value == "__image_url__":
return PLACEHOLDER_IMAGE_URL
if value == "__icon_url__":
return PLACEHOLDER_ICON_URL
return value
def build_template_example(
template_id: str, layout: PresentationLayoutModel
) -> dict[str, Any]:
slides = []
for slide in layout.slides:
example_content = replace_special_placeholders(
build_schema_example(slide.json_schema)
)
slides.append({"layout": slide.id, "content": example_content})
return {
"template": template_id,
"slides": slides,
}

View file

@ -0,0 +1,18 @@
import aiohttp
from fastapi import HTTPException
from templates.presentation_layout import PresentationLayoutModel
async def get_layout_by_name(layout_name: str) -> PresentationLayoutModel:
url = f"http://localhost/api/template?group={layout_name}"
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
if response.status != 200:
error_text = await response.text()
raise HTTPException(
status_code=404,
detail=f"Template '{layout_name}' not found: {error_text}",
)
layout_json = await response.json()
return PresentationLayoutModel(**layout_json)

View file

@ -0,0 +1,707 @@
import os
import random
import re
import uuid
from datetime import datetime
from typing import Any, List, Optional
import aiohttp
from fastapi import Body, Depends, File, Form, HTTPException, Path, Query, UploadFile
from pydantic import BaseModel
from sqlalchemy import func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import delete, select
from constants.presentation import DEFAULT_TEMPLATES
from models.sql.presentation_layout_code import PresentationLayoutCodeModel
from models.sql.template import TemplateModel
from models.sql.template_create_info import TemplateCreateInfoModel
from services.database import get_async_session
from services.export_task_service import EXPORT_TASK_SERVICE
from templates.example import build_template_example
from templates.get_layout_by_name import get_layout_by_name
from templates.presentation_layout import PresentationLayoutModel
from templates.preview import (
FontsUploadAndSlidesPreviewResponse,
upload_fonts_and_slides_preview_handler,
)
from templates.prompts import (
SLIDE_LAYOUT_CREATION_SYSTEM_PROMPT,
SLIDE_LAYOUT_EDIT_SECTION_SYSTEM_PROMPT,
SLIDE_LAYOUT_EDIT_SYSTEM_PROMPT,
)
from templates.providers import edit_slide_layout_code, generate_slide_layout_code
from utils.asset_directory_utils import (
resolve_app_path_to_filesystem,
resolve_image_path_to_filesystem,
)
class TemplateDetail(BaseModel):
id: str
name: str
total_layouts: Optional[int] = None
class TemplateLayoutData(BaseModel):
template: uuid.UUID
layout_id: str
layout_name: str
layout_code: str
fonts: Optional[Any] = None
class TemplateData(BaseModel):
id: uuid.UUID
init_id: Optional[uuid.UUID] = None
name: str
description: Optional[str] = None
created_at: datetime
class GetTemplateLayoutsResponse(BaseModel):
layouts: list[TemplateLayoutData]
template: Optional[TemplateData] = None
fonts: Optional[Any] = None
class TemplateExample(BaseModel):
template: str
slides: List[dict]
class CreateTemplateInitRequest(BaseModel):
pptx_url: str
slide_image_urls: List[str]
fonts: dict = {}
class CreateSlideLayoutRequest(BaseModel):
id: uuid.UUID
index: int
class CreateSlideLayoutResponse(BaseModel):
react_component: str
class EditSlideLayoutRequest(BaseModel):
react_component: str
prompt: str
class EditSlideLayoutResponse(CreateSlideLayoutResponse):
pass
class EditSlideLayoutSectionRequest(BaseModel):
react_component: str
section: str
prompt: str
class EditSlideLayoutSectionResponse(CreateSlideLayoutResponse):
pass
class SaveTemplateLayoutData(BaseModel):
layout_id: str
layout_name: str
layout_code: str
class SaveTemplateRequest(BaseModel):
template_info_id: uuid.UUID
name: str
description: Optional[str] = None
layouts: List[SaveTemplateLayoutData]
class SaveTemplateResponse(BaseModel):
id: uuid.UUID
name: str
description: Optional[str] = None
created_at: datetime
class CloneTemplateRequest(BaseModel):
id: str
name: str
description: Optional[str] = None
class UpdateTemplateRequest(BaseModel):
id: uuid.UUID
layouts: List[SaveTemplateLayoutData]
class SaveSlideLayoutRequest(BaseModel):
template_id: uuid.UUID
layout_id: str
layout_code: str
class CloneSlideLayoutRequest(BaseModel):
template_id: str
layout_id: str
layout_name: Optional[str] = None
def _strip_code_fences(value: str) -> str:
return (
value.replace("```tsx", "")
.replace("```typescript", "")
.replace("```ts", "")
.replace("```", "")
.strip()
)
def _normalize_layout_code_for_create(code: str) -> str:
normalized = _strip_code_fences(code)
normalized = (
normalized.replace("image_url", "__image_url__")
.replace("icon_url", "__icon_url__")
.replace("image_prompt", "__image_prompt__")
.replace("icon_query", "__icon_query__")
)
first_import_match = re.search(r"(?m)^\s*import\b", normalized)
if first_import_match:
normalized = normalized[first_import_match.start() :]
first_export_match = re.search(r"(?m)^\s*export\b", normalized)
if first_export_match:
normalized = normalized[: first_export_match.start()]
normalized = re.sub(
r"(?ms)^\s*(?:import|export)\b.*?;(?:\r?\n|$)",
"",
normalized,
)
normalized = re.sub(
r"(?m)^\s*(?:import|export)\b.*(?:\r?\n|$)",
"",
normalized,
)
normalized = normalized.strip()
normalized = re.sub(
r'(layoutId\s*=\s*["\'])([^"\']+)(["\'])',
lambda match: (
match.group(0)
if re.search(r"-\d{4}$", match.group(2))
else f"{match.group(1)}{match.group(2)}-{random.randint(1000, 9999)}{match.group(3)}"
),
normalized,
)
return normalized
def _update_layout_id_in_code(code: str) -> tuple[str, str]:
match = re.search(r'(layoutId\s*=\s*["\'])([^"\']+)(["\'])', code)
if not match:
raise HTTPException(status_code=400, detail="layoutId not found in layout code")
current_id = match.group(2)
suffix = f"{random.randint(1000, 9999)}"
new_id = re.sub(r"-\d{4}$", f"-{suffix}", current_id)
if new_id == current_id:
new_id = f"{current_id}-{suffix}"
new_code = re.sub(
r'(layoutId\s*=\s*["\'])([^"\']+)(["\'])',
f"\\1{new_id}\\3",
code,
count=1,
)
return new_code, new_id
async def _download_image_bytes(image_url: str) -> bytes:
async with aiohttp.ClientSession() as session:
async with session.get(image_url) as response:
if response.status != 200:
raise HTTPException(
status_code=400,
detail=f"Failed to download slide image: {image_url}",
)
return await response.read()
async def _read_image_bytes_and_media_type(image_url: str) -> tuple[bytes, str]:
actual_image_path = resolve_image_path_to_filesystem(image_url)
if actual_image_path and os.path.isfile(actual_image_path):
with open(actual_image_path, "rb") as image_file:
image_bytes = image_file.read()
file_extension = os.path.splitext(actual_image_path)[1].lower()
else:
image_bytes = await _download_image_bytes(image_url)
file_extension = os.path.splitext(image_url)[1].lower()
media_type_map = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
}
return image_bytes, media_type_map.get(file_extension, "image/png")
async def get_all_templates(
include_defaults: bool = Query(
default=True, description="Whether to include default templates"
),
sql_session: AsyncSession = Depends(get_async_session),
):
result = await sql_session.execute(
select(
TemplateModel.id,
TemplateModel.name,
func.count(PresentationLayoutCodeModel.id).label("total_layouts"),
)
.join(
PresentationLayoutCodeModel,
PresentationLayoutCodeModel.presentation == TemplateModel.id,
)
.group_by(TemplateModel.id, TemplateModel.name)
)
rows = result.all()
templates: list[TemplateDetail] = []
if include_defaults:
templates.extend(
TemplateDetail(id=template, name=template) for template in DEFAULT_TEMPLATES
)
templates.extend(
TemplateDetail(
id=f"custom-{template_id}",
name=template_name,
total_layouts=total_layouts,
)
for template_id, template_name, total_layouts in rows
)
return templates
async def get_layouts(
template_id: str = Path(..., description="The id of the template"),
session: AsyncSession = Depends(get_async_session),
):
if not template_id or not template_id.strip():
raise HTTPException(status_code=400, detail="Template ID cannot be empty")
try:
cleaned_template_id = template_id.replace("custom-", "")
template_id_uuid = uuid.UUID(cleaned_template_id)
except Exception as exc:
raise HTTPException(status_code=400, detail="Invalid custom template ID") from exc
result = await session.execute(
select(PresentationLayoutCodeModel).where(
PresentationLayoutCodeModel.presentation == template_id_uuid
)
)
layouts_db = result.scalars().all()
if not layouts_db:
raise HTTPException(
status_code=404,
detail=f"No layouts found for template ID: {template_id}",
)
template_meta = await session.get(TemplateModel, template_id_uuid)
template = None
if template_meta:
template = TemplateData(
id=template_id_uuid,
init_id=None,
name=template_meta.name,
description=template_meta.description,
created_at=template_meta.created_at,
)
layouts = [
TemplateLayoutData(
template=template_id_uuid,
layout_id=layout.layout_id,
layout_name=layout.layout_name,
layout_code=layout.layout_code,
fonts=layout.fonts,
)
for layout in layouts_db
]
return GetTemplateLayoutsResponse(
layouts=layouts,
template=template,
fonts=layouts[0].fonts if layouts else None,
)
async def get_template_by_id(
id: str = Path(
...,
description=f"The id of the template, must be one of {', '.join(DEFAULT_TEMPLATES)} or your custom template",
),
sql_session: AsyncSession = Depends(get_async_session),
):
if id.startswith("custom-"):
try:
template_id = uuid.UUID(id.replace("custom-", ""))
except Exception as exc:
raise HTTPException(
status_code=400,
detail="Template not found. Please use a valid template.",
) from exc
template = await sql_session.get(TemplateModel, template_id)
if not template:
raise HTTPException(
status_code=400,
detail="Template not found. Please use a valid template.",
)
return await get_layout_by_name(id)
async def get_template_example(
id: str = Path(
...,
description=f"The id of the template, must be one of {', '.join(DEFAULT_TEMPLATES)} or your custom template",
),
sql_session: AsyncSession = Depends(get_async_session),
):
template = await get_template_by_id(id=id, sql_session=sql_session)
return TemplateExample(**build_template_example(id, template))
async def upload_fonts_and_slides_preview(
pptx_file: UploadFile = File(..., description="PPTX file to preview"),
font_files: Optional[List[UploadFile]] = File(
default=None, description="Font files to upload"
),
original_font_names: Optional[List[str]] = Form(default=None),
):
return await upload_fonts_and_slides_preview_handler(
pptx_file=pptx_file,
font_files=font_files,
original_font_names=original_font_names,
max_slides=25,
)
async def init_create_template(
request: CreateTemplateInitRequest,
sql_session: AsyncSession = Depends(get_async_session),
):
if not request.slide_image_urls:
raise HTTPException(
status_code=400, detail="At least one slide image is required"
)
pptx_path = resolve_app_path_to_filesystem(request.pptx_url)
if not pptx_path or not os.path.isfile(pptx_path):
raise HTTPException(status_code=400, detail="PPTX file not found")
pptx_document = await EXPORT_TASK_SERVICE.convert_pptx_to_html(
pptx_path, get_fonts=False
)
if not pptx_document.slides:
raise HTTPException(
status_code=500,
detail="PPTX-to-HTML export returned no slides",
)
if len(pptx_document.slides) < len(request.slide_image_urls):
raise HTTPException(
status_code=400,
detail=(
"PPTX-to-HTML export returned fewer slides than the preview images. "
f"Expected at least {len(request.slide_image_urls)}, got {len(pptx_document.slides)}."
),
)
slide_htmls = pptx_document.slides[: len(request.slide_image_urls)]
template_create_info = TemplateCreateInfoModel(
fonts=request.fonts or {},
pptx_url=request.pptx_url,
slide_image_urls=request.slide_image_urls,
slide_htmls=slide_htmls,
)
sql_session.add(template_create_info)
await sql_session.commit()
await sql_session.refresh(template_create_info)
return template_create_info.id
async def create_slide_layout(
request: CreateSlideLayoutRequest = Body(...),
sql_session: AsyncSession = Depends(get_async_session),
):
template_info = await sql_session.get(TemplateCreateInfoModel, request.id)
if not template_info:
raise HTTPException(status_code=400, detail="Template not found")
total_slides = len(template_info.slide_htmls)
if request.index < 0 or request.index >= total_slides:
raise HTTPException(status_code=400, detail="Invalid slide index")
slide_html = template_info.slide_htmls[request.index]
slide_image_url = template_info.slide_image_urls[request.index]
image_bytes, media_type = await _read_image_bytes_and_media_type(slide_image_url)
fonts_text = ""
if template_info.fonts:
font_names = [font.replace(" ", "_") for font in template_info.fonts.keys()]
fonts_text = "#PROVIDED FONTS\n- " + "\n- ".join(font_names)
user_text = f"{fonts_text}\n\n#SLIDE HTML REFERENCE\n{slide_html}"
react_component = await generate_slide_layout_code(
system_prompt=SLIDE_LAYOUT_CREATION_SYSTEM_PROMPT,
user_text=user_text,
image_bytes=image_bytes,
media_type=media_type,
)
normalized_react_component = _normalize_layout_code_for_create(react_component)
return CreateSlideLayoutResponse(react_component=normalized_react_component)
async def edit_slide_layout(
request: EditSlideLayoutRequest,
):
user_text = f"#Prompt\n{request.prompt}\n\n#TSX code\n{request.react_component}"
react_component = await edit_slide_layout_code(
system_prompt=SLIDE_LAYOUT_EDIT_SYSTEM_PROMPT,
user_text=user_text,
)
return EditSlideLayoutResponse(react_component=_strip_code_fences(react_component))
async def edit_slide_layout_section(
request: EditSlideLayoutSectionRequest,
):
user_text = (
f"#Prompt\n{request.prompt}\n\n"
f"#Section to make changes around\n{request.section}\n\n"
f"#TSX code\n{request.react_component}"
)
react_component = await edit_slide_layout_code(
system_prompt=SLIDE_LAYOUT_EDIT_SECTION_SYSTEM_PROMPT,
user_text=user_text,
)
return EditSlideLayoutSectionResponse(
react_component=_strip_code_fences(react_component)
)
async def save_template(
request: SaveTemplateRequest,
sql_session: AsyncSession = Depends(get_async_session),
):
if not request.layouts:
raise HTTPException(status_code=400, detail="Layouts are required")
template_info = await sql_session.get(TemplateCreateInfoModel, request.template_info_id)
if not template_info:
raise HTTPException(status_code=400, detail="Template info not found")
template = TemplateModel(
id=uuid.uuid4(),
name=request.name,
description=request.description,
)
sql_session.add(template)
sql_session.add_all(
[
PresentationLayoutCodeModel(
presentation=template.id,
layout_id=layout.layout_id,
layout_name=layout.layout_name,
layout_code=layout.layout_code,
fonts=template_info.fonts,
)
for layout in request.layouts
]
)
await sql_session.commit()
await sql_session.refresh(template)
return SaveTemplateResponse(
id=template.id,
name=template.name,
description=template.description,
created_at=template.created_at,
)
async def clone_template(
request: CloneTemplateRequest = Body(...),
sql_session: AsyncSession = Depends(get_async_session),
):
if not request.id or not request.id.strip():
raise HTTPException(status_code=400, detail="Template ID cannot be empty")
try:
template_id_uuid = uuid.UUID(request.id.replace("custom-", ""))
except Exception as exc:
raise HTTPException(status_code=400, detail="Invalid custom template ID") from exc
template = await sql_session.get(TemplateModel, template_id_uuid)
if not template:
raise HTTPException(
status_code=400,
detail="Template not found. Please use a valid template.",
)
result = await sql_session.execute(
select(PresentationLayoutCodeModel).where(
PresentationLayoutCodeModel.presentation == template_id_uuid
)
)
layouts_db = result.scalars().all()
if not layouts_db:
raise HTTPException(status_code=400, detail="No layouts found for template")
new_template = TemplateModel(
id=uuid.uuid4(),
name=request.name,
description=template.description
if request.description is None
else request.description,
)
sql_session.add(new_template)
sql_session.add_all(
[
PresentationLayoutCodeModel(
presentation=new_template.id,
layout_id=layout.layout_id,
layout_name=layout.layout_name,
layout_code=layout.layout_code,
fonts=layout.fonts,
)
for layout in layouts_db
]
)
await sql_session.commit()
await sql_session.refresh(new_template)
return SaveTemplateResponse(
id=new_template.id,
name=new_template.name,
description=new_template.description,
created_at=new_template.created_at,
)
async def update_template(
request: UpdateTemplateRequest,
sql_session: AsyncSession = Depends(get_async_session),
):
if not request.layouts:
raise HTTPException(status_code=400, detail="Layouts are required")
template = await sql_session.get(TemplateModel, request.id)
if not template:
raise HTTPException(status_code=400, detail="Template not found")
existing_layout = await sql_session.scalar(
select(PresentationLayoutCodeModel).where(
PresentationLayoutCodeModel.presentation == request.id
)
)
fonts = existing_layout.fonts if existing_layout else None
await sql_session.execute(
delete(PresentationLayoutCodeModel).where(
PresentationLayoutCodeModel.presentation == request.id
)
)
sql_session.add_all(
[
PresentationLayoutCodeModel(
presentation=template.id,
layout_id=layout.layout_id,
layout_name=layout.layout_name,
layout_code=layout.layout_code,
fonts=fonts,
)
for layout in request.layouts
]
)
await sql_session.commit()
return SaveTemplateResponse(
id=template.id,
name=template.name,
description=template.description,
created_at=template.created_at,
)
async def save_slide_layout(
request: SaveSlideLayoutRequest,
sql_session: AsyncSession = Depends(get_async_session),
):
template = await sql_session.get(TemplateModel, request.template_id)
if not template:
raise HTTPException(status_code=400, detail="Template not found")
layout = await sql_session.scalar(
select(PresentationLayoutCodeModel).where(
PresentationLayoutCodeModel.presentation == request.template_id,
PresentationLayoutCodeModel.layout_id == request.layout_id,
)
)
if not layout:
raise HTTPException(status_code=400, detail="Layout not found")
layout.layout_code = request.layout_code
sql_session.add(layout)
await sql_session.commit()
async def clone_slide_layout(
request: CloneSlideLayoutRequest = Body(...),
sql_session: AsyncSession = Depends(get_async_session),
):
if not request.template_id or not request.template_id.strip():
raise HTTPException(status_code=400, detail="Template ID cannot be empty")
try:
template_id_uuid = uuid.UUID(request.template_id.replace("custom-", ""))
except Exception as exc:
raise HTTPException(status_code=400, detail="Invalid custom template ID") from exc
template = await sql_session.get(TemplateModel, template_id_uuid)
if not template:
raise HTTPException(status_code=400, detail="Template not found")
layout = await sql_session.scalar(
select(PresentationLayoutCodeModel).where(
PresentationLayoutCodeModel.presentation == template_id_uuid,
PresentationLayoutCodeModel.layout_id == request.layout_id,
)
)
if not layout:
raise HTTPException(status_code=400, detail="Layout not found")
new_layout_code, new_layout_id = _update_layout_id_in_code(layout.layout_code)
new_layout = PresentationLayoutCodeModel(
presentation=template_id_uuid,
layout_id=new_layout_id,
layout_name=request.layout_name or layout.layout_name,
layout_code=new_layout_code,
fonts=layout.fonts,
)
sql_session.add(new_layout)
await sql_session.commit()
await sql_session.refresh(new_layout)
return SaveTemplateLayoutData(
layout_id=new_layout.layout_id,
layout_name=new_layout.layout_name,
layout_code=new_layout.layout_code,
)

View file

@ -0,0 +1,40 @@
from typing import List, Optional
from fastapi import HTTPException
from pydantic import BaseModel, Field
from models.presentation_structure_model import PresentationStructureModel
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) -> PresentationStructureModel:
return PresentationStructureModel(
slides=[index for index in range(len(self.slides))]
)
def to_string(self) -> str:
message = "## 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

View file

@ -0,0 +1,220 @@
SLIDE_LAYOUT_CREATION_SYSTEM_PROMPT = """
You need to generate a Zod schema and a TSX React component and provide it as output.
Provide reusable TSX code which can be used as template to generate new slides with different content.
# Steps:
1. Analyze the slide image to understand the visual hierarchy.
3. Classify elements into decorative and content elements.
4. Group content elements into logical sections like Header, Body, BulletPoints, etc.
5. Generate a Zod schema for the content elements.
6. Generate id, name and description for the layout.
6. Generate a TSX React component using the Zod schema and the HTML reference.
# Decorative Elements:
- Arrows, Lines, Shapes, etc.
- Images with Grid patterns, background patterns, gradients, solid colors, etc.
- Background of infographics like funnel, timeline, etc.
- Company name, logos, etc.
- Images covering the entire slide.
- Images containing company name, logos, etc.
# Decorative Elements Rules:
- Use them exactly as they are in the HTML reference.
- Do not change decorative images and icons urls.
- Images containing company name, logos, etc should be identified as decorative elements.
# Content Elements:
- Title, Description, BulletPoints, etc.
- Graphs, Charts, etc.
- Images and Icons representing textual content like title, description, bullet points, etc.
- Meaningful Images and Icons.
- Icons in infographics that represent the data.
# Content Elements Rules:
- Properly identify between images and icons elements.
- Image content:
- Image field should be 'z.object({"image_url": z.string(), "image_prompt": z.string().max(100)})'
- Replace actual image url with '/static/images/replaceable_template_image.png'
- Icon content:
- Icon field should be 'z.object({"icon_url": z.string(), "icon_query": z.string().max(30)})'
- Replace actual icon url with '/static/icons/placeholder.svg'
- Add color styling to the icon to match the color in the image.
- Make sure the urls are correct.
# Layout Rules:
- The layout should be fixed 1280px width and 720px height.
- Adjust the positions and sizes of elements to fit the layout.
- Try to keep the positions and sizes of elements as close to HTML reference as possible.
# Flexible Positioning and Sizes Rules:
- Must not use 'absolute' positioning for elements.
- Must use 'flex', 'grid', 'margin', 'padding', 'gap', 'basis', 'justify', 'align', etc for positioning of elements.
- For variable length lists, wrap list into a container and center it.
- Don't use specific sizes (height, width) for elements if not necessary.
# Schema Field Name and Description Rules:
- Must not use content specific words.
- Only use words based on what content types are present in the slide image.
- Use words like 'title', description', 'heading', 'image', 'graph', 'table', 'bullet points', etc.
- Must not use words like 'budget', 'market', 'revenue', 'sales', 'growth', 'workflow', 'channel', 'plannedValue', 'actualValue', etc.
# Layout ID, Name and Description Rules:
- Must only use slide structure to derive layout id, name and description.
- Informations like: Type of content, position of content, etc. should be used.
- layoutId example: title-description-right-image.
- layoutName example: Title Description Image.
- layoutDescription example: A slide with a title, description, and an image on right.
# Zod Schema Rules:
- "describe" must be added for every fields.
- Add `.default(...)` to every top-level field directly inside the initial `z.object({ ... })` shape.
- Must not put a single `default` on the whole object like `const Schema = z.object({ ... }).default({ ... })`.
- Top level fields are those not nested inside other fields.
- Don't mention string type in schema like "url()", "email()", etc.
- Table must be object with "columns" and "rows" fields.
- "columns" must be an array of strings.
- "rows" must be an array of arrays of strings.
- Graph must be object with "categories" and "series" fields.
- "categories" must be an array of strings.
- "series" must be an array of objects with {"name": string, "data": array of numbers}.
- Must not use z.record() anywhere in the schema.
# String and Array Field Rules:
- Every string field must include `.max(...)`; every array field must include `.max(...)`.
- For strings, set `max` to the exact character count of the text content it represents.
- For arrays, set `max` to the exact item count of the array content it represents.
- Choose a `max` that keeps the longest allowed content from overflowing its container.
# Table Rules:
- Construct "tr -> th" by iterating over the "columns" field.
- Construct "tr -> td" by iterating over the "rows" field.
- Make sure table height and width adjusts to fit the content.
# Grahps, Charts, etc Rules:
- Identify if graphs, charts, etc are present in the slide image.
- Identify the type of graph, chart, etc.
- If present, generate a zod schema for the graph, chart, etc.
- Generate TSX code for the graph, chart, etc. even if it is not present in the HTML reference.
- Use graph schema and image to generate the TSX code.
- Use Recharts library for graphs.
# Fonts Rules:
- Check for "PROVIDED FONTS".
- Must use fonts only from "PROVIDED FONTS".
- Add "font-[\"font-name\"]" to every text element in the slide.
# Page Number Rules:
- Identify if the slide contains page number from provided HTML reference and image.
- If page number is present, add a "page: z.number().min(1).meta({ description: "Page number" })" field in the schema.
# React Component Rules:
- React component must be named dynamicSlideLayout.
- dynamicSlideLayout must take "{ data }: { data: Partial<z.infer<typeof Schema>> }" as props.
- Wrap the code inside these classes: "relative w-full rounded-sm max-w-[1280px] shadow-lg max-h-[720px] aspect-video bg-white z-20 mx-auto overflow-hidden".
- Make sure camelCase is used for all styles. For e.g. "letter-spacing" should be "letterSpacing".
- Schema.parse must not be used in the code.
- Use 'const {field1, field2, ...} = data;' to access the data.
- field1 or field2 or ... can be undefined, so use optional chaining to access them.
- Don't use "min-height" on cards and instead make its height grow/shrink to fit the content.
- Make sure cards/items are centered vertically and horizontally in the available space.
- Make sure no element is scrollable.
- Don't add any animations, transitions, or effects.
- Make sure no content elements are overflowing the slide boundaries.
# Import and Export Rules:
- All import statements must be defined at the top.
- Export using 'export {Schema, layoutId, layoutName, layoutDescription, dynamicSlideLayout}' statement at the bottom.
- There must be only one 'export' statement in the whole TSX code.
# Output Code Rules:
- Code should be in following order:
- Zod Schema (Schema)
- Layout ID, Name and Description (layoutId, layoutName, layoutDescription)
- React Component (dynamicSlideLayout)
- Give just one valid TSX code as output.
- Don't add comments in the code.
- Make sure the generated code is valid TSX code.
- Give only code as output and nothing else. (no json, no markdown, no text, no explanation)
- Go through generated code and make sure all rules are followed.
- Think as long as you can and iterate as many times as necessary to make sure all rules are followed.
"""
SLIDE_LAYOUT_EDIT_SYSTEM_PROMPT = """
You need to edit the given TSX code of the slide layout code according to the prompt and provide it as output.
# Steps
1. Analyze the TSX code to understand the slide layout.
2. Analyze the prompt to understand the changes to be made.
3. Edit the TSX code according to the prompt.
4. Provide the updated TSX code as output.
# Rules
- Make sure the changes does not break the existing code.
- Make sure to follow the pattern of the existing code.
- Make sure there are no unused schema fields after the changes are made.
# Icons and Images Rules
Follow these rules if new icons/images are asked:
- Image field should be 'z.object({"image_url": z.string(), "image_prompt": z.string().max(100)})'
- Use this as default image url: '/static/images/replaceable_template_image.png'
- Icon field should be 'z.object({"icon_url": z.string(), "icon_query": z.string().max(30)})'
- Use this as default icon url: '/static/icons/placeholder.svg'
# Schema Rules
- "describe" must be added for every fields.
- "default" must be added in top level fields of schema.
- Top level fields are those not nested inside other fields.
- Must set max for every string and array fields.
- Must set max to a number that will not cause overflow on max content.
# Graphs And Table Rules
Follow these rules if new graphs/tables are asked:
1. Schema Rules
- Table must be object with "columns" and "rows" fields.
- "columns" must be an array of strings.
- "rows" must be an array of arrays of strings.
- Graph must be object with "categories" and "series" fields.
- "categories" must be an array of strings.
- "series" must be an array of objects with {"name": string, "data": array of numbers}.
2. React Component Rules
- Use recharts library for graphs.
# Common Prompts
1. Fix the slide
- Check if text/cards/items is overflowing the slide boundaries or text/cards/items are overlapping.
- If yes, fix by moving the element to a better position or resizing the element.
# Output Rules
- Make sure the schema and react component are valid.
- No matter what prompt is given, don't break the code.
- Provide only the updated TSX code as output and nothing else. (no json, no markdown, no text, no explanation)
"""
SLIDE_LAYOUT_EDIT_SECTION_SYSTEM_PROMPT = """
You need to edit the given TSX code of the slide layout code according to the prompt and provide it as output.
# Steps
1. Analyze the TSX code to understand the slide layout.
2. Analyze the prompt to understand the changes to be made.
3. Edit the TSX code according to the prompt.
4. Provide the updated TSX code as output.
# Rules
- Changes should be made only around the mentioned "section to make changes around".
- Make sure the changes does not break the existing code.
- Make sure to follow the pattern of the existing code.
- Make sure there are no unused schema fields after the changes are made.
# Icons and Images Rules
Follow these rules if new icons/images are asked:
- Image field should be 'z.object({"image_url": z.string(), "image_prompt": z.string().max(100)})'
- Use this as default image url: '/static/images/replaceable_template_image.png'
- Icon field should be 'z.object({"icon_url": z.string(), "icon_query": z.string().max(30)})'
- Use this as default icon url: '/static/icons/placeholder.svg'
# Output Rules
- Make sure the schema and react component are valid.
- No matter what prompt is given, don't break the code.
- Provide only the updated TSX code as output and nothing else. (no json, no markdown, no text, no explanation)
"""

View file

@ -0,0 +1,425 @@
import asyncio
import base64
from dataclasses import dataclass
import time
from typing import Any, Awaitable, Callable, Optional
from anthropic import AsyncAnthropic
from fastapi import HTTPException
from google import genai
from google.genai import types as google_types
from openai import AsyncOpenAI
from enums.llm_provider import LLMProvider
from utils.get_env import (
get_anthropic_api_key_env,
get_codex_access_token_env,
get_codex_account_id_env,
get_codex_refresh_token_env,
get_codex_token_expires_env,
get_google_api_key_env,
get_openai_api_key_env,
)
from utils.llm_provider import get_llm_provider, get_model
from utils.set_env import (
set_codex_access_token_env,
set_codex_account_id_env,
set_codex_refresh_token_env,
set_codex_token_expires_env,
)
MAX_ATTEMPTS_PER_PROVIDER = 4
@dataclass(frozen=True)
class TemplateProviderSpec:
provider: LLMProvider
model: str
@dataclass(frozen=True)
class PlainLLMProvider:
name: str
call: Callable[[], Awaitable[str]]
def get_template_provider_spec() -> TemplateProviderSpec:
provider = get_llm_provider()
if provider == LLMProvider.OPENAI:
return TemplateProviderSpec(provider=provider, model=get_model())
if provider == LLMProvider.CODEX:
return TemplateProviderSpec(provider=provider, model=get_model())
if provider == LLMProvider.GOOGLE:
return TemplateProviderSpec(provider=provider, model=get_model())
if provider == LLMProvider.ANTHROPIC:
return TemplateProviderSpec(provider=provider, model=get_model())
raise HTTPException(
status_code=400,
detail="Template generation only supports OpenAI, Codex, Google, or Anthropic.",
)
async def run_plain_provider_buckets(*, providers: list[PlainLLMProvider]) -> str:
last_exception: Optional[Exception] = None
for provider in providers:
for attempt in range(1, MAX_ATTEMPTS_PER_PROVIDER + 1):
try:
response_text = await provider.call()
if response_text:
return response_text
raise ValueError("No output from template generation provider")
except Exception as exc:
last_exception = exc
if isinstance(last_exception, HTTPException):
raise last_exception
raise HTTPException(status_code=500, detail="Failed to generate template output")
def _read_openai_response_text(response) -> str:
output_text = getattr(response, "output_text", None)
if output_text:
return output_text
text = getattr(response, "text", None)
if text:
return text
return ""
def _get_openai_client() -> AsyncOpenAI:
api_key = get_openai_api_key_env()
if not api_key:
raise HTTPException(status_code=400, detail="OPENAI_API_KEY is not set")
return AsyncOpenAI(api_key=api_key, timeout=120.0)
def _get_codex_headers() -> dict:
access_token = get_codex_access_token_env()
if not access_token:
raise HTTPException(
status_code=400,
detail="Codex OAuth access token is not set. Please authenticate via /api/v1/ppt/codex/auth/initiate",
)
expires_str = get_codex_token_expires_env()
if expires_str:
try:
expires_ms = int(expires_str)
now_ms = int(time.time() * 1000)
if now_ms >= expires_ms - 60_000:
refresh_token = get_codex_refresh_token_env()
if refresh_token:
from utils.oauth.openai_codex import (
TokenSuccess,
get_account_id,
refresh_access_token,
)
result = refresh_access_token(refresh_token)
if isinstance(result, TokenSuccess):
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)
access_token = result.access
except (TypeError, ValueError):
pass
account_id = get_codex_account_id_env() or ""
return {
"Authorization": f"Bearer {access_token}",
"chatgpt-account-id": account_id,
"OpenAI-Beta": "responses=experimental",
"originator": "pi",
}
def _get_codex_client() -> AsyncOpenAI:
headers = _get_codex_headers()
access_token = (headers.get("Authorization") or "").replace("Bearer ", "").strip()
default_headers = {
key: value
for key, value in headers.items()
if key.lower() not in {"authorization", "content-type", "accept"}
}
return AsyncOpenAI(
base_url="https://chatgpt.com/backend-api/codex",
api_key=access_token or "codex",
default_headers=default_headers,
timeout=120.0,
)
def _get_google_client() -> genai.Client:
api_key = get_google_api_key_env()
if not api_key:
raise HTTPException(status_code=400, detail="GOOGLE_API_KEY is not set")
return genai.Client(api_key=api_key)
def _get_anthropic_client() -> AsyncAnthropic:
api_key = get_anthropic_api_key_env()
if not api_key:
raise HTTPException(status_code=400, detail="ANTHROPIC_API_KEY is not set")
return AsyncAnthropic(api_key=api_key)
async def _call_openai_like(
*,
client: AsyncOpenAI,
model: str,
system_prompt: str,
user_text: str,
image_bytes: Optional[bytes] = None,
media_type: str = "image/png",
) -> str:
content = [{"type": "input_text", "text": user_text}]
if image_bytes:
content.insert(
0,
{
"type": "input_image",
"image_url": f"data:{media_type};base64,{base64.b64encode(image_bytes).decode('utf-8')}",
},
)
response = await client.responses.create(
model=model,
instructions=system_prompt,
input=[{"role": "user", "content": content}],
text={"verbosity": "medium"},
store=False,
)
output_text = _read_openai_response_text(response)
if not output_text:
raise HTTPException(status_code=500, detail="No output from template provider")
return output_text
def _response_event_to_dict(event: Any) -> dict:
if isinstance(event, dict):
return event
if hasattr(event, "model_dump"):
return event.model_dump()
return {
"type": getattr(event, "type", None),
"delta": getattr(event, "delta", None),
"text": getattr(event, "text", None),
"item": getattr(event, "item", None),
"response": getattr(event, "response", None),
"error": getattr(event, "error", None),
"message": getattr(event, "message", None),
}
async def _call_codex(
*,
model: str,
system_prompt: str,
user_text: str,
image_bytes: Optional[bytes] = None,
media_type: str = "image/png",
) -> str:
client = _get_codex_client()
content = [{"type": "input_text", "text": user_text}]
if image_bytes:
content.insert(
0,
{
"type": "input_image",
"image_url": f"data:{media_type};base64,{base64.b64encode(image_bytes).decode('utf-8')}",
},
)
stream = await client.responses.create(
model=model,
instructions=system_prompt,
input=[{"role": "user", "content": content}],
text={"verbosity": "medium"},
store=False,
stream=True,
)
text_parts: list[str] = []
async for event in stream:
payload = _response_event_to_dict(event)
event_type = payload.get("type") or ""
if event_type == "response.output_text.delta":
delta = payload.get("delta") or ""
if delta:
text_parts.append(delta)
continue
if event_type == "response.output_text.done":
text = payload.get("text") or ""
if text and not text_parts:
text_parts.append(text)
continue
if event_type in ("response.error", "response.failed", "error"):
error_detail = payload.get("message") or payload.get("error") or str(payload)
raise HTTPException(status_code=502, detail=f"Codex error: {error_detail}"[:400])
output_text = "".join(text_parts).strip()
if not output_text:
raise HTTPException(status_code=500, detail="No output from template provider")
return output_text
async def _call_google(
*,
model: str,
system_prompt: str,
user_text: str,
image_bytes: Optional[bytes] = None,
media_type: str = "image/png",
) -> str:
client = _get_google_client()
parts = [google_types.Part.from_text(text=user_text)]
if image_bytes:
parts.append(google_types.Part.from_bytes(data=image_bytes, mime_type=media_type))
response = await asyncio.to_thread(
client.models.generate_content,
model=model,
contents=[google_types.Content(role="user", parts=parts)],
config=google_types.GenerateContentConfig(
system_instruction=system_prompt,
response_mime_type="text/plain",
),
)
output_text = getattr(response, "text", None) or ""
if not output_text:
raise HTTPException(status_code=500, detail="No output from template provider")
return output_text
async def _call_anthropic(
*,
model: str,
system_prompt: str,
user_text: str,
image_bytes: Optional[bytes] = None,
media_type: str = "image/png",
) -> str:
client = _get_anthropic_client()
content = [{"type": "text", "text": user_text}]
if image_bytes:
content.append(
{
"type": "image",
"source": {
"type": "base64",
"media_type": media_type,
"data": base64.b64encode(image_bytes).decode("utf-8"),
},
}
)
response = await client.messages.create(
model=model,
max_tokens=8192,
system=system_prompt,
messages=[{"role": "user", "content": content}],
)
output_text = "".join(
block.text for block in response.content if getattr(block, "type", None) == "text"
)
if not output_text:
raise HTTPException(status_code=500, detail="No output from template provider")
return output_text
def _build_provider_call(
*,
spec: Optional[TemplateProviderSpec] = None,
system_prompt: str,
user_text: str,
image_bytes: Optional[bytes] = None,
media_type: str = "image/png",
) -> PlainLLMProvider:
spec = spec or get_template_provider_spec()
if spec.provider == LLMProvider.OPENAI:
return PlainLLMProvider(
name="OpenAI",
call=lambda: _call_openai_like(
client=_get_openai_client(),
model=spec.model,
system_prompt=system_prompt,
user_text=user_text,
image_bytes=image_bytes,
media_type=media_type,
),
)
if spec.provider == LLMProvider.CODEX:
return PlainLLMProvider(
name="Codex",
call=lambda: _call_codex(
model=spec.model,
system_prompt=system_prompt,
user_text=user_text,
image_bytes=image_bytes,
media_type=media_type,
),
)
if spec.provider == LLMProvider.GOOGLE:
return PlainLLMProvider(
name="Google",
call=lambda: _call_google(
model=spec.model,
system_prompt=system_prompt,
user_text=user_text,
image_bytes=image_bytes,
media_type=media_type,
),
)
if spec.provider == LLMProvider.ANTHROPIC:
return PlainLLMProvider(
name="Anthropic",
call=lambda: _call_anthropic(
model=spec.model,
system_prompt=system_prompt,
user_text=user_text,
image_bytes=image_bytes,
media_type=media_type,
),
)
raise HTTPException(
status_code=400,
detail="Template generation only supports OpenAI, Codex, Google, or Anthropic.",
)
async def generate_slide_layout_code(
*,
system_prompt: str,
user_text: str,
image_bytes: bytes,
media_type: str = "image/png",
) -> str:
provider = _build_provider_call(
system_prompt=system_prompt,
user_text=user_text,
image_bytes=image_bytes,
media_type=media_type,
)
return await run_plain_provider_buckets(providers=[provider])
async def edit_slide_layout_code(
*,
system_prompt: str,
user_text: str,
) -> str:
provider = _build_provider_call(
system_prompt=system_prompt,
user_text=user_text,
)
return await run_plain_provider_buckets(providers=[provider])

View file

@ -0,0 +1,65 @@
import uuid
from fastapi import APIRouter
from templates.handler import (
CreateSlideLayoutResponse,
EditSlideLayoutResponse,
EditSlideLayoutSectionResponse,
FontsUploadAndSlidesPreviewResponse,
GetTemplateLayoutsResponse,
PresentationLayoutModel,
SaveTemplateLayoutData,
SaveTemplateResponse,
TemplateDetail,
TemplateExample,
clone_slide_layout,
clone_template,
create_slide_layout,
edit_slide_layout,
edit_slide_layout_section,
get_all_templates,
get_layouts,
get_template_by_id,
get_template_example,
init_create_template,
save_slide_layout,
save_template,
update_template,
upload_fonts_and_slides_preview,
)
TEMPLATE_ROUTER = APIRouter(prefix="/template", tags=["Template"])
TEMPLATE_ROUTER.get("/all", response_model=list[TemplateDetail])(get_all_templates)
TEMPLATE_ROUTER.get(
"/{template_id}/layouts", response_model=GetTemplateLayoutsResponse
)(get_layouts)
TEMPLATE_ROUTER.get("/{id}", response_model=PresentationLayoutModel)(get_template_by_id)
TEMPLATE_ROUTER.get("/{id}/example", response_model=TemplateExample)(
get_template_example
)
TEMPLATE_ROUTER.post(
"/fonts-upload-and-slides-preview",
response_model=FontsUploadAndSlidesPreviewResponse,
)(upload_fonts_and_slides_preview)
TEMPLATE_ROUTER.post("/create/init", response_model=uuid.UUID)(init_create_template)
TEMPLATE_ROUTER.post("/slide-layout/create", response_model=CreateSlideLayoutResponse)(
create_slide_layout
)
TEMPLATE_ROUTER.post("/create/slide-layout", response_model=CreateSlideLayoutResponse)(
create_slide_layout
)
TEMPLATE_ROUTER.post("/slide-layout/edit", response_model=EditSlideLayoutResponse)(
edit_slide_layout
)
TEMPLATE_ROUTER.post(
"/slide-layout/edit-section", response_model=EditSlideLayoutSectionResponse
)(edit_slide_layout_section)
TEMPLATE_ROUTER.post("/save", response_model=SaveTemplateResponse)(save_template)
TEMPLATE_ROUTER.post("/clone", response_model=SaveTemplateResponse)(clone_template)
TEMPLATE_ROUTER.put("/update", response_model=SaveTemplateResponse)(update_template)
TEMPLATE_ROUTER.post("/slide-layout/save", status_code=200)(save_slide_layout)
TEMPLATE_ROUTER.post("/slide-layout/clone", response_model=SaveTemplateLayoutData)(
clone_slide_layout
)

View file

@ -156,10 +156,6 @@ def get_migrate_database_on_startup_env():
return os.getenv("MIGRATE_DATABASE_ON_STARTUP")
def get_next_public_fast_api_env():
return os.getenv("FASTAPI_PUBLIC_URL")
def get_sentry_dsn_env():
return os.getenv("SENTRY_DSN")

View file

@ -1,18 +1,5 @@
import aiohttp
from fastapi import HTTPException
from models.presentation_layout import PresentationLayoutModel
from typing import List
"""Re-export for callers that import from `utils.get_layout_by_name`."""
async def get_layout_by_name(layout_name: str) -> PresentationLayoutModel:
url = f"http://localhost/api/template?group={layout_name}"
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
if response.status != 200:
error_text = await response.text()
raise HTTPException(
status_code=404,
detail=f"Template '{layout_name}' not found: {error_text}"
)
layout_json = await response.json()
# Parse the JSON into your Pydantic model
return PresentationLayoutModel(**layout_json)
from templates.get_layout_by_name import get_layout_by_name
__all__ = ["get_layout_by_name"]

View file

@ -0,0 +1,126 @@
"""
Map presentation UI language strings (LanguageType enum values from Next.js) to
Tesseract / LiteParse OCR language codes (ISO 639-3 where applicable).
Keep keys in sync with:
electron/servers/nextjs/app/(presentation-generator)/upload/type.ts LanguageType
"""
from __future__ import annotations
import re
from typing import Optional
# Values must match `LanguageType` string literals in the upload UI.
PRESENTATION_LANGUAGE_TO_TESSERACT: dict[str, str] = {
"English": "eng",
"Spanish (Español)": "spa",
"French (Français)": "fra",
"German (Deutsch)": "deu",
"Portuguese (Português)": "por",
"Italian (Italiano)": "ita",
"Dutch (Nederlands)": "nld",
"Russian (Русский)": "rus",
"Chinese (Simplified - 中文, 汉语)": "chi_sim",
"Chinese (Traditional - 中文, 漢語)": "chi_tra",
"Japanese (日本語)": "jpn",
"Korean (한국어)": "kor",
"Arabic (العربية)": "ara",
"Hindi (हिन्दी)": "hin",
"Bengali (বাংলা)": "ben",
"Polish (Polski)": "pol",
"Czech (Čeština)": "ces",
"Slovak (Slovenčina)": "slk",
"Hungarian (Magyar)": "hun",
"Romanian (Română)": "ron",
"Bulgarian (Български)": "bul",
"Greek (Ελληνικά)": "ell",
"Serbian (Српски / Srpski)": "srp",
"Croatian (Hrvatski)": "hrv",
"Bosnian (Bosanski)": "bos",
"Slovenian (Slovenščina)": "slv",
"Finnish (Suomi)": "fin",
"Swedish (Svenska)": "swe",
"Danish (Dansk)": "dan",
"Norwegian (Norsk)": "nor",
"Icelandic (Íslenska)": "isl",
"Lithuanian (Lietuvių)": "lit",
"Latvian (Latviešu)": "lav",
"Estonian (Eesti)": "est",
"Maltese (Malti)": "mlt",
"Welsh (Cymraeg)": "cym",
"Irish (Gaeilge)": "gle",
"Scottish Gaelic (Gàidhlig)": "gla",
"Ukrainian (Українська)": "ukr",
"Hebrew (עברית)": "heb",
"Persian/Farsi (فارسی)": "fas",
"Turkish (Türkçe)": "tur",
"Kurdish (Kurdî / کوردی)": "kmr",
"Pashto (پښتو)": "pus",
"Dari (دری)": "prs",
"Uzbek (Oʻzbek)": "uzb",
"Kazakh (Қазақша)": "kaz",
"Tajik (Тоҷикӣ)": "tgk",
"Turkmen (Türkmençe)": "tuk",
"Azerbaijani (Azərbaycan dili)": "aze",
"Urdu (اردو)": "urd",
"Tamil (தமிழ்)": "tam",
"Telugu (తెలుగు)": "tel",
"Marathi (मराठी)": "mar",
"Punjabi (ਪੰਜਾਬੀ / پنجابی)": "pan",
"Gujarati (ગુજરાતી)": "guj",
"Malayalam (മലയാളം)": "mal",
"Kannada (ಕನ್ನಡ)": "kan",
"Odia (ଓଡ଼ିଆ)": "ori",
"Sinhala (සිංහල)": "sin",
"Nepali (नेपाली)": "nep",
"Thai (ไทย)": "tha",
"Vietnamese (Tiếng Việt)": "vie",
"Lao (ລາວ)": "lao",
"Khmer (ភាសាខ្មែរ)": "khm",
"Burmese (မြန်မာစာ)": "mya",
"Tagalog/Filipino (Tagalog/Filipino)": "tgl",
"Javanese (Basa Jawa)": "jav",
"Sundanese (Basa Sunda)": "sun",
"Malay (Bahasa Melayu)": "msa",
"Mongolian (Монгол)": "mon",
"Swahili (Kiswahili)": "swa",
"Hausa (Hausa)": "hau",
"Yoruba (Yorùbá)": "yor",
"Igbo (Igbo)": "ibo",
"Amharic (አማርኛ)": "amh",
"Zulu (isiZulu)": "zul",
"Xhosa (isiXhosa)": "xho",
"Shona (ChiShona)": "sna",
"Somali (Soomaaliga)": "som",
"Basque (Euskara)": "eus",
"Catalan (Català)": "cat",
"Galician (Galego)": "glg",
"Quechua (Runasimi)": "que",
"Nahuatl (Nāhuatl)": "nah",
"Hawaiian (ʻŌlelo Hawaiʻi)": "haw",
"Maori (Te Reo Māori)": "mri",
# No dedicated Tahitian traineddata in default Tesseract bundles.
"Tahitian (Reo Tahiti)": "eng",
"Samoan (Gagana Samoa)": "smo",
}
_LOWER_MAP = {k.lower(): v for k, v in PRESENTATION_LANGUAGE_TO_TESSERACT.items()}
_OCR_CODE_RE = re.compile(r"^[a-zA-Z0-9_,+]+$")
def presentation_language_to_ocr_code(language: Optional[str]) -> str:
"""Resolve UI language label to a Tesseract language code; default English."""
if language is None:
return "eng"
s = str(language).strip()
if not s:
return "eng"
if s in PRESENTATION_LANGUAGE_TO_TESSERACT:
code = PRESENTATION_LANGUAGE_TO_TESSERACT[s]
else:
code = _LOWER_MAP.get(s.lower(), "eng")
if not _OCR_CODE_RE.fullmatch(code):
return "eng"
return code

View file

@ -1,14 +1,11 @@
import asyncio
import os
from typing import List, Optional, Tuple
from typing import List, Optional
from models.image_prompt import ImagePrompt
from models.sql.image_asset import ImageAsset
from models.sql.slide import SlideModel
from services.icon_finder_service import ICON_FINDER_SERVICE
from services.image_generation_service import ImageGenerationService
from utils.asset_directory_utils import get_images_directory
from utils.dict_utils import get_dict_at_path, get_dict_paths_with_key, set_dict_at_path
from utils.path_helpers import get_resource_path
async def process_slide_and_fetch_assets(
@ -59,7 +56,7 @@ async def process_slide_and_fetch_assets(
image_dict = get_dict_at_path(slide.content, asset_path)
if isinstance(result, ImageAsset):
return_assets.append(result)
image_dict["__image_url__"] = result.file_url
image_dict["__image_url__"] = result.path
else:
image_dict["__image_url__"] = result
set_dict_at_path(slide.content, asset_path, image_dict)
@ -172,7 +169,7 @@ async def process_old_and_new_slides_and_fetch_assets(
fetched_image = new_images[i]
if isinstance(fetched_image, ImageAsset):
new_assets.append(fetched_image)
image_url = fetched_image.file_url
image_url = fetched_image.path
else:
image_url = fetched_image
new_image_dicts[i]["__image_url__"] = image_url

831
servers/fastapi/uv.lock generated

File diff suppressed because it is too large Load diff

View file

@ -60,7 +60,7 @@ const ImageProvider = ({ llmConfig, setLlmConfig }: { llmConfig: LLMConfig, setL
DALL·E 3 Image Quality
</label>
<div className="">
<Select value={llmConfig.DALL_E_3_QUALITY} onValueChange={(value) => input_field_changed(value, "DALL_E_3_QUALITY")}>
<Select value={llmConfig.DALL_E_3_QUALITY || 'standard'} onValueChange={(value) => input_field_changed(value, "DALL_E_3_QUALITY")}>
<SelectTrigger className="w-full h-12 px-4 py-4 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors hover:border-gray-400 justify-between">
<SelectValue placeholder="Select a quality" />
</SelectTrigger>
@ -84,7 +84,7 @@ const ImageProvider = ({ llmConfig, setLlmConfig }: { llmConfig: LLMConfig, setL
</label>
<div className="">
<Select
value={llmConfig.GPT_IMAGE_1_5_QUALITY}
value={llmConfig.GPT_IMAGE_1_5_QUALITY || 'low'}
onValueChange={(value) => input_field_changed(value, "GPT_IMAGE_1_5_QUALITY")}
>
<SelectTrigger
@ -175,7 +175,7 @@ const ImageProvider = ({ llmConfig, setLlmConfig }: { llmConfig: LLMConfig, setL
<PopoverContent
className="p-0"
align="start"
style={{ width: "var(--radix-popover-trigger-width)" }}
style={{ width: "300px" }}
>
<Command>
<CommandInput placeholder="Search provider..." />

View file

@ -46,16 +46,16 @@ interface CodexModel {
}
const CHATGPT_MODELS: CodexModel[] = [
{ id: "gpt-5.1", name: "GPT-5.1" },
{ id: "gpt-5.1-codex-max", name: "GPT-5.1 Codex Max" },
{ id: "gpt-5.2", name: "GPT-5.2" },
{ id: "gpt-5.2-codex", name: "GPT-5.2 Codex" },
{ id: "gpt-5.3-codex", name: "GPT-5.3 Codex" },
{ id: "gpt-5.4-mini", name: "GPT-5.4 Mini" },
{ id: "gpt-5.4", name: "GPT-5.4" },
{ id: "gpt-5.2-codex", name: "GPT-5.2-Codex" },
{ id: "gpt-5.1-codex-max", name: "GPT-5.1-Codex-Max" },
{ 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.4-mini";
const DEFAULT_CODEX_MODEL = "gpt-5.1-codex-mini";
export default function CodexConfig({
codexModel,

View file

@ -19,7 +19,11 @@ import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
import SettingSideBar from "./SettingSideBar";
import TextProvider from "./TextProvider";
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";
const STOCK_IMAGE_PROVIDERS = new Set(["pexels", "pixabay"]);
// Button state interface
interface ButtonState {
@ -34,8 +38,10 @@ interface ButtonState {
const SettingsPage = () => {
const router = useRouter();
const pathname = usePathname();
const [mode, setMode] = useState<'nanobanana' | 'presenton'>('presenton')
const [selectedProvider, setSelectedProvider] = useState<'text-provider' | 'image-provider'>('text-provider')
const [mode, setMode] = useState<"nanobanana" | "presenton">("presenton");
const [selectedProvider, setSelectedProvider] = useState<
"text-provider" | "image-provider" | "privacy"
>("text-provider");
const userConfigState = useSelector((state: RootState) => state.userConfig);
const [llmConfig, setLlmConfig] = useState<LLMConfig>(
userConfigState.llm_config
@ -71,15 +77,53 @@ const SettingsPage = () => {
return 0;
}, [downloadingModel?.downloaded, downloadingModel?.size]);
const ensureSelectedStockProviderReady = async (): Promise<boolean> => {
if (llmConfig.DISABLE_IMAGE_GENERATION) {
return true;
}
const provider = (llmConfig.IMAGE_PROVIDER || "").toLowerCase();
if (!STOCK_IMAGE_PROVIDERS.has(provider)) {
return true;
}
const providerApiKey =
provider === "pexels" ? llmConfig.PEXELS_API_KEY : llmConfig.PIXABAY_API_KEY;
try {
await ImagesApi.searchStockImages("business", 1, {
provider,
apiKey: providerApiKey,
strictApiKey: true,
});
return true;
} catch (error: any) {
notify.error(
"Cannot save settings",
error?.message ||
`Unable to reach ${provider} with the provided API key. Please verify your settings and try again.`
);
return false;
}
};
const handleSaveConfig = async () => {
trackEvent(MixpanelEvent.Settings_SaveConfiguration_Button_Clicked, { pathname });
trackEvent(MixpanelEvent.Settings_SaveConfiguration_Button_Clicked, {
pathname,
});
const validationError = getLLMConfigValidationError(llmConfig);
if (validationError) {
notify.error("Cannot save settings", validationError);
return;
}
const providerReady = await ensureSelectedStockProviderReady();
if (!providerReady) {
return;
}
try {
setButtonState(prev => ({
setButtonState((prev) => ({
...prev,
isLoading: true,
isDisabled: true,
@ -112,21 +156,19 @@ const SettingsPage = () => {
"Settings saved",
"Your configuration was saved successfully."
);
setButtonState(prev => ({
setButtonState((prev) => ({
...prev,
isLoading: false,
isDisabled: false,
text: "Save Configuration",
}));
trackEvent(MixpanelEvent.Navigation, { from: pathname, to: "/upload" });
router.push("/upload");
} catch (error) {
const message =
error instanceof Error
? error.message
: "Something went wrong while saving.";
notify.error("Could not save settings", message);
setButtonState(prev => ({
setButtonState((prev) => ({
...prev,
isLoading: false,
isDisabled: false,
@ -211,7 +253,6 @@ const SettingsPage = () => {
return null;
}
const textProviderKey = llmConfig.LLM || "openai";
const textProviderLabel =
LLM_PROVIDERS[textProviderKey]?.label || textProviderKey;
@ -226,7 +267,9 @@ const SettingsPage = () => {
? llmConfig.OLLAMA_MODEL
: textProviderKey === "custom"
? llmConfig.CUSTOM_MODEL
: "";
: textProviderKey === "codex"
? llmConfig.CODEX_MODEL
: "";
const textSummary = selectedTextModel
? `${textProviderLabel} (${selectedTextModel})`
: textProviderLabel;
@ -234,22 +277,29 @@ const SettingsPage = () => {
const imageSummary = llmConfig.DISABLE_IMAGE_GENERATION
? "Image generation disabled"
: llmConfig.IMAGE_PROVIDER
? IMAGE_PROVIDERS[llmConfig.IMAGE_PROVIDER]?.label || llmConfig.IMAGE_PROVIDER
? IMAGE_PROVIDERS[llmConfig.IMAGE_PROVIDER]?.label ||
llmConfig.IMAGE_PROVIDER
: "No image provider";
return (
<div className="h-screen font-syne flex flex-col overflow-hidden relative">
<div
className='fixed z-0 bottom-[-14.5rem] left-0 w-full h-full'
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%)',
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%)",
}}
/>
<main className="w-full mx-auto gap-6 overflow-hidden flex ">
<SettingSideBar mode={mode} setMode={setMode} selectedProvider={selectedProvider} setSelectedProvider={setSelectedProvider} />
<SettingSideBar
mode={mode}
setMode={setMode}
selectedProvider={selectedProvider}
setSelectedProvider={setSelectedProvider}
/>
<div className="w-full">
<div className="sticky top-0 right-0 z-50 py-[28px] backdrop-blur mb-4 ">
<div className="flex gap-3 items-center ">
@ -259,26 +309,29 @@ const SettingsPage = () => {
<p className="text-[10px] px-2.5 py-0.5 rounded-[50px] text-[#7A5AF8] border border-[#EDEEEF] font-medium ">
{textSummary} · {imageSummary}
</p>
</div>
</div>
{mode === 'nanobanana' && <div className=" w-full bg-[#F9F8F8] p-7 rounded-[20px]">
<h4>Nano Banana</h4>
</div>}
{mode === 'presenton' && selectedProvider === 'text-provider' && <TextProvider
onInputChange={(value, field) => {
setLlmConfig(prev => ({
...prev,
[field]: value
}));
}}
llmConfig={llmConfig}
/>}
{mode === 'presenton' && selectedProvider === 'image-provider' && <ImageProvider llmConfig={llmConfig} setLlmConfig={setLlmConfig} />}
{mode === "nanobanana" && (
<div className=" w-full bg-[#F9F8F8] p-7 rounded-[20px]">
<h4>Nano Banana</h4>
</div>
)}
{mode === "presenton" && selectedProvider === "text-provider" && (
<TextProvider
onInputChange={(value, field) => {
setLlmConfig((prev) => ({
...prev,
[field]: value,
}));
}}
llmConfig={llmConfig}
/>
)}
{mode === "presenton" && selectedProvider === "image-provider" && (
<ImageProvider llmConfig={llmConfig} setLlmConfig={setLlmConfig} />
)}
{selectedProvider === "privacy" && <PrivacySettings />}
</div>
</main>
@ -288,13 +341,15 @@ const SettingsPage = () => {
onClick={handleSaveConfig}
disabled={buttonState.isDisabled}
style={{
background: "linear-gradient(270deg, #D5CAFC 2.4%, #E3D2EB 27.88%, #F4DCD3 69.23%, #FDE4C2 100%)",
background:
"linear-gradient(270deg, #D5CAFC 2.4%, #E3D2EB 27.88%, #F4DCD3 69.23%, #FDE4C2 100%)",
color: "#101323",
}}
className={`w-full font-syne font-semibold flex items-center justify-center gap-2 py-3 px-5 rounded-[58px] transition-all duration-500 ${buttonState.isDisabled
? "bg-gray-400 cursor-not-allowed"
: "bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 focus:ring-4 focus:ring-blue-200"
} text-white`}
className={`w-full font-syne font-semibold flex items-center justify-center gap-2 py-3 px-5 rounded-[58px] transition-all duration-500 ${
buttonState.isDisabled
? "bg-gray-400 cursor-not-allowed"
: "bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 focus:ring-4 focus:ring-blue-200"
} text-white`}
>
{buttonState.isLoading ? (
<div className="flex items-center justify-center gap-2">

View file

@ -1,30 +1,39 @@
import React from 'react'
const SettingSideBar = ({ mode, setMode, selectedProvider, setSelectedProvider }: { mode: 'nanobanana' | 'presenton', setMode: (mode: 'nanobanana' | 'presenton') => void, selectedProvider: 'text-provider' | 'image-provider', setSelectedProvider: (provider: 'text-provider' | 'image-provider') => void }) => {
import { Shield } from 'lucide-react'
import { IMAGE_PROVIDERS, LLM_PROVIDERS } from '@/utils/providerConstants'
import { useSelector } from 'react-redux'
import { RootState } from '@/store/store'
const SettingSideBar = ({ mode, setMode, selectedProvider, setSelectedProvider }: { mode: 'nanobanana' | 'presenton', setMode: (mode: 'nanobanana' | 'presenton') => void, selectedProvider: 'text-provider' | 'image-provider' | 'privacy', setSelectedProvider: (provider: 'text-provider' | 'image-provider' | 'privacy') => void }) => {
const { llm_config } = useSelector((state: RootState) => state.userConfig)
const textProviderIcon = LLM_PROVIDERS[llm_config.LLM as keyof typeof LLM_PROVIDERS]?.icon
const imageProviderIcon = IMAGE_PROVIDERS[llm_config.IMAGE_PROVIDER as keyof typeof IMAGE_PROVIDERS]?.icon || '/providers/pexel.png'
return (
<div className='w-full max-w-[230px] h-screen px-4 pt-[22px] bg-[#F9FAFB]'>
<div className='w-full max-w-[230px] h-screen px-3 pt-[22px] bg-[#F9FAFB] flex flex-col'>
<p className='text-xs text-black font-medium border-b mt-[3.15rem] border-[#E1E1E5] pb-3.5'>FILTER BY:</p>
<div className='mt-6'>
<div className='mt-6 flex-1'>
<p className='text-[#3A3A3A] text-xs font-medium pb-2.5'>Select Mode</p>
<div className='p-1 rounded-[40px] bg-[#ffffff] w-fit border border-[#EDEEEF] flex items-center justify-center mb-[34px] '>
<button className='px-3 py-2 text-xs font-medium text-[#3A3A3A] rounded-[70px]'
<div className='p-0.5 rounded-[40px] bg-[#ffffff] w-fit border border-[#EDEEEF] flex items-center justify-center mb-[34px] '>
<button className='px-3 font-syne h-[26px] text-[10px] font-medium text-[#3A3A3A] rounded-[70px]'
onClick={() => setMode('presenton')}
style={{
background: mode === 'presenton' ? '#F4F3FF' : 'transparent',
color: mode === 'presenton' ? '#5146E5' : '#3A3A3A'
}}
>Presenton</button>
>Template Based
</button>
<svg xmlns="http://www.w3.org/2000/svg" className='mx-1' width="2" height="17" viewBox="0 0 2 17" fill="none">
<path d="M1 0V16.5" stroke="#EDECEC" strokeWidth="2" />
</svg>
<div className='relative'>
<button className='px-3 py-2 text-xs font-medium rounded-[70px] cursor-not-allowed opacity-60'
<button className='px-3 font-syne h-[26px] text-[10px] font-medium rounded-[70px] cursor-not-allowed opacity-60'
disabled
style={{
background: 'transparent',
color: '#9CA3AF'
}}
>
Nanobanana
Image Based
</button>
<span className='absolute -top-2 -right-5 text-[7px] uppercase tracking-wide bg-[#F4F3FF] text-[#5146E5] border border-[#D9D6FE] rounded-full px-1.5 py-0.5 whitespace-nowrap'>
Coming soon
@ -35,24 +44,24 @@ const SettingSideBar = ({ mode, setMode, selectedProvider, setSelectedProvider }
</div>
<p className='text-[#3A3A3A] text-xs font-medium pb-2.5'>Select Provider</p>
{mode === 'presenton' && <div className='space-y-2.5'>
<button className={` w-full rounded-[6px] p-3 py-4 flex items-center gap-1.5 border ${selectedProvider === 'text-provider' ? 'bg-[#F4F3FF] border-[#D9D6FE]' : 'bg-white border-[#E1E1E5]'}`} onClick={() => setSelectedProvider('text-provider')}>
<div className='relative w-6 h-6 rounded-full overflow-hidden border border-[#EDEEEF]'>
<button className={` w-full rounded-[6px] px-3 py-4 flex items-center gap-1.5 border ${selectedProvider === 'text-provider' ? 'bg-[#F4F3FF] border-[#D9D6FE]' : 'bg-white border-[#EDEEEF]'}`} onClick={() => setSelectedProvider('text-provider')}>
<div className='relative w-[18px] h-[18px] rounded-full overflow-hidden border border-[#EDEEEF]'>
<img src='/providers/openai.png' className=' object-cover w-full h-full overflow-hidden' alt='google' />
<img src={textProviderIcon} className=' object-cover w-full h-full overflow-hidden' alt='google' />
</div>
<p className='text-[#191919] text-xs font-medium' >Text Provider</p>
</button>
<button className={` w-full rounded-[6px] p-3 py-4 flex items-center gap-1.5 border ${selectedProvider === 'image-provider' ? 'bg-[#F4F3FF] border-[#D9D6FE]' : 'bg-white border-[#E1E1E5]'}`} onClick={() => setSelectedProvider('image-provider')}>
<div className='relative w-6 h-6 rounded-full overflow-hidden border border-[#EDEEEF]'>
<img src='/providers/image-provider.png' className=' object-cover w-full h-full overflow-hidden' alt='google' />
<button className={` w-full rounded-[6px] px-3 py-4 flex items-center gap-1.5 border ${selectedProvider === 'image-provider' ? 'bg-[#F4F3FF] border-[#D9D6FE]' : 'bg-white border-[#EDEEEF]'}`} onClick={() => setSelectedProvider('image-provider')}>
<div className='relative w-[18px] h-[18px] rounded-full overflow-hidden border border-[#EDEEEF]'>
<img src={imageProviderIcon} className=' object-cover w-full h-full overflow-hidden' alt='google' />
</div>
<p className='text-[#191919] text-xs font-medium' >Image Provider</p>
</button>
</div>}
{
mode === 'nanobanana' && <div>
<button className={` w-full rounded-[6px] p-3 py-4 flex items-center gap-1.5 border bg-[#F4F3FF] border-[#D9D6FE]`}>
<div className='relative w-6 h-6 rounded-full overflow-hidden border border-[#EDEEEF]'>
<button className={` w-full rounded-[6px] px-3 py-4 flex items-center gap-1.5 border bg-[#F4F3FF] border-[#D9D6FE]`}>
<div className='relative w-[18px] h-[18px] rounded-full overflow-hidden border border-[#EDEEEF]'>
<img src='/providers/openai.png' className=' object-cover w-full h-full overflow-hidden' alt='google' />
</div>
@ -61,6 +70,19 @@ const SettingSideBar = ({ mode, setMode, selectedProvider, setSelectedProvider }
</div>
}
</div>
<div className='border-t border-[#E1E1E5] py-5 relative z-50'>
<p className='text-[#3A3A3A] text-xs font-medium pb-2.5'>Other</p>
<button
className={`w-full rounded-[6px] p-3 py-4 flex items-center gap-1.5 border ${selectedProvider === 'privacy' ? 'bg-[#F4F3FF] border-[#D9D6FE]' : 'bg-white border-[#EDEEEF]'}`}
onClick={() => setSelectedProvider('privacy')}
>
<div className='relative w-6 h-6 rounded-full overflow-hidden border border-[#EDEEEF] flex items-center justify-center bg-white'>
<Shield className='w-3.5 h-3.5 text-[#5146E5]' />
</div>
<p className='text-[#191919] text-xs font-medium'>Usage Analytics</p>
</button>
</div>
</div>
)
}

View file

@ -1,17 +1,15 @@
import ToolTip from '@/components/ToolTip';
import { Button } from '@/components/ui/button';
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Switch } from '@/components/ui/switch';
import { cn } from '@/lib/utils';
import { LLMConfig } from '@/types/llm_config';
import { getApiUrl } from '@/utils/api';
import { LLM_PROVIDERS } from '@/utils/providerConstants';
import { Check, Loader2, Eye, EyeOff, ChevronUp } from 'lucide-react';
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { notify } from '@/components/ui/sonner';
import { toast } from 'sonner';
import { getApiUrl } from '@/utils/api';
import CodexConfig from '@/components/CodexConfig';
import CodexConfig from './SettingCodex';
interface OpenAIConfigProps {
@ -19,6 +17,13 @@ interface OpenAIConfigProps {
onInputChange: (value: string | boolean, field: string) => void;
llmConfig: LLMConfig;
}
interface ModelOption {
value: string;
label: string;
size?: string;
}
const TextProvider = ({
onInputChange,
@ -28,7 +33,7 @@ const TextProvider = ({
) => {
const [openProviderSelect, setOpenProviderSelect] = useState(false);
const [openModelSelect, setOpenModelSelect] = useState(false);
const [availableModels, setAvailableModels] = useState<string[]>([]);
const [availableModels, setAvailableModels] = useState<ModelOption[]>([]);
const [modelsLoading, setModelsLoading] = useState(false);
const [modelsChecked, setModelsChecked] = useState(false);
const [showApiKey, setShowApiKey] = useState(false);
@ -159,19 +164,48 @@ const TextProvider = ({
if (response.ok) {
const data = await response.json();
const normalizedModels: string[] = selectedProvider === 'ollama'
const normalizedModels: ModelOption[] = selectedProvider === 'ollama'
? Array.isArray(data)
? data.map((model: { value?: string; label?: string }) => model.value || model.label || '').filter(Boolean)
? data
.map((model) => {
if (typeof model === 'string') {
return {
value: model,
label: model,
};
}
if (model && typeof model === 'object') {
const typedModel = model as { value?: string; label?: string; size?: string };
return {
value: typedModel.value || typedModel.label || '',
label: typedModel.label || typedModel.value || '',
size: typedModel.size,
};
}
return {
value: '',
label: '',
};
})
.filter((model: ModelOption) => Boolean(model.value))
: []
: Array.isArray(data)
? data
.filter((model): model is string => typeof model === 'string')
.map((model) => ({
value: model,
label: model,
}))
: [];
setAvailableModels(normalizedModels);
setModelsChecked(true);
if (normalizedModels.length > 0 && currentModelField) {
if (currentModel && normalizedModels.includes(currentModel)) {
const modelValues = normalizedModels.map((model) => model.value);
if (currentModel && modelValues.includes(currentModel)) {
onInputChange(currentModel, currentModelField);
return;
}
@ -183,16 +217,19 @@ const TextProvider = ({
? 'models/gemini-2.5-flash'
: selectedProvider === 'anthropic'
? 'claude-sonnet-4-20250514'
: normalizedModels[0];
: modelValues[0];
const nextModel = normalizedModels.includes(preferredDefault) ? preferredDefault : normalizedModels[0];
const nextModel = modelValues.includes(preferredDefault) ? preferredDefault : modelValues[0];
onInputChange(nextModel, currentModelField);
}
} else {
console.error('Failed to fetch models');
setAvailableModels([]);
setModelsChecked(true);
toast.error(`Failed to fetch ${modelLabel} models`);
notify.error(
'Could not load models',
`The server could not list ${modelLabel} models. Check your API key or endpoint and try again.`
);
}
} catch (error) {
console.error('Error fetching models:', error);
@ -215,8 +252,8 @@ const TextProvider = ({
return (
<div className="space-y-6 bg-[#F9F8F8] p-7 rounded-[12px] ">
{/* API Key Input */}
<div className="mb-4 flex items-center justify-between rounded-[12px] bg-white pt-5 pb-10 px-10">
<div className=" max-w-[290px] pb-[50px]">
<div className="mb-4 flex items-end justify-between rounded-[12px] bg-white pt-5 pb-10 px-10">
<div className=" max-w-[290px] ">
<div className='w-[60px] h-[60px] rounded-[4px] flex items-center justify-center'
style={{ backgroundColor: '#4C55541A' }}
>
@ -231,11 +268,10 @@ const TextProvider = ({
Choosing where text content comes from
</p>
</div>
<div>
<div className={`flex gap-4 justify-end ${selectedProvider === 'codex' ? 'items-end' : 'items-start'}`}>
<div className="relative w-[205px] ">
<div className='flex flex-col justify-end items-end gap-4'>
<div className={`flex gap-4 justify-end ${selectedProvider === 'codex' ? 'items-end' : 'items-start'}`}>
<div className={`relative ${selectedProvider === 'codex' ? 'w-[240px]' : 'w-[222px]'}`}>
<div className="flex flex-col justify-start ">
<label className="block text-sm font-medium text-gray-700 mb-2">
Select Text Provider
</label>
@ -248,7 +284,7 @@ const TextProvider = ({
variant="outline"
role="combobox"
aria-expanded={openProviderSelect}
className="w-[205px] h-12 px-4 py-4 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors hover:border-gray-400 justify-between"
className="w-[222px] h-12 px-4 py-4 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors hover:border-gray-400 justify-between"
>
<div className="flex gap-3 items-center">
<span className="text-sm font-medium text-gray-900">
@ -264,7 +300,7 @@ const TextProvider = ({
<PopoverContent
className="p-0"
align="start"
style={{ width: "var(--radix-popover-trigger-width)" }}
style={{ width: "300px" }}
>
<Command>
<CommandInput placeholder="Search provider..." />
@ -310,10 +346,8 @@ const TextProvider = ({
</PopoverContent>
</Popover>
</div>
</div>
<div className="relative flex flex-col justify-end items-end w-[205px] ">
<div className={`relative flex flex-col justify-end ${selectedProvider === 'codex' ? 'items-end w-[262px] max-w-full' : 'items-end w-[222px]'}`}>
<div className="flex flex-col justify-start w-full ">
{selectedProvider === 'ollama' ? (
<>
@ -357,8 +391,9 @@ const TextProvider = ({
</>
)}
</>
) : selectedProvider === 'codex' ? (
<div className="w-full mt-0 rounded-[12px]">
) : selectedProvider === 'codex' ?
<div className='w-full mt-0 rounded-[12px] '>
<CodexConfig
codexModel={llmConfig.CODEX_MODEL || ''}
onInputChange={(value, field) => {
@ -367,7 +402,7 @@ const TextProvider = ({
}}
/>
</div>
) : (
: (
<>
<label className="block text-sm font-medium capitalize text-gray-700 mb-2">
{selectedProvider === 'custom' ? 'Custom LLM API Key' : `${llmConfig.LLM} API Key`}
@ -402,8 +437,6 @@ const TextProvider = ({
</div>
{selectedProvider !== 'ollama' && selectedProvider !== 'codex' && (!modelsChecked || (modelsChecked && availableModels.length === 0)) && (
<button
@ -429,93 +462,101 @@ const TextProvider = ({
"Check models"
)}
</button>
)}
</div>
{/* Model Selection - only show if models are available */}
{selectedProvider !== 'codex' && modelsChecked && availableModels.length > 0 ? (
<div className="w-[205px]">
<div>
<label className="block text-sm font-medium text-gray-700 mb-3">
{selectedProvider === 'ollama' ? 'Choose a supported model' : `Select ${modelLabel} Model`}
</label>
<div className="w-full">
<Popover
open={openModelSelect}
onOpenChange={setOpenModelSelect}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={openModelSelect}
className="w-full h-12 px-4 py-4 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors hover:border-gray-400 justify-between"
>
<span className="text-sm truncate font-medium text-gray-900">
{currentModel
? availableModels.find(model => model === currentModel) || currentModel
: "Select a model"}
</span>
<ChevronUp className="w-4 h-4 text-gray-500" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
align="start"
style={{ width: "var(--radix-popover-trigger-width)" }}
</div>
{/* Model Selection - only show if models are available */}
{selectedProvider !== 'codex' && modelsChecked && availableModels.length > 0 ? (
<div className="w-[222px]">
<div>
<label className="block text-sm font-medium text-gray-700 mb-3">
{selectedProvider === 'ollama' ? 'Choose a supported model' : `Select ${modelLabel} Model`}
</label>
<div className="w-full">
<Popover
open={openModelSelect}
onOpenChange={setOpenModelSelect}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={openModelSelect}
className="w-full h-12 px-4 py-4 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors hover:border-gray-400 justify-between"
>
<Command>
<CommandInput placeholder="Search models..." />
<CommandList>
<CommandEmpty>No model found.</CommandEmpty>
<CommandGroup>
{availableModels.map((model, index) => (
<CommandItem
key={index}
value={model}
onSelect={(value) => {
if (currentModelField) {
onInputChange(value, currentModelField);
}
setOpenModelSelect(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
currentModel === model
? "opacity-100"
: "opacity-0"
)}
/>
<div className="flex gap-3 items-center">
<div className="flex flex-col space-y-1 flex-1">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium text-gray-900">
{model}
<span className="text-sm truncate font-medium text-gray-900">
{(() => {
if (!currentModel) return "Select a model";
const selectedModel = availableModels.find((model) => model.value === currentModel);
if (!selectedModel) return currentModel;
if (selectedProvider === 'ollama' && selectedModel.size) {
return `${selectedModel.label} (${selectedModel.size})`;
}
return selectedModel.label;
})()}
</span>
<ChevronUp className="w-4 h-4 text-gray-500" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
align="start"
style={{ width: "var(--radix-popover-trigger-width)" }}
>
<Command>
<CommandInput placeholder="Search models..." />
<CommandList>
<CommandEmpty>No model found.</CommandEmpty>
<CommandGroup>
{availableModels.map((model) => (
<CommandItem
key={model.value}
value={model.value}
onSelect={() => {
if (currentModelField) {
onInputChange(model.value, currentModelField);
}
setOpenModelSelect(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
currentModel === model.value
? "opacity-100"
: "opacity-0"
)}
/>
<div className="flex gap-3 items-center">
<div className="flex flex-col space-y-1 flex-1">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium text-gray-900">
{model.label}
</span>
{selectedProvider === 'ollama' && model.size ? (
<span className="text-xs font-medium text-gray-500">
{model.size}
</span>
</div>
) : null}
</div>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
</div>
) : null}
</div>
</div>
) : null}
</div>
</div>
{/* Show message if no models found */}
{selectedProvider !== 'codex' && modelsChecked && availableModels.length === 0 && (
{modelsChecked && availableModels.length === 0 && (
<div className="mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
<p className="text-sm text-yellow-800">
No models found. Please make sure your provider credentials are valid and the selected provider is reachable.
@ -524,8 +565,8 @@ const TextProvider = ({
)}
{/* Web Grounding Toggle - show at the end, below models dropdown */}
<div className="bg-white flex justify-between items-center p-10 rounded-[12px]">
{/* <div className="bg-white flex justify-between items-center p-10 rounded-[12px]">
<div className=' max-w-[290px]'>
<h4 className="text-xl font-normal text-[#191919]">Advanced</h4>
@ -534,8 +575,7 @@ const TextProvider = ({
</p>
</div>
<div className="flex items-center gap-4">
<div className="w-[205px]">
<div className="w-[222px]">
<div className="flex items-center mb-4 gap-2.5 ">
<Switch
checked={!!llmConfig.WEB_GROUNDING}
@ -545,16 +585,9 @@ const TextProvider = ({
Enable Web Grounding
</label>
</div>
</div>
{/* <div className="w-[295px]"></div> */}
</div>
</div>
</div> */}
</div>
)
}

View file

@ -1,76 +1,113 @@
import { Card } from "@/components/ui/card";
export default function LoadingProfile() {
function Shimmer({ className }: { className?: string }) {
return (
<div className="h-screen bg-gradient-to-b font-instrument_sans from-gray-50 to-white flex flex-col overflow-hidden">
{/* Header Skeleton */}
<div className="flex-shrink-0 bg-white border-b border-gray-200 p-4">
<div className="container mx-auto max-w-3xl">
<div className="flex items-center justify-between">
<div className="h-8 w-32 bg-gray-200 animate-pulse rounded-md" />
<div className="flex items-center gap-4">
<div className="h-8 w-8 bg-gray-200 animate-pulse rounded-full" />
<div className="h-8 w-24 bg-gray-200 animate-pulse rounded-md" />
<div
className={`bg-[#E1E1E5] animate-pulse rounded-md ${className ?? ""}`}
aria-hidden
/>
);
}
export default function LoadingSettings() {
return (
<div className="h-screen font-syne flex flex-col overflow-hidden relative">
<div
className="fixed z-0 bottom-[-14.5rem] left-0 w-full h-full pointer-events-none"
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%)",
}}
/>
<main className="w-full mx-auto gap-6 overflow-hidden flex">
{/* SettingSideBar structure */}
<div className="w-full max-w-[230px] h-screen px-4 pt-[22px] bg-[#F9FAFB] flex flex-col shrink-0">
<div className="mt-[3.15rem] border-b border-[#E1E1E5] pb-3.5">
<Shimmer className="h-3 w-16" />
</div>
<div className="mt-6 flex-1 min-h-0">
<Shimmer className="h-3 w-24 mb-2.5" />
<div className="p-0.5 rounded-[40px] bg-white w-full max-w-[210px] border border-[#EDEEEF] flex items-center mb-[34px] h-[30px]">
<Shimmer className="h-[26px] flex-1 rounded-[70px] mx-0.5" />
<Shimmer className="h-[26px] flex-1 rounded-[70px] mx-0.5 opacity-70" />
</div>
<Shimmer className="h-3 w-28 mb-2.5" />
<div className="space-y-2.5">
{[0, 1].map((i) => (
<div
key={i}
className="w-full rounded-[6px] px-3 py-4 flex items-center gap-1.5 border border-[#EDEEEF] bg-white"
>
<Shimmer className="h-[18px] w-[18px] rounded-full shrink-0" />
<Shimmer className="h-3 flex-1 max-w-[100px]" />
</div>
))}
</div>
</div>
<div className="border-t border-[#E1E1E5] py-5">
<Shimmer className="h-3 w-12 mb-2.5" />
<div className="w-full rounded-[6px] p-3 py-4 flex items-center gap-1.5 border border-[#EDEEEF] bg-white">
<Shimmer className="h-6 w-6 rounded-full shrink-0" />
<Shimmer className="h-3 w-16" />
</div>
</div>
</div>
</div>
{/* Main Content Skeleton */}
<main className="flex-1 container mx-auto px-4 max-w-3xl overflow-hidden flex flex-col">
<div className="flex-1 overflow-hidden">
{/* LLM Selection Content Skeleton */}
<div className="space-y-6 p-6">
{/* Page Title */}
<div className="space-y-2">
<div className="h-8 w-48 bg-gray-200 animate-pulse rounded-md" />
<div className="h-5 w-72 bg-gray-200 animate-pulse rounded-md" />
{/* Main column — matches SettingPage + TextProvider default */}
<div className="w-full min-w-0 flex flex-col">
<div className="sticky top-0 right-0 z-50 py-[28px] backdrop-blur mb-4">
<div className="flex gap-3 items-center flex-wrap">
<Shimmer className="h-8 w-[132px] rounded-md" />
<Shimmer className="h-[22px] w-[min(320px,55%)] rounded-[50px]" />
</div>
</div>
{/* LLM Provider Cards */}
<div className="space-y-4">
{[...Array(3)].map((_, index) => (
<Card key={index} className="p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="h-10 w-10 bg-gray-200 animate-pulse rounded-md" />
<div className="space-y-1">
<div className="h-5 w-32 bg-gray-200 animate-pulse rounded-md" />
<div className="h-4 w-48 bg-gray-200 animate-pulse rounded-md" />
</div>
</div>
<div className="h-6 w-6 bg-gray-200 animate-pulse rounded-full" />
</div>
{/* Configuration Fields */}
<div className="space-y-4">
{[...Array(2)].map((_, fieldIndex) => (
<div key={fieldIndex} className="space-y-2">
<div className="h-4 w-24 bg-gray-200 animate-pulse rounded-md" />
<div className="h-10 w-full bg-gray-200 animate-pulse rounded-md" />
</div>
))}
</div>
</Card>
))}
</div>
{/* Model Selection */}
<Card className="p-6">
<div className="space-y-4">
<div className="h-5 w-32 bg-gray-200 animate-pulse rounded-md" />
<div className="h-10 w-full bg-gray-200 animate-pulse rounded-md" />
<div className="space-y-6 bg-[#F9F8F8] p-7 rounded-[12px] pr-4 sm:pr-7">
{/* TextProvider top card: white panel, icon + copy left, controls right */}
<div className="mb-4 flex flex-col lg:flex-row lg:items-end lg:justify-between gap-8 rounded-[12px] bg-white pt-5 pb-10 px-6 sm:px-10">
<div className="max-w-[290px] shrink-0">
<Shimmer className="w-[60px] h-[60px] rounded-[4px]" />
<Shimmer className="h-6 w-48 mt-2.5 mb-2" />
<Shimmer className="h-4 w-full max-w-[260px]" />
<Shimmer className="h-4 w-40 mt-1.5" />
</div>
</Card>
<div className="flex flex-col items-stretch lg:items-end gap-4 flex-1 min-w-0">
<div className="flex flex-col sm:flex-row gap-4 sm:justify-end w-full">
<div className="w-full sm:w-[222px]">
<Shimmer className="h-4 w-36 mb-2" />
<Shimmer className="h-12 w-full rounded-lg" />
</div>
<div className="w-full sm:w-[222px]">
<Shimmer className="h-4 w-28 mb-2" />
<Shimmer className="h-12 w-full rounded-lg" />
</div>
</div>
<div className="w-full sm:w-[222px] sm:ml-auto">
<Shimmer className="h-4 w-40 mb-2" />
<Shimmer className="h-12 w-full rounded-lg" />
</div>
</div>
</div>
{/* TextProvider “Advanced” card */}
<div className="bg-white flex flex-col sm:flex-row sm:justify-between sm:items-center gap-6 p-6 sm:p-10 rounded-[12px]">
<div className="max-w-[290px] shrink-0">
<Shimmer className="h-6 w-28 mb-2" />
<Shimmer className="h-4 w-52" />
</div>
<div className="flex items-center gap-2.5 w-full sm:w-[222px] sm:justify-start">
<Shimmer className="h-6 w-11 rounded-full shrink-0" />
<Shimmer className="h-4 flex-1 max-w-[160px]" />
</div>
</div>
</div>
</div>
</main>
{/* Fixed Bottom Button Skeleton */}
<div className="flex-shrink-0 bg-white border-t border-gray-200 p-4">
<div className="container mx-auto max-w-3xl">
<div className="h-12 w-full bg-gray-200 animate-pulse rounded-lg" />
</div>
{/* Fixed save button — matches SettingPage placement */}
<div className="mx-auto fixed bottom-20 right-5 z-40">
<Shimmer className="h-12 w-[200px] sm:w-[240px] rounded-[58px]" />
</div>
</div>
);

View file

@ -1,18 +1,23 @@
import path from "path";
import fs from "fs";
import puppeteer from "puppeteer";
import { sanitizeFilename } from "@/app/(presentation-generator)/utils/others";
import { NextResponse, NextRequest } from "next/server";
export async function POST(req: NextRequest) {
const { id, title } = await req.json();
if (!id) {
return NextResponse.json(
{ error: "Missing Presentation ID" },
{ status: 400 }
);
import { sanitizeFilename } from "@/app/(presentation-generator)/utils/others";
import {
bundledExportPackageAvailable,
runBundledPdfExport,
} from "@/lib/run-bundled-pdf-export";
async function exportPdfWithInlinePuppeteer(
id: string,
title: string | undefined
): Promise<{ path: string }> {
let nextjsUrl = process.env.NEXT_PUBLIC_URL;
if (!nextjsUrl) {
nextjsUrl = "http://127.0.0.1";
}
const browser = await puppeteer.launch({
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH,
headless: true,
@ -34,7 +39,7 @@ export async function POST(req: NextRequest) {
page.setDefaultNavigationTimeout(300000);
page.setDefaultTimeout(300000);
await page.goto(`http://localhost/pdf-maker?id=${id}`, {
await page.goto(`${nextjsUrl}/pdf-maker?id=${id}`, {
waitUntil: "networkidle0",
timeout: 300000,
});
@ -78,15 +83,12 @@ export async function POST(req: NextRequest) {
margin: { top: 0, right: 0, bottom: 0, left: 0 },
});
browser.close();
await browser.close();
const sanitizedTitle = sanitizeFilename(title ?? "presentation");
const appDataDirectory = process.env.APP_DATA_DIRECTORY!;
if (!appDataDirectory) {
return NextResponse.json({
error: "App data directory not found",
status: 500,
});
throw new Error("App data directory not found");
}
const destinationPath = path.join(
appDataDirectory,
@ -96,8 +98,41 @@ export async function POST(req: NextRequest) {
await fs.promises.mkdir(path.dirname(destinationPath), { recursive: true });
await fs.promises.writeFile(destinationPath, pdfBuffer);
return NextResponse.json({
success: true,
path: destinationPath,
});
return { path: destinationPath };
}
export async function POST(req: NextRequest) {
const { id, title } = await req.json();
if (!id) {
return NextResponse.json(
{ error: "Missing Presentation ID" },
{ status: 400 }
);
}
try {
if (await bundledExportPackageAvailable()) {
const { path: outPath } = await runBundledPdfExport({
presentationId: id,
title,
});
return NextResponse.json({
success: true,
path: outPath,
});
}
const { path: outPath } = await exportPdfWithInlinePuppeteer(id, title);
return NextResponse.json({
success: true,
path: outPath,
});
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
console.error("[export-as-pdf]", message);
return NextResponse.json(
{ error: message, success: false },
{ status: 500 }
);
}
}

View file

@ -33,16 +33,16 @@ interface CodexModel {
}
export const CHATGPT_MODELS: CodexModel[] = [
{ id: "gpt-5.1", name: "GPT-5.1" },
{ id: "gpt-5.1-codex-max", name: "GPT-5.1 Codex Max" },
{ id: "gpt-5.2", name: "GPT-5.2" },
{ id: "gpt-5.2-codex", name: "GPT-5.2 Codex" },
{ id: "gpt-5.3-codex", name: "GPT-5.3 Codex" },
{ id: "gpt-5.4 mini", name: "GPT-5.4 Mini" },
{ id: "gpt-5.4", name: "GPT-5.4" },
{ id: "gpt-5.2-codex", name: "GPT-5.2-Codex" },
{ id: "gpt-5.1-codex-max", name: "GPT-5.1-Codex-Max" },
{ 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.4-mini";
export const DEFAULT_CODEX_MODEL = "gpt-5.1-codex-mini";
export default function CodexConfig({
codexModel,

View file

@ -0,0 +1,139 @@
import path from "path";
import os from "os";
import fs from "fs/promises";
import { spawn } from "child_process";
import { sanitizeFilename } from "@/app/(presentation-generator)/utils/others";
/** Repo `presentation-export/` at app root (`/app/presentation-export` in Docker). */
export function getExportPackageRoot(): string {
return (
process.env.EXPORT_PACKAGE_ROOT?.trim() ||
path.join(process.cwd(), "..", "..", "presentation-export")
);
}
export function getPresentonAppRoot(): string {
return (
process.env.PRESENTON_APP_ROOT?.trim() ||
path.join(process.cwd(), "..", "..")
);
}
function bundledConverterPath(exportRoot: string): string {
const fromEnv = process.env.BUILT_PYTHON_MODULE_PATH?.trim();
if (fromEnv) {
return fromEnv;
}
if (process.platform === "linux" && process.arch === "x64") {
return path.join(exportRoot, "py", "convert-linux-x64");
}
throw new Error(
`No bundled export converter for ${process.platform}/${process.arch}. Set BUILT_PYTHON_MODULE_PATH.`
);
}
export async function bundledExportPackageAvailable(): Promise<boolean> {
try {
const root = getExportPackageRoot();
await fs.access(path.join(root, "index.js"));
await fs.access(bundledConverterPath(root));
return true;
} catch {
return false;
}
}
export type BundledPdfExportResult = { path: string };
/**
* Runs the bundled export entrypoint (`presentation-export/index.js`) with
* `BUILT_PYTHON_MODULE_PATH` pointing at the PyInstaller converter binary.
*/
export async function runBundledPdfExport(params: {
presentationId: string;
title: string | undefined;
}): Promise<BundledPdfExportResult> {
const { presentationId, title } = params;
const exportRoot = getExportPackageRoot();
const indexJs = path.join(exportRoot, "index.js");
const converter = bundledConverterPath(exportRoot);
const appRoot = getPresentonAppRoot();
await fs.access(indexJs);
await fs.access(converter);
const nextjsUrl =
process.env.NEXT_PUBLIC_URL?.trim() || "http://127.0.0.1";
const q = new URLSearchParams({ id: presentationId });
const fastapiUrl = process.env.NEXT_PUBLIC_FAST_API?.trim();
if (fastapiUrl) {
q.set("fastapiUrl", fastapiUrl);
}
const pptUrl = `${nextjsUrl}/pdf-maker?${q.toString()}`;
const tempBase =
process.env.TEMP_DIRECTORY?.trim() || path.join(os.tmpdir(), "presenton");
await fs.mkdir(tempBase, { recursive: true });
const workDir = await fs.mkdtemp(path.join(tempBase, "export-"));
const exportTaskPath = path.join(workDir, "export_task.json");
const exportTask = {
type: "export",
url: pptUrl,
format: "pdf",
title: sanitizeFilename(title ?? "presentation"),
fastapiUrl: fastapiUrl || undefined,
};
await fs.writeFile(exportTaskPath, JSON.stringify(exportTask), "utf8");
const responsePath = exportTaskPath.replace(/\.json$/i, ".response.json");
await new Promise<void>((resolve, reject) => {
const child = spawn(process.execPath, [indexJs, exportTaskPath], {
cwd: appRoot,
stdio: ["ignore", "pipe", "pipe"],
env: {
...process.env,
BUILT_PYTHON_MODULE_PATH: converter,
ELECTRON_RUN_AS_NODE: "0",
},
});
const stderr: Buffer[] = [];
const stdout: Buffer[] = [];
child.stderr?.on("data", (d) => stderr.push(d));
child.stdout?.on("data", (d) => stdout.push(d));
child.on("error", reject);
child.on("exit", (code) => {
if (code === 0) {
resolve();
} else {
const errText = Buffer.concat(stderr).toString("utf8").trim();
const outText = Buffer.concat(stdout).toString("utf8").trim();
reject(
new Error(
`Export process exited with code ${code}${errText ? `. ${errText}` : ""}${outText ? ` stdout: ${outText}` : ""}`
)
);
}
});
});
const responseRaw = await fs.readFile(responsePath, "utf8");
const responseData = JSON.parse(responseRaw) as { path?: string };
if (!responseData?.path || typeof responseData.path !== "string") {
throw new Error("Export finished but response did not include a path.");
}
let outPath = responseData.path;
if (!path.isAbsolute(outPath)) {
const appData = process.env.APP_DATA_DIRECTORY?.trim();
if (!appData) {
throw new Error("APP_DATA_DIRECTORY is required for relative export paths.");
}
outPath = path.join(appData, outPath);
}
return { path: outPath };
}

File diff suppressed because one or more lines are too long