feat: adds working new template generation with openai, google, anthropic and codex; fix: improves libreoffice binary detection

This commit is contained in:
sauravniraula 2026-04-08 17:58:49 +05:45
parent 36432f5f52
commit 92de673e11
No known key found for this signature in database
GPG key ID: 60FCC1B5A5E83326
14 changed files with 697 additions and 164 deletions

View file

@ -86,6 +86,7 @@ const createWindow = () => {
async function startServers(fastApiPort: number, nextjsPort: number) {
try {
const sofficePath = getSofficePath();
const fastApi = await startFastApiServer(
fastapiDir,
fastApiPort,
@ -122,9 +123,11 @@ async function startServers(fastApiPort: number, nextjsPort: number) {
TEMP_DIRECTORY: tempDir,
USER_CONFIG_PATH: userConfigPath,
MIGRATE_DATABASE_ON_STARTUP: "True",
// Resolved by libreoffice-check.ts at startup; lets Python invoke the
// exact binary path instead of relying on the system PATH.
SOFFICE_PATH: getSofficePath(),
// Resolved by libreoffice-check.ts at startup when available; lets
// Python invoke the exact binary path instead of relying on PATH.
...(sofficePath && {
SOFFICE_PATH: sofficePath,
}),
IMAGEMAGICK_BINARY: getImageMagickBinaryPath(),
LITEPARSE_RUNNER_PATH: getLiteParseRunnerPath(),
// Use Electron's embedded runtime for LiteParse so parsing does not
@ -163,25 +166,38 @@ async function startServers(fastApiPort: number, nextjsPort: number) {
async function stopServers() {
if (fastApiProcess?.pid) {
console.log("Closing FastAPI...");
console.log("Force killing FastAPI...");
try {
await killProcess(fastApiProcess.pid);
} catch {
await killProcess(fastApiProcess.pid, "SIGKILL");
} catch (error) {
console.error("Failed to force kill FastAPI:", error);
}
fastApiProcess = undefined;
}
if (nextjsProcess) {
if (isDev) {
console.log("Closing NextJS...");
if ("pid" in nextjsProcess && nextjsProcess.pid) {
console.log("Force killing NextJS...");
try {
await killProcess(nextjsProcess.pid);
} catch {
await killProcess(nextjsProcess.pid, "SIGKILL");
} catch (error) {
console.error("Failed to force kill NextJS:", error);
}
} else {
} else if (typeof nextjsProcess.close === "function") {
console.log("Closing NextJS...");
nextjsProcess.close();
}
nextjsProcess = undefined;
}
}
async function forceQuitApp(exitCode = 0) {
if (isStopping) return;
isStopping = true;
stopUpdateChecker();
try {
await stopServers();
} finally {
app.exit(exitCode);
}
}
@ -280,29 +296,17 @@ app.whenReady().then(async () => {
});
app.on("window-all-closed", async () => {
stopUpdateChecker();
await stopServers();
app.quit();
await forceQuitApp(0);
});
app.on("before-quit", async (event) => {
if (isStopping) return;
isStopping = true;
event.preventDefault();
try {
await stopServers();
} finally {
app.quit();
}
await forceQuitApp(0);
});
app.on("will-quit", async (event) => {
if (isStopping) return;
isStopping = true;
event.preventDefault();
try {
await stopServers();
} finally {
app.quit();
}
await forceQuitApp(0);
});

View file

@ -31,8 +31,8 @@ interface FastApiEnv {
TEMP_DIRECTORY?: string,
USER_CONFIG_PATH?: string,
MIGRATE_DATABASE_ON_STARTUP?: string,
/** Absolute path to the soffice binary resolved at startup by libreoffice-check.ts. */
SOFFICE_PATH?: string,
/** Absolute path to the resolved LibreOffice executable discovered at startup. */
SOFFICE_PATH?: string,
/** Absolute path to the ImageMagick binary resolved at startup by imagemagick-check.ts. */
IMAGEMAGICK_BINARY?: string,
/** Absolute path to the bundled LiteParse runner script. */
@ -90,4 +90,4 @@ interface UserConfig {
interface IPCStatus {
success: boolean,
message?: string,
}
}

View file

@ -239,24 +239,81 @@ export function getLinuxInstallCommand(): { cmd: string; args: string[] } | null
}
// ---------------------------------------------------------------------------
// Resolved path set once by checkLibreOfficeBeforeWindow()
// Resolved path populated by successful detection
// ---------------------------------------------------------------------------
/**
* The resolved soffice binary path discovered at startup.
* Defaults to the bare command name so callers always get a usable string
* even if the check has not run yet (e.g. in non-Electron environments).
* The resolved LibreOffice executable path discovered at startup.
* Empty until a successful detection populates it.
*/
let resolvedSofficePath: string = "soffice";
let resolvedSofficePath = "";
/**
* Returns the resolved soffice binary path found during startup detection.
* Returns the resolved LibreOffice executable path found during startup detection.
*
* Pass as the `SOFFICE_PATH` env var to the FastAPI subprocess so Python
* code can invoke the exact binary rather than relying on `PATH`.
*/
export function getSofficePath(): string {
return resolvedSofficePath;
export function getSofficePath(): string | undefined {
return resolvedSofficePath || undefined;
}
function setResolvedSofficePath(candidate?: string): void {
if (!candidate) {
return;
}
resolvedSofficePath = candidate;
}
function firstNonEmptyLine(value: string): string | undefined {
return value
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.length > 0);
}
async function getVersionForBinary(binaryPath: string): Promise<string | undefined> {
try {
const quoted = `"${binaryPath}"`;
const { stdout } = await execAsync(`${quoted} --version`, {
timeout: 8_000,
windowsHide: (process.platform as string) === "win32",
});
return stdout.trim() || undefined;
} catch {
return undefined;
}
}
async function resolveLibreOfficeFromPath(): Promise<string | undefined> {
if (process.platform === "win32") {
try {
const { stdout } = await execAsync("where soffice.exe", {
timeout: 8_000,
windowsHide: true,
});
return firstNonEmptyLine(stdout);
} catch {
return undefined;
}
}
try {
const { stdout } = await execAsync("command -v soffice || command -v libreoffice", {
timeout: 8_000,
windowsHide: false,
});
const resolved = firstNonEmptyLine(stdout);
if (!resolved) {
return undefined;
}
if (path.isAbsolute(resolved)) {
return resolved;
}
return undefined;
} catch {
return undefined;
}
}
// ---------------------------------------------------------------------------
@ -266,7 +323,7 @@ export function getSofficePath(): string {
/**
* Attempts to detect LibreOffice by:
* 1. Checking well-known installation paths for the binary (fast, no shell).
* 2. Falling back to `soffice --version` via the shell (catches PATH installs).
* 2. Falling back to resolving the binary from PATH.
*
* Returns an object indicating whether LibreOffice was found and, when it
* was, the version string reported by the binary.
@ -275,6 +332,7 @@ export async function isLibreOfficeInstalled(): Promise<LibreOfficeCheckResult>
// --- Step 1: check well-known paths synchronously (no exec overhead) ---
for (const candidate of getCandidatePaths()) {
if (fs.existsSync(candidate)) {
setResolvedSofficePath(candidate);
// On Windows, avoid probing with "--version" because some LibreOffice
// builds open a transient console window for this command.
if (process.platform === "win32") {
@ -282,53 +340,24 @@ export async function isLibreOfficeInstalled(): Promise<LibreOfficeCheckResult>
}
// Binary found at a known location try to get the version string.
try {
const quoted = `"${candidate}"`;
const { stdout } = await execAsync(`${quoted} --version`, {
timeout: 8_000,
windowsHide: (process.platform as string) === "win32",
});
return { installed: true, version: stdout.trim(), path: candidate };
} catch {
// Binary exists but failed to execute still treat as installed.
return { installed: true, path: candidate };
const version = await getVersionForBinary(candidate);
if (version) {
return { installed: true, version, path: candidate };
}
// Binary exists but failed to execute still treat as installed.
return { installed: true, path: candidate };
}
}
// --- Step 2: try the PATH-based command ---
if (process.platform === "win32") {
try {
// Use "where" for PATH detection without launching LibreOffice itself.
const { stdout } = await execAsync("where soffice.exe", {
timeout: 8_000,
windowsHide: true,
});
const firstPath = stdout
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.length > 0);
if (firstPath) {
return { installed: true, path: firstPath };
}
} catch {
// Keep behavior: if PATH lookup fails, report not installed.
}
const pathBinary = await resolveLibreOfficeFromPath();
if (!pathBinary) {
return { installed: false };
}
try {
const { stdout } = await execAsync("soffice --version", {
timeout: 8_000,
windowsHide: (process.platform as string) === "win32",
});
// Found via PATH record the bare command name as the path so callers
// can pass it directly to subprocess invocations.
return { installed: true, version: stdout.trim(), path: "soffice" };
} catch {
// Command not found or timed out LibreOffice is not available.
return { installed: false };
}
setResolvedSofficePath(pathBinary);
const version = await getVersionForBinary(pathBinary);
return { installed: true, version, path: pathBinary };
}
// ---------------------------------------------------------------------------
@ -426,4 +455,4 @@ export async function checkLibreOfficeBeforeWindow(
// Always proceed never block the app
return true;
}
}

View file

@ -0,0 +1,39 @@
"""template create info
Revision ID: 95b5127e93cd
Revises: 82abdbc476a7
Create Date: 2026-04-08 13:44:21.132802
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
# revision identifiers, used by Alembic.
revision: str = '95b5127e93cd'
down_revision: Union[str, None] = '82abdbc476a7'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('template_create_infos',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('fonts', sa.JSON(), nullable=True),
sa.Column('pptx_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('slide_htmls', sa.JSON(), nullable=False),
sa.Column('slide_image_urls', sa.JSON(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('template_create_infos')
# ### end Alembic commands ###

View file

@ -4,4 +4,4 @@ OPENAI_URL = "https://api.openai.com/v1"
DEFAULT_OPENAI_MODEL = "gpt-4.1"
DEFAULT_GOOGLE_MODEL = "models/gemini-2.5-flash"
DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-4-20250514"
DEFAULT_CODEX_MODEL = "gpt-5.4-mini"
DEFAULT_CODEX_MODEL = "gpt-5.2-codex"

View file

@ -0,0 +1,246 @@
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_next_public_fast_api_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 = get_next_public_fast_api_env()
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()

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View file

@ -2,8 +2,8 @@ from typing import Any
from templates.presentation_layout import PresentationLayoutModel
PLACEHOLDER_IMAGE_URL = "https://presenton-public-assets.s3.ap-southeast-1.amazonaws.com/replaceable_template_image.png"
PLACEHOLDER_ICON_URL = "https://presenton-public.s3.ap-southeast-1.amazonaws.com/static/icons/placeholder.svg"
PLACEHOLDER_IMAGE_URL = "/static/images/replaceable_template_image.png"
PLACEHOLDER_ICON_URL = "/static/icons/placeholder.svg"
def build_schema_example(schema: dict) -> Any:

View file

@ -17,9 +17,9 @@ 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.pptx_html_stub import BASIC_TEMPLATE_HTML
from templates.presentation_layout import PresentationLayoutModel
from templates.preview import (
FontsUploadAndSlidesPreviewResponse,
@ -31,7 +31,10 @@ from templates.prompts import (
SLIDE_LAYOUT_EDIT_SYSTEM_PROMPT,
)
from templates.providers import edit_slide_layout_code, generate_slide_layout_code
from utils.asset_directory_utils import resolve_image_path_to_filesystem
from utils.asset_directory_utils import (
resolve_app_path_to_filesystem,
resolve_image_path_to_filesystem,
)
class TemplateDetail(BaseModel):
@ -378,13 +381,12 @@ async def upload_fonts_and_slides_preview(
default=None, description="Font files to upload"
),
original_font_names: Optional[List[str]] = Form(default=None),
max_slides: Optional[int] = Query(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=max_slides,
max_slides=25,
)
@ -397,11 +399,34 @@ async def init_create_template(
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=[BASIC_TEMPLATE_HTML for _ in request.slide_image_urls],
slide_htmls=slide_htmls,
)
sql_session.add(template_create_info)
await sql_session.commit()
@ -437,10 +462,9 @@ async def create_slide_layout(
image_bytes=image_bytes,
media_type=media_type,
)
normalized_react_component = _normalize_layout_code_for_create(react_component)
return CreateSlideLayoutResponse(
react_component=_normalize_layout_code_for_create(react_component)
)
return CreateSlideLayoutResponse(react_component=normalized_react_component)
async def edit_slide_layout(

View file

@ -1,30 +0,0 @@
BASIC_TEMPLATE_HTML = """
<!-- TODO: pptx to html conversion -->
<div class="relative w-full rounded-sm max-w-[1280px] shadow-lg max-h-[720px] aspect-video bg-white z-20 mx-auto overflow-hidden">
<section class="flex h-full w-full flex-col justify-between bg-white px-[72px] py-[64px]">
<div class="space-y-[18px]">
<div class="h-[20px] w-[180px] rounded-full bg-slate-200"></div>
<div class="h-[72px] w-[70%] rounded-[20px] bg-slate-100"></div>
<div class="space-y-[10px] pt-[8px]">
<div class="h-[16px] w-[82%] rounded-full bg-slate-100"></div>
<div class="h-[16px] w-[78%] rounded-full bg-slate-100"></div>
<div class="h-[16px] w-[66%] rounded-full bg-slate-100"></div>
</div>
</div>
<div class="grid grid-cols-3 gap-[18px]">
<div class="rounded-[24px] border border-slate-200 bg-slate-50 p-[24px]">
<div class="h-[18px] w-[55%] rounded-full bg-slate-200"></div>
<div class="mt-[18px] h-[96px] rounded-[18px] bg-white"></div>
</div>
<div class="rounded-[24px] border border-slate-200 bg-slate-50 p-[24px]">
<div class="h-[18px] w-[55%] rounded-full bg-slate-200"></div>
<div class="mt-[18px] h-[96px] rounded-[18px] bg-white"></div>
</div>
<div class="rounded-[24px] border border-slate-200 bg-slate-50 p-[24px]">
<div class="h-[18px] w-[55%] rounded-full bg-slate-200"></div>
<div class="mt-[18px] h-[96px] rounded-[18px] bg-white"></div>
</div>
</div>
</section>
</div>
""".strip()

View file

@ -34,10 +34,10 @@ Provide reusable TSX code which can be used as template to generate new slides w
- 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 'https://presenton-public-assets.s3.ap-southeast-1.amazonaws.com/replaceable_template_image.png'
- 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 'https://presenton-public.s3.ap-southeast-1.amazonaws.com/static/icons/placeholder.svg'
- 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.
@ -156,9 +156,9 @@ You need to edit the given TSX code of the slide layout code according to the pr
# 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: 'https://presenton-public-assets.s3.ap-southeast-1.amazonaws.com/replaceable_template_image.png'
- 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: 'https://presenton-public.s3.ap-southeast-1.amazonaws.com/static/icons/placeholder.svg'
- Use this as default icon url: '/static/icons/placeholder.svg'
# Schema Rules
- "describe" must be added for every fields.
@ -208,9 +208,9 @@ You need to edit the given TSX code of the slide layout code according to the pr
# 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: 'https://presenton-public-assets.s3.ap-southeast-1.amazonaws.com/replaceable_template_image.png'
- 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: 'https://presenton-public.s3.ap-southeast-1.amazonaws.com/static/icons/placeholder.svg'
- Use this as default icon url: '/static/icons/placeholder.svg'
# Output Rules
- Make sure the schema and react component are valid.

View file

@ -2,7 +2,7 @@ import asyncio
import base64
from dataclasses import dataclass
import time
from typing import Awaitable, Callable, Optional
from typing import Any, Awaitable, Callable, Optional
from anthropic import AsyncAnthropic
from fastapi import HTTPException
@ -20,7 +20,7 @@ from utils.get_env import (
get_google_api_key_env,
get_openai_api_key_env,
)
from utils.llm_provider import get_llm_provider
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,
@ -28,10 +28,6 @@ from utils.set_env import (
set_codex_token_expires_env,
)
OPENAI_TEMPLATE_MODEL = "gpt-5.4"
CODEX_TEMPLATE_MODEL = "gpt-5.4"
GOOGLE_TEMPLATE_MODEL = "gemini-3.1"
ANTHROPIC_TEMPLATE_MODEL = "opus 4.6"
MAX_ATTEMPTS_PER_PROVIDER = 4
@ -46,17 +42,16 @@ 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=OPENAI_TEMPLATE_MODEL)
return TemplateProviderSpec(provider=provider, model=get_model())
if provider == LLMProvider.CODEX:
return TemplateProviderSpec(provider=provider, model=CODEX_TEMPLATE_MODEL)
return TemplateProviderSpec(provider=provider, model=get_model())
if provider == LLMProvider.GOOGLE:
return TemplateProviderSpec(provider=provider, model=GOOGLE_TEMPLATE_MODEL)
return TemplateProviderSpec(provider=provider, model=get_model())
if provider == LLMProvider.ANTHROPIC:
return TemplateProviderSpec(provider=provider, model=ANTHROPIC_TEMPLATE_MODEL)
return TemplateProviderSpec(provider=provider, model=get_model())
raise HTTPException(
status_code=400,
@ -68,7 +63,7 @@ async def run_plain_provider_buckets(*, providers: list[PlainLLMProvider]) -> st
last_exception: Optional[Exception] = None
for provider in providers:
for _ in range(MAX_ATTEMPTS_PER_PROVIDER):
for attempt in range(1, MAX_ATTEMPTS_PER_PROVIDER + 1):
try:
response_text = await provider.call()
if response_text:
@ -180,7 +175,6 @@ async def _call_openai_like(
user_text: str,
image_bytes: Optional[bytes] = None,
media_type: str = "image/png",
reasoning_effort: str = "medium",
) -> str:
content = [{"type": "input_text", "text": user_text}]
if image_bytes:
@ -196,7 +190,6 @@ async def _call_openai_like(
model=model,
instructions=system_prompt,
input=[{"role": "user", "content": content}],
reasoning={"effort": reasoning_effort},
text={"verbosity": "medium"},
store=False,
)
@ -206,6 +199,78 @@ async def _call_openai_like(
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,
@ -272,13 +337,13 @@ async def _call_anthropic(
def _build_provider_call(
*,
spec: Optional[TemplateProviderSpec] = None,
system_prompt: str,
user_text: str,
image_bytes: Optional[bytes] = None,
media_type: str = "image/png",
reasoning_effort: str = "medium",
) -> PlainLLMProvider:
spec = get_template_provider_spec()
spec = spec or get_template_provider_spec()
if spec.provider == LLMProvider.OPENAI:
return PlainLLMProvider(
@ -290,20 +355,17 @@ def _build_provider_call(
user_text=user_text,
image_bytes=image_bytes,
media_type=media_type,
reasoning_effort=reasoning_effort,
),
)
if spec.provider == LLMProvider.CODEX:
return PlainLLMProvider(
name="Codex",
call=lambda: _call_openai_like(
client=_get_codex_client(),
call=lambda: _call_codex(
model=spec.model,
system_prompt=system_prompt,
user_text=user_text,
image_bytes=image_bytes,
media_type=media_type,
reasoning_effort=reasoning_effort,
),
)
if spec.provider == LLMProvider.GOOGLE:
@ -347,7 +409,6 @@ async def generate_slide_layout_code(
user_text=user_text,
image_bytes=image_bytes,
media_type=media_type,
reasoning_effort="high",
)
return await run_plain_provider_buckets(providers=[provider])

View file

@ -4,6 +4,7 @@ import re
from datetime import datetime, timezone
import pytest
from fastapi import HTTPException
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy.sql import Delete, Select
@ -13,6 +14,7 @@ from enums.llm_provider import LLMProvider
from models.sql.presentation_layout_code import PresentationLayoutCodeModel
from models.sql.template import TemplateModel
from models.sql.template_create_info import TemplateCreateInfoModel
from services.export_task_service import PptxToHtmlDocument
from templates.handler import (
CloneSlideLayoutRequest,
CreateSlideLayoutRequest,
@ -32,17 +34,13 @@ from templates.handler import (
update_template,
upload_fonts_and_slides_preview,
)
from templates.pptx_html_stub import BASIC_TEMPLATE_HTML
from templates.preview import (
FontCheckResponse,
FontsUploadAndSlidesPreviewResponse,
check_fonts_in_pptx_handler,
)
from templates.providers import (
ANTHROPIC_TEMPLATE_MODEL,
CODEX_TEMPLATE_MODEL,
GOOGLE_TEMPLATE_MODEL,
OPENAI_TEMPLATE_MODEL,
generate_slide_layout_code,
get_template_provider_spec,
)
@ -200,8 +198,30 @@ def test_router_registration_replaces_old_routes(api_client):
assert "/api/v1/ppt/pptx-fonts/process" not in paths
def test_template_create_init_stores_stub_htmls():
def test_template_create_init_stores_exported_htmls(tmp_path, monkeypatch):
session = FakeAsyncSession()
pptx_path = tmp_path / "presentation.pptx"
pptx_path.write_bytes(b"pptx")
async def fake_convert(pptx_path_value: str, get_fonts: bool = False):
assert pptx_path_value == str(pptx_path)
assert get_fonts is False
return PptxToHtmlDocument(
slides=["<div>slide 1</div>", "<div>slide 2</div>"],
width=1280,
height=720,
images_dir="images",
fonts_dir="fonts",
)
monkeypatch.setattr(
"templates.handler.resolve_app_path_to_filesystem",
lambda _value: str(pptx_path),
)
monkeypatch.setattr(
"templates.handler.EXPORT_TASK_SERVICE.convert_pptx_to_html",
fake_convert,
)
template_info_id = asyncio.run(
init_create_template(
@ -224,13 +244,102 @@ def test_template_create_init_stores_stub_htmls():
)
template_info = session.template_infos[template_info_id]
assert template_info.slide_htmls == [BASIC_TEMPLATE_HTML, BASIC_TEMPLATE_HTML]
assert template_info.slide_htmls == ["<div>slide 1</div>", "<div>slide 2</div>"]
assert template_info.slide_image_urls == [
"/app_data/images/a/slide_1.png",
"/app_data/images/a/slide_2.png",
]
def test_template_create_init_truncates_exported_htmls_to_preview_count(tmp_path, monkeypatch):
session = FakeAsyncSession()
pptx_path = tmp_path / "presentation.pptx"
pptx_path.write_bytes(b"pptx")
async def fake_convert(_pptx_path_value: str, get_fonts: bool = False):
assert get_fonts is False
return PptxToHtmlDocument(
slides=["<div>slide 1</div>", "<div>slide 2</div>", "<div>slide 3</div>"],
width=1280,
height=720,
images_dir="images",
fonts_dir="fonts",
)
monkeypatch.setattr(
"templates.handler.resolve_app_path_to_filesystem",
lambda _value: str(pptx_path),
)
monkeypatch.setattr(
"templates.handler.EXPORT_TASK_SERVICE.convert_pptx_to_html",
fake_convert,
)
template_info_id = asyncio.run(
init_create_template(
request=type(
"Request",
(),
{
"pptx_url": "/app_data/uploads/template-previews/test/presentation.pptx",
"slide_image_urls": ["/app_data/images/a/slide_1.png"],
"fonts": {},
},
)(),
sql_session=session,
)
)
assert session.template_infos[template_info_id].slide_htmls == ["<div>slide 1</div>"]
def test_template_create_init_fails_when_export_returns_too_few_slides(tmp_path, monkeypatch):
session = FakeAsyncSession()
pptx_path = tmp_path / "presentation.pptx"
pptx_path.write_bytes(b"pptx")
async def fake_convert(_pptx_path_value: str, get_fonts: bool = False):
assert get_fonts is False
return PptxToHtmlDocument(
slides=["<div>slide 1</div>"],
width=1280,
height=720,
images_dir="images",
fonts_dir="fonts",
)
monkeypatch.setattr(
"templates.handler.resolve_app_path_to_filesystem",
lambda _value: str(pptx_path),
)
monkeypatch.setattr(
"templates.handler.EXPORT_TASK_SERVICE.convert_pptx_to_html",
fake_convert,
)
with pytest.raises(HTTPException) as exc:
asyncio.run(
init_create_template(
request=type(
"Request",
(),
{
"pptx_url": "/app_data/uploads/template-previews/test/presentation.pptx",
"slide_image_urls": [
"/app_data/images/a/slide_1.png",
"/app_data/images/a/slide_2.png",
],
"fonts": {},
},
)(),
sql_session=session,
)
)
assert exc.value.status_code == 400
assert "returned fewer slides than the preview images" in exc.value.detail
def test_fonts_check_endpoint(monkeypatch):
async def fake_font_check(_pptx_path: str, _temp_dir: str):
return [("Inter", "https://fonts.googleapis.com/css2?family=Inter&display=swap")], [("Custom Font", None)]
@ -293,25 +402,27 @@ def test_fonts_upload_and_preview_route_uses_new_handler(monkeypatch):
def test_provider_spec_mapping_and_restrictions(monkeypatch):
monkeypatch.setattr("templates.providers.get_model", lambda: "user-selected-model")
monkeypatch.setattr("templates.providers.get_llm_provider", lambda: LLMProvider.OPENAI)
spec = get_template_provider_spec()
assert spec.provider == LLMProvider.OPENAI
assert spec.model == OPENAI_TEMPLATE_MODEL
monkeypatch.setattr("templates.providers.get_llm_provider", lambda: LLMProvider.CODEX)
spec = get_template_provider_spec()
assert spec.provider == LLMProvider.CODEX
assert spec.model == CODEX_TEMPLATE_MODEL
assert spec.model == "user-selected-model"
monkeypatch.setattr("templates.providers.get_llm_provider", lambda: LLMProvider.GOOGLE)
spec = get_template_provider_spec()
assert spec.provider == LLMProvider.GOOGLE
assert spec.model == GOOGLE_TEMPLATE_MODEL
assert spec.model == "user-selected-model"
monkeypatch.setattr("templates.providers.get_llm_provider", lambda: LLMProvider.ANTHROPIC)
spec = get_template_provider_spec()
assert spec.provider == LLMProvider.ANTHROPIC
assert spec.model == ANTHROPIC_TEMPLATE_MODEL
assert spec.model == "user-selected-model"
monkeypatch.setattr("templates.providers.get_llm_provider", lambda: LLMProvider.CODEX)
spec = get_template_provider_spec()
assert spec.provider == LLMProvider.CODEX
assert spec.model == "user-selected-model"
monkeypatch.setattr("templates.providers.get_llm_provider", lambda: LLMProvider.OLLAMA)
with pytest.raises(Exception) as exc:
@ -319,6 +430,48 @@ def test_provider_spec_mapping_and_restrictions(monkeypatch):
assert "Template generation only supports OpenAI, Codex, Google, or Anthropic." in str(exc.value)
def test_generate_slide_layout_code_uses_streaming_for_codex(monkeypatch):
create_kwargs = {}
class FakeResponses:
async def create(self, **kwargs):
create_kwargs.update(kwargs)
async def _stream():
yield {"type": "response.output_text.delta", "delta": "const layoutId = "}
yield {"type": "response.output_text.delta", "delta": '"title-image";'}
return _stream()
class FakeClient:
def __init__(self):
self.responses = FakeResponses()
monkeypatch.setattr("templates.providers.get_llm_provider", lambda: LLMProvider.CODEX)
monkeypatch.setattr("templates.providers.get_model", lambda: "user-selected-model")
monkeypatch.setattr("templates.providers._get_codex_client", lambda: FakeClient())
response = asyncio.run(
generate_slide_layout_code(
system_prompt="system prompt",
user_text="user text",
image_bytes=PNG_BYTES,
media_type="image/png",
)
)
assert response == 'const layoutId = "title-image";'
assert create_kwargs["model"] == "user-selected-model"
assert create_kwargs["stream"] is True
assert create_kwargs["store"] is False
assert create_kwargs["text"] == {"verbosity": "medium"}
assert create_kwargs["input"][0]["content"][0]["type"] == "input_image"
assert create_kwargs["input"][0]["content"][1] == {
"type": "input_text",
"text": "user text",
}
def test_create_and_edit_slide_layout_routes_use_provider_layer(tmp_path, monkeypatch):
session = FakeAsyncSession()
image_path = tmp_path / "slide.png"

View file

@ -5,12 +5,13 @@ from urllib.parse import urlparse, unquote
from utils.get_env import get_app_data_directory_env
def resolve_image_path_to_filesystem(path_or_url: str) -> Optional[str]:
def resolve_app_path_to_filesystem(path_or_url: str) -> Optional[str]:
"""
Resolve an image path or URL to an actual filesystem path.
Resolve an app-served path or URL to an actual filesystem path.
Handles:
- Path strings: /app_data/images/..., /static/..., absolute paths, relative
- file:// URLs returned by export runtimes
- HTTP URLs whose path component is an absolute filesystem path (Mac/Electron):
When img src is /Users/.../images/xxx.png, browser resolves to
http://origin/Users/.../images/xxx.png. Next.js returns 404 for these.
@ -21,10 +22,12 @@ def resolve_image_path_to_filesystem(path_or_url: str) -> Optional[str]:
return None
# Extract path from HTTP URL if needed
path = path_or_url
if path_or_url.startswith("http"):
if path_or_url.startswith("http") or path_or_url.startswith("file:"):
try:
parsed = urlparse(path_or_url)
path = unquote(parsed.path)
if parsed.scheme == "file" and os.name == "nt" and path.startswith("/"):
path = path[1:]
except Exception:
return None
# Handle /app_data/images/
@ -63,6 +66,10 @@ def resolve_image_path_to_filesystem(path_or_url: str) -> Optional[str]:
return actual if os.path.isfile(actual) else None
def resolve_image_path_to_filesystem(path_or_url: str) -> Optional[str]:
return resolve_app_path_to_filesystem(path_or_url)
def get_images_directory():
images_directory = os.path.join(get_app_data_directory_env(), "images")
os.makedirs(images_directory, exist_ok=True)