diff --git a/electron/app/main.ts b/electron/app/main.ts index 420efdb8..15d85348 100644 --- a/electron/app/main.ts +++ b/electron/app/main.ts @@ -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); }); diff --git a/electron/app/types/index.d.ts b/electron/app/types/index.d.ts index 0d066a2d..cee175bc 100644 --- a/electron/app/types/index.d.ts +++ b/electron/app/types/index.d.ts @@ -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, -} \ No newline at end of file +} diff --git a/electron/app/utils/libreoffice-check.ts b/electron/app/utils/libreoffice-check.ts index 37709513..aae8c459 100644 --- a/electron/app/utils/libreoffice-check.ts +++ b/electron/app/utils/libreoffice-check.ts @@ -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 { + 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 { + 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 // --- 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 } // 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; -} \ No newline at end of file +} diff --git a/electron/servers/fastapi/alembic/versions/95b5127e93cd_template_create_info.py b/electron/servers/fastapi/alembic/versions/95b5127e93cd_template_create_info.py new file mode 100644 index 00000000..d1ec463e --- /dev/null +++ b/electron/servers/fastapi/alembic/versions/95b5127e93cd_template_create_info.py @@ -0,0 +1,39 @@ +"""template create info + +Revision ID: 95b5127e93cd +Revises: 82abdbc476a7 +Create Date: 2026-04-08 13:44:21.132802 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + + +# revision identifiers, used by Alembic. +revision: str = '95b5127e93cd' +down_revision: Union[str, None] = '82abdbc476a7' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('template_create_infos', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('fonts', sa.JSON(), nullable=True), + sa.Column('pptx_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('slide_htmls', sa.JSON(), nullable=False), + sa.Column('slide_image_urls', sa.JSON(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('template_create_infos') + # ### end Alembic commands ### diff --git a/electron/servers/fastapi/constants/llm.py b/electron/servers/fastapi/constants/llm.py index 2b5613c2..21eacb73 100644 --- a/electron/servers/fastapi/constants/llm.py +++ b/electron/servers/fastapi/constants/llm.py @@ -4,4 +4,4 @@ OPENAI_URL = "https://api.openai.com/v1" DEFAULT_OPENAI_MODEL = "gpt-4.1" DEFAULT_GOOGLE_MODEL = "models/gemini-2.5-flash" DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-4-20250514" -DEFAULT_CODEX_MODEL = "gpt-5.4-mini" +DEFAULT_CODEX_MODEL = "gpt-5.2-codex" diff --git a/electron/servers/fastapi/services/export_task_service.py b/electron/servers/fastapi/services/export_task_service.py new file mode 100644 index 00000000..571dee01 --- /dev/null +++ b/electron/servers/fastapi/services/export_task_service.py @@ -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() diff --git a/electron/servers/fastapi/static/images/replaceable_template_image.png b/electron/servers/fastapi/static/images/replaceable_template_image.png new file mode 100644 index 00000000..838d7f9d Binary files /dev/null and b/electron/servers/fastapi/static/images/replaceable_template_image.png differ diff --git a/electron/servers/fastapi/templates/example.py b/electron/servers/fastapi/templates/example.py index fab145d3..1658e56c 100644 --- a/electron/servers/fastapi/templates/example.py +++ b/electron/servers/fastapi/templates/example.py @@ -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: diff --git a/electron/servers/fastapi/templates/handler.py b/electron/servers/fastapi/templates/handler.py index 39f06fdf..d6cf9dc4 100644 --- a/electron/servers/fastapi/templates/handler.py +++ b/electron/servers/fastapi/templates/handler.py @@ -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( diff --git a/electron/servers/fastapi/templates/pptx_html_stub.py b/electron/servers/fastapi/templates/pptx_html_stub.py deleted file mode 100644 index e07cb462..00000000 --- a/electron/servers/fastapi/templates/pptx_html_stub.py +++ /dev/null @@ -1,30 +0,0 @@ -BASIC_TEMPLATE_HTML = """ - -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-""".strip() diff --git a/electron/servers/fastapi/templates/prompts.py b/electron/servers/fastapi/templates/prompts.py index 619f9d26..a2215af4 100644 --- a/electron/servers/fastapi/templates/prompts.py +++ b/electron/servers/fastapi/templates/prompts.py @@ -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. diff --git a/electron/servers/fastapi/templates/providers.py b/electron/servers/fastapi/templates/providers.py index 452c0bf5..0dc69fa2 100644 --- a/electron/servers/fastapi/templates/providers.py +++ b/electron/servers/fastapi/templates/providers.py @@ -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]) diff --git a/electron/servers/fastapi/tests/test_template_api.py b/electron/servers/fastapi/tests/test_template_api.py index 6a702c7b..edaae340 100644 --- a/electron/servers/fastapi/tests/test_template_api.py +++ b/electron/servers/fastapi/tests/test_template_api.py @@ -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=["
slide 1
", "
slide 2
"], + 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 == ["
slide 1
", "
slide 2
"] 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=["
slide 1
", "
slide 2
", "
slide 3
"], + 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 == ["
slide 1
"] + + +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=["
slide 1
"], + 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" diff --git a/electron/servers/fastapi/utils/asset_directory_utils.py b/electron/servers/fastapi/utils/asset_directory_utils.py index a3e44be7..5f8f13f8 100644 --- a/electron/servers/fastapi/utils/asset_directory_utils.py +++ b/electron/servers/fastapi/utils/asset_directory_utils.py @@ -5,12 +5,13 @@ from urllib.parse import urlparse, unquote from utils.get_env import get_app_data_directory_env -def resolve_image_path_to_filesystem(path_or_url: str) -> Optional[str]: +def resolve_app_path_to_filesystem(path_or_url: str) -> Optional[str]: """ - Resolve an image path or URL to an actual filesystem path. + Resolve an app-served path or URL to an actual filesystem path. Handles: - Path strings: /app_data/images/..., /static/..., absolute paths, relative + - file:// URLs returned by export runtimes - HTTP URLs whose path component is an absolute filesystem path (Mac/Electron): When img src is /Users/.../images/xxx.png, browser resolves to http://origin/Users/.../images/xxx.png. Next.js returns 404 for these. @@ -21,10 +22,12 @@ def resolve_image_path_to_filesystem(path_or_url: str) -> Optional[str]: return None # Extract path from HTTP URL if needed path = path_or_url - if path_or_url.startswith("http"): + if path_or_url.startswith("http") or path_or_url.startswith("file:"): try: parsed = urlparse(path_or_url) path = unquote(parsed.path) + if parsed.scheme == "file" and os.name == "nt" and path.startswith("/"): + path = path[1:] except Exception: return None # Handle /app_data/images/ @@ -63,6 +66,10 @@ def resolve_image_path_to_filesystem(path_or_url: str) -> Optional[str]: return actual if os.path.isfile(actual) else None +def resolve_image_path_to_filesystem(path_or_url: str) -> Optional[str]: + return resolve_app_path_to_filesystem(path_or_url) + + def get_images_directory(): images_directory = os.path.join(get_app_data_directory_env(), "images") os.makedirs(images_directory, exist_ok=True)