feat: adds working new template generation with openai, google, anthropic and codex; fix: improves libreoffice binary detection
This commit is contained in:
parent
36432f5f52
commit
92de673e11
14 changed files with 697 additions and 164 deletions
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
6
electron/app/types/index.d.ts
vendored
6
electron/app/types/index.d.ts
vendored
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ###
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
246
electron/servers/fastapi/services/export_task_service.py
Normal file
246
electron/servers/fastapi/services/export_task_service.py
Normal 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 |
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue