Merge branch 'main' into feat/sentry-setup
0
.codex
Normal file
4
.gitignore
vendored
|
|
@ -19,7 +19,5 @@ chroma
|
|||
container.db
|
||||
.next-build
|
||||
.cursor
|
||||
|
||||
# Agent skill artifacts
|
||||
.agents/
|
||||
.agents
|
||||
skills-lock.json
|
||||
1
electron/.gitignore
vendored
|
|
@ -12,6 +12,7 @@ app_data
|
|||
tmp
|
||||
debug
|
||||
.fastembed_cache
|
||||
.codex
|
||||
|
||||
generated_models
|
||||
nltk
|
||||
|
|
|
|||
|
|
@ -71,7 +71,8 @@ const createWindow = () => {
|
|||
win = new BrowserWindow({
|
||||
width: 1280,
|
||||
height: 720,
|
||||
show: false, // Shown after LibreOffice check so "Skip" doesn't quit the app
|
||||
show: false, // Reveal once the launch screen has painted to avoid a blank flash.
|
||||
backgroundColor: "#f3f5ff",
|
||||
icon: path.join(baseDir, "resources/ui/assets/images/presenton_short_filled.png"),
|
||||
webPreferences: {
|
||||
webSecurity: false,
|
||||
|
|
@ -103,10 +104,19 @@ const createWindow = () => {
|
|||
}
|
||||
return { action: "allow" };
|
||||
});
|
||||
|
||||
win.once("ready-to-show", () => {
|
||||
if (!win || win.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
win.show();
|
||||
win.focus();
|
||||
});
|
||||
};
|
||||
|
||||
async function startServers(fastApiPort: number, nextjsPort: number) {
|
||||
try {
|
||||
const sofficePath = getSofficePath();
|
||||
const fastApi = await startFastApiServer(
|
||||
fastapiDir,
|
||||
fastApiPort,
|
||||
|
|
@ -140,12 +150,15 @@ async function startServers(fastApiPort: number, nextjsPort: number) {
|
|||
DALL_E_3_QUALITY: process.env.DALL_E_3_QUALITY,
|
||||
GPT_IMAGE_1_5_QUALITY: process.env.GPT_IMAGE_1_5_QUALITY,
|
||||
APP_DATA_DIRECTORY: appDataDir,
|
||||
FASTAPI_PUBLIC_URL: process.env.NEXT_PUBLIC_FAST_API,
|
||||
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
|
||||
|
|
@ -184,25 +197,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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -238,7 +264,7 @@ app.whenReady().then(async () => {
|
|||
startupStatus.puppeteer = chromeOk ? "installed" : "missing";
|
||||
startupStatus.imagemagick = imageMagickOk ? "installed" : "missing";
|
||||
|
||||
// Show and focus main window
|
||||
// Ensure the launch screen stays visible and focused during the server boot.
|
||||
win?.show();
|
||||
win?.focus();
|
||||
|
||||
|
|
@ -301,29 +327,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);
|
||||
});
|
||||
|
|
|
|||
5
electron/app/types/index.d.ts
vendored
|
|
@ -28,10 +28,11 @@ interface FastApiEnv {
|
|||
DALL_E_3_QUALITY?: string,
|
||||
GPT_IMAGE_1_5_QUALITY?: string,
|
||||
APP_DATA_DIRECTORY?: string,
|
||||
FASTAPI_PUBLIC_URL?: string,
|
||||
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. */
|
||||
/** 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,
|
||||
|
|
@ -90,4 +91,4 @@ interface UserConfig {
|
|||
interface IPCStatus {
|
||||
success: boolean,
|
||||
message?: string,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ export function setupEnv(fastApiPort: number, nextjsPort: number) {
|
|||
process.env.SENTRY_RELEASE = process.env.SENTRY_RELEASE || `presenton-electron@${process.env.APP_VERSION}`;
|
||||
process.env.SENTRY_ENVIRONMENT = process.env.SENTRY_ENVIRONMENT || (app.isPackaged ? 'production' : 'development');
|
||||
process.env.NEXT_PUBLIC_FAST_API = `${localhost}:${fastApiPort}`;
|
||||
process.env.FASTAPI_PUBLIC_URL = process.env.NEXT_PUBLIC_FAST_API;
|
||||
process.env.TEMP_DIRECTORY = tempDir;
|
||||
process.env.NEXT_PUBLIC_USER_CONFIG_PATH = userConfigPath;
|
||||
process.env.NEXT_PUBLIC_URL = `${localhost}:${nextjsPort}`;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ const VERSION_JSON_URL =
|
|||
"https://raw.githubusercontent.com/presenton/presenton/refs/heads/main/electron/version.json";
|
||||
|
||||
const CURRENT_VERSION = app.getVersion();
|
||||
const WEBSITE_DOWNLOAD_URL = "https://presenton.ai/download";
|
||||
|
||||
/** Maximum number of fetch attempts (polls). */
|
||||
const MAX_ATTEMPTS = 3;
|
||||
|
|
@ -33,20 +34,13 @@ function log(msg: string): void {
|
|||
interface VersionResponse {
|
||||
version: string;
|
||||
message?: string;
|
||||
downloads: {
|
||||
downloads?: {
|
||||
linux: string;
|
||||
mac: string;
|
||||
windows: string;
|
||||
};
|
||||
}
|
||||
|
||||
function getDownloadUrlForPlatform(downloads: VersionResponse["downloads"]): string {
|
||||
const platform = process.platform;
|
||||
if (platform === "darwin") return downloads.mac;
|
||||
if (platform === "win32") return downloads.windows;
|
||||
return downloads.linux;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple semver comparison that strips pre-release labels for numeric comparison.
|
||||
* Returns true if `remote` is strictly newer than `current`.
|
||||
|
|
@ -235,7 +229,7 @@ async function checkForUpdatesWithRetry(win: BrowserWindow): Promise<void> {
|
|||
const newer = isNewerVersion(CURRENT_VERSION, data.version);
|
||||
log(`Remote ${data.version} vs current ${CURRENT_VERSION} -> newer? ${newer}`);
|
||||
if (newer) {
|
||||
const downloadUrl = getDownloadUrlForPlatform(data.downloads);
|
||||
const downloadUrl = WEBSITE_DOWNLOAD_URL;
|
||||
log(`Injecting banner for ${data.version} (after ${INJECT_DELAY_MS}ms delay)`);
|
||||
scheduleBannerInjection(win, data.version, downloadUrl, data.message);
|
||||
} else {
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 115 KiB |
|
Before Width: | Height: | Size: 3 KiB After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 453 B After Width: | Height: | Size: 830 B |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 815 B After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 2 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 2.9 KiB |
BIN
electron/build/logo.png
Normal file
|
After Width: | Height: | Size: 118 KiB |
|
|
@ -1,8 +1,8 @@
|
|||
{
|
||||
"name": "presenton",
|
||||
"productName": "Presenton Open Source",
|
||||
"version": "0.7.0-beta",
|
||||
"exportVersion": "v0.1.1",
|
||||
"version": "0.7.1-beta",
|
||||
"exportVersion": "v0.2.0",
|
||||
"main": "app_dist/main.js",
|
||||
"description": "Open-Source AI Presentation Generator",
|
||||
"homepage": "https://presenton.ai",
|
||||
|
|
|
|||
BIN
electron/resources/ui/assets/fonts/Syne-Medium.ttf
Normal file
BIN
electron/resources/ui/assets/fonts/Syne-Regular.ttf
Normal file
BIN
electron/resources/ui/assets/fonts/Unbounded-Medium.ttf
Normal file
BIN
electron/resources/ui/assets/fonts/Unbounded-SemiBold.ttf
Normal file
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 43 KiB |
|
|
@ -1,182 +1,212 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Presenton</title>
|
||||
<link rel="stylesheet" href="../assets/css/tailwind.css">
|
||||
<script src="./script.js"></script>
|
||||
<style>
|
||||
.loading-circle {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
border-top-color: white;
|
||||
animation: spin 1s ease-in-out infinite;
|
||||
@font-face {
|
||||
font-family: "Unbounded";
|
||||
src: url("../assets/fonts/Unbounded-Medium.ttf") format("truetype");
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Syne";
|
||||
src: url("../assets/fonts/Syne-Regular.ttf") format("truetype");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.status-panel {
|
||||
@font-face {
|
||||
font-family: "Syne";
|
||||
src: url("../assets/fonts/Syne-Medium.ttf") format("truetype");
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg-top: #ffffff;
|
||||
--bg-bottom: #f2f4ff;
|
||||
--title: #2d3347;
|
||||
--subtitle: #9298ae;
|
||||
--hint: #7e86a0;
|
||||
--track: rgba(255, 255, 255, 0.76);
|
||||
--track-border: rgba(128, 120, 212, 0.12);
|
||||
--fill-start: #735cf7;
|
||||
--fill-mid: #6e67f7;
|
||||
--fill-end: #6b8eff;
|
||||
--progress: 0.24;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 100vw;
|
||||
min-height: 100vh;
|
||||
overflow: hidden;
|
||||
font-family: "Syne", "Segoe UI", system-ui, sans-serif;
|
||||
color: var(--title);
|
||||
background:
|
||||
radial-gradient(circle at 20% 18%, rgba(127, 104, 255, 0.16), transparent 28%),
|
||||
radial-gradient(circle at 82% 84%, rgba(108, 144, 255, 0.12), transparent 30%),
|
||||
linear-gradient(135deg, var(--bg-top) 0%, #f8f9ff 42%, var(--bg-bottom) 100%);
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
background: rgba(17, 24, 39, 0.9);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
max-width: 80vw;
|
||||
}
|
||||
|
||||
.status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.status-name {
|
||||
flex: 1;
|
||||
color: #e5e7eb;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
display: inline-block;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.status-icon.loading {
|
||||
background: #3b82f6;
|
||||
animation: pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.status-icon.ok {
|
||||
background: #10b981;
|
||||
}
|
||||
|
||||
.status-icon.error {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
opacity: 0.35;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.35;
|
||||
}
|
||||
}
|
||||
|
||||
.status-tooltip {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
margin-top: 8px;
|
||||
background: rgba(17, 24, 39, 0.95);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
font-size: 11px;
|
||||
color: #e5e7eb;
|
||||
white-space: normal;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 200px;
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
inset: -20%;
|
||||
pointer-events: none;
|
||||
transition: opacity 120ms ease, transform 120ms ease;
|
||||
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.28);
|
||||
background:
|
||||
radial-gradient(circle at center, rgba(255, 255, 255, 0.55), transparent 45%),
|
||||
radial-gradient(circle at 30% 20%, rgba(145, 124, 255, 0.14), transparent 24%);
|
||||
filter: blur(90px);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.status-panel:hover .status-tooltip {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
.launch-screen {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: min(100%, 540px);
|
||||
padding: 24px 28px 32px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status-tooltip-line {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
.logo {
|
||||
width: min(54vw, 300px);
|
||||
margin: 0 auto 36px;
|
||||
}
|
||||
|
||||
.status-tooltip-line span {
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: clamp(1.3rem, 2.05vw, 1.72rem);
|
||||
line-height: 1.12;
|
||||
letter-spacing: -0.045em;
|
||||
font-family: "Unbounded", "Syne", system-ui, sans-serif;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-tooltip-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
.subtitle {
|
||||
margin: 14px 0 0;
|
||||
font-size: clamp(0.98rem, 1.8vw, 1.04rem);
|
||||
line-height: 1.5;
|
||||
color: var(--subtitle);
|
||||
font-weight: 500;
|
||||
font-family: "Syne", "Segoe UI", system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.status-tooltip-label::before {
|
||||
.progress-track {
|
||||
position: relative;
|
||||
width: min(100%, 360px);
|
||||
height: 12px;
|
||||
margin: 40px auto 0;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: var(--track);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px var(--track-border),
|
||||
0 14px 32px rgba(112, 105, 186, 0.12);
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, var(--fill-start) 0%, var(--fill-mid) 56%, var(--fill-end) 100%);
|
||||
box-shadow: 0 6px 18px rgba(112, 97, 242, 0.28);
|
||||
transform: scaleX(var(--progress));
|
||||
transform-origin: left center;
|
||||
transition: transform 380ms ease;
|
||||
}
|
||||
|
||||
.progress-fill::after {
|
||||
content: "";
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 9999px;
|
||||
background: #6b7280;
|
||||
box-shadow: 0 0 0 2px rgba(107, 114, 128, 0.2);
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(90deg,
|
||||
transparent 0%,
|
||||
rgba(255, 255, 255, 0.12) 36%,
|
||||
rgba(255, 255, 255, 0.58) 50%,
|
||||
rgba(255, 255, 255, 0.12) 64%,
|
||||
transparent 100%);
|
||||
animation: shimmer 1.85s linear infinite;
|
||||
}
|
||||
|
||||
.status-tooltip-label.loading::before {
|
||||
background: #3b82f6;
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.25);
|
||||
animation: pulse 1s ease-in-out infinite;
|
||||
.hint {
|
||||
min-height: 1.5rem;
|
||||
margin: 16px 0 0;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.5;
|
||||
color: var(--hint);
|
||||
letter-spacing: 0.01em;
|
||||
font-family: "Syne", "Segoe UI", system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.status-tooltip-label.ok::before {
|
||||
background: #10b981;
|
||||
box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.25);
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
transform: translateX(-125%);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateX(220%);
|
||||
}
|
||||
}
|
||||
|
||||
.status-tooltip-label.error::before {
|
||||
background: #ef4444;
|
||||
box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.25);
|
||||
@media (max-width: 640px) {
|
||||
.launch-screen {
|
||||
width: min(100%, 440px);
|
||||
padding: 16px 24px 24px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: min(74vw, 260px);
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: clamp(1.35rem, 6.7vw, 1.8rem);
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.progress-track {
|
||||
width: min(100%, 320px);
|
||||
margin-top: 34px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body class="bg-gray-900 text-white flex flex-col items-center justify-center h-screen">
|
||||
<img src="../assets/images/presenton_logo.png" alt="Presenton Logo" class="h-20">
|
||||
<p class="mt-16 text-lg">Just a moment...</p>
|
||||
<div class="loading-circle mt-8"></div>
|
||||
|
||||
<div class="status-panel" id="dependencies-panel">
|
||||
<div class="status-row">
|
||||
<span class="status-icon loading" id="icon-dependencies"></span>
|
||||
<span class="status-name" id="status-dependencies">Dependencies</span>
|
||||
<body>
|
||||
<main class="launch-screen" aria-live="polite">
|
||||
<img src="../assets/images/presenton_logo.png" alt="Presenton" class="logo">
|
||||
<h1 class="title">Launching Presenton...</h1>
|
||||
<p class="subtitle" data-startup-subtitle>Please wait a moment</p>
|
||||
<div class="progress-track" role="progressbar" aria-label="Launching Presenton" aria-valuemin="0"
|
||||
aria-valuemax="100" aria-valuenow="24" data-startup-meter>
|
||||
<div class="progress-fill" data-startup-progress></div>
|
||||
</div>
|
||||
<div class="status-tooltip" id="dependencies-tooltip">
|
||||
<div class="status-tooltip-line">
|
||||
<span class="status-tooltip-label loading" id="tooltip-label-libreoffice">LibreOffice</span>
|
||||
<span id="tooltip-libreoffice">Checking...</span>
|
||||
</div>
|
||||
<div class="status-tooltip-line">
|
||||
<span class="status-tooltip-label loading" id="tooltip-label-puppeteer">Chromium</span>
|
||||
<span id="tooltip-puppeteer">Checking...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint" data-startup-hint>Preparing your workspace</p>
|
||||
</main>
|
||||
<script src="./script.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -1,91 +1,121 @@
|
|||
window.addEventListener("DOMContentLoaded", () => {
|
||||
const statusMap = {
|
||||
checking: "Checking...",
|
||||
installed: "Installed",
|
||||
missing: "Missing",
|
||||
installing: "Installing...",
|
||||
downloading: "Downloading...",
|
||||
downloaded: "Downloaded",
|
||||
skipped: "Skipped",
|
||||
failed: "Failed",
|
||||
};
|
||||
const labelMap = {
|
||||
libreoffice: "LibreOffice",
|
||||
puppeteer: "Chromium",
|
||||
imagemagick: "ImageMagick",
|
||||
};
|
||||
const subtitleEl = document.querySelector("[data-startup-subtitle]");
|
||||
const hintEl = document.querySelector("[data-startup-hint]");
|
||||
const progressEl = document.querySelector("[data-startup-progress]");
|
||||
const meterEl = document.querySelector("[data-startup-meter]");
|
||||
|
||||
const dependenciesEl = document.getElementById("status-dependencies");
|
||||
const dependenciesIcon = document.getElementById("icon-dependencies");
|
||||
const dependenciesTooltip = document.getElementById("dependencies-tooltip");
|
||||
const libreofficeTooltip = document.getElementById("tooltip-libreoffice");
|
||||
const puppeteerTooltip = document.getElementById("tooltip-puppeteer");
|
||||
const libreofficeLabel = document.getElementById("tooltip-label-libreoffice");
|
||||
const puppeteerLabel = document.getElementById("tooltip-label-puppeteer");
|
||||
const currentStatus = {
|
||||
libreoffice: "checking",
|
||||
puppeteer: "checking",
|
||||
imagemagick: "checking",
|
||||
};
|
||||
|
||||
function setStatus(name, status) {
|
||||
if (currentStatus[name] !== undefined) {
|
||||
currentStatus[name] = status;
|
||||
const completeStates = new Set(["installed", "downloaded", "skipped"]);
|
||||
const errorStates = new Set(["missing", "failed"]);
|
||||
|
||||
let visualProgress = 0.24;
|
||||
let targetProgress = 0.58;
|
||||
|
||||
function applyProgress(value) {
|
||||
const clampedValue = Math.max(0.18, Math.min(value, 1));
|
||||
if (progressEl) {
|
||||
progressEl.style.setProperty("--progress", String(clampedValue));
|
||||
progressEl.style.transform = `scaleX(${clampedValue})`;
|
||||
}
|
||||
if (dependenciesEl) dependenciesEl.textContent = "Dependencies";
|
||||
|
||||
const statuses = Object.values(currentStatus);
|
||||
const hasError = statuses.some((s) => s === "missing" || s === "failed");
|
||||
const isBusy = statuses.some((s) => s === "checking" || s === "installing" || s === "downloading");
|
||||
const isDone = statuses.every((s) => s === "installed" || s === "downloaded" || s === "skipped");
|
||||
|
||||
let iconClass = "loading";
|
||||
let iconText = "";
|
||||
if (hasError) {
|
||||
iconClass = "error";
|
||||
iconText = "×";
|
||||
} else if (isDone && !isBusy) {
|
||||
iconClass = "ok";
|
||||
iconText = "✓";
|
||||
} else {
|
||||
iconClass = "loading";
|
||||
iconText = "";
|
||||
if (meterEl) {
|
||||
meterEl.setAttribute("aria-valuenow", String(Math.round(clampedValue * 100)));
|
||||
}
|
||||
|
||||
if (dependenciesIcon) {
|
||||
dependenciesIcon.className = `status-icon ${iconClass}`;
|
||||
}
|
||||
|
||||
const libreofficeStatus = currentStatus.libreoffice;
|
||||
const puppeteerStatus = currentStatus.puppeteer;
|
||||
const libreofficeText = statusMap[libreofficeStatus] || libreofficeStatus;
|
||||
const puppeteerText = statusMap[puppeteerStatus] || puppeteerStatus;
|
||||
|
||||
const toDotClass = (value) => {
|
||||
if (value === "missing" || value === "failed") return "error";
|
||||
if (value === "installed" || value === "downloaded" || value === "skipped") return "ok";
|
||||
return "loading";
|
||||
};
|
||||
|
||||
if (libreofficeTooltip) libreofficeTooltip.textContent = libreofficeText;
|
||||
if (puppeteerTooltip) puppeteerTooltip.textContent = puppeteerText;
|
||||
if (libreofficeLabel) libreofficeLabel.className = `status-tooltip-label ${toDotClass(libreofficeStatus)}`;
|
||||
if (puppeteerLabel) puppeteerLabel.className = `status-tooltip-label ${toDotClass(puppeteerStatus)}`;
|
||||
if (dependenciesTooltip) dependenciesTooltip.setAttribute("aria-live", "polite");
|
||||
}
|
||||
|
||||
function updateStateCopy() {
|
||||
const statuses = Object.values(currentStatus);
|
||||
const hasError = statuses.some((status) => errorStates.has(status));
|
||||
const isInstalling = statuses.some(
|
||||
(status) => status === "installing" || status === "downloading"
|
||||
);
|
||||
const isChecking = statuses.some((status) => status === "checking");
|
||||
const isReady =
|
||||
statuses.length > 0 &&
|
||||
statuses.every((status) => completeStates.has(status));
|
||||
|
||||
if (hasError) {
|
||||
targetProgress = Math.max(targetProgress, 0.54);
|
||||
if (subtitleEl) subtitleEl.textContent = "Please wait a moment";
|
||||
if (hintEl) hintEl.textContent = "Setup required before launch";
|
||||
return;
|
||||
}
|
||||
|
||||
if (isInstalling) {
|
||||
targetProgress = Math.max(targetProgress, 0.72);
|
||||
if (subtitleEl) subtitleEl.textContent = "Please wait a moment";
|
||||
if (hintEl) hintEl.textContent = "Installing required components";
|
||||
return;
|
||||
}
|
||||
|
||||
if (isChecking) {
|
||||
targetProgress = Math.max(targetProgress, 0.58);
|
||||
if (subtitleEl) subtitleEl.textContent = "Please wait a moment";
|
||||
if (hintEl) hintEl.textContent = "Checking required components";
|
||||
return;
|
||||
}
|
||||
|
||||
if (isReady) {
|
||||
targetProgress = 0.88;
|
||||
if (subtitleEl) subtitleEl.textContent = "Please wait a moment";
|
||||
if (hintEl) hintEl.textContent = "Opening your workspace";
|
||||
return;
|
||||
}
|
||||
|
||||
targetProgress = Math.max(targetProgress, 0.68);
|
||||
if (subtitleEl) subtitleEl.textContent = "Please wait a moment";
|
||||
if (hintEl) hintEl.textContent = "Preparing your workspace";
|
||||
}
|
||||
|
||||
function setStatus(name, status) {
|
||||
if (!(name in currentStatus)) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentStatus[name] = status;
|
||||
updateStateCopy();
|
||||
}
|
||||
|
||||
function animateProgress() {
|
||||
visualProgress += (targetProgress - visualProgress) * 0.08;
|
||||
applyProgress(visualProgress);
|
||||
window.requestAnimationFrame(animateProgress);
|
||||
}
|
||||
|
||||
updateStateCopy();
|
||||
applyProgress(visualProgress);
|
||||
animateProgress();
|
||||
|
||||
window.setInterval(() => {
|
||||
if (targetProgress < 0.82) {
|
||||
targetProgress = Math.min(0.82, targetProgress + 0.03);
|
||||
}
|
||||
}, 1200);
|
||||
|
||||
if (window.electron?.onStartupStatus) {
|
||||
window.electron.onStartupStatus((payload) => {
|
||||
if (!payload) return;
|
||||
setStatus(payload.name, payload.status);
|
||||
});
|
||||
}
|
||||
|
||||
if (window.electron?.getStartupStatus) {
|
||||
window.electron.getStartupStatus().then((statusMap) => {
|
||||
if (!statusMap) return;
|
||||
if (statusMap.libreoffice) setStatus("libreoffice", statusMap.libreoffice);
|
||||
if (statusMap.puppeteer) setStatus("puppeteer", statusMap.puppeteer);
|
||||
if (statusMap.imagemagick) setStatus("imagemagick", statusMap.imagemagick);
|
||||
if (statusMap.imagemagick) {
|
||||
setStatus("imagemagick", statusMap.imagemagick);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("beforeunload", () => {
|
||||
targetProgress = 1;
|
||||
applyProgress(1);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -70,10 +70,6 @@ function syncMigrations() {
|
|||
throw new Error(`No migration files found in source directory: ${sourceDir}`);
|
||||
}
|
||||
|
||||
for (const filename of listPythonMigrations(targetDir)) {
|
||||
fs.unlinkSync(path.join(targetDir, filename));
|
||||
}
|
||||
|
||||
for (const filename of sourceFiles) {
|
||||
fs.copyFileSync(path.join(sourceDir, filename), path.join(targetDir, filename));
|
||||
}
|
||||
|
|
@ -82,7 +78,7 @@ function syncMigrations() {
|
|||
validateSingleHead(targetDir, targetFiles);
|
||||
|
||||
console.log(
|
||||
`Synced ${targetFiles.length} migration files and verified a single Alembic head.`
|
||||
`Synced ${sourceFiles.length} migration files into ${targetFiles.length} target migrations and verified a single Alembic head.`
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ from models.sql.presentation_layout_code import ( # noqa: F401, E402
|
|||
)
|
||||
from models.sql.slide import SlideModel # noqa: F401, E402
|
||||
from models.sql.template import TemplateModel # noqa: F401, E402
|
||||
from models.sql.template_create_info import TemplateCreateInfoModel # noqa: F401, E402
|
||||
from models.sql.webhook_subscription import WebhookSubscription # noqa: F401, E402
|
||||
|
||||
alembic_config = context.config
|
||||
|
|
|
|||
|
|
@ -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 ###
|
||||
|
|
@ -16,25 +16,32 @@ from fastapi import APIRouter, HTTPException
|
|||
from pydantic import BaseModel
|
||||
|
||||
from utils.oauth.openai_codex import (
|
||||
CodexAccountProfile,
|
||||
OAuthCallbackServer,
|
||||
TokenSuccess,
|
||||
create_authorization_flow,
|
||||
exchange_authorization_code,
|
||||
get_account_id,
|
||||
get_account_profile,
|
||||
parse_authorization_input,
|
||||
refresh_access_token,
|
||||
)
|
||||
from utils.get_env import (
|
||||
get_codex_access_token_env,
|
||||
get_codex_email_env,
|
||||
get_codex_is_pro_env,
|
||||
get_codex_refresh_token_env,
|
||||
get_codex_token_expires_env,
|
||||
get_codex_username_env,
|
||||
)
|
||||
from utils.set_env import (
|
||||
set_codex_access_token_env,
|
||||
set_codex_account_id_env,
|
||||
set_codex_email_env,
|
||||
set_codex_is_pro_env,
|
||||
set_codex_refresh_token_env,
|
||||
set_codex_token_expires_env,
|
||||
set_codex_model_env,
|
||||
set_codex_username_env,
|
||||
)
|
||||
from utils.user_config import save_codex_tokens_to_user_config
|
||||
|
||||
|
|
@ -60,6 +67,9 @@ class InitiateResponse(BaseModel):
|
|||
class StatusResponse(BaseModel):
|
||||
status: str # "pending" | "success" | "failed"
|
||||
account_id: Optional[str] = None
|
||||
username: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
is_pro: Optional[bool] = None
|
||||
detail: Optional[str] = None
|
||||
|
||||
|
||||
|
|
@ -69,11 +79,17 @@ class ExchangeRequest(BaseModel):
|
|||
|
||||
|
||||
class ExchangeResponse(BaseModel):
|
||||
account_id: str
|
||||
account_id: Optional[str] = None
|
||||
username: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
is_pro: Optional[bool] = None
|
||||
|
||||
|
||||
class RefreshResponse(BaseModel):
|
||||
account_id: Optional[str]
|
||||
username: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
is_pro: Optional[bool] = None
|
||||
detail: str
|
||||
|
||||
|
||||
|
|
@ -81,16 +97,31 @@ class RefreshResponse(BaseModel):
|
|||
# Helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _store_token(result: TokenSuccess) -> Optional[str]:
|
||||
"""Persist token fields in env vars and userConfig.json. Returns account_id or None."""
|
||||
def _parse_optional_bool(value: Optional[str]) -> Optional[bool]:
|
||||
if value is None:
|
||||
return None
|
||||
normalized = value.strip().lower()
|
||||
if normalized in {"true", "1", "yes", "y"}:
|
||||
return True
|
||||
if normalized in {"false", "0", "no", "n"}:
|
||||
return False
|
||||
return None
|
||||
|
||||
|
||||
def _store_token(result: TokenSuccess) -> CodexAccountProfile:
|
||||
"""Persist token fields in env vars and userConfig.json. Returns parsed profile."""
|
||||
set_codex_access_token_env(result.access)
|
||||
set_codex_refresh_token_env(result.refresh)
|
||||
set_codex_token_expires_env(str(result.expires))
|
||||
account_id = get_account_id(result.access)
|
||||
if account_id:
|
||||
set_codex_account_id_env(account_id)
|
||||
|
||||
profile = get_account_profile(result.access, result.id_token)
|
||||
set_codex_account_id_env(profile.account_id or "")
|
||||
set_codex_username_env(profile.username or "")
|
||||
set_codex_email_env(profile.email or "")
|
||||
set_codex_is_pro_env("" if profile.is_pro is None else str(profile.is_pro))
|
||||
|
||||
save_codex_tokens_to_user_config()
|
||||
return account_id
|
||||
return profile
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -166,8 +197,14 @@ async def poll_codex_auth_status(session_id: str):
|
|||
if not isinstance(result, TokenSuccess):
|
||||
return StatusResponse(status="failed", detail=result.reason)
|
||||
|
||||
account_id = _store_token(result)
|
||||
return StatusResponse(status="success", account_id=account_id)
|
||||
profile = _store_token(result)
|
||||
return StatusResponse(
|
||||
status="success",
|
||||
account_id=profile.account_id,
|
||||
username=profile.username,
|
||||
email=profile.email,
|
||||
is_pro=profile.is_pro,
|
||||
)
|
||||
|
||||
|
||||
@CODEX_AUTH_ROUTER.post("/exchange", response_model=ExchangeResponse)
|
||||
|
|
@ -207,11 +244,16 @@ async def exchange_codex_code(body: ExchangeRequest):
|
|||
if not isinstance(result, TokenSuccess):
|
||||
raise HTTPException(status_code=502, detail=f"Token exchange failed: {result.reason}")
|
||||
|
||||
account_id = _store_token(result)
|
||||
if not account_id:
|
||||
profile = _store_token(result)
|
||||
if not profile.account_id:
|
||||
raise HTTPException(status_code=502, detail="Token exchanged but could not extract account ID")
|
||||
|
||||
return ExchangeResponse(account_id=account_id)
|
||||
return ExchangeResponse(
|
||||
account_id=profile.account_id,
|
||||
username=profile.username,
|
||||
email=profile.email,
|
||||
is_pro=profile.is_pro,
|
||||
)
|
||||
|
||||
|
||||
@CODEX_AUTH_ROUTER.post("/refresh", response_model=RefreshResponse)
|
||||
|
|
@ -232,9 +274,12 @@ async def refresh_codex_token():
|
|||
if not isinstance(result, TokenSuccess):
|
||||
raise HTTPException(status_code=502, detail=f"Token refresh failed: {result.reason}")
|
||||
|
||||
account_id = _store_token(result)
|
||||
profile = _store_token(result)
|
||||
return RefreshResponse(
|
||||
account_id=account_id,
|
||||
account_id=profile.account_id,
|
||||
username=profile.username,
|
||||
email=profile.email,
|
||||
is_pro=profile.is_pro,
|
||||
detail="Token refreshed successfully",
|
||||
)
|
||||
|
||||
|
|
@ -260,8 +305,18 @@ async def get_codex_auth_status():
|
|||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
account_id = get_account_id(access_token)
|
||||
return StatusResponse(status="authenticated", account_id=account_id)
|
||||
profile = get_account_profile(access_token)
|
||||
return StatusResponse(
|
||||
status="authenticated",
|
||||
account_id=profile.account_id,
|
||||
username=profile.username or get_codex_username_env(),
|
||||
email=profile.email or get_codex_email_env(),
|
||||
is_pro=(
|
||||
profile.is_pro
|
||||
if profile.is_pro is not None
|
||||
else _parse_optional_bool(get_codex_is_pro_env())
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@CODEX_AUTH_ROUTER.post("/logout")
|
||||
|
|
@ -273,6 +328,9 @@ async def logout_codex():
|
|||
set_codex_refresh_token_env("")
|
||||
set_codex_token_expires_env("")
|
||||
set_codex_account_id_env("")
|
||||
set_codex_username_env("")
|
||||
set_codex_email_env("")
|
||||
set_codex_is_pro_env("")
|
||||
set_codex_model_env("")
|
||||
save_codex_tokens_to_user_config()
|
||||
return {"detail": "Logged out successfully"}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
import os
|
||||
import uuid
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Any, Optional
|
||||
from fastapi import APIRouter, HTTPException, File, UploadFile
|
||||
from pydantic import BaseModel
|
||||
from templates.preview import FontCheckResponse, check_fonts_in_pptx_handler
|
||||
from utils.asset_directory_utils import get_app_data_directory_env
|
||||
import uuid
|
||||
|
||||
try:
|
||||
from fontTools.ttLib import TTFont
|
||||
|
|
@ -287,6 +286,9 @@ async def get_uploaded_fonts():
|
|||
)
|
||||
|
||||
|
||||
FONTS_ROUTER.post("/check", response_model=FontCheckResponse)(check_fonts_in_pptx_handler)
|
||||
|
||||
|
||||
@FONTS_ROUTER.delete("/delete/{filename}")
|
||||
async def delete_font(filename: str):
|
||||
"""
|
||||
|
|
@ -330,4 +332,4 @@ async def delete_font(filename: str):
|
|||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Error deleting font: {str(e)}"
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
from typing import List
|
||||
from fastapi import APIRouter, Depends, File, UploadFile, HTTPException
|
||||
from fastapi import APIRouter, Depends, File, UploadFile, HTTPException, Query, Header
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlmodel import select
|
||||
|
||||
|
|
@ -8,6 +8,9 @@ from models.sql.image_asset import ImageAsset
|
|||
from services.database import get_async_session
|
||||
from services.image_generation_service import ImageGenerationService
|
||||
from utils.asset_directory_utils import get_images_directory
|
||||
from utils.get_env import get_pexels_api_key_env, get_pixabay_api_key_env
|
||||
from utils.image_provider import get_selected_image_provider
|
||||
from enums.image_provider import ImageProvider
|
||||
import os
|
||||
import uuid
|
||||
from utils.file_utils import get_file_name_with_random_uuid
|
||||
|
|
@ -15,6 +18,75 @@ from utils.file_utils import get_file_name_with_random_uuid
|
|||
IMAGES_ROUTER = APIRouter(prefix="/images", tags=["Images"])
|
||||
|
||||
|
||||
def _normalize_stock_provider(provider: str | None) -> str:
|
||||
normalized_provider = (provider or "").strip().lower()
|
||||
if normalized_provider in {"pixels", "pixel", "pexel"}:
|
||||
normalized_provider = "pexels"
|
||||
|
||||
if normalized_provider:
|
||||
if normalized_provider in {"pexels", "pixabay"}:
|
||||
return normalized_provider
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="provider must be either 'pexels' or 'pixabay'",
|
||||
)
|
||||
|
||||
selected_provider = get_selected_image_provider()
|
||||
if selected_provider == ImageProvider.PIXABAY:
|
||||
return "pixabay"
|
||||
return "pexels"
|
||||
|
||||
|
||||
@IMAGES_ROUTER.get("/search", response_model=List[str])
|
||||
async def search_stock_images(
|
||||
query: str,
|
||||
limit: int = Query(default=12, ge=1, le=30),
|
||||
provider: str | None = Query(default=None),
|
||||
strict_api_key: bool = Query(default=False),
|
||||
x_provider_api_key: str | None = Header(default=None, alias="X-Provider-Api-Key"),
|
||||
):
|
||||
normalized_provider = _normalize_stock_provider(provider)
|
||||
|
||||
image_generation_service = ImageGenerationService(get_images_directory())
|
||||
|
||||
if normalized_provider == "pexels":
|
||||
api_key = (x_provider_api_key or get_pexels_api_key_env() or "").strip()
|
||||
if strict_api_key and not api_key:
|
||||
raise HTTPException(status_code=401, detail="Pexels API key is required")
|
||||
|
||||
# Pexels can return cached public responses for common queries.
|
||||
# Use a nonce query in strict mode to force a real auth check.
|
||||
if strict_api_key:
|
||||
validation_query = f"__presenton_auth_check_{uuid.uuid4().hex}"
|
||||
await image_generation_service.get_image_from_pexels(
|
||||
validation_query,
|
||||
api_key=api_key,
|
||||
limit=1,
|
||||
)
|
||||
|
||||
images = await image_generation_service.get_image_from_pexels(
|
||||
query,
|
||||
api_key=api_key,
|
||||
limit=limit,
|
||||
)
|
||||
if isinstance(images, str):
|
||||
return [images] if images else []
|
||||
return images
|
||||
|
||||
api_key = (x_provider_api_key or get_pixabay_api_key_env() or "").strip()
|
||||
if strict_api_key and not api_key:
|
||||
raise HTTPException(status_code=401, detail="Pixabay API key is required")
|
||||
|
||||
images = await image_generation_service.get_image_from_pixabay(
|
||||
query,
|
||||
api_key=api_key,
|
||||
limit=limit,
|
||||
)
|
||||
if isinstance(images, str):
|
||||
return [images] if images else []
|
||||
return images
|
||||
|
||||
|
||||
@IMAGES_ROUTER.get("/generate")
|
||||
async def generate_image(
|
||||
prompt: str, sql_session: AsyncSession = Depends(get_async_session)
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
from fastapi import APIRouter, HTTPException
|
||||
import aiohttp
|
||||
from typing import List, Any
|
||||
from utils.get_layout_by_name import get_layout_by_name
|
||||
from models.presentation_layout import PresentationLayoutModel
|
||||
from templates.get_layout_by_name import get_layout_by_name
|
||||
from templates.presentation_layout import PresentationLayoutModel
|
||||
|
||||
LAYOUTS_ROUTER = APIRouter(prefix="/layouts", tags=["Layouts"])
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import asyncio
|
||||
import json
|
||||
import math
|
||||
import traceback
|
||||
import uuid
|
||||
import dirtyjson
|
||||
|
|
@ -19,8 +18,11 @@ from models.sse_response import (
|
|||
from services.temp_file_service import TEMP_FILE_SERVICE
|
||||
from services.database import get_async_session
|
||||
from services.documents_loader import DocumentsLoader
|
||||
from utils.outline_utils import (
|
||||
get_no_of_outlines_to_generate_for_n_slides,
|
||||
get_presentation_title_from_presentation_outline,
|
||||
)
|
||||
from utils.llm_calls.generate_presentation_outlines import generate_ppt_outline
|
||||
from utils.ppt_utils import get_presentation_title_from_outlines
|
||||
|
||||
OUTLINES_ROUTER = APIRouter(prefix="/outlines", tags=["Outlines"])
|
||||
|
||||
|
|
@ -54,12 +56,14 @@ async def stream_outlines(
|
|||
|
||||
presentation_outlines_text = ""
|
||||
|
||||
n_slides_to_generate = presentation.n_slides
|
||||
if presentation.include_table_of_contents:
|
||||
needed_toc_count = math.ceil((presentation.n_slides - 1) / 10)
|
||||
n_slides_to_generate -= math.ceil(
|
||||
(presentation.n_slides - needed_toc_count) / 10
|
||||
if presentation.n_slides > 0:
|
||||
n_slides_to_generate = get_no_of_outlines_to_generate_for_n_slides(
|
||||
n_slides=presentation.n_slides,
|
||||
toc=presentation.include_table_of_contents,
|
||||
title_slide=presentation.include_title_slide,
|
||||
)
|
||||
else:
|
||||
n_slides_to_generate = None
|
||||
|
||||
async for chunk in generate_ppt_outline(
|
||||
presentation.content,
|
||||
|
|
@ -71,6 +75,7 @@ async def stream_outlines(
|
|||
presentation.instructions,
|
||||
presentation.include_title_slide,
|
||||
presentation.web_search,
|
||||
presentation.include_table_of_contents,
|
||||
):
|
||||
# Give control to the event loop
|
||||
await asyncio.sleep(0)
|
||||
|
|
@ -99,12 +104,30 @@ async def stream_outlines(
|
|||
|
||||
presentation_outlines = PresentationOutlineModel(**presentation_outlines_json)
|
||||
|
||||
presentation_outlines.slides = presentation_outlines.slides[
|
||||
:n_slides_to_generate
|
||||
]
|
||||
if (
|
||||
n_slides_to_generate is not None
|
||||
and len(presentation_outlines.slides) != n_slides_to_generate
|
||||
):
|
||||
yield SSEErrorResponse(
|
||||
detail=(
|
||||
"Failed to generate presentation outlines with requested "
|
||||
"number of slides. Please try again."
|
||||
)
|
||||
).to_string()
|
||||
return
|
||||
|
||||
if n_slides_to_generate is not None:
|
||||
presentation_outlines.slides = presentation_outlines.slides[
|
||||
:n_slides_to_generate
|
||||
]
|
||||
|
||||
if presentation.n_slides <= 0:
|
||||
presentation.n_slides = len(presentation_outlines.slides)
|
||||
|
||||
presentation.outlines = presentation_outlines.model_dump()
|
||||
presentation.title = get_presentation_title_from_outlines(presentation_outlines)
|
||||
presentation.title = get_presentation_title_from_presentation_outline(
|
||||
presentation_outlines
|
||||
)
|
||||
|
||||
sql_session.add(presentation)
|
||||
await sql_session.commit()
|
||||
|
|
|
|||
|
|
@ -1,116 +0,0 @@
|
|||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import subprocess
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, UploadFile, File, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from services.documents_loader import DocumentsLoader
|
||||
from utils.asset_directory_utils import get_images_directory
|
||||
import uuid
|
||||
from constants.documents import PDF_MIME_TYPES
|
||||
|
||||
|
||||
PDF_SLIDES_ROUTER = APIRouter(prefix="/pdf-slides", tags=["PDF Slides"])
|
||||
|
||||
|
||||
class PdfSlideData(BaseModel):
|
||||
slide_number: int
|
||||
screenshot_url: str
|
||||
|
||||
|
||||
class PdfSlidesResponse(BaseModel):
|
||||
success: bool
|
||||
slides: List[PdfSlideData]
|
||||
total_slides: int
|
||||
|
||||
|
||||
@PDF_SLIDES_ROUTER.post("/process", response_model=PdfSlidesResponse)
|
||||
async def process_pdf_slides(
|
||||
pdf_file: UploadFile = File(..., description="PDF file to process")
|
||||
):
|
||||
"""
|
||||
Process a PDF file to extract slide screenshots.
|
||||
|
||||
This endpoint:
|
||||
1. Validates the uploaded PDF file
|
||||
2. Uses ImageMagick to convert PDF pages to PNG images
|
||||
3. Returns screenshot URLs for each slide/page
|
||||
|
||||
Note: Font installation is not needed since PDFs already have fonts embedded.
|
||||
"""
|
||||
|
||||
# Validate PDF file
|
||||
if pdf_file.content_type not in PDF_MIME_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid file type. Expected PDF file, got {pdf_file.content_type}",
|
||||
)
|
||||
# Enforce 100MB size limit
|
||||
if (
|
||||
hasattr(pdf_file, "size")
|
||||
and pdf_file.size
|
||||
and pdf_file.size > (100 * 1024 * 1024)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="PDF file exceeded max upload size of 100 MB",
|
||||
)
|
||||
|
||||
# Create temporary directory for processing
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
try:
|
||||
# Save uploaded PDF file
|
||||
pdf_path = os.path.join(temp_dir, "presentation.pdf")
|
||||
with open(pdf_path, "wb") as f:
|
||||
pdf_content = await pdf_file.read()
|
||||
f.write(pdf_content)
|
||||
|
||||
# Generate screenshots from PDF using ImageMagick
|
||||
screenshot_paths = await DocumentsLoader.get_page_images_from_pdf_async(
|
||||
pdf_path, temp_dir
|
||||
)
|
||||
print(f"Generated {len(screenshot_paths)} PDF screenshots")
|
||||
|
||||
# Move screenshots to images directory and generate URLs
|
||||
images_dir = get_images_directory()
|
||||
presentation_id = uuid.uuid4()
|
||||
presentation_images_dir = os.path.join(images_dir, str(presentation_id))
|
||||
os.makedirs(presentation_images_dir, exist_ok=True)
|
||||
|
||||
slides_data = []
|
||||
|
||||
for i, screenshot_path in enumerate(screenshot_paths, 1):
|
||||
# Move screenshot to permanent location
|
||||
screenshot_filename = f"slide_{i}.png"
|
||||
permanent_screenshot_path = os.path.join(
|
||||
presentation_images_dir, screenshot_filename
|
||||
)
|
||||
|
||||
if (
|
||||
os.path.exists(screenshot_path)
|
||||
and os.path.getsize(screenshot_path) > 0
|
||||
):
|
||||
# Use shutil.copy2 instead of os.rename to handle cross-device moves
|
||||
shutil.copy2(screenshot_path, permanent_screenshot_path)
|
||||
screenshot_url = (
|
||||
f"/app_data/images/{presentation_id}/{screenshot_filename}"
|
||||
)
|
||||
else:
|
||||
# Fallback if screenshot generation failed or file is empty placeholder
|
||||
screenshot_url = "/static/images/placeholder.jpg"
|
||||
|
||||
slides_data.append(
|
||||
PdfSlideData(slide_number=i, screenshot_url=screenshot_url)
|
||||
)
|
||||
|
||||
return PdfSlidesResponse(
|
||||
success=True, slides=slides_data, total_slides=len(slides_data)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error processing PDF slides: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to process PDF: {str(e)}"
|
||||
)
|
||||
|
|
@ -1,641 +0,0 @@
|
|||
import os
|
||||
import shutil
|
||||
import zipfile
|
||||
import tempfile
|
||||
import subprocess
|
||||
import uuid
|
||||
from typing import List, Optional, Dict
|
||||
from fastapi import APIRouter, UploadFile, File, HTTPException
|
||||
from pydantic import BaseModel
|
||||
import aiohttp
|
||||
import asyncio
|
||||
import xml.etree.ElementTree as ET
|
||||
import re
|
||||
|
||||
from services.documents_loader import DocumentsLoader
|
||||
from utils.asset_directory_utils import get_images_directory
|
||||
import uuid
|
||||
from constants.documents import PPTX_MIME_TYPES
|
||||
|
||||
|
||||
def _get_soffice_binary() -> str:
|
||||
"""Return the soffice binary to use for LibreOffice subprocess calls.
|
||||
|
||||
When running inside the Electron desktop app, the main process resolves the
|
||||
exact soffice binary path at startup and forwards it via the ``SOFFICE_PATH``
|
||||
environment variable. Falling back to the bare ``"soffice"`` command keeps
|
||||
Docker / server deployments working unchanged.
|
||||
"""
|
||||
configured = os.environ.get("SOFFICE_PATH")
|
||||
if configured:
|
||||
return configured
|
||||
return "soffice.exe" if os.name == "nt" else "soffice"
|
||||
|
||||
|
||||
def _windows_hidden_subprocess_kwargs() -> Dict[str, object]:
|
||||
"""Return subprocess kwargs that suppress Windows console windows."""
|
||||
if os.name != "nt":
|
||||
return {}
|
||||
|
||||
startupinfo = subprocess.STARTUPINFO()
|
||||
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
||||
return {
|
||||
"creationflags": getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
"startupinfo": startupinfo,
|
||||
}
|
||||
|
||||
|
||||
PPTX_SLIDES_ROUTER = APIRouter(prefix="/pptx-slides", tags=["PPTX Slides"])
|
||||
|
||||
|
||||
class SlideData(BaseModel):
|
||||
slide_number: int
|
||||
screenshot_url: str
|
||||
xml_content: str
|
||||
normalized_fonts: List[str]
|
||||
|
||||
|
||||
class FontAnalysisResult(BaseModel):
|
||||
internally_supported_fonts: List[
|
||||
Dict[str, str]
|
||||
] # [{"name": "Open Sans", "google_fonts_url": "..."}]
|
||||
not_supported_fonts: List[str] # ["Custom Font Name"]
|
||||
|
||||
|
||||
class PptxSlidesResponse(BaseModel):
|
||||
success: bool
|
||||
slides: List[SlideData]
|
||||
total_slides: int
|
||||
fonts: Optional[FontAnalysisResult] = None
|
||||
|
||||
|
||||
# NEW: Fonts-only router and response for PPTX
|
||||
class PptxFontsResponse(BaseModel):
|
||||
success: bool
|
||||
fonts: FontAnalysisResult
|
||||
|
||||
|
||||
PPTX_FONTS_ROUTER = APIRouter(prefix="/pptx-fonts", tags=["PPTX Fonts"])
|
||||
|
||||
# NEW: Normalize font family names by removing style/weight/stretch descriptors and splitting camel case
|
||||
_STYLE_TOKENS = {
|
||||
# styles
|
||||
"italic",
|
||||
"italics",
|
||||
"ital",
|
||||
"oblique",
|
||||
"roman",
|
||||
# combined style shortcuts
|
||||
"bolditalic",
|
||||
"bolditalics",
|
||||
# weights
|
||||
"thin",
|
||||
"hairline",
|
||||
"extralight",
|
||||
"ultralight",
|
||||
"light",
|
||||
"demilight",
|
||||
"semilight",
|
||||
"book",
|
||||
"regular",
|
||||
"normal",
|
||||
"medium",
|
||||
"semibold",
|
||||
"demibold",
|
||||
"bold",
|
||||
"extrabold",
|
||||
"ultrabold",
|
||||
"black",
|
||||
"extrablack",
|
||||
"ultrablack",
|
||||
"heavy",
|
||||
# width/stretch
|
||||
"narrow",
|
||||
"condensed",
|
||||
"semicondensed",
|
||||
"extracondensed",
|
||||
"ultracondensed",
|
||||
"expanded",
|
||||
"semiexpanded",
|
||||
"extraexpanded",
|
||||
"ultraexpanded",
|
||||
}
|
||||
# Modifiers commonly used with style tokens
|
||||
_STYLE_MODIFIERS = {"semi", "demi", "extra", "ultra"}
|
||||
|
||||
|
||||
def _insert_spaces_in_camel_case(value: str) -> str:
|
||||
# Insert space before capital letters preceded by lowercase or digits (e.g., MontserratBold -> Montserrat Bold)
|
||||
value = re.sub(r"(?<=[a-z0-9])([A-Z])", r" \1", value)
|
||||
# Handle sequences like BoldItalic -> Bold Italic
|
||||
value = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1 \2", value)
|
||||
return value
|
||||
|
||||
|
||||
def normalize_font_family_name(raw_name: str) -> str:
|
||||
if not raw_name:
|
||||
return raw_name
|
||||
# Replace separators with spaces
|
||||
name = raw_name.replace("_", " ").replace("-", " ")
|
||||
# Insert spaces in camel case
|
||||
name = _insert_spaces_in_camel_case(name)
|
||||
# Collapse multiple spaces
|
||||
name = re.sub(r"\s+", " ", name).strip()
|
||||
# Lowercase helper for matching but keep original casing for output
|
||||
lower_name = name.lower()
|
||||
# Quick cut: if the full string ends with a pure style suffix, trim it
|
||||
for style in sorted(_STYLE_TOKENS, key=len, reverse=True):
|
||||
if lower_name.endswith(" " + style):
|
||||
name = name[: -(len(style) + 1)]
|
||||
lower_name = lower_name[: -(len(style) + 1)]
|
||||
break
|
||||
# Tokenize
|
||||
tokens_original = name.split(" ")
|
||||
tokens_filtered: List[str] = []
|
||||
for index, tok in enumerate(tokens_original):
|
||||
lower_tok = tok.lower()
|
||||
# Always keep the first token to avoid stripping families like "Black Ops One"
|
||||
if index == 0:
|
||||
tokens_filtered.append(tok)
|
||||
continue
|
||||
# Drop style tokens and standalone modifiers
|
||||
if lower_tok in _STYLE_TOKENS or lower_tok in _STYLE_MODIFIERS:
|
||||
continue
|
||||
tokens_filtered.append(tok)
|
||||
# If everything except first token was dropped and first token is a style token (unlikely), fallback to original
|
||||
if not tokens_filtered:
|
||||
tokens_filtered = tokens_original
|
||||
normalized = " ".join(tokens_filtered).strip()
|
||||
# Final cleanup of leftover multiple spaces
|
||||
normalized = re.sub(r"\s+", " ", normalized)
|
||||
return normalized
|
||||
|
||||
|
||||
def extract_fonts_from_oxml(xml_content: str) -> List[str]:
|
||||
"""
|
||||
Extract font names from OXML content.
|
||||
|
||||
Args:
|
||||
xml_content: OXML content as string
|
||||
|
||||
Returns:
|
||||
List of unique font names found in the OXML
|
||||
"""
|
||||
fonts = set()
|
||||
|
||||
try:
|
||||
# Parse the XML content
|
||||
root = ET.fromstring(xml_content)
|
||||
|
||||
# Define namespaces commonly used in OXML
|
||||
namespaces = {
|
||||
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
|
||||
"p": "http://schemas.openxmlformats.org/presentationml/2006/main",
|
||||
"r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
|
||||
}
|
||||
|
||||
# Search for font references in various OXML elements
|
||||
# Look for latin fonts
|
||||
for font_elem in root.findall(".//a:latin", namespaces):
|
||||
if "typeface" in font_elem.attrib:
|
||||
fonts.add(font_elem.attrib["typeface"])
|
||||
|
||||
# Look for east asian fonts
|
||||
for font_elem in root.findall(".//a:ea", namespaces):
|
||||
if "typeface" in font_elem.attrib:
|
||||
fonts.add(font_elem.attrib["typeface"])
|
||||
|
||||
# Look for complex script fonts
|
||||
for font_elem in root.findall(".//a:cs", namespaces):
|
||||
if "typeface" in font_elem.attrib:
|
||||
fonts.add(font_elem.attrib["typeface"])
|
||||
|
||||
# Look for font references in theme elements
|
||||
for font_elem in root.findall(".//a:font", namespaces):
|
||||
if "typeface" in font_elem.attrib:
|
||||
fonts.add(font_elem.attrib["typeface"])
|
||||
|
||||
# Look for rPr (run properties) font references
|
||||
for rpr_elem in root.findall(".//a:rPr", namespaces):
|
||||
for font_elem in rpr_elem.findall(".//a:latin", namespaces):
|
||||
if "typeface" in font_elem.attrib:
|
||||
fonts.add(font_elem.attrib["typeface"])
|
||||
|
||||
# Also search without namespace prefix for compatibility
|
||||
for font_elem in root.findall(".//latin"):
|
||||
if "typeface" in font_elem.attrib:
|
||||
fonts.add(font_elem.attrib["typeface"])
|
||||
|
||||
# Regex fallback for fonts that might be missed
|
||||
font_pattern = r'typeface="([^"]+)"'
|
||||
regex_fonts = re.findall(font_pattern, xml_content)
|
||||
fonts.update(regex_fonts)
|
||||
|
||||
# Filter out system fonts and empty values
|
||||
system_fonts = {"+mn-lt", "+mj-lt", "+mn-ea", "+mj-ea", "+mn-cs", "+mj-cs", ""}
|
||||
fonts = {font for font in fonts if font not in system_fonts and font.strip()}
|
||||
|
||||
return list(fonts)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error extracting fonts from OXML: {e}")
|
||||
return []
|
||||
|
||||
|
||||
async def check_google_font_availability(font_name: str) -> bool:
|
||||
"""
|
||||
Check if a font is available in Google Fonts.
|
||||
|
||||
Args:
|
||||
font_name: Name of the font to check
|
||||
|
||||
Returns:
|
||||
True if font is available in Google Fonts, False otherwise
|
||||
"""
|
||||
try:
|
||||
formatted_name = font_name.replace(" ", "+")
|
||||
url = f"https://fonts.googleapis.com/css2?family={formatted_name}&display=swap"
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.head(
|
||||
url, timeout=aiohttp.ClientTimeout(total=10)
|
||||
) as response:
|
||||
return response.status == 200
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error checking Google Font availability for {font_name}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def analyze_fonts_in_all_slides(slide_xmls: List[str]) -> FontAnalysisResult:
|
||||
"""
|
||||
Analyze fonts across all slides and determine Google Fonts availability.
|
||||
|
||||
Args:
|
||||
slide_xmls: List of OXML content strings from all slides
|
||||
|
||||
Returns:
|
||||
FontAnalysisResult with supported and unsupported fonts
|
||||
"""
|
||||
# Extract fonts from all slides
|
||||
raw_fonts = set()
|
||||
for xml_content in slide_xmls:
|
||||
slide_fonts = extract_fonts_from_oxml(xml_content)
|
||||
raw_fonts.update(slide_fonts)
|
||||
|
||||
# Normalize to root families (e.g., "Montserrat Italic" -> "Montserrat")
|
||||
normalized_fonts = {normalize_font_family_name(f) for f in raw_fonts}
|
||||
# Remove empties if any
|
||||
normalized_fonts = {f for f in normalized_fonts if f}
|
||||
|
||||
if not normalized_fonts:
|
||||
return FontAnalysisResult(internally_supported_fonts=[], not_supported_fonts=[])
|
||||
|
||||
# Check each normalized font's availability in Google Fonts concurrently
|
||||
tasks = [check_google_font_availability(font) for font in normalized_fonts]
|
||||
results = await asyncio.gather(*tasks)
|
||||
|
||||
internally_supported_fonts = []
|
||||
not_supported_fonts = []
|
||||
|
||||
for font, is_available in zip(normalized_fonts, results):
|
||||
if is_available:
|
||||
formatted_name = font.replace(" ", "+")
|
||||
google_fonts_url = f"https://fonts.googleapis.com/css2?family={formatted_name}&display=swap"
|
||||
internally_supported_fonts.append(
|
||||
{"name": font, "google_fonts_url": google_fonts_url}
|
||||
)
|
||||
else:
|
||||
not_supported_fonts.append(font)
|
||||
|
||||
return FontAnalysisResult(
|
||||
internally_supported_fonts=internally_supported_fonts, not_supported_fonts=[]
|
||||
)
|
||||
|
||||
|
||||
@PPTX_SLIDES_ROUTER.post("/process", response_model=PptxSlidesResponse)
|
||||
async def process_pptx_slides(
|
||||
pptx_file: UploadFile = File(..., description="PPTX file to process"),
|
||||
fonts: Optional[List[UploadFile]] = File(None, description="Optional font files"),
|
||||
):
|
||||
"""
|
||||
Process a PPTX file to extract slide screenshots and XML content.
|
||||
|
||||
This endpoint:
|
||||
1. Validates the uploaded PPTX file
|
||||
2. Installs any provided font files
|
||||
3. Unzips the PPTX to extract slide XMLs
|
||||
4. Uses LibreOffice to generate slide screenshots
|
||||
5. Returns both screenshot URLs and XML content for each slide
|
||||
"""
|
||||
|
||||
# Validate PPTX file
|
||||
if pptx_file.content_type not in PPTX_MIME_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid file type. Expected PPTX file, got {pptx_file.content_type}",
|
||||
)
|
||||
# Enforce 100MB size limit
|
||||
if (
|
||||
hasattr(pptx_file, "size")
|
||||
and pptx_file.size
|
||||
and pptx_file.size > (100 * 1024 * 1024)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="PPTX file exceeded max upload size of 100 MB",
|
||||
)
|
||||
|
||||
# Create temporary directory for processing
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
if True:
|
||||
# Save uploaded PPTX file
|
||||
pptx_path = os.path.join(temp_dir, "presentation.pptx")
|
||||
with open(pptx_path, "wb") as f:
|
||||
pptx_content = await pptx_file.read()
|
||||
f.write(pptx_content)
|
||||
|
||||
# Install fonts if provided
|
||||
if fonts:
|
||||
await _install_fonts(fonts, temp_dir)
|
||||
|
||||
# Extract slide XMLs from PPTX
|
||||
slide_xmls = _extract_slide_xmls(pptx_path, temp_dir)
|
||||
|
||||
# Convert PPTX to PDF
|
||||
pdf_path = await _convert_pptx_to_pdf(pptx_path, temp_dir)
|
||||
|
||||
# Generate screenshots using LibreOffice
|
||||
screenshot_paths = await DocumentsLoader.get_page_images_from_pdf_async(
|
||||
pdf_path, temp_dir
|
||||
)
|
||||
print(f"Screenshot paths: {screenshot_paths}")
|
||||
|
||||
# Analyze fonts across all slides
|
||||
font_analysis = await analyze_fonts_in_all_slides(slide_xmls)
|
||||
print(
|
||||
f"Font analysis completed: {len(font_analysis.internally_supported_fonts)} supported, {len(font_analysis.not_supported_fonts)} not supported"
|
||||
)
|
||||
|
||||
# Move screenshots to images directory and generate URLs
|
||||
images_dir = get_images_directory()
|
||||
presentation_id = uuid.uuid4()
|
||||
presentation_images_dir = os.path.join(images_dir, str(presentation_id))
|
||||
os.makedirs(presentation_images_dir, exist_ok=True)
|
||||
|
||||
slides_data = []
|
||||
|
||||
for i, (xml_content, screenshot_path) in enumerate(
|
||||
zip(slide_xmls, screenshot_paths), 1
|
||||
):
|
||||
# Move screenshot to permanent location
|
||||
screenshot_filename = f"slide_{i}.png"
|
||||
permanent_screenshot_path = os.path.join(
|
||||
presentation_images_dir, screenshot_filename
|
||||
)
|
||||
|
||||
if (
|
||||
os.path.exists(screenshot_path)
|
||||
and os.path.getsize(screenshot_path) > 0
|
||||
):
|
||||
# Use shutil.copy2 instead of os.rename to handle cross-device moves
|
||||
shutil.copy2(screenshot_path, permanent_screenshot_path)
|
||||
screenshot_url = (
|
||||
f"/app_data/images/{presentation_id}/{screenshot_filename}"
|
||||
)
|
||||
else:
|
||||
# Fallback if screenshot generation failed or file is empty placeholder
|
||||
screenshot_url = "/static/images/placeholder.jpg"
|
||||
|
||||
# Compute normalized fonts for this slide
|
||||
raw_slide_fonts = extract_fonts_from_oxml(xml_content)
|
||||
normalized_fonts = sorted(
|
||||
{normalize_font_family_name(f) for f in raw_slide_fonts if f}
|
||||
)
|
||||
|
||||
slides_data.append(
|
||||
SlideData(
|
||||
slide_number=i,
|
||||
screenshot_url=screenshot_url,
|
||||
xml_content=xml_content,
|
||||
normalized_fonts=normalized_fonts,
|
||||
)
|
||||
)
|
||||
|
||||
return PptxSlidesResponse(
|
||||
success=True,
|
||||
slides=slides_data,
|
||||
total_slides=len(slides_data),
|
||||
fonts=font_analysis,
|
||||
)
|
||||
|
||||
|
||||
# NEW: Fonts-only endpoint leveraging the same font extraction/analysis
|
||||
@PPTX_FONTS_ROUTER.post("/process", response_model=PptxFontsResponse)
|
||||
async def process_pptx_fonts(
|
||||
pptx_file: UploadFile = File(..., description="PPTX file to analyze fonts from")
|
||||
):
|
||||
"""
|
||||
Analyze a PPTX file and return only the fonts used in the document.
|
||||
|
||||
Uses the exact same font extraction and analysis utilities as the /pptx-slides endpoint.
|
||||
"""
|
||||
# Validate PPTX file
|
||||
if pptx_file.content_type not in PPTX_MIME_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid file type. Expected PPTX file, got {pptx_file.content_type}",
|
||||
)
|
||||
|
||||
# Create temporary directory for processing
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
# Save uploaded PPTX file
|
||||
pptx_path = os.path.join(temp_dir, "presentation.pptx")
|
||||
with open(pptx_path, "wb") as f:
|
||||
pptx_content = await pptx_file.read()
|
||||
f.write(pptx_content)
|
||||
|
||||
# Extract slide XMLs from PPTX
|
||||
slide_xmls = _extract_slide_xmls(pptx_path, temp_dir)
|
||||
|
||||
# Analyze fonts across all slides (same logic as in /pptx-slides)
|
||||
font_analysis = await analyze_fonts_in_all_slides(slide_xmls)
|
||||
|
||||
return PptxFontsResponse(
|
||||
success=True,
|
||||
fonts=font_analysis,
|
||||
)
|
||||
|
||||
|
||||
def _create_font_alias_config(raw_fonts: List[str]) -> str:
|
||||
"""Create a temporary fontconfig configuration that aliases variant family names to normalized root families.
|
||||
Returns the path to the config file.
|
||||
"""
|
||||
# Build mapping from raw -> normalized where different
|
||||
mappings: Dict[str, str] = {}
|
||||
for f in raw_fonts:
|
||||
normalized = normalize_font_family_name(f)
|
||||
if normalized and normalized != f:
|
||||
mappings[f] = normalized
|
||||
# Create config only if we have mappings
|
||||
fd, fonts_conf_path = tempfile.mkstemp(prefix="fonts_alias_", suffix=".conf")
|
||||
os.close(fd)
|
||||
with open(fonts_conf_path, "w", encoding="utf-8") as cfg:
|
||||
cfg.write(
|
||||
"""<?xml version='1.0'?>
|
||||
<!DOCTYPE fontconfig SYSTEM "urn:fontconfig:fonts.dtd">
|
||||
<fontconfig>
|
||||
<include>/etc/fonts/fonts.conf</include>
|
||||
"""
|
||||
)
|
||||
for src, dst in mappings.items():
|
||||
cfg.write(
|
||||
f"""
|
||||
<match target="pattern">
|
||||
<test name="family" compare="eq">
|
||||
<string>{src}</string>
|
||||
</test>
|
||||
<edit name="family" mode="assign" binding="strong">
|
||||
<string>{dst}</string>
|
||||
</edit>
|
||||
</match>
|
||||
"""
|
||||
)
|
||||
cfg.write("\n</fontconfig>\n")
|
||||
return fonts_conf_path
|
||||
|
||||
|
||||
async def _install_fonts(fonts: List[UploadFile], temp_dir: str) -> None:
|
||||
"""Install provided font files to the system."""
|
||||
fonts_dir = os.path.join(temp_dir, "fonts")
|
||||
os.makedirs(fonts_dir, exist_ok=True)
|
||||
|
||||
for font_file in fonts:
|
||||
# Save font file
|
||||
font_path = os.path.join(fonts_dir, font_file.filename)
|
||||
with open(font_path, "wb") as f:
|
||||
font_content = await font_file.read()
|
||||
f.write(font_content)
|
||||
|
||||
# Install font (copy to system fonts directory)
|
||||
try:
|
||||
subprocess.run(
|
||||
["cp", font_path, "/usr/share/fonts/truetype/"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Warning: Failed to install font {font_file.filename}: {e}")
|
||||
|
||||
# Refresh font cache
|
||||
try:
|
||||
subprocess.run(["fc-cache", "-f", "-v"], check=True, capture_output=True)
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Warning: Failed to refresh font cache: {e}")
|
||||
|
||||
|
||||
def _extract_slide_xmls(pptx_path: str, temp_dir: str) -> List[str]:
|
||||
"""Extract slide XML content from PPTX file."""
|
||||
slide_xmls = []
|
||||
extract_dir = os.path.join(temp_dir, "pptx_extract")
|
||||
|
||||
try:
|
||||
# Unzip PPTX file
|
||||
with zipfile.ZipFile(pptx_path, "r") as zip_ref:
|
||||
zip_ref.extractall(extract_dir)
|
||||
|
||||
# Look for slides in ppt/slides/ directory
|
||||
slides_dir = os.path.join(extract_dir, "ppt", "slides")
|
||||
|
||||
if not os.path.exists(slides_dir):
|
||||
raise Exception("No slides directory found in PPTX file")
|
||||
|
||||
# Get all slide XML files and sort them numerically
|
||||
slide_files = [
|
||||
f
|
||||
for f in os.listdir(slides_dir)
|
||||
if f.startswith("slide") and f.endswith(".xml")
|
||||
]
|
||||
slide_files.sort(key=lambda x: int(x.replace("slide", "").replace(".xml", "")))
|
||||
|
||||
# Read XML content from each slide
|
||||
for slide_file in slide_files:
|
||||
slide_path = os.path.join(slides_dir, slide_file)
|
||||
with open(slide_path, "r", encoding="utf-8") as f:
|
||||
slide_xmls.append(f.read())
|
||||
|
||||
return slide_xmls
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to extract slide XMLs: {str(e)}")
|
||||
|
||||
|
||||
async def _convert_pptx_to_pdf(pptx_path: str, temp_dir: str) -> str:
|
||||
"""Generate PNG screenshots of PPTX slides using LibreOffice + ImageMagick."""
|
||||
screenshots_dir = os.path.join(temp_dir, "screenshots")
|
||||
os.makedirs(screenshots_dir, exist_ok=True)
|
||||
|
||||
try:
|
||||
# First, get the number of slides by extracting XMLs
|
||||
slide_xmls = _extract_slide_xmls(pptx_path, temp_dir)
|
||||
slide_count = len(slide_xmls)
|
||||
|
||||
# Build font alias config to force variant families to resolve to normalized root families
|
||||
raw_fonts: List[str] = []
|
||||
for xml in slide_xmls:
|
||||
raw_fonts.extend(extract_fonts_from_oxml(xml))
|
||||
raw_fonts = list({f for f in raw_fonts if f})
|
||||
fonts_conf_path = _create_font_alias_config(raw_fonts)
|
||||
env = os.environ.copy()
|
||||
env["FONTCONFIG_FILE"] = fonts_conf_path
|
||||
|
||||
print(f"Found {slide_count} slides in presentation")
|
||||
|
||||
# Step 1: Convert PPTX to PDF using LibreOffice
|
||||
print("Starting LibreOffice PDF conversion...")
|
||||
pdf_filename = "temp_presentation.pdf"
|
||||
pdf_path = os.path.join(screenshots_dir, pdf_filename)
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
_get_soffice_binary(),
|
||||
"--headless",
|
||||
"--convert-to",
|
||||
"pdf",
|
||||
"--outdir",
|
||||
screenshots_dir,
|
||||
pptx_path,
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=500,
|
||||
env=env,
|
||||
**_windows_hidden_subprocess_kwargs(),
|
||||
)
|
||||
|
||||
print(f"LibreOffice PDF conversion output: {result.stdout}")
|
||||
if result.stderr:
|
||||
print(f"LibreOffice PDF conversion warnings: {result.stderr}")
|
||||
except subprocess.TimeoutExpired:
|
||||
raise Exception("LibreOffice PDF conversion timed out after 120 seconds")
|
||||
except subprocess.CalledProcessError as e:
|
||||
error_msg = e.stderr if e.stderr else str(e)
|
||||
raise Exception(f"LibreOffice PDF conversion failed: {error_msg}")
|
||||
|
||||
# Find the generated PDF file (LibreOffice uses original filename)
|
||||
pdf_files = [f for f in os.listdir(screenshots_dir) if f.endswith(".pdf")]
|
||||
if not pdf_files:
|
||||
raise Exception("LibreOffice failed to generate PDF file")
|
||||
|
||||
actual_pdf_path = os.path.join(screenshots_dir, pdf_files[0])
|
||||
print(f"Generated PDF: {actual_pdf_path}")
|
||||
return actual_pdf_path
|
||||
|
||||
except Exception as e:
|
||||
# Re-raise the specific exceptions we've already handled
|
||||
if "timed out" in str(e) or "failed:" in str(e):
|
||||
raise
|
||||
# Handle any other unexpected exceptions
|
||||
raise Exception(f"Screenshot generation failed: {str(e)}")
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
import asyncio
|
||||
from datetime import datetime
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import random
|
||||
import traceback
|
||||
|
|
@ -12,7 +11,7 @@ from fastapi.responses import StreamingResponse
|
|||
from sqlalchemy import delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlmodel import select
|
||||
from constants.presentation import DEFAULT_TEMPLATES
|
||||
from constants.presentation import DEFAULT_TEMPLATES, MAX_NUMBER_OF_SLIDES
|
||||
from enums.webhook_event import WebhookEvent
|
||||
from models.api_error_model import APIErrorModel
|
||||
from models.generate_presentation_request import GeneratePresentationRequest
|
||||
|
|
@ -25,16 +24,13 @@ from models.presentation_outline_model import (
|
|||
from enums.tone import Tone
|
||||
from enums.verbosity import Verbosity
|
||||
from models.pptx_models import PptxPresentationModel
|
||||
from models.presentation_layout import PresentationLayoutModel
|
||||
from models.presentation_structure_model import PresentationStructureModel
|
||||
from models.presentation_with_slides import (
|
||||
PresentationWithSlides,
|
||||
)
|
||||
from models.sql.template import TemplateModel
|
||||
|
||||
from services.documents_loader import DocumentsLoader
|
||||
from services.webhook_service import WebhookService
|
||||
from utils.get_layout_by_name import get_layout_by_name
|
||||
from services.image_generation_service import ImageGenerationService
|
||||
from utils.dict_utils import deep_update
|
||||
from utils.export_utils import export_presentation
|
||||
|
|
@ -58,19 +54,41 @@ from utils.llm_calls.generate_slide_content import (
|
|||
get_slide_content_from_type_and_outline,
|
||||
)
|
||||
from utils.ppt_utils import (
|
||||
get_presentation_title_from_outlines,
|
||||
select_toc_or_list_slide_layout_index,
|
||||
)
|
||||
from utils.outline_utils import (
|
||||
get_images_for_slides_from_outline,
|
||||
get_no_of_outlines_to_generate_for_n_slides,
|
||||
get_no_of_toc_required_for_n_outlines,
|
||||
get_presentation_outline_model_with_toc,
|
||||
get_presentation_title_from_presentation_outline,
|
||||
)
|
||||
from utils.process_slides import (
|
||||
process_slide_add_placeholder_assets,
|
||||
process_slide_and_fetch_assets,
|
||||
)
|
||||
from templates.get_layout_by_name import get_layout_by_name
|
||||
from templates.presentation_layout import PresentationLayoutModel
|
||||
import uuid
|
||||
|
||||
|
||||
PRESENTATION_ROUTER = APIRouter(prefix="/presentation", tags=["Presentation"])
|
||||
|
||||
|
||||
def _insert_toc_layouts(
|
||||
structure: PresentationStructureModel,
|
||||
n_toc_slides: int,
|
||||
include_title_slide: bool,
|
||||
toc_slide_layout_index: int,
|
||||
):
|
||||
if n_toc_slides <= 0 or toc_slide_layout_index == -1:
|
||||
return
|
||||
|
||||
insertion_index = 1 if include_title_slide else 0
|
||||
for i in range(n_toc_slides):
|
||||
structure.slides.insert(insertion_index + i, toc_slide_layout_index)
|
||||
|
||||
|
||||
@PRESENTATION_ROUTER.get("/all", response_model=List[PresentationWithSlides])
|
||||
async def get_all_presentations(sql_session: AsyncSession = Depends(get_async_session)):
|
||||
presentations_with_slides = []
|
||||
|
|
@ -129,8 +147,8 @@ async def delete_presentation(
|
|||
@PRESENTATION_ROUTER.post("/create", response_model=PresentationModel)
|
||||
async def create_presentation(
|
||||
content: Annotated[str, Body()],
|
||||
n_slides: Annotated[int, Body()],
|
||||
language: Annotated[str, Body()],
|
||||
n_slides: Annotated[Optional[int], Body()] = None,
|
||||
language: Annotated[Optional[str], Body()] = None,
|
||||
file_paths: Annotated[Optional[List[str]], Body()] = None,
|
||||
tone: Annotated[Tone, Body()] = Tone.DEFAULT,
|
||||
verbosity: Annotated[Verbosity, Body()] = Verbosity.STANDARD,
|
||||
|
|
@ -141,19 +159,34 @@ async def create_presentation(
|
|||
sql_session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
|
||||
if include_table_of_contents and n_slides < 3:
|
||||
if n_slides is not None and n_slides < 1:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Number of slides must be greater than 0",
|
||||
)
|
||||
|
||||
if n_slides is not None and n_slides > MAX_NUMBER_OF_SLIDES:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Number of slides cannot be greater than {MAX_NUMBER_OF_SLIDES}",
|
||||
)
|
||||
|
||||
if include_table_of_contents and n_slides is not None and n_slides < 3:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Number of slides cannot be less than 3 if table of contents is included",
|
||||
)
|
||||
|
||||
presentation_id = uuid.uuid4()
|
||||
language_to_store = (language or "").strip()
|
||||
# DB schema stores an int; 0 is used as internal marker for auto slide count.
|
||||
n_slides_to_store = n_slides if n_slides is not None else 0
|
||||
|
||||
presentation = PresentationModel(
|
||||
id=presentation_id,
|
||||
content=content,
|
||||
n_slides=n_slides,
|
||||
language=language,
|
||||
n_slides=n_slides_to_store,
|
||||
language=language_to_store,
|
||||
file_paths=file_paths,
|
||||
tone=tone.value,
|
||||
verbosity=verbosity.value,
|
||||
|
|
@ -210,40 +243,24 @@ async def prepare_presentation(
|
|||
presentation_structure.slides[index] = random_slide_index
|
||||
|
||||
if presentation.include_table_of_contents:
|
||||
n_toc_slides = presentation.n_slides - total_outlines
|
||||
n_toc_slides = get_no_of_toc_required_for_n_outlines(
|
||||
n_outlines=total_outlines,
|
||||
title_slide=presentation.include_title_slide,
|
||||
target_total_slides=(presentation.n_slides if presentation.n_slides > 0 else None),
|
||||
)
|
||||
toc_slide_layout_index = select_toc_or_list_slide_layout_index(layout)
|
||||
if toc_slide_layout_index != -1:
|
||||
outline_index = 1 if presentation.include_title_slide else 0
|
||||
for i in range(n_toc_slides):
|
||||
outlines_to = outline_index + 10
|
||||
if total_outlines == outlines_to:
|
||||
outlines_to -= 1
|
||||
|
||||
presentation_structure.slides.insert(
|
||||
i + 1 if presentation.include_title_slide else i,
|
||||
toc_slide_layout_index,
|
||||
)
|
||||
toc_outline = "Table of Contents\n\n"
|
||||
|
||||
for outline in presentation_outline_model.slides[
|
||||
outline_index:outlines_to
|
||||
]:
|
||||
page_number = (
|
||||
outline_index - i + n_toc_slides + 1
|
||||
if presentation.include_title_slide
|
||||
else outline_index - i + n_toc_slides
|
||||
)
|
||||
toc_outline += f"Slide page number: {page_number}\n Slide Content: {outline.content[:100]}\n\n"
|
||||
outline_index += 1
|
||||
|
||||
outline_index += 1
|
||||
|
||||
presentation_outline_model.slides.insert(
|
||||
i + 1 if presentation.include_title_slide else i,
|
||||
SlideOutlineModel(
|
||||
content=toc_outline,
|
||||
),
|
||||
)
|
||||
_insert_toc_layouts(
|
||||
presentation_structure,
|
||||
n_toc_slides,
|
||||
presentation.include_title_slide,
|
||||
toc_slide_layout_index,
|
||||
)
|
||||
if toc_slide_layout_index != -1 and n_toc_slides > 0:
|
||||
presentation_outline_model = get_presentation_outline_model_with_toc(
|
||||
outline=presentation_outline_model,
|
||||
n_toc_slides=n_toc_slides,
|
||||
title_slide=presentation.include_title_slide,
|
||||
)
|
||||
|
||||
sql_session.add(presentation)
|
||||
presentation.outlines = presentation_outline_model.model_dump(mode="json")
|
||||
|
|
@ -279,6 +296,7 @@ async def stream_presentation(
|
|||
structure = presentation.get_structure()
|
||||
layout = presentation.get_layout()
|
||||
outline = presentation.get_presentation_outline()
|
||||
image_urls_for_slides = get_images_for_slides_from_outline(outline.slides)
|
||||
|
||||
# These tasks will be gathered and awaited after all slides are generated
|
||||
async_assets_generation_tasks = []
|
||||
|
|
@ -319,7 +337,17 @@ async def stream_presentation(
|
|||
|
||||
# This will mutate slide - start task immediately so it runs in parallel with next slide LLM generation
|
||||
async_assets_generation_tasks.append(
|
||||
asyncio.create_task(process_slide_and_fetch_assets(image_generation_service, slide))
|
||||
asyncio.create_task(
|
||||
process_slide_and_fetch_assets(
|
||||
image_generation_service,
|
||||
slide,
|
||||
outline_image_urls=(
|
||||
image_urls_for_slides[i]
|
||||
if i < len(image_urls_for_slides)
|
||||
else None
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
yield SSEResponse(
|
||||
|
|
@ -375,11 +403,11 @@ async def update_presentation(
|
|||
raise HTTPException(status_code=404, detail="Presentation not found")
|
||||
|
||||
presentation_update_dict = {}
|
||||
if n_slides:
|
||||
if n_slides is not None:
|
||||
presentation_update_dict["n_slides"] = n_slides
|
||||
if title:
|
||||
presentation_update_dict["title"] = title
|
||||
if theme:
|
||||
if theme or theme is None:
|
||||
presentation_update_dict["theme"] = theme
|
||||
|
||||
if presentation_update_dict:
|
||||
|
|
@ -465,13 +493,28 @@ async def check_if_api_request_is_valid(
|
|||
detail="Either content or slides markdown or files is required to generate presentation",
|
||||
)
|
||||
|
||||
# Making sure number of slides is greater than 0
|
||||
if request.n_slides <= 0:
|
||||
if request.n_slides is not None and request.n_slides <= 0:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Number of slides must be greater than 0",
|
||||
)
|
||||
|
||||
if request.n_slides is not None and request.n_slides > MAX_NUMBER_OF_SLIDES:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Number of slides cannot be greater than {MAX_NUMBER_OF_SLIDES}",
|
||||
)
|
||||
|
||||
if (
|
||||
request.include_table_of_contents
|
||||
and request.n_slides is not None
|
||||
and request.n_slides < 3
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Number of slides cannot be less than 3 if table of contents is included",
|
||||
)
|
||||
|
||||
# Checking if template is valid
|
||||
if request.template not in DEFAULT_TEMPLATES:
|
||||
request.template = request.template.lower()
|
||||
|
|
@ -502,6 +545,7 @@ async def generate_presentation_handler(
|
|||
):
|
||||
try:
|
||||
using_slides_markdown = False
|
||||
language_to_use = (request.language or "").strip() or None
|
||||
|
||||
if request.slides_markdown:
|
||||
using_slides_markdown = True
|
||||
|
|
@ -529,30 +573,27 @@ async def generate_presentation_handler(
|
|||
|
||||
# Finding number of slides to generate by considering table of contents
|
||||
n_slides_to_generate = request.n_slides
|
||||
if request.include_table_of_contents:
|
||||
needed_toc_count = math.ceil(
|
||||
(
|
||||
(request.n_slides - 1)
|
||||
if request.include_title_slide
|
||||
else request.n_slides
|
||||
if request.include_table_of_contents and request.n_slides is not None:
|
||||
n_slides_to_generate = (
|
||||
get_no_of_outlines_to_generate_for_n_slides(
|
||||
n_slides=request.n_slides,
|
||||
toc=True,
|
||||
title_slide=request.include_title_slide,
|
||||
)
|
||||
/ 10
|
||||
)
|
||||
n_slides_to_generate -= math.ceil(
|
||||
(request.n_slides - needed_toc_count) / 10
|
||||
)
|
||||
|
||||
presentation_outlines_text = ""
|
||||
async for chunk in generate_ppt_outline(
|
||||
request.content,
|
||||
n_slides_to_generate,
|
||||
request.language,
|
||||
language_to_use,
|
||||
additional_context,
|
||||
request.tone.value,
|
||||
request.verbosity.value,
|
||||
request.instructions,
|
||||
request.include_title_slide,
|
||||
request.web_search,
|
||||
request.include_table_of_contents,
|
||||
):
|
||||
|
||||
if isinstance(chunk, HTTPException):
|
||||
|
|
@ -573,7 +614,20 @@ async def generate_presentation_handler(
|
|||
presentation_outlines = PresentationOutlineModel(
|
||||
**presentation_outlines_json
|
||||
)
|
||||
total_outlines = n_slides_to_generate
|
||||
|
||||
if (
|
||||
n_slides_to_generate is not None
|
||||
and len(presentation_outlines.slides) != n_slides_to_generate
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
"Failed to generate presentation outlines with requested "
|
||||
"number of slides. Please try again."
|
||||
),
|
||||
)
|
||||
|
||||
total_outlines = len(presentation_outlines.slides)
|
||||
|
||||
else:
|
||||
# Setting outlines to slides markdown
|
||||
|
|
@ -621,50 +675,42 @@ async def generate_presentation_handler(
|
|||
if presentation_structure.slides[index] >= total_slide_layouts:
|
||||
presentation_structure.slides[index] = random_slide_index
|
||||
|
||||
# Injecting table of contents to the presentation structure and outlines
|
||||
if request.include_table_of_contents and not using_slides_markdown:
|
||||
n_toc_slides = request.n_slides - total_outlines
|
||||
should_include_toc = (
|
||||
request.include_table_of_contents and not using_slides_markdown
|
||||
)
|
||||
if should_include_toc:
|
||||
n_toc_slides = get_no_of_toc_required_for_n_outlines(
|
||||
n_outlines=total_outlines,
|
||||
title_slide=request.include_title_slide,
|
||||
target_total_slides=request.n_slides,
|
||||
)
|
||||
toc_slide_layout_index = select_toc_or_list_slide_layout_index(layout_model)
|
||||
if toc_slide_layout_index != -1:
|
||||
outline_index = 1 if request.include_title_slide else 0
|
||||
for i in range(n_toc_slides):
|
||||
outlines_to = outline_index + 10
|
||||
if total_outlines == outlines_to:
|
||||
outlines_to -= 1
|
||||
_insert_toc_layouts(
|
||||
presentation_structure,
|
||||
n_toc_slides,
|
||||
request.include_title_slide,
|
||||
toc_slide_layout_index,
|
||||
)
|
||||
if toc_slide_layout_index != -1 and n_toc_slides > 0:
|
||||
presentation_outlines = get_presentation_outline_model_with_toc(
|
||||
outline=presentation_outlines,
|
||||
n_toc_slides=n_toc_slides,
|
||||
title_slide=request.include_title_slide,
|
||||
)
|
||||
|
||||
presentation_structure.slides.insert(
|
||||
i + 1 if request.include_title_slide else i,
|
||||
toc_slide_layout_index,
|
||||
)
|
||||
toc_outline = "Table of Contents\n\n"
|
||||
|
||||
for outline in presentation_outlines.slides[
|
||||
outline_index:outlines_to
|
||||
]:
|
||||
page_number = (
|
||||
outline_index - i + n_toc_slides + 1
|
||||
if request.include_title_slide
|
||||
else outline_index - i + n_toc_slides
|
||||
)
|
||||
toc_outline += f"Slide page number: {page_number}\n Slide Content: {outline.content[:100]}\n\n"
|
||||
outline_index += 1
|
||||
|
||||
outline_index += 1
|
||||
|
||||
presentation_outlines.slides.insert(
|
||||
i + 1 if request.include_title_slide else i,
|
||||
SlideOutlineModel(
|
||||
content=toc_outline,
|
||||
),
|
||||
)
|
||||
final_n_slides = request.n_slides
|
||||
if final_n_slides is None:
|
||||
final_n_slides = len(presentation_outlines.slides)
|
||||
|
||||
# Create PresentationModel
|
||||
presentation = PresentationModel(
|
||||
id=presentation_id,
|
||||
content=request.content,
|
||||
n_slides=request.n_slides,
|
||||
language=request.language,
|
||||
title=get_presentation_title_from_outlines(presentation_outlines),
|
||||
n_slides=final_n_slides,
|
||||
language=language_to_use or "",
|
||||
title=get_presentation_title_from_presentation_outline(
|
||||
presentation_outlines
|
||||
),
|
||||
outlines=presentation_outlines.model_dump(),
|
||||
layout=layout_model.model_dump(),
|
||||
structure=presentation_structure.model_dump(),
|
||||
|
|
@ -701,7 +747,7 @@ async def generate_presentation_handler(
|
|||
get_slide_content_from_type_and_outline(
|
||||
slide_layouts[i],
|
||||
presentation_outlines.slides[i],
|
||||
request.language,
|
||||
language_to_use,
|
||||
request.tone.value,
|
||||
request.verbosity.value,
|
||||
request.instructions,
|
||||
|
|
@ -726,10 +772,23 @@ async def generate_presentation_handler(
|
|||
slides.append(slide)
|
||||
batch_slides.append(slide)
|
||||
|
||||
if using_slides_markdown:
|
||||
image_urls_for_batch = get_images_for_slides_from_outline(
|
||||
presentation_outlines.slides[start:end]
|
||||
)
|
||||
else:
|
||||
image_urls_for_batch = [[] for _ in batch_slides]
|
||||
|
||||
# Start asset fetch tasks immediately so they run in parallel with next batch's LLM calls
|
||||
asset_tasks = [
|
||||
asyncio.create_task(process_slide_and_fetch_assets(image_generation_service, slide))
|
||||
for slide in batch_slides
|
||||
asyncio.create_task(
|
||||
process_slide_and_fetch_assets(
|
||||
image_generation_service,
|
||||
slide,
|
||||
outline_image_urls=image_urls_for_batch[offset],
|
||||
)
|
||||
)
|
||||
for offset, slide in enumerate(batch_slides)
|
||||
]
|
||||
async_assets_generation_tasks.extend(asset_tasks)
|
||||
|
||||
|
|
@ -821,6 +880,8 @@ async def generate_presentation_sync(
|
|||
return await generate_presentation_handler(
|
||||
request, presentation_id, None, sql_session
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
raise HTTPException(status_code=500, detail="Presentation generation failed")
|
||||
|
|
|
|||
|
|
@ -1,13 +1,10 @@
|
|||
from fastapi import APIRouter
|
||||
|
||||
from api.v1.ppt.endpoints.slide_to_html import SLIDE_TO_HTML_ROUTER, HTML_TO_REACT_ROUTER, HTML_EDIT_ROUTER, LAYOUT_MANAGEMENT_ROUTER
|
||||
from api.v1.ppt.endpoints.presentation import PRESENTATION_ROUTER
|
||||
from api.v1.ppt.endpoints.anthropic import ANTHROPIC_ROUTER
|
||||
from api.v1.ppt.endpoints.google import GOOGLE_ROUTER
|
||||
from api.v1.ppt.endpoints.openai import OPENAI_ROUTER
|
||||
from api.v1.ppt.endpoints.files import FILES_ROUTER
|
||||
from api.v1.ppt.endpoints.pptx_slides import PPTX_SLIDES_ROUTER
|
||||
from api.v1.ppt.endpoints.pdf_slides import PDF_SLIDES_ROUTER
|
||||
from api.v1.ppt.endpoints.fonts import FONTS_ROUTER
|
||||
from api.v1.ppt.endpoints.icons import ICONS_ROUTER
|
||||
from api.v1.ppt.endpoints.images import IMAGES_ROUTER
|
||||
|
|
@ -15,9 +12,9 @@ from api.v1.ppt.endpoints.ollama import OLLAMA_ROUTER
|
|||
from api.v1.ppt.endpoints.outlines import OUTLINES_ROUTER
|
||||
from api.v1.ppt.endpoints.slide import SLIDE_ROUTER
|
||||
from api.v1.ppt.endpoints.codex_auth import CODEX_AUTH_ROUTER
|
||||
from api.v1.ppt.endpoints.pptx_slides import PPTX_FONTS_ROUTER
|
||||
from api.v1.ppt.endpoints.theme import THEMES_ROUTER
|
||||
from api.v1.ppt.endpoints.theme_generate import THEME_ROUTER
|
||||
from templates.router import TEMPLATE_ROUTER
|
||||
|
||||
|
||||
API_V1_PPT_ROUTER = APIRouter(prefix="/api/v1/ppt")
|
||||
|
|
@ -26,20 +23,14 @@ API_V1_PPT_ROUTER.include_router(FILES_ROUTER)
|
|||
API_V1_PPT_ROUTER.include_router(FONTS_ROUTER)
|
||||
API_V1_PPT_ROUTER.include_router(OUTLINES_ROUTER)
|
||||
API_V1_PPT_ROUTER.include_router(PRESENTATION_ROUTER)
|
||||
API_V1_PPT_ROUTER.include_router(PPTX_SLIDES_ROUTER)
|
||||
API_V1_PPT_ROUTER.include_router(SLIDE_ROUTER)
|
||||
API_V1_PPT_ROUTER.include_router(SLIDE_TO_HTML_ROUTER)
|
||||
API_V1_PPT_ROUTER.include_router(HTML_TO_REACT_ROUTER)
|
||||
API_V1_PPT_ROUTER.include_router(HTML_EDIT_ROUTER)
|
||||
API_V1_PPT_ROUTER.include_router(LAYOUT_MANAGEMENT_ROUTER)
|
||||
API_V1_PPT_ROUTER.include_router(TEMPLATE_ROUTER)
|
||||
API_V1_PPT_ROUTER.include_router(IMAGES_ROUTER)
|
||||
API_V1_PPT_ROUTER.include_router(ICONS_ROUTER)
|
||||
API_V1_PPT_ROUTER.include_router(THEMES_ROUTER)
|
||||
API_V1_PPT_ROUTER.include_router(THEME_ROUTER)
|
||||
API_V1_PPT_ROUTER.include_router(OLLAMA_ROUTER)
|
||||
API_V1_PPT_ROUTER.include_router(PDF_SLIDES_ROUTER)
|
||||
API_V1_PPT_ROUTER.include_router(OPENAI_ROUTER)
|
||||
API_V1_PPT_ROUTER.include_router(ANTHROPIC_ROUTER)
|
||||
API_V1_PPT_ROUTER.include_router(GOOGLE_ROUTER)
|
||||
API_V1_PPT_ROUTER.include_router(CODEX_AUTH_ROUTER)
|
||||
API_V1_PPT_ROUTER.include_router(PPTX_FONTS_ROUTER)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -1 +1,2 @@
|
|||
DEFAULT_TEMPLATES = ["general", "modern", "standard", "swift"]
|
||||
MAX_NUMBER_OF_SLIDES = 50
|
||||
|
|
|
|||
|
|
@ -18,9 +18,13 @@ class GeneratePresentationRequest(BaseModel):
|
|||
default=Verbosity.STANDARD, description="How verbose the presentation should be"
|
||||
)
|
||||
web_search: bool = Field(default=False, description="Whether to enable web search")
|
||||
n_slides: int = Field(default=8, description="Number of slides to generate")
|
||||
language: str = Field(
|
||||
default="English", description="Language for the presentation"
|
||||
n_slides: Optional[int] = Field(
|
||||
default=None,
|
||||
description="Number of slides to generate. If omitted, model auto-detects slide count.",
|
||||
)
|
||||
language: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Language for the presentation. If omitted, model auto-detects language.",
|
||||
)
|
||||
template: str = Field(
|
||||
default="general", description="Template to use for the presentation"
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ import uuid
|
|||
from sqlalchemy import JSON, Column, DateTime, String
|
||||
from sqlmodel import Boolean, Field, SQLModel
|
||||
|
||||
from models.presentation_layout import PresentationLayoutModel
|
||||
from models.presentation_outline_model import PresentationOutlineModel
|
||||
from models.presentation_structure_model import PresentationStructureModel
|
||||
from templates.presentation_layout import PresentationLayoutModel
|
||||
from utils.datetime_utils import get_current_utc_datetime
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,14 @@
|
|||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
from typing import Optional
|
||||
import uuid
|
||||
from sqlalchemy import Column, DateTime, Text, JSON
|
||||
from sqlmodel import SQLModel, Field
|
||||
|
||||
from sqlalchemy import JSON, Column, DateTime, Text
|
||||
from sqlmodel import Field, SQLModel
|
||||
|
||||
from utils.datetime_utils import get_current_utc_datetime
|
||||
|
||||
|
||||
class PresentationLayoutCodeModel(SQLModel, table=True):
|
||||
"""Model for storing presentation layout codes"""
|
||||
|
||||
__tablename__ = "presentation_layout_codes"
|
||||
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
|
|
@ -19,8 +18,10 @@ class PresentationLayoutCodeModel(SQLModel, table=True):
|
|||
layout_code: str = Field(
|
||||
sa_column=Column(Text), description="TSX/React component code for the layout"
|
||||
)
|
||||
fonts: Optional[List[str]] = Field(
|
||||
sa_column=Column(JSON), default=None, description="Optional list of font links"
|
||||
fonts: Optional[dict[str, str] | list[str]] = Field(
|
||||
default=None,
|
||||
sa_column=Column(JSON, nullable=True),
|
||||
description="Optional font metadata associated with the layout",
|
||||
)
|
||||
created_at: datetime = Field(
|
||||
sa_column=Column(
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
from datetime import datetime
|
||||
from typing import Optional
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import Column, DateTime
|
||||
from sqlmodel import SQLModel, Field
|
||||
from sqlmodel import Field, SQLModel
|
||||
|
||||
from utils.datetime_utils import get_current_utc_datetime
|
||||
|
||||
|
|
|
|||
25
electron/servers/fastapi/models/sql/template_create_info.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import JSON, Column, DateTime
|
||||
from sqlmodel import Field, SQLModel
|
||||
|
||||
from utils.datetime_utils import get_current_utc_datetime
|
||||
|
||||
|
||||
class TemplateCreateInfoModel(SQLModel, table=True):
|
||||
__tablename__ = "template_create_infos"
|
||||
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
fonts: dict[str, str] | None = Field(
|
||||
default=None,
|
||||
sa_column=Column(JSON, nullable=True),
|
||||
)
|
||||
pptx_url: str | None = Field(default=None)
|
||||
slide_htmls: list[str] = Field(sa_column=Column(JSON, nullable=False))
|
||||
slide_image_urls: list[str] = Field(sa_column=Column(JSON, nullable=False))
|
||||
created_at: datetime = Field(
|
||||
sa_column=Column(
|
||||
DateTime(timezone=True), nullable=False, default=get_current_utc_datetime
|
||||
)
|
||||
)
|
||||
|
|
@ -55,3 +55,6 @@ class UserConfig(BaseModel):
|
|||
CODEX_REFRESH_TOKEN: Optional[str] = None
|
||||
CODEX_TOKEN_EXPIRES: Optional[str] = None
|
||||
CODEX_ACCOUNT_ID: Optional[str] = None
|
||||
CODEX_USERNAME: Optional[str] = None
|
||||
CODEX_EMAIL: Optional[str] = None
|
||||
CODEX_IS_PRO: Optional[bool] = None
|
||||
|
|
|
|||
|
|
@ -43,4 +43,4 @@ dev = [
|
|||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["api*", "enums*", "models*", "services*", "constants*", "utils*"]
|
||||
include = ["api*", "enums*", "models*", "services*", "constants*", "utils*", "templates*"]
|
||||
|
|
|
|||
|
|
@ -15,8 +15,8 @@ if __name__ == "__main__":
|
|||
reload = args.reload == "true"
|
||||
host = "127.0.0.1"
|
||||
|
||||
# Provide a predictable public URL for services that need absolute asset links.
|
||||
os.environ.setdefault("FASTAPI_PUBLIC_URL", f"http://{host}:{args.port}")
|
||||
# Always bind absolute asset generation to the active runtime port.
|
||||
os.environ["FASTAPI_PUBLIC_URL"] = f"http://{host}:{args.port}"
|
||||
|
||||
uvicorn.run(
|
||||
"api.main:app",
|
||||
|
|
|
|||
|
|
@ -14,10 +14,11 @@ from models.sql.async_presentation_generation_status import (
|
|||
from models.sql.image_asset import ImageAsset
|
||||
from models.sql.key_value import KeyValueSqlModel
|
||||
from models.sql.ollama_pull_status import OllamaPullStatus
|
||||
from models.sql.presentation import PresentationModel
|
||||
from models.sql.slide import SlideModel
|
||||
from models.sql.presentation_layout_code import PresentationLayoutCodeModel
|
||||
from models.sql.presentation import PresentationModel
|
||||
from models.sql.template import TemplateModel
|
||||
from models.sql.template_create_info import TemplateCreateInfoModel
|
||||
from models.sql.slide import SlideModel
|
||||
from models.sql.webhook_subscription import WebhookSubscription
|
||||
from utils.db_utils import get_database_url_and_connect_args
|
||||
from utils.get_env import get_app_data_directory_env
|
||||
|
|
@ -65,6 +66,7 @@ async def create_db_and_tables():
|
|||
KeyValueSqlModel.__table__,
|
||||
ImageAsset.__table__,
|
||||
PresentationLayoutCodeModel.__table__,
|
||||
TemplateCreateInfoModel.__table__,
|
||||
TemplateModel.__table__,
|
||||
WebhookSubscription.__table__,
|
||||
AsyncPresentationGenerationTaskModel.__table__,
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ LOGGER = logging.getLogger(__name__)
|
|||
|
||||
|
||||
class DocumentsLoader:
|
||||
DECOMPOSE_TIMEOUT_SECONDS = 600
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
|
@ -39,7 +40,9 @@ class DocumentsLoader:
|
|||
):
|
||||
self._file_paths = file_paths
|
||||
self._ocr_language = presentation_language_to_ocr_code(presentation_language)
|
||||
self.liteparse_service = LiteParseService()
|
||||
self.liteparse_service = LiteParseService(
|
||||
timeout_seconds=self.DECOMPOSE_TIMEOUT_SECONDS
|
||||
)
|
||||
self.document_conversion_service = DocumentConversionService()
|
||||
self.document_service: Any = (
|
||||
DocumentServiceCls() if DocumentServiceCls is not None else None
|
||||
|
|
@ -142,6 +145,7 @@ class DocumentsLoader:
|
|||
converted_path = self.document_conversion_service.convert_office_to_pdf(
|
||||
file_path,
|
||||
temp_dir,
|
||||
timeout_seconds=self.DECOMPOSE_TIMEOUT_SECONDS,
|
||||
)
|
||||
return self._parse_with_liteparse(converted_path)
|
||||
|
||||
|
|
@ -149,6 +153,7 @@ class DocumentsLoader:
|
|||
converted_path = self.document_conversion_service.convert_office_to_pdf(
|
||||
file_path,
|
||||
conversion_dir,
|
||||
timeout_seconds=self.DECOMPOSE_TIMEOUT_SECONDS,
|
||||
)
|
||||
return self._parse_with_liteparse(converted_path)
|
||||
|
||||
|
|
@ -157,6 +162,7 @@ class DocumentsLoader:
|
|||
converted_path = self.document_conversion_service.convert_image_to_png(
|
||||
file_path,
|
||||
temp_dir,
|
||||
timeout_seconds=self.DECOMPOSE_TIMEOUT_SECONDS,
|
||||
)
|
||||
return self._parse_with_liteparse(converted_path)
|
||||
|
||||
|
|
@ -164,6 +170,7 @@ class DocumentsLoader:
|
|||
converted_path = self.document_conversion_service.convert_image_to_png(
|
||||
file_path,
|
||||
conversion_dir,
|
||||
timeout_seconds=self.DECOMPOSE_TIMEOUT_SECONDS,
|
||||
)
|
||||
return self._parse_with_liteparse(converted_path)
|
||||
|
||||
|
|
|
|||
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()
|
||||
|
|
@ -221,24 +221,92 @@ class ImageGenerationService:
|
|||
prompt, output_directory, "gemini-3-pro-image-preview"
|
||||
)
|
||||
|
||||
async def get_image_from_pexels(self, prompt: str) -> str:
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
response = await session.get(
|
||||
f"https://api.pexels.com/v1/search?query={prompt}&per_page=1",
|
||||
headers={"Authorization": f"{get_pexels_api_key_env()}"},
|
||||
)
|
||||
data = await response.json()
|
||||
image_url = data["photos"][0]["src"]["large"]
|
||||
return image_url
|
||||
async def get_image_from_pexels(
|
||||
self, prompt: str, api_key: str | None = None, limit: int = 1
|
||||
) -> str | list[str]:
|
||||
per_page = max(1, min(limit, 80))
|
||||
resolved_api_key = (api_key or get_pexels_api_key_env() or "").strip()
|
||||
|
||||
async def get_image_from_pixabay(self, prompt: str) -> str:
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
response = await session.get(
|
||||
f"https://pixabay.com/api/?key={get_pixabay_api_key_env()}&q={prompt}&image_type=photo&per_page=3"
|
||||
"https://api.pexels.com/v1/search",
|
||||
params={"query": prompt, "per_page": per_page},
|
||||
headers={"Authorization": resolved_api_key} if resolved_api_key else {},
|
||||
timeout=aiohttp.ClientTimeout(total=20),
|
||||
)
|
||||
|
||||
if response.status in {401, 403}:
|
||||
raise HTTPException(status_code=401, detail="Invalid Pexels API key")
|
||||
if response.status != 200:
|
||||
error_text = await response.text()
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"Pexels request failed: {error_text}",
|
||||
)
|
||||
|
||||
data = await response.json()
|
||||
image_url = data["hits"][0]["largeImageURL"]
|
||||
return image_url
|
||||
photos = data.get("photos", [])
|
||||
image_urls = [
|
||||
photo.get("src", {}).get("large")
|
||||
for photo in photos
|
||||
if photo.get("src", {}).get("large")
|
||||
]
|
||||
|
||||
if limit <= 1:
|
||||
return image_urls[0] if image_urls else ""
|
||||
return image_urls[:limit]
|
||||
|
||||
async def get_image_from_pixabay(
|
||||
self, prompt: str, api_key: str | None = None, limit: int = 1
|
||||
) -> str | list[str]:
|
||||
per_page = max(3, min(limit, 200))
|
||||
resolved_api_key = (api_key or get_pixabay_api_key_env() or "").strip()
|
||||
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
response = await session.get(
|
||||
"https://pixabay.com/api/",
|
||||
params={
|
||||
"key": resolved_api_key,
|
||||
"q": prompt[:99],
|
||||
"image_type": "photo",
|
||||
"per_page": per_page,
|
||||
},
|
||||
timeout=aiohttp.ClientTimeout(total=20),
|
||||
)
|
||||
|
||||
if response.status in {401, 403}:
|
||||
error_text = await response.text()
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail=f"Invalid Pixabay API key: {error_text}",
|
||||
)
|
||||
if response.status == 400:
|
||||
error_text = await response.text()
|
||||
if "api key" in error_text.lower():
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail=f"Invalid Pixabay API key: {error_text}",
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Pixabay request invalid: {error_text}",
|
||||
)
|
||||
if response.status != 200:
|
||||
error_text = await response.text()
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"Pixabay request failed: {error_text}",
|
||||
)
|
||||
|
||||
data = await response.json()
|
||||
hits = data.get("hits", [])
|
||||
image_urls = [
|
||||
hit.get("largeImageURL") for hit in hits if hit.get("largeImageURL")
|
||||
]
|
||||
|
||||
if limit <= 1:
|
||||
return image_urls[0] if image_urls else ""
|
||||
return image_urls[:limit]
|
||||
|
||||
async def generate_image_comfyui(self, prompt: str, output_directory: str) -> str:
|
||||
"""
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 18 KiB |
1
electron/servers/fastapi/templates/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
__all__ = []
|
||||
98
electron/servers/fastapi/templates/example.py
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
from typing import Any
|
||||
|
||||
from templates.presentation_layout import PresentationLayoutModel
|
||||
|
||||
PLACEHOLDER_IMAGE_URL = "/static/images/replaceable_template_image.png"
|
||||
PLACEHOLDER_ICON_URL = "/static/icons/placeholder.svg"
|
||||
|
||||
|
||||
def build_schema_example(schema: dict) -> Any:
|
||||
if not isinstance(schema, dict):
|
||||
return None
|
||||
|
||||
if "default" in schema:
|
||||
return schema["default"]
|
||||
|
||||
for key in ("anyOf", "oneOf", "allOf"):
|
||||
options = schema.get(key)
|
||||
if isinstance(options, list):
|
||||
for option in options:
|
||||
example = build_schema_example(option)
|
||||
if example is not None:
|
||||
return example
|
||||
|
||||
enum_values = schema.get("enum")
|
||||
if enum_values:
|
||||
return enum_values[0]
|
||||
|
||||
schema_type = schema.get("type")
|
||||
if schema_type == "object":
|
||||
properties = schema.get("properties", {})
|
||||
result = {}
|
||||
for field_name, field_schema in properties.items():
|
||||
result[field_name] = build_schema_example(field_schema)
|
||||
return result
|
||||
|
||||
if schema_type == "array":
|
||||
items_schema = schema.get("items", {})
|
||||
if "default" in schema:
|
||||
return schema["default"]
|
||||
item_example = build_schema_example(items_schema)
|
||||
return [] if item_example is None else [item_example]
|
||||
|
||||
if schema_type == "string":
|
||||
schema_description = (schema.get("description") or "").lower()
|
||||
if "icon" in schema_description:
|
||||
return PLACEHOLDER_ICON_URL
|
||||
if "image" in schema_description or "url" in schema_description:
|
||||
return PLACEHOLDER_IMAGE_URL
|
||||
return "Sample text"
|
||||
|
||||
if schema_type == "integer":
|
||||
return schema.get("minimum", 1)
|
||||
|
||||
if schema_type == "number":
|
||||
return schema.get("minimum", 1)
|
||||
|
||||
if schema_type == "boolean":
|
||||
return False
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def replace_special_placeholders(value: Any) -> Any:
|
||||
if isinstance(value, dict):
|
||||
result = {}
|
||||
for key, child in value.items():
|
||||
if key == "__image_url__":
|
||||
result[key] = PLACEHOLDER_IMAGE_URL
|
||||
elif key == "__icon_url__":
|
||||
result[key] = PLACEHOLDER_ICON_URL
|
||||
else:
|
||||
result[key] = replace_special_placeholders(child)
|
||||
return result
|
||||
|
||||
if isinstance(value, list):
|
||||
return [replace_special_placeholders(item) for item in value]
|
||||
|
||||
if value == "__image_url__":
|
||||
return PLACEHOLDER_IMAGE_URL
|
||||
if value == "__icon_url__":
|
||||
return PLACEHOLDER_ICON_URL
|
||||
return value
|
||||
|
||||
|
||||
def build_template_example(
|
||||
template_id: str, layout: PresentationLayoutModel
|
||||
) -> dict[str, Any]:
|
||||
slides = []
|
||||
for slide in layout.slides:
|
||||
example_content = replace_special_placeholders(
|
||||
build_schema_example(slide.json_schema)
|
||||
)
|
||||
slides.append({"layout": slide.id, "content": example_content})
|
||||
|
||||
return {
|
||||
"template": template_id,
|
||||
"slides": slides,
|
||||
}
|
||||
167
electron/servers/fastapi/templates/font_utils.py
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
import asyncio
|
||||
import re
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import Iterable
|
||||
|
||||
import aiohttp
|
||||
|
||||
_STYLE_TOKENS = {
|
||||
"italic",
|
||||
"italics",
|
||||
"ital",
|
||||
"oblique",
|
||||
"roman",
|
||||
"bolditalic",
|
||||
"bolditalics",
|
||||
"thin",
|
||||
"hairline",
|
||||
"extralight",
|
||||
"ultralight",
|
||||
"light",
|
||||
"demilight",
|
||||
"semilight",
|
||||
"book",
|
||||
"regular",
|
||||
"normal",
|
||||
"medium",
|
||||
"semibold",
|
||||
"demibold",
|
||||
"bold",
|
||||
"extrabold",
|
||||
"ultrabold",
|
||||
"black",
|
||||
"extrablack",
|
||||
"ultrablack",
|
||||
"heavy",
|
||||
"narrow",
|
||||
"condensed",
|
||||
"semicondensed",
|
||||
"extracondensed",
|
||||
"ultracondensed",
|
||||
"expanded",
|
||||
"semiexpanded",
|
||||
"extraexpanded",
|
||||
"ultraexpanded",
|
||||
}
|
||||
_STYLE_MODIFIERS = {"semi", "demi", "extra", "ultra"}
|
||||
|
||||
|
||||
def _insert_spaces_in_camel_case(value: str) -> str:
|
||||
value = re.sub(r"(?<=[a-z0-9])([A-Z])", r" \1", value)
|
||||
value = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1 \2", value)
|
||||
return value
|
||||
|
||||
|
||||
def normalize_font_family_name(raw_name: str) -> str:
|
||||
if not raw_name:
|
||||
return raw_name
|
||||
|
||||
name = raw_name.replace("_", " ").replace("-", " ")
|
||||
name = _insert_spaces_in_camel_case(name)
|
||||
name = re.sub(r"\s+", " ", name).strip()
|
||||
lower_name = name.lower()
|
||||
|
||||
for style in sorted(_STYLE_TOKENS, key=len, reverse=True):
|
||||
suffix = " " + style
|
||||
if lower_name.endswith(suffix):
|
||||
name = name[: -len(suffix)]
|
||||
lower_name = lower_name[: -len(suffix)]
|
||||
break
|
||||
|
||||
tokens_original = name.split(" ")
|
||||
tokens_filtered: list[str] = []
|
||||
for index, token in enumerate(tokens_original):
|
||||
lower_token = token.lower()
|
||||
if index == 0:
|
||||
tokens_filtered.append(token)
|
||||
continue
|
||||
if lower_token in _STYLE_TOKENS or lower_token in _STYLE_MODIFIERS:
|
||||
continue
|
||||
tokens_filtered.append(token)
|
||||
|
||||
if not tokens_filtered:
|
||||
tokens_filtered = tokens_original
|
||||
|
||||
return re.sub(r"\s+", " ", " ".join(tokens_filtered).strip())
|
||||
|
||||
|
||||
def extract_fonts_from_oxml(xml_content: str) -> list[str]:
|
||||
fonts = set()
|
||||
|
||||
try:
|
||||
root = ET.fromstring(xml_content)
|
||||
namespaces = {
|
||||
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
|
||||
"p": "http://schemas.openxmlformats.org/presentationml/2006/main",
|
||||
"r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
|
||||
}
|
||||
|
||||
for xpath in (".//a:latin", ".//a:ea", ".//a:cs", ".//a:font"):
|
||||
for font_elem in root.findall(xpath, namespaces):
|
||||
typeface = font_elem.attrib.get("typeface")
|
||||
if typeface:
|
||||
fonts.add(typeface)
|
||||
|
||||
for rpr_elem in root.findall(".//a:rPr", namespaces):
|
||||
for font_elem in rpr_elem.findall(".//a:latin", namespaces):
|
||||
typeface = font_elem.attrib.get("typeface")
|
||||
if typeface:
|
||||
fonts.add(typeface)
|
||||
|
||||
for font_elem in root.findall(".//latin"):
|
||||
typeface = font_elem.attrib.get("typeface")
|
||||
if typeface:
|
||||
fonts.add(typeface)
|
||||
|
||||
fonts.update(re.findall(r'typeface="([^"]+)"', xml_content))
|
||||
|
||||
system_fonts = {"+mn-lt", "+mj-lt", "+mn-ea", "+mj-ea", "+mn-cs", "+mj-cs", ""}
|
||||
return sorted(font for font in fonts if font not in system_fonts and font.strip())
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def get_google_font_css_url(font_name: str) -> str:
|
||||
return f"https://fonts.googleapis.com/css2?family={font_name.replace(' ', '+')}&display=swap"
|
||||
|
||||
|
||||
async def check_google_font_availability(font_name: str) -> bool:
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.head(
|
||||
get_google_font_css_url(font_name),
|
||||
timeout=aiohttp.ClientTimeout(total=10),
|
||||
) as response:
|
||||
return response.status == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def collect_normalized_fonts_from_xmls(slide_xmls: Iterable[str]) -> list[str]:
|
||||
raw_fonts = set()
|
||||
for xml_content in slide_xmls:
|
||||
raw_fonts.update(extract_fonts_from_oxml(xml_content))
|
||||
|
||||
normalized_fonts = {normalize_font_family_name(font) for font in raw_fonts}
|
||||
return sorted(font for font in normalized_fonts if font)
|
||||
|
||||
|
||||
async def get_available_and_unavailable_fonts(
|
||||
font_names: Iterable[str],
|
||||
) -> tuple[list[tuple[str, str]], list[tuple[str, None]]]:
|
||||
normalized_fonts = sorted({font for font in font_names if font})
|
||||
if not normalized_fonts:
|
||||
return [], []
|
||||
|
||||
results = await asyncio.gather(
|
||||
*[check_google_font_availability(font) for font in normalized_fonts]
|
||||
)
|
||||
|
||||
available_fonts: list[tuple[str, str]] = []
|
||||
unavailable_fonts: list[tuple[str, None]] = []
|
||||
for font_name, is_available in zip(normalized_fonts, results):
|
||||
if is_available:
|
||||
available_fonts.append((font_name, get_google_font_css_url(font_name)))
|
||||
else:
|
||||
unavailable_fonts.append((font_name, None))
|
||||
return available_fonts, unavailable_fonts
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
import aiohttp
|
||||
from fastapi import HTTPException
|
||||
from models.presentation_layout import PresentationLayoutModel
|
||||
from typing import List
|
||||
|
||||
from templates.presentation_layout import PresentationLayoutModel
|
||||
|
||||
|
||||
async def get_layout_by_name(layout_name: str) -> PresentationLayoutModel:
|
||||
url = f"http://localhost/api/template?group={layout_name}"
|
||||
|
|
@ -11,8 +12,7 @@ async def get_layout_by_name(layout_name: str) -> PresentationLayoutModel:
|
|||
error_text = await response.text()
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Template '{layout_name}' not found: {error_text}"
|
||||
detail=f"Template '{layout_name}' not found: {error_text}",
|
||||
)
|
||||
layout_json = await response.json()
|
||||
# Parse the JSON into your Pydantic model
|
||||
return PresentationLayoutModel(**layout_json)
|
||||
707
electron/servers/fastapi/templates/handler.py
Normal file
|
|
@ -0,0 +1,707 @@
|
|||
import os
|
||||
import random
|
||||
import re
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any, List, Optional
|
||||
|
||||
import aiohttp
|
||||
from fastapi import Body, Depends, File, Form, HTTPException, Path, Query, UploadFile
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlmodel import delete, select
|
||||
|
||||
from constants.presentation import DEFAULT_TEMPLATES
|
||||
from models.sql.presentation_layout_code import PresentationLayoutCodeModel
|
||||
from models.sql.template import TemplateModel
|
||||
from models.sql.template_create_info import TemplateCreateInfoModel
|
||||
from services.database import get_async_session
|
||||
from services.export_task_service import EXPORT_TASK_SERVICE
|
||||
from templates.example import build_template_example
|
||||
from templates.get_layout_by_name import get_layout_by_name
|
||||
from templates.presentation_layout import PresentationLayoutModel
|
||||
from templates.preview import (
|
||||
FontsUploadAndSlidesPreviewResponse,
|
||||
upload_fonts_and_slides_preview_handler,
|
||||
)
|
||||
from templates.prompts import (
|
||||
SLIDE_LAYOUT_CREATION_SYSTEM_PROMPT,
|
||||
SLIDE_LAYOUT_EDIT_SECTION_SYSTEM_PROMPT,
|
||||
SLIDE_LAYOUT_EDIT_SYSTEM_PROMPT,
|
||||
)
|
||||
from templates.providers import edit_slide_layout_code, generate_slide_layout_code
|
||||
from utils.asset_directory_utils import (
|
||||
resolve_app_path_to_filesystem,
|
||||
resolve_image_path_to_filesystem,
|
||||
)
|
||||
|
||||
|
||||
class TemplateDetail(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
total_layouts: Optional[int] = None
|
||||
|
||||
|
||||
class TemplateLayoutData(BaseModel):
|
||||
template: uuid.UUID
|
||||
layout_id: str
|
||||
layout_name: str
|
||||
layout_code: str
|
||||
fonts: Optional[Any] = None
|
||||
|
||||
|
||||
class TemplateData(BaseModel):
|
||||
id: uuid.UUID
|
||||
init_id: Optional[uuid.UUID] = None
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class GetTemplateLayoutsResponse(BaseModel):
|
||||
layouts: list[TemplateLayoutData]
|
||||
template: Optional[TemplateData] = None
|
||||
fonts: Optional[Any] = None
|
||||
|
||||
|
||||
class TemplateExample(BaseModel):
|
||||
template: str
|
||||
slides: List[dict]
|
||||
|
||||
|
||||
class CreateTemplateInitRequest(BaseModel):
|
||||
pptx_url: str
|
||||
slide_image_urls: List[str]
|
||||
fonts: dict = {}
|
||||
|
||||
|
||||
class CreateSlideLayoutRequest(BaseModel):
|
||||
id: uuid.UUID
|
||||
index: int
|
||||
|
||||
|
||||
class CreateSlideLayoutResponse(BaseModel):
|
||||
react_component: str
|
||||
|
||||
|
||||
class EditSlideLayoutRequest(BaseModel):
|
||||
react_component: str
|
||||
prompt: str
|
||||
|
||||
|
||||
class EditSlideLayoutResponse(CreateSlideLayoutResponse):
|
||||
pass
|
||||
|
||||
|
||||
class EditSlideLayoutSectionRequest(BaseModel):
|
||||
react_component: str
|
||||
section: str
|
||||
prompt: str
|
||||
|
||||
|
||||
class EditSlideLayoutSectionResponse(CreateSlideLayoutResponse):
|
||||
pass
|
||||
|
||||
|
||||
class SaveTemplateLayoutData(BaseModel):
|
||||
layout_id: str
|
||||
layout_name: str
|
||||
layout_code: str
|
||||
|
||||
|
||||
class SaveTemplateRequest(BaseModel):
|
||||
template_info_id: uuid.UUID
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
layouts: List[SaveTemplateLayoutData]
|
||||
|
||||
|
||||
class SaveTemplateResponse(BaseModel):
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class CloneTemplateRequest(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class UpdateTemplateRequest(BaseModel):
|
||||
id: uuid.UUID
|
||||
layouts: List[SaveTemplateLayoutData]
|
||||
|
||||
|
||||
class SaveSlideLayoutRequest(BaseModel):
|
||||
template_id: uuid.UUID
|
||||
layout_id: str
|
||||
layout_code: str
|
||||
|
||||
|
||||
class CloneSlideLayoutRequest(BaseModel):
|
||||
template_id: str
|
||||
layout_id: str
|
||||
layout_name: Optional[str] = None
|
||||
|
||||
|
||||
def _strip_code_fences(value: str) -> str:
|
||||
return (
|
||||
value.replace("```tsx", "")
|
||||
.replace("```typescript", "")
|
||||
.replace("```ts", "")
|
||||
.replace("```", "")
|
||||
.strip()
|
||||
)
|
||||
|
||||
|
||||
def _normalize_layout_code_for_create(code: str) -> str:
|
||||
normalized = _strip_code_fences(code)
|
||||
normalized = (
|
||||
normalized.replace("image_url", "__image_url__")
|
||||
.replace("icon_url", "__icon_url__")
|
||||
.replace("image_prompt", "__image_prompt__")
|
||||
.replace("icon_query", "__icon_query__")
|
||||
)
|
||||
|
||||
first_import_match = re.search(r"(?m)^\s*import\b", normalized)
|
||||
if first_import_match:
|
||||
normalized = normalized[first_import_match.start() :]
|
||||
|
||||
first_export_match = re.search(r"(?m)^\s*export\b", normalized)
|
||||
if first_export_match:
|
||||
normalized = normalized[: first_export_match.start()]
|
||||
|
||||
normalized = re.sub(
|
||||
r"(?ms)^\s*(?:import|export)\b.*?;(?:\r?\n|$)",
|
||||
"",
|
||||
normalized,
|
||||
)
|
||||
normalized = re.sub(
|
||||
r"(?m)^\s*(?:import|export)\b.*(?:\r?\n|$)",
|
||||
"",
|
||||
normalized,
|
||||
)
|
||||
normalized = normalized.strip()
|
||||
normalized = re.sub(
|
||||
r'(layoutId\s*=\s*["\'])([^"\']+)(["\'])',
|
||||
lambda match: (
|
||||
match.group(0)
|
||||
if re.search(r"-\d{4}$", match.group(2))
|
||||
else f"{match.group(1)}{match.group(2)}-{random.randint(1000, 9999)}{match.group(3)}"
|
||||
),
|
||||
normalized,
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
def _update_layout_id_in_code(code: str) -> tuple[str, str]:
|
||||
match = re.search(r'(layoutId\s*=\s*["\'])([^"\']+)(["\'])', code)
|
||||
if not match:
|
||||
raise HTTPException(status_code=400, detail="layoutId not found in layout code")
|
||||
|
||||
current_id = match.group(2)
|
||||
suffix = f"{random.randint(1000, 9999)}"
|
||||
new_id = re.sub(r"-\d{4}$", f"-{suffix}", current_id)
|
||||
if new_id == current_id:
|
||||
new_id = f"{current_id}-{suffix}"
|
||||
|
||||
new_code = re.sub(
|
||||
r'(layoutId\s*=\s*["\'])([^"\']+)(["\'])',
|
||||
f"\\1{new_id}\\3",
|
||||
code,
|
||||
count=1,
|
||||
)
|
||||
return new_code, new_id
|
||||
|
||||
|
||||
async def _download_image_bytes(image_url: str) -> bytes:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(image_url) as response:
|
||||
if response.status != 200:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Failed to download slide image: {image_url}",
|
||||
)
|
||||
return await response.read()
|
||||
|
||||
|
||||
async def _read_image_bytes_and_media_type(image_url: str) -> tuple[bytes, str]:
|
||||
actual_image_path = resolve_image_path_to_filesystem(image_url)
|
||||
if actual_image_path and os.path.isfile(actual_image_path):
|
||||
with open(actual_image_path, "rb") as image_file:
|
||||
image_bytes = image_file.read()
|
||||
file_extension = os.path.splitext(actual_image_path)[1].lower()
|
||||
else:
|
||||
image_bytes = await _download_image_bytes(image_url)
|
||||
file_extension = os.path.splitext(image_url)[1].lower()
|
||||
|
||||
media_type_map = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
}
|
||||
return image_bytes, media_type_map.get(file_extension, "image/png")
|
||||
|
||||
|
||||
async def get_all_templates(
|
||||
include_defaults: bool = Query(
|
||||
default=True, description="Whether to include default templates"
|
||||
),
|
||||
sql_session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
result = await sql_session.execute(
|
||||
select(
|
||||
TemplateModel.id,
|
||||
TemplateModel.name,
|
||||
func.count(PresentationLayoutCodeModel.id).label("total_layouts"),
|
||||
)
|
||||
.join(
|
||||
PresentationLayoutCodeModel,
|
||||
PresentationLayoutCodeModel.presentation == TemplateModel.id,
|
||||
)
|
||||
.group_by(TemplateModel.id, TemplateModel.name)
|
||||
)
|
||||
rows = result.all()
|
||||
|
||||
templates: list[TemplateDetail] = []
|
||||
if include_defaults:
|
||||
templates.extend(
|
||||
TemplateDetail(id=template, name=template) for template in DEFAULT_TEMPLATES
|
||||
)
|
||||
|
||||
templates.extend(
|
||||
TemplateDetail(
|
||||
id=f"custom-{template_id}",
|
||||
name=template_name,
|
||||
total_layouts=total_layouts,
|
||||
)
|
||||
for template_id, template_name, total_layouts in rows
|
||||
)
|
||||
return templates
|
||||
|
||||
|
||||
async def get_layouts(
|
||||
template_id: str = Path(..., description="The id of the template"),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
if not template_id or not template_id.strip():
|
||||
raise HTTPException(status_code=400, detail="Template ID cannot be empty")
|
||||
|
||||
try:
|
||||
cleaned_template_id = template_id.replace("custom-", "")
|
||||
template_id_uuid = uuid.UUID(cleaned_template_id)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail="Invalid custom template ID") from exc
|
||||
|
||||
result = await session.execute(
|
||||
select(PresentationLayoutCodeModel).where(
|
||||
PresentationLayoutCodeModel.presentation == template_id_uuid
|
||||
)
|
||||
)
|
||||
layouts_db = result.scalars().all()
|
||||
if not layouts_db:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"No layouts found for template ID: {template_id}",
|
||||
)
|
||||
|
||||
template_meta = await session.get(TemplateModel, template_id_uuid)
|
||||
template = None
|
||||
if template_meta:
|
||||
template = TemplateData(
|
||||
id=template_id_uuid,
|
||||
init_id=None,
|
||||
name=template_meta.name,
|
||||
description=template_meta.description,
|
||||
created_at=template_meta.created_at,
|
||||
)
|
||||
|
||||
layouts = [
|
||||
TemplateLayoutData(
|
||||
template=template_id_uuid,
|
||||
layout_id=layout.layout_id,
|
||||
layout_name=layout.layout_name,
|
||||
layout_code=layout.layout_code,
|
||||
fonts=layout.fonts,
|
||||
)
|
||||
for layout in layouts_db
|
||||
]
|
||||
return GetTemplateLayoutsResponse(
|
||||
layouts=layouts,
|
||||
template=template,
|
||||
fonts=layouts[0].fonts if layouts else None,
|
||||
)
|
||||
|
||||
|
||||
async def get_template_by_id(
|
||||
id: str = Path(
|
||||
...,
|
||||
description=f"The id of the template, must be one of {', '.join(DEFAULT_TEMPLATES)} or your custom template",
|
||||
),
|
||||
sql_session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
if id.startswith("custom-"):
|
||||
try:
|
||||
template_id = uuid.UUID(id.replace("custom-", ""))
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Template not found. Please use a valid template.",
|
||||
) from exc
|
||||
|
||||
template = await sql_session.get(TemplateModel, template_id)
|
||||
if not template:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Template not found. Please use a valid template.",
|
||||
)
|
||||
|
||||
return await get_layout_by_name(id)
|
||||
|
||||
|
||||
async def get_template_example(
|
||||
id: str = Path(
|
||||
...,
|
||||
description=f"The id of the template, must be one of {', '.join(DEFAULT_TEMPLATES)} or your custom template",
|
||||
),
|
||||
sql_session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
template = await get_template_by_id(id=id, sql_session=sql_session)
|
||||
return TemplateExample(**build_template_example(id, template))
|
||||
|
||||
|
||||
async def upload_fonts_and_slides_preview(
|
||||
pptx_file: UploadFile = File(..., description="PPTX file to preview"),
|
||||
font_files: Optional[List[UploadFile]] = File(
|
||||
default=None, description="Font files to upload"
|
||||
),
|
||||
original_font_names: Optional[List[str]] = Form(default=None),
|
||||
):
|
||||
return await upload_fonts_and_slides_preview_handler(
|
||||
pptx_file=pptx_file,
|
||||
font_files=font_files,
|
||||
original_font_names=original_font_names,
|
||||
max_slides=25,
|
||||
)
|
||||
|
||||
|
||||
async def init_create_template(
|
||||
request: CreateTemplateInitRequest,
|
||||
sql_session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
if not request.slide_image_urls:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="At least one slide image is required"
|
||||
)
|
||||
|
||||
pptx_path = resolve_app_path_to_filesystem(request.pptx_url)
|
||||
if not pptx_path or not os.path.isfile(pptx_path):
|
||||
raise HTTPException(status_code=400, detail="PPTX file not found")
|
||||
|
||||
pptx_document = await EXPORT_TASK_SERVICE.convert_pptx_to_html(
|
||||
pptx_path, get_fonts=False
|
||||
)
|
||||
if not pptx_document.slides:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="PPTX-to-HTML export returned no slides",
|
||||
)
|
||||
|
||||
if len(pptx_document.slides) < len(request.slide_image_urls):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
"PPTX-to-HTML export returned fewer slides than the preview images. "
|
||||
f"Expected at least {len(request.slide_image_urls)}, got {len(pptx_document.slides)}."
|
||||
),
|
||||
)
|
||||
|
||||
slide_htmls = pptx_document.slides[: len(request.slide_image_urls)]
|
||||
template_create_info = TemplateCreateInfoModel(
|
||||
fonts=request.fonts or {},
|
||||
pptx_url=request.pptx_url,
|
||||
slide_image_urls=request.slide_image_urls,
|
||||
slide_htmls=slide_htmls,
|
||||
)
|
||||
sql_session.add(template_create_info)
|
||||
await sql_session.commit()
|
||||
await sql_session.refresh(template_create_info)
|
||||
return template_create_info.id
|
||||
|
||||
|
||||
async def create_slide_layout(
|
||||
request: CreateSlideLayoutRequest = Body(...),
|
||||
sql_session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
template_info = await sql_session.get(TemplateCreateInfoModel, request.id)
|
||||
if not template_info:
|
||||
raise HTTPException(status_code=400, detail="Template not found")
|
||||
|
||||
total_slides = len(template_info.slide_htmls)
|
||||
if request.index < 0 or request.index >= total_slides:
|
||||
raise HTTPException(status_code=400, detail="Invalid slide index")
|
||||
|
||||
slide_html = template_info.slide_htmls[request.index]
|
||||
slide_image_url = template_info.slide_image_urls[request.index]
|
||||
image_bytes, media_type = await _read_image_bytes_and_media_type(slide_image_url)
|
||||
|
||||
fonts_text = ""
|
||||
if template_info.fonts:
|
||||
font_names = [font.replace(" ", "_") for font in template_info.fonts.keys()]
|
||||
fonts_text = "#PROVIDED FONTS\n- " + "\n- ".join(font_names)
|
||||
|
||||
user_text = f"{fonts_text}\n\n#SLIDE HTML REFERENCE\n{slide_html}"
|
||||
react_component = await generate_slide_layout_code(
|
||||
system_prompt=SLIDE_LAYOUT_CREATION_SYSTEM_PROMPT,
|
||||
user_text=user_text,
|
||||
image_bytes=image_bytes,
|
||||
media_type=media_type,
|
||||
)
|
||||
normalized_react_component = _normalize_layout_code_for_create(react_component)
|
||||
|
||||
return CreateSlideLayoutResponse(react_component=normalized_react_component)
|
||||
|
||||
|
||||
async def edit_slide_layout(
|
||||
request: EditSlideLayoutRequest,
|
||||
):
|
||||
user_text = f"#Prompt\n{request.prompt}\n\n#TSX code\n{request.react_component}"
|
||||
react_component = await edit_slide_layout_code(
|
||||
system_prompt=SLIDE_LAYOUT_EDIT_SYSTEM_PROMPT,
|
||||
user_text=user_text,
|
||||
)
|
||||
return EditSlideLayoutResponse(react_component=_strip_code_fences(react_component))
|
||||
|
||||
|
||||
async def edit_slide_layout_section(
|
||||
request: EditSlideLayoutSectionRequest,
|
||||
):
|
||||
user_text = (
|
||||
f"#Prompt\n{request.prompt}\n\n"
|
||||
f"#Section to make changes around\n{request.section}\n\n"
|
||||
f"#TSX code\n{request.react_component}"
|
||||
)
|
||||
react_component = await edit_slide_layout_code(
|
||||
system_prompt=SLIDE_LAYOUT_EDIT_SECTION_SYSTEM_PROMPT,
|
||||
user_text=user_text,
|
||||
)
|
||||
return EditSlideLayoutSectionResponse(
|
||||
react_component=_strip_code_fences(react_component)
|
||||
)
|
||||
|
||||
|
||||
async def save_template(
|
||||
request: SaveTemplateRequest,
|
||||
sql_session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
if not request.layouts:
|
||||
raise HTTPException(status_code=400, detail="Layouts are required")
|
||||
|
||||
template_info = await sql_session.get(TemplateCreateInfoModel, request.template_info_id)
|
||||
if not template_info:
|
||||
raise HTTPException(status_code=400, detail="Template info not found")
|
||||
|
||||
template = TemplateModel(
|
||||
id=uuid.uuid4(),
|
||||
name=request.name,
|
||||
description=request.description,
|
||||
)
|
||||
sql_session.add(template)
|
||||
|
||||
sql_session.add_all(
|
||||
[
|
||||
PresentationLayoutCodeModel(
|
||||
presentation=template.id,
|
||||
layout_id=layout.layout_id,
|
||||
layout_name=layout.layout_name,
|
||||
layout_code=layout.layout_code,
|
||||
fonts=template_info.fonts,
|
||||
)
|
||||
for layout in request.layouts
|
||||
]
|
||||
)
|
||||
await sql_session.commit()
|
||||
await sql_session.refresh(template)
|
||||
|
||||
return SaveTemplateResponse(
|
||||
id=template.id,
|
||||
name=template.name,
|
||||
description=template.description,
|
||||
created_at=template.created_at,
|
||||
)
|
||||
|
||||
|
||||
async def clone_template(
|
||||
request: CloneTemplateRequest = Body(...),
|
||||
sql_session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
if not request.id or not request.id.strip():
|
||||
raise HTTPException(status_code=400, detail="Template ID cannot be empty")
|
||||
|
||||
try:
|
||||
template_id_uuid = uuid.UUID(request.id.replace("custom-", ""))
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail="Invalid custom template ID") from exc
|
||||
|
||||
template = await sql_session.get(TemplateModel, template_id_uuid)
|
||||
if not template:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Template not found. Please use a valid template.",
|
||||
)
|
||||
|
||||
result = await sql_session.execute(
|
||||
select(PresentationLayoutCodeModel).where(
|
||||
PresentationLayoutCodeModel.presentation == template_id_uuid
|
||||
)
|
||||
)
|
||||
layouts_db = result.scalars().all()
|
||||
if not layouts_db:
|
||||
raise HTTPException(status_code=400, detail="No layouts found for template")
|
||||
|
||||
new_template = TemplateModel(
|
||||
id=uuid.uuid4(),
|
||||
name=request.name,
|
||||
description=template.description
|
||||
if request.description is None
|
||||
else request.description,
|
||||
)
|
||||
sql_session.add(new_template)
|
||||
|
||||
sql_session.add_all(
|
||||
[
|
||||
PresentationLayoutCodeModel(
|
||||
presentation=new_template.id,
|
||||
layout_id=layout.layout_id,
|
||||
layout_name=layout.layout_name,
|
||||
layout_code=layout.layout_code,
|
||||
fonts=layout.fonts,
|
||||
)
|
||||
for layout in layouts_db
|
||||
]
|
||||
)
|
||||
await sql_session.commit()
|
||||
await sql_session.refresh(new_template)
|
||||
|
||||
return SaveTemplateResponse(
|
||||
id=new_template.id,
|
||||
name=new_template.name,
|
||||
description=new_template.description,
|
||||
created_at=new_template.created_at,
|
||||
)
|
||||
|
||||
|
||||
async def update_template(
|
||||
request: UpdateTemplateRequest,
|
||||
sql_session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
if not request.layouts:
|
||||
raise HTTPException(status_code=400, detail="Layouts are required")
|
||||
|
||||
template = await sql_session.get(TemplateModel, request.id)
|
||||
if not template:
|
||||
raise HTTPException(status_code=400, detail="Template not found")
|
||||
|
||||
existing_layout = await sql_session.scalar(
|
||||
select(PresentationLayoutCodeModel).where(
|
||||
PresentationLayoutCodeModel.presentation == request.id
|
||||
)
|
||||
)
|
||||
fonts = existing_layout.fonts if existing_layout else None
|
||||
|
||||
await sql_session.execute(
|
||||
delete(PresentationLayoutCodeModel).where(
|
||||
PresentationLayoutCodeModel.presentation == request.id
|
||||
)
|
||||
)
|
||||
sql_session.add_all(
|
||||
[
|
||||
PresentationLayoutCodeModel(
|
||||
presentation=template.id,
|
||||
layout_id=layout.layout_id,
|
||||
layout_name=layout.layout_name,
|
||||
layout_code=layout.layout_code,
|
||||
fonts=fonts,
|
||||
)
|
||||
for layout in request.layouts
|
||||
]
|
||||
)
|
||||
await sql_session.commit()
|
||||
|
||||
return SaveTemplateResponse(
|
||||
id=template.id,
|
||||
name=template.name,
|
||||
description=template.description,
|
||||
created_at=template.created_at,
|
||||
)
|
||||
|
||||
|
||||
async def save_slide_layout(
|
||||
request: SaveSlideLayoutRequest,
|
||||
sql_session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
template = await sql_session.get(TemplateModel, request.template_id)
|
||||
if not template:
|
||||
raise HTTPException(status_code=400, detail="Template not found")
|
||||
|
||||
layout = await sql_session.scalar(
|
||||
select(PresentationLayoutCodeModel).where(
|
||||
PresentationLayoutCodeModel.presentation == request.template_id,
|
||||
PresentationLayoutCodeModel.layout_id == request.layout_id,
|
||||
)
|
||||
)
|
||||
if not layout:
|
||||
raise HTTPException(status_code=400, detail="Layout not found")
|
||||
|
||||
layout.layout_code = request.layout_code
|
||||
sql_session.add(layout)
|
||||
await sql_session.commit()
|
||||
|
||||
|
||||
async def clone_slide_layout(
|
||||
request: CloneSlideLayoutRequest = Body(...),
|
||||
sql_session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
if not request.template_id or not request.template_id.strip():
|
||||
raise HTTPException(status_code=400, detail="Template ID cannot be empty")
|
||||
|
||||
try:
|
||||
template_id_uuid = uuid.UUID(request.template_id.replace("custom-", ""))
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail="Invalid custom template ID") from exc
|
||||
|
||||
template = await sql_session.get(TemplateModel, template_id_uuid)
|
||||
if not template:
|
||||
raise HTTPException(status_code=400, detail="Template not found")
|
||||
|
||||
layout = await sql_session.scalar(
|
||||
select(PresentationLayoutCodeModel).where(
|
||||
PresentationLayoutCodeModel.presentation == template_id_uuid,
|
||||
PresentationLayoutCodeModel.layout_id == request.layout_id,
|
||||
)
|
||||
)
|
||||
if not layout:
|
||||
raise HTTPException(status_code=400, detail="Layout not found")
|
||||
|
||||
new_layout_code, new_layout_id = _update_layout_id_in_code(layout.layout_code)
|
||||
new_layout = PresentationLayoutCodeModel(
|
||||
presentation=template_id_uuid,
|
||||
layout_id=new_layout_id,
|
||||
layout_name=request.layout_name or layout.layout_name,
|
||||
layout_code=new_layout_code,
|
||||
fonts=layout.fonts,
|
||||
)
|
||||
sql_session.add(new_layout)
|
||||
await sql_session.commit()
|
||||
await sql_session.refresh(new_layout)
|
||||
|
||||
return SaveTemplateLayoutData(
|
||||
layout_id=new_layout.layout_id,
|
||||
layout_name=new_layout.layout_name,
|
||||
layout_code=new_layout.layout_code,
|
||||
)
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
from typing import List, Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
|
@ -25,15 +26,15 @@ class PresentationLayoutModel(BaseModel):
|
|||
status_code=404, detail=f"Slide layout {slide_layout_id} not found"
|
||||
)
|
||||
|
||||
def to_presentation_structure(self):
|
||||
def to_presentation_structure(self) -> PresentationStructureModel:
|
||||
return PresentationStructureModel(
|
||||
slides=[index for index in range(len(self.slides))]
|
||||
)
|
||||
|
||||
def to_string(self):
|
||||
message = f"## Presentation Layout\n\n"
|
||||
def to_string(self) -> str:
|
||||
message = "## Presentation Layout\n\n"
|
||||
for index, slide in enumerate(self.slides):
|
||||
message += f"### Slide Layout: {index}: \n"
|
||||
message += f"- Name: {slide.name or slide.json_schema.get('title')} \n"
|
||||
message += f"- Description: {slide.description} \n\n"
|
||||
message += f"### Slide Layout: {index}\n"
|
||||
message += f"- Name: {slide.name or slide.json_schema.get('title')}\n"
|
||||
message += f"- Description: {slide.description}\n\n"
|
||||
return message
|
||||
477
electron/servers/fastapi/templates/preview.py
Normal file
|
|
@ -0,0 +1,477 @@
|
|||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import uuid
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from fastapi import File, HTTPException, UploadFile
|
||||
from pydantic import BaseModel
|
||||
|
||||
from constants.documents import PPTX_MIME_TYPES
|
||||
from services.documents_loader import DocumentsLoader
|
||||
from templates.font_utils import (
|
||||
collect_normalized_fonts_from_xmls,
|
||||
get_available_and_unavailable_fonts,
|
||||
)
|
||||
from utils.get_env import get_app_data_directory_env
|
||||
|
||||
try:
|
||||
from fontTools.ttLib import TTFont
|
||||
|
||||
FONTTOOLS_AVAILABLE = True
|
||||
except ImportError:
|
||||
FONTTOOLS_AVAILABLE = False
|
||||
|
||||
|
||||
SUPPORTED_FONT_EXTENSIONS = {
|
||||
".ttf": "font/ttf",
|
||||
".otf": "font/otf",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
".eot": "application/vnd.ms-fontobject",
|
||||
}
|
||||
|
||||
|
||||
class FontInfo(BaseModel):
|
||||
name: str
|
||||
url: str | None = None
|
||||
|
||||
|
||||
class FontCheckResponse(BaseModel):
|
||||
available_fonts: List[FontInfo]
|
||||
unavailable_fonts: List[FontInfo]
|
||||
|
||||
|
||||
class FontsUploadAndSlidesPreviewResponse(BaseModel):
|
||||
slide_image_urls: List[str]
|
||||
pptx_url: str
|
||||
modified_pptx_url: str
|
||||
fonts: dict
|
||||
|
||||
|
||||
@dataclass
|
||||
class StoredFont:
|
||||
display_name: str
|
||||
url: str
|
||||
temp_path: str
|
||||
|
||||
|
||||
def _get_soffice_binary() -> str:
|
||||
configured = os.environ.get("SOFFICE_PATH")
|
||||
if configured:
|
||||
return configured
|
||||
return "soffice.exe" if os.name == "nt" else "soffice"
|
||||
|
||||
|
||||
def _windows_hidden_subprocess_kwargs() -> Dict[str, object]:
|
||||
if os.name != "nt":
|
||||
return {}
|
||||
|
||||
startupinfo = subprocess.STARTUPINFO()
|
||||
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
||||
return {
|
||||
"creationflags": getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
"startupinfo": startupinfo,
|
||||
}
|
||||
|
||||
|
||||
def _app_data_directory() -> str:
|
||||
app_data_dir = get_app_data_directory_env() or "/tmp/presenton"
|
||||
os.makedirs(app_data_dir, exist_ok=True)
|
||||
return app_data_dir
|
||||
|
||||
|
||||
def _get_fonts_directory() -> str:
|
||||
fonts_dir = os.path.join(_app_data_directory(), "fonts")
|
||||
os.makedirs(fonts_dir, exist_ok=True)
|
||||
return fonts_dir
|
||||
|
||||
|
||||
def _get_images_directory() -> str:
|
||||
images_dir = os.path.join(_app_data_directory(), "images")
|
||||
os.makedirs(images_dir, exist_ok=True)
|
||||
return images_dir
|
||||
|
||||
|
||||
def _get_template_uploads_directory() -> str:
|
||||
uploads_dir = os.path.join(_app_data_directory(), "uploads", "template-previews")
|
||||
os.makedirs(uploads_dir, exist_ok=True)
|
||||
return uploads_dir
|
||||
|
||||
|
||||
def _write_bytes_to_path(path: str, data: bytes) -> None:
|
||||
with open(path, "wb") as file:
|
||||
file.write(data)
|
||||
|
||||
|
||||
def _copy_file(source_path: str, destination_path: str) -> None:
|
||||
shutil.copy2(source_path, destination_path)
|
||||
|
||||
|
||||
def _extract_font_name_from_file(file_path: str) -> str:
|
||||
filename = os.path.basename(file_path)
|
||||
base_name = os.path.splitext(filename)[0]
|
||||
if not FONTTOOLS_AVAILABLE:
|
||||
return base_name
|
||||
|
||||
try:
|
||||
font = TTFont(file_path)
|
||||
if "name" in font:
|
||||
name_table = font["name"]
|
||||
for name_id in (1, 4, 6):
|
||||
for record in name_table.names:
|
||||
if record.nameID != name_id:
|
||||
continue
|
||||
if record.langID in (0x409, 0):
|
||||
font_name = record.toUnicode().strip()
|
||||
if font_name:
|
||||
font.close()
|
||||
return font_name
|
||||
for record in name_table.names:
|
||||
if record.nameID != 1:
|
||||
continue
|
||||
font_name = record.toUnicode().strip()
|
||||
if font_name:
|
||||
font.close()
|
||||
return font_name
|
||||
font.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return base_name
|
||||
|
||||
|
||||
def _validate_pptx_file(pptx_file: UploadFile) -> None:
|
||||
filename = getattr(pptx_file, "filename", "") or ""
|
||||
if not filename.lower().endswith(".pptx"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Invalid file type. Expected PPTX file",
|
||||
)
|
||||
if pptx_file.content_type and pptx_file.content_type not in PPTX_MIME_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid file type. Expected PPTX file, got {pptx_file.content_type}",
|
||||
)
|
||||
|
||||
|
||||
def _ensure_valid_font_file(font_file: UploadFile) -> None:
|
||||
filename = font_file.filename or ""
|
||||
extension = os.path.splitext(filename)[1].lower()
|
||||
if extension not in SUPPORTED_FONT_EXTENSIONS:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid font file. Supported formats: {', '.join(SUPPORTED_FONT_EXTENSIONS.keys())}",
|
||||
)
|
||||
|
||||
|
||||
async def _persist_custom_fonts(
|
||||
font_files: Optional[List[UploadFile]],
|
||||
original_font_names: Optional[List[str]],
|
||||
temp_dir: str,
|
||||
) -> list[StoredFont]:
|
||||
if not font_files:
|
||||
return []
|
||||
|
||||
stored_fonts: list[StoredFont] = []
|
||||
fonts_dir = _get_fonts_directory()
|
||||
|
||||
for index, font_file in enumerate(font_files):
|
||||
_ensure_valid_font_file(font_file)
|
||||
|
||||
original_name = (
|
||||
original_font_names[index]
|
||||
if original_font_names and index < len(original_font_names)
|
||||
else None
|
||||
)
|
||||
extension = os.path.splitext(font_file.filename or "")[1].lower()
|
||||
unique_name = f"{Path(font_file.filename or f'font_{index}').stem}_{uuid.uuid4().hex[:8]}{extension}"
|
||||
temp_font_path = os.path.join(temp_dir, unique_name)
|
||||
permanent_font_path = os.path.join(fonts_dir, unique_name)
|
||||
font_bytes = await font_file.read()
|
||||
|
||||
await asyncio.to_thread(_write_bytes_to_path, temp_font_path, font_bytes)
|
||||
await asyncio.to_thread(_write_bytes_to_path, permanent_font_path, font_bytes)
|
||||
|
||||
actual_font_name = await asyncio.to_thread(
|
||||
_extract_font_name_from_file, permanent_font_path
|
||||
)
|
||||
display_name = original_name or actual_font_name
|
||||
stored_fonts.append(
|
||||
StoredFont(
|
||||
display_name=display_name,
|
||||
url=f"/app_data/fonts/{unique_name}",
|
||||
temp_path=temp_font_path,
|
||||
)
|
||||
)
|
||||
|
||||
return stored_fonts
|
||||
|
||||
|
||||
def _create_font_alias_config(raw_fonts: List[str]) -> str:
|
||||
mappings: Dict[str, str] = {}
|
||||
for font_name in raw_fonts:
|
||||
normalized = font_name
|
||||
if not normalized:
|
||||
continue
|
||||
mappings[font_name] = normalized
|
||||
|
||||
fd, fonts_conf_path = tempfile.mkstemp(prefix="fonts_alias_", suffix=".conf")
|
||||
os.close(fd)
|
||||
with open(fonts_conf_path, "w", encoding="utf-8") as cfg:
|
||||
cfg.write(
|
||||
"""<?xml version='1.0'?>
|
||||
<!DOCTYPE fontconfig SYSTEM "urn:fontconfig:fonts.dtd">
|
||||
<fontconfig>
|
||||
<include>/etc/fonts/fonts.conf</include>
|
||||
"""
|
||||
)
|
||||
for source_family, destination_family in mappings.items():
|
||||
if source_family == destination_family:
|
||||
continue
|
||||
cfg.write(
|
||||
f"""
|
||||
<match target="pattern">
|
||||
<test name="family" compare="eq">
|
||||
<string>{source_family}</string>
|
||||
</test>
|
||||
<edit name="family" mode="assign" binding="strong">
|
||||
<string>{destination_family}</string>
|
||||
</edit>
|
||||
</match>
|
||||
"""
|
||||
)
|
||||
cfg.write("\n</fontconfig>\n")
|
||||
return fonts_conf_path
|
||||
|
||||
|
||||
async def _install_fonts(font_paths: List[str]) -> None:
|
||||
if not font_paths:
|
||||
return
|
||||
|
||||
for font_path in font_paths:
|
||||
try:
|
||||
subprocess.run(
|
||||
["cp", font_path, "/usr/share/fonts/truetype/"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
except subprocess.CalledProcessError:
|
||||
continue
|
||||
|
||||
try:
|
||||
subprocess.run(["fc-cache", "-f", "-v"], check=True, capture_output=True)
|
||||
except subprocess.CalledProcessError:
|
||||
pass
|
||||
|
||||
|
||||
def extract_slide_xmls(pptx_path: str, temp_dir: str) -> List[str]:
|
||||
slide_xmls: list[str] = []
|
||||
extract_dir = os.path.join(temp_dir, "pptx_extract")
|
||||
|
||||
with zipfile.ZipFile(pptx_path, "r") as zip_ref:
|
||||
zip_ref.extractall(extract_dir)
|
||||
|
||||
slides_dir = os.path.join(extract_dir, "ppt", "slides")
|
||||
if not os.path.exists(slides_dir):
|
||||
raise HTTPException(status_code=400, detail="No slides directory found in PPTX")
|
||||
|
||||
slide_files = [
|
||||
file_name
|
||||
for file_name in os.listdir(slides_dir)
|
||||
if file_name.startswith("slide") and file_name.endswith(".xml")
|
||||
]
|
||||
slide_files.sort(key=lambda value: int(re.sub(r"[^0-9]", "", value) or "0"))
|
||||
|
||||
for slide_file in slide_files:
|
||||
slide_path = os.path.join(slides_dir, slide_file)
|
||||
with open(slide_path, "r", encoding="utf-8") as slide_handle:
|
||||
slide_xmls.append(slide_handle.read())
|
||||
|
||||
return slide_xmls
|
||||
|
||||
|
||||
async def convert_pptx_to_pdf(
|
||||
pptx_path: str,
|
||||
temp_dir: str,
|
||||
slide_xmls: Optional[List[str]] = None,
|
||||
) -> str:
|
||||
screenshots_dir = os.path.join(temp_dir, "screenshots")
|
||||
os.makedirs(screenshots_dir, exist_ok=True)
|
||||
|
||||
slide_xmls = slide_xmls or extract_slide_xmls(pptx_path, temp_dir)
|
||||
raw_fonts = collect_normalized_fonts_from_xmls(slide_xmls)
|
||||
fonts_conf_path = _create_font_alias_config(raw_fonts)
|
||||
env = os.environ.copy()
|
||||
env["FONTCONFIG_FILE"] = fonts_conf_path
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
[
|
||||
_get_soffice_binary(),
|
||||
"--headless",
|
||||
"--convert-to",
|
||||
"pdf",
|
||||
"--outdir",
|
||||
screenshots_dir,
|
||||
pptx_path,
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=500,
|
||||
env=env,
|
||||
**_windows_hidden_subprocess_kwargs(),
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="LibreOffice PDF conversion timed out after 500 seconds",
|
||||
) from exc
|
||||
except subprocess.CalledProcessError as exc:
|
||||
error_message = exc.stderr if exc.stderr else str(exc)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"LibreOffice PDF conversion failed: {error_message}",
|
||||
) from exc
|
||||
|
||||
pdf_files = [file_name for file_name in os.listdir(screenshots_dir) if file_name.endswith(".pdf")]
|
||||
if not pdf_files:
|
||||
raise HTTPException(
|
||||
status_code=500, detail="LibreOffice failed to generate a PDF file"
|
||||
)
|
||||
|
||||
return os.path.join(screenshots_dir, pdf_files[0])
|
||||
|
||||
|
||||
async def store_slide_images(
|
||||
screenshot_paths: List[str],
|
||||
session_id: uuid.UUID,
|
||||
) -> List[str]:
|
||||
images_dir = _get_images_directory()
|
||||
target_dir = os.path.join(images_dir, str(session_id))
|
||||
os.makedirs(target_dir, exist_ok=True)
|
||||
|
||||
slide_image_urls: list[str] = []
|
||||
for index, screenshot_path in enumerate(screenshot_paths, start=1):
|
||||
file_name = f"slide_{index}.png"
|
||||
destination_path = os.path.join(target_dir, file_name)
|
||||
|
||||
if os.path.exists(screenshot_path) and os.path.getsize(screenshot_path) > 0:
|
||||
await asyncio.to_thread(_copy_file, screenshot_path, destination_path)
|
||||
slide_image_urls.append(f"/app_data/images/{session_id}/{file_name}")
|
||||
else:
|
||||
slide_image_urls.append("/static/images/placeholder.jpg")
|
||||
|
||||
return slide_image_urls
|
||||
|
||||
|
||||
async def store_uploaded_pptx(
|
||||
pptx_path: str,
|
||||
session_id: uuid.UUID,
|
||||
) -> str:
|
||||
uploads_dir = _get_template_uploads_directory()
|
||||
target_dir = os.path.join(uploads_dir, str(session_id))
|
||||
os.makedirs(target_dir, exist_ok=True)
|
||||
|
||||
destination_path = os.path.join(target_dir, "presentation.pptx")
|
||||
await asyncio.to_thread(_copy_file, pptx_path, destination_path)
|
||||
return f"/app_data/uploads/template-previews/{session_id}/presentation.pptx"
|
||||
|
||||
|
||||
async def get_available_and_unavailable_fonts_for_pptx(
|
||||
pptx_path: str, temp_dir: str
|
||||
) -> tuple[list[tuple[str, str]], list[tuple[str, None]]]:
|
||||
slide_xmls = extract_slide_xmls(pptx_path, temp_dir)
|
||||
normalized_fonts = collect_normalized_fonts_from_xmls(slide_xmls)
|
||||
return await get_available_and_unavailable_fonts(normalized_fonts)
|
||||
|
||||
|
||||
async def check_fonts_in_pptx_handler(
|
||||
pptx_file: UploadFile = File(..., description="PPTX file to analyze fonts from")
|
||||
) -> FontCheckResponse:
|
||||
_validate_pptx_file(pptx_file)
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
pptx_path = os.path.join(temp_dir, "presentation.pptx")
|
||||
pptx_content = await pptx_file.read()
|
||||
await asyncio.to_thread(_write_bytes_to_path, pptx_path, pptx_content)
|
||||
|
||||
available_fonts_data, unavailable_fonts_data = (
|
||||
await get_available_and_unavailable_fonts_for_pptx(pptx_path, temp_dir)
|
||||
)
|
||||
|
||||
return FontCheckResponse(
|
||||
available_fonts=[
|
||||
FontInfo(name=name, url=url) for name, url in available_fonts_data
|
||||
],
|
||||
unavailable_fonts=[
|
||||
FontInfo(name=name, url=url) for name, url in unavailable_fonts_data
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
async def upload_fonts_and_slides_preview_handler(
|
||||
pptx_file: UploadFile,
|
||||
font_files: Optional[List[UploadFile]] = None,
|
||||
original_font_names: Optional[List[str]] = None,
|
||||
max_slides: Optional[int] = None,
|
||||
) -> FontsUploadAndSlidesPreviewResponse:
|
||||
if (font_files and not original_font_names) or (
|
||||
original_font_names and not font_files
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Both font_files and original_font_names must be provided together",
|
||||
)
|
||||
if font_files and original_font_names and len(font_files) != len(original_font_names):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Number of font files must match number of original font names",
|
||||
)
|
||||
|
||||
_validate_pptx_file(pptx_file)
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
pptx_path = os.path.join(temp_dir, "presentation.pptx")
|
||||
pptx_content = await pptx_file.read()
|
||||
await asyncio.to_thread(_write_bytes_to_path, pptx_path, pptx_content)
|
||||
|
||||
stored_fonts = await _persist_custom_fonts(
|
||||
font_files=font_files,
|
||||
original_font_names=original_font_names,
|
||||
temp_dir=temp_dir,
|
||||
)
|
||||
await _install_fonts([font.temp_path for font in stored_fonts])
|
||||
|
||||
slide_xmls = extract_slide_xmls(pptx_path, temp_dir)
|
||||
pdf_path = await convert_pptx_to_pdf(pptx_path, temp_dir, slide_xmls=slide_xmls)
|
||||
screenshot_paths = await DocumentsLoader.get_page_images_from_pdf_async(
|
||||
pdf_path, temp_dir
|
||||
)
|
||||
|
||||
if max_slides and len(screenshot_paths) > max_slides:
|
||||
screenshot_paths = screenshot_paths[:max_slides]
|
||||
|
||||
session_id = uuid.uuid4()
|
||||
slide_image_urls = await store_slide_images(screenshot_paths, session_id)
|
||||
pptx_url = await store_uploaded_pptx(pptx_path, session_id)
|
||||
|
||||
available_fonts, _ = await get_available_and_unavailable_fonts(
|
||||
collect_normalized_fonts_from_xmls(slide_xmls)
|
||||
)
|
||||
fonts: dict[str, str] = {name: url for name, url in available_fonts}
|
||||
fonts.update({font.display_name: font.url for font in stored_fonts})
|
||||
|
||||
return FontsUploadAndSlidesPreviewResponse(
|
||||
slide_image_urls=slide_image_urls,
|
||||
pptx_url=pptx_url,
|
||||
modified_pptx_url=pptx_url,
|
||||
fonts=fonts,
|
||||
)
|
||||
220
electron/servers/fastapi/templates/prompts.py
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
SLIDE_LAYOUT_CREATION_SYSTEM_PROMPT = """
|
||||
You need to generate a Zod schema and a TSX React component and provide it as output.
|
||||
Provide reusable TSX code which can be used as template to generate new slides with different content.
|
||||
|
||||
# Steps:
|
||||
1. Analyze the slide image to understand the visual hierarchy.
|
||||
3. Classify elements into decorative and content elements.
|
||||
4. Group content elements into logical sections like Header, Body, BulletPoints, etc.
|
||||
5. Generate a Zod schema for the content elements.
|
||||
6. Generate id, name and description for the layout.
|
||||
6. Generate a TSX React component using the Zod schema and the HTML reference.
|
||||
|
||||
# Decorative Elements:
|
||||
- Arrows, Lines, Shapes, etc.
|
||||
- Images with Grid patterns, background patterns, gradients, solid colors, etc.
|
||||
- Background of infographics like funnel, timeline, etc.
|
||||
- Company name, logos, etc.
|
||||
- Images covering the entire slide.
|
||||
- Images containing company name, logos, etc.
|
||||
|
||||
# Decorative Elements Rules:
|
||||
- Use them exactly as they are in the HTML reference.
|
||||
- Do not change decorative images and icons urls.
|
||||
- Images containing company name, logos, etc should be identified as decorative elements.
|
||||
|
||||
# Content Elements:
|
||||
- Title, Description, BulletPoints, etc.
|
||||
- Graphs, Charts, etc.
|
||||
- Images and Icons representing textual content like title, description, bullet points, etc.
|
||||
- Meaningful Images and Icons.
|
||||
- Icons in infographics that represent the data.
|
||||
|
||||
# Content Elements Rules:
|
||||
- Properly identify between images and icons elements.
|
||||
- Image content:
|
||||
- Image field should be 'z.object({"image_url": z.string(), "image_prompt": z.string().max(100)})'
|
||||
- Replace actual image url with '/static/images/replaceable_template_image.png'
|
||||
- Icon content:
|
||||
- Icon field should be 'z.object({"icon_url": z.string(), "icon_query": z.string().max(30)})'
|
||||
- Replace actual icon url with '/static/icons/placeholder.svg'
|
||||
- Add color styling to the icon to match the color in the image.
|
||||
- Make sure the urls are correct.
|
||||
|
||||
# Layout Rules:
|
||||
- The layout should be fixed 1280px width and 720px height.
|
||||
- Adjust the positions and sizes of elements to fit the layout.
|
||||
- Try to keep the positions and sizes of elements as close to HTML reference as possible.
|
||||
|
||||
# Flexible Positioning and Sizes Rules:
|
||||
- Must not use 'absolute' positioning for elements.
|
||||
- Must use 'flex', 'grid', 'margin', 'padding', 'gap', 'basis', 'justify', 'align', etc for positioning of elements.
|
||||
- For variable length lists, wrap list into a container and center it.
|
||||
- Don't use specific sizes (height, width) for elements if not necessary.
|
||||
|
||||
# Schema Field Name and Description Rules:
|
||||
- Must not use content specific words.
|
||||
- Only use words based on what content types are present in the slide image.
|
||||
- Use words like 'title', description', 'heading', 'image', 'graph', 'table', 'bullet points', etc.
|
||||
- Must not use words like 'budget', 'market', 'revenue', 'sales', 'growth', 'workflow', 'channel', 'plannedValue', 'actualValue', etc.
|
||||
|
||||
# Layout ID, Name and Description Rules:
|
||||
- Must only use slide structure to derive layout id, name and description.
|
||||
- Informations like: Type of content, position of content, etc. should be used.
|
||||
- layoutId example: title-description-right-image.
|
||||
- layoutName example: Title Description Image.
|
||||
- layoutDescription example: A slide with a title, description, and an image on right.
|
||||
|
||||
# Zod Schema Rules:
|
||||
- "describe" must be added for every fields.
|
||||
- Add `.default(...)` to every top-level field directly inside the initial `z.object({ ... })` shape.
|
||||
- Must not put a single `default` on the whole object like `const Schema = z.object({ ... }).default({ ... })`.
|
||||
- Top level fields are those not nested inside other fields.
|
||||
- Don't mention string type in schema like "url()", "email()", etc.
|
||||
- Table must be object with "columns" and "rows" fields.
|
||||
- "columns" must be an array of strings.
|
||||
- "rows" must be an array of arrays of strings.
|
||||
- Graph must be object with "categories" and "series" fields.
|
||||
- "categories" must be an array of strings.
|
||||
- "series" must be an array of objects with {"name": string, "data": array of numbers}.
|
||||
- Must not use z.record() anywhere in the schema.
|
||||
|
||||
# String and Array Field Rules:
|
||||
- Every string field must include `.max(...)`; every array field must include `.max(...)`.
|
||||
- For strings, set `max` to the exact character count of the text content it represents.
|
||||
- For arrays, set `max` to the exact item count of the array content it represents.
|
||||
- Choose a `max` that keeps the longest allowed content from overflowing its container.
|
||||
|
||||
# Table Rules:
|
||||
- Construct "tr -> th" by iterating over the "columns" field.
|
||||
- Construct "tr -> td" by iterating over the "rows" field.
|
||||
- Make sure table height and width adjusts to fit the content.
|
||||
|
||||
# Grahps, Charts, etc Rules:
|
||||
- Identify if graphs, charts, etc are present in the slide image.
|
||||
- Identify the type of graph, chart, etc.
|
||||
- If present, generate a zod schema for the graph, chart, etc.
|
||||
- Generate TSX code for the graph, chart, etc. even if it is not present in the HTML reference.
|
||||
- Use graph schema and image to generate the TSX code.
|
||||
- Use Recharts library for graphs.
|
||||
|
||||
# Fonts Rules:
|
||||
- Check for "PROVIDED FONTS".
|
||||
- Must use fonts only from "PROVIDED FONTS".
|
||||
- Add "font-[\"font-name\"]" to every text element in the slide.
|
||||
|
||||
# Page Number Rules:
|
||||
- Identify if the slide contains page number from provided HTML reference and image.
|
||||
- If page number is present, add a "page: z.number().min(1).meta({ description: "Page number" })" field in the schema.
|
||||
|
||||
# React Component Rules:
|
||||
- React component must be named dynamicSlideLayout.
|
||||
- dynamicSlideLayout must take "{ data }: { data: Partial<z.infer<typeof Schema>> }" as props.
|
||||
- Wrap the code inside these classes: "relative w-full rounded-sm max-w-[1280px] shadow-lg max-h-[720px] aspect-video bg-white z-20 mx-auto overflow-hidden".
|
||||
- Make sure camelCase is used for all styles. For e.g. "letter-spacing" should be "letterSpacing".
|
||||
- Schema.parse must not be used in the code.
|
||||
- Use 'const {field1, field2, ...} = data;' to access the data.
|
||||
- field1 or field2 or ... can be undefined, so use optional chaining to access them.
|
||||
- Don't use "min-height" on cards and instead make its height grow/shrink to fit the content.
|
||||
- Make sure cards/items are centered vertically and horizontally in the available space.
|
||||
- Make sure no element is scrollable.
|
||||
- Don't add any animations, transitions, or effects.
|
||||
- Make sure no content elements are overflowing the slide boundaries.
|
||||
|
||||
# Import and Export Rules:
|
||||
- All import statements must be defined at the top.
|
||||
- Export using 'export {Schema, layoutId, layoutName, layoutDescription, dynamicSlideLayout}' statement at the bottom.
|
||||
- There must be only one 'export' statement in the whole TSX code.
|
||||
|
||||
# Output Code Rules:
|
||||
- Code should be in following order:
|
||||
- Zod Schema (Schema)
|
||||
- Layout ID, Name and Description (layoutId, layoutName, layoutDescription)
|
||||
- React Component (dynamicSlideLayout)
|
||||
- Give just one valid TSX code as output.
|
||||
- Don't add comments in the code.
|
||||
- Make sure the generated code is valid TSX code.
|
||||
- Give only code as output and nothing else. (no json, no markdown, no text, no explanation)
|
||||
|
||||
- Go through generated code and make sure all rules are followed.
|
||||
- Think as long as you can and iterate as many times as necessary to make sure all rules are followed.
|
||||
"""
|
||||
|
||||
SLIDE_LAYOUT_EDIT_SYSTEM_PROMPT = """
|
||||
You need to edit the given TSX code of the slide layout code according to the prompt and provide it as output.
|
||||
|
||||
# Steps
|
||||
1. Analyze the TSX code to understand the slide layout.
|
||||
2. Analyze the prompt to understand the changes to be made.
|
||||
3. Edit the TSX code according to the prompt.
|
||||
4. Provide the updated TSX code as output.
|
||||
|
||||
# Rules
|
||||
- Make sure the changes does not break the existing code.
|
||||
- Make sure to follow the pattern of the existing code.
|
||||
- Make sure there are no unused schema fields after the changes are made.
|
||||
|
||||
# Icons and Images Rules
|
||||
Follow these rules if new icons/images are asked:
|
||||
- Image field should be 'z.object({"image_url": z.string(), "image_prompt": z.string().max(100)})'
|
||||
- Use this as default image url: '/static/images/replaceable_template_image.png'
|
||||
- Icon field should be 'z.object({"icon_url": z.string(), "icon_query": z.string().max(30)})'
|
||||
- Use this as default icon url: '/static/icons/placeholder.svg'
|
||||
|
||||
# Schema Rules
|
||||
- "describe" must be added for every fields.
|
||||
- "default" must be added in top level fields of schema.
|
||||
- Top level fields are those not nested inside other fields.
|
||||
- Must set max for every string and array fields.
|
||||
- Must set max to a number that will not cause overflow on max content.
|
||||
|
||||
# Graphs And Table Rules
|
||||
Follow these rules if new graphs/tables are asked:
|
||||
1. Schema Rules
|
||||
- Table must be object with "columns" and "rows" fields.
|
||||
- "columns" must be an array of strings.
|
||||
- "rows" must be an array of arrays of strings.
|
||||
- Graph must be object with "categories" and "series" fields.
|
||||
- "categories" must be an array of strings.
|
||||
- "series" must be an array of objects with {"name": string, "data": array of numbers}.
|
||||
2. React Component Rules
|
||||
- Use recharts library for graphs.
|
||||
|
||||
# Common Prompts
|
||||
1. Fix the slide
|
||||
- Check if text/cards/items is overflowing the slide boundaries or text/cards/items are overlapping.
|
||||
- If yes, fix by moving the element to a better position or resizing the element.
|
||||
|
||||
# Output Rules
|
||||
- Make sure the schema and react component are valid.
|
||||
- No matter what prompt is given, don't break the code.
|
||||
- Provide only the updated TSX code as output and nothing else. (no json, no markdown, no text, no explanation)
|
||||
"""
|
||||
|
||||
SLIDE_LAYOUT_EDIT_SECTION_SYSTEM_PROMPT = """
|
||||
You need to edit the given TSX code of the slide layout code according to the prompt and provide it as output.
|
||||
|
||||
# Steps
|
||||
1. Analyze the TSX code to understand the slide layout.
|
||||
2. Analyze the prompt to understand the changes to be made.
|
||||
3. Edit the TSX code according to the prompt.
|
||||
4. Provide the updated TSX code as output.
|
||||
|
||||
# Rules
|
||||
- Changes should be made only around the mentioned "section to make changes around".
|
||||
- Make sure the changes does not break the existing code.
|
||||
- Make sure to follow the pattern of the existing code.
|
||||
- Make sure there are no unused schema fields after the changes are made.
|
||||
|
||||
# Icons and Images Rules
|
||||
Follow these rules if new icons/images are asked:
|
||||
- Image field should be 'z.object({"image_url": z.string(), "image_prompt": z.string().max(100)})'
|
||||
- Use this as default image url: '/static/images/replaceable_template_image.png'
|
||||
- Icon field should be 'z.object({"icon_url": z.string(), "icon_query": z.string().max(30)})'
|
||||
- Use this as default icon url: '/static/icons/placeholder.svg'
|
||||
|
||||
# Output Rules
|
||||
- Make sure the schema and react component are valid.
|
||||
- No matter what prompt is given, don't break the code.
|
||||
- Provide only the updated TSX code as output and nothing else. (no json, no markdown, no text, no explanation)
|
||||
"""
|
||||
425
electron/servers/fastapi/templates/providers.py
Normal file
|
|
@ -0,0 +1,425 @@
|
|||
import asyncio
|
||||
import base64
|
||||
from dataclasses import dataclass
|
||||
import time
|
||||
from typing import Any, Awaitable, Callable, Optional
|
||||
|
||||
from anthropic import AsyncAnthropic
|
||||
from fastapi import HTTPException
|
||||
from google import genai
|
||||
from google.genai import types as google_types
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from enums.llm_provider import LLMProvider
|
||||
from utils.get_env import (
|
||||
get_anthropic_api_key_env,
|
||||
get_codex_access_token_env,
|
||||
get_codex_account_id_env,
|
||||
get_codex_refresh_token_env,
|
||||
get_codex_token_expires_env,
|
||||
get_google_api_key_env,
|
||||
get_openai_api_key_env,
|
||||
)
|
||||
from utils.llm_provider import get_llm_provider, get_model
|
||||
from utils.set_env import (
|
||||
set_codex_access_token_env,
|
||||
set_codex_account_id_env,
|
||||
set_codex_refresh_token_env,
|
||||
set_codex_token_expires_env,
|
||||
)
|
||||
|
||||
MAX_ATTEMPTS_PER_PROVIDER = 4
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TemplateProviderSpec:
|
||||
provider: LLMProvider
|
||||
model: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PlainLLMProvider:
|
||||
name: str
|
||||
call: Callable[[], Awaitable[str]]
|
||||
|
||||
def get_template_provider_spec() -> TemplateProviderSpec:
|
||||
provider = get_llm_provider()
|
||||
if provider == LLMProvider.OPENAI:
|
||||
return TemplateProviderSpec(provider=provider, model=get_model())
|
||||
if provider == LLMProvider.CODEX:
|
||||
return TemplateProviderSpec(provider=provider, model=get_model())
|
||||
if provider == LLMProvider.GOOGLE:
|
||||
return TemplateProviderSpec(provider=provider, model=get_model())
|
||||
if provider == LLMProvider.ANTHROPIC:
|
||||
return TemplateProviderSpec(provider=provider, model=get_model())
|
||||
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Template generation only supports OpenAI, Codex, Google, or Anthropic.",
|
||||
)
|
||||
|
||||
|
||||
async def run_plain_provider_buckets(*, providers: list[PlainLLMProvider]) -> str:
|
||||
last_exception: Optional[Exception] = None
|
||||
|
||||
for provider in providers:
|
||||
for attempt in range(1, MAX_ATTEMPTS_PER_PROVIDER + 1):
|
||||
try:
|
||||
response_text = await provider.call()
|
||||
if response_text:
|
||||
return response_text
|
||||
raise ValueError("No output from template generation provider")
|
||||
except Exception as exc:
|
||||
last_exception = exc
|
||||
|
||||
if isinstance(last_exception, HTTPException):
|
||||
raise last_exception
|
||||
raise HTTPException(status_code=500, detail="Failed to generate template output")
|
||||
|
||||
|
||||
def _read_openai_response_text(response) -> str:
|
||||
output_text = getattr(response, "output_text", None)
|
||||
if output_text:
|
||||
return output_text
|
||||
text = getattr(response, "text", None)
|
||||
if text:
|
||||
return text
|
||||
return ""
|
||||
|
||||
|
||||
def _get_openai_client() -> AsyncOpenAI:
|
||||
api_key = get_openai_api_key_env()
|
||||
if not api_key:
|
||||
raise HTTPException(status_code=400, detail="OPENAI_API_KEY is not set")
|
||||
return AsyncOpenAI(api_key=api_key, timeout=120.0)
|
||||
|
||||
|
||||
def _get_codex_headers() -> dict:
|
||||
access_token = get_codex_access_token_env()
|
||||
if not access_token:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Codex OAuth access token is not set. Please authenticate via /api/v1/ppt/codex/auth/initiate",
|
||||
)
|
||||
|
||||
expires_str = get_codex_token_expires_env()
|
||||
if expires_str:
|
||||
try:
|
||||
expires_ms = int(expires_str)
|
||||
now_ms = int(time.time() * 1000)
|
||||
if now_ms >= expires_ms - 60_000:
|
||||
refresh_token = get_codex_refresh_token_env()
|
||||
if refresh_token:
|
||||
from utils.oauth.openai_codex import (
|
||||
TokenSuccess,
|
||||
get_account_id,
|
||||
refresh_access_token,
|
||||
)
|
||||
|
||||
result = refresh_access_token(refresh_token)
|
||||
if isinstance(result, TokenSuccess):
|
||||
set_codex_access_token_env(result.access)
|
||||
set_codex_refresh_token_env(result.refresh)
|
||||
set_codex_token_expires_env(str(result.expires))
|
||||
account_id = get_account_id(result.access)
|
||||
if account_id:
|
||||
set_codex_account_id_env(account_id)
|
||||
access_token = result.access
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
account_id = get_codex_account_id_env() or ""
|
||||
return {
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"chatgpt-account-id": account_id,
|
||||
"OpenAI-Beta": "responses=experimental",
|
||||
"originator": "pi",
|
||||
}
|
||||
|
||||
|
||||
def _get_codex_client() -> AsyncOpenAI:
|
||||
headers = _get_codex_headers()
|
||||
access_token = (headers.get("Authorization") or "").replace("Bearer ", "").strip()
|
||||
default_headers = {
|
||||
key: value
|
||||
for key, value in headers.items()
|
||||
if key.lower() not in {"authorization", "content-type", "accept"}
|
||||
}
|
||||
return AsyncOpenAI(
|
||||
base_url="https://chatgpt.com/backend-api/codex",
|
||||
api_key=access_token or "codex",
|
||||
default_headers=default_headers,
|
||||
timeout=120.0,
|
||||
)
|
||||
|
||||
|
||||
def _get_google_client() -> genai.Client:
|
||||
api_key = get_google_api_key_env()
|
||||
if not api_key:
|
||||
raise HTTPException(status_code=400, detail="GOOGLE_API_KEY is not set")
|
||||
return genai.Client(api_key=api_key)
|
||||
|
||||
|
||||
def _get_anthropic_client() -> AsyncAnthropic:
|
||||
api_key = get_anthropic_api_key_env()
|
||||
if not api_key:
|
||||
raise HTTPException(status_code=400, detail="ANTHROPIC_API_KEY is not set")
|
||||
return AsyncAnthropic(api_key=api_key)
|
||||
|
||||
|
||||
async def _call_openai_like(
|
||||
*,
|
||||
client: AsyncOpenAI,
|
||||
model: str,
|
||||
system_prompt: str,
|
||||
user_text: str,
|
||||
image_bytes: Optional[bytes] = None,
|
||||
media_type: str = "image/png",
|
||||
) -> str:
|
||||
content = [{"type": "input_text", "text": user_text}]
|
||||
if image_bytes:
|
||||
content.insert(
|
||||
0,
|
||||
{
|
||||
"type": "input_image",
|
||||
"image_url": f"data:{media_type};base64,{base64.b64encode(image_bytes).decode('utf-8')}",
|
||||
},
|
||||
)
|
||||
|
||||
response = await client.responses.create(
|
||||
model=model,
|
||||
instructions=system_prompt,
|
||||
input=[{"role": "user", "content": content}],
|
||||
text={"verbosity": "medium"},
|
||||
store=False,
|
||||
)
|
||||
output_text = _read_openai_response_text(response)
|
||||
if not output_text:
|
||||
raise HTTPException(status_code=500, detail="No output from template provider")
|
||||
return output_text
|
||||
|
||||
|
||||
def _response_event_to_dict(event: Any) -> dict:
|
||||
if isinstance(event, dict):
|
||||
return event
|
||||
if hasattr(event, "model_dump"):
|
||||
return event.model_dump()
|
||||
return {
|
||||
"type": getattr(event, "type", None),
|
||||
"delta": getattr(event, "delta", None),
|
||||
"text": getattr(event, "text", None),
|
||||
"item": getattr(event, "item", None),
|
||||
"response": getattr(event, "response", None),
|
||||
"error": getattr(event, "error", None),
|
||||
"message": getattr(event, "message", None),
|
||||
}
|
||||
|
||||
|
||||
async def _call_codex(
|
||||
*,
|
||||
model: str,
|
||||
system_prompt: str,
|
||||
user_text: str,
|
||||
image_bytes: Optional[bytes] = None,
|
||||
media_type: str = "image/png",
|
||||
) -> str:
|
||||
client = _get_codex_client()
|
||||
content = [{"type": "input_text", "text": user_text}]
|
||||
if image_bytes:
|
||||
content.insert(
|
||||
0,
|
||||
{
|
||||
"type": "input_image",
|
||||
"image_url": f"data:{media_type};base64,{base64.b64encode(image_bytes).decode('utf-8')}",
|
||||
},
|
||||
)
|
||||
|
||||
stream = await client.responses.create(
|
||||
model=model,
|
||||
instructions=system_prompt,
|
||||
input=[{"role": "user", "content": content}],
|
||||
text={"verbosity": "medium"},
|
||||
store=False,
|
||||
stream=True,
|
||||
)
|
||||
|
||||
text_parts: list[str] = []
|
||||
|
||||
async for event in stream:
|
||||
payload = _response_event_to_dict(event)
|
||||
event_type = payload.get("type") or ""
|
||||
|
||||
if event_type == "response.output_text.delta":
|
||||
delta = payload.get("delta") or ""
|
||||
if delta:
|
||||
text_parts.append(delta)
|
||||
continue
|
||||
|
||||
if event_type == "response.output_text.done":
|
||||
text = payload.get("text") or ""
|
||||
if text and not text_parts:
|
||||
text_parts.append(text)
|
||||
continue
|
||||
|
||||
if event_type in ("response.error", "response.failed", "error"):
|
||||
error_detail = payload.get("message") or payload.get("error") or str(payload)
|
||||
raise HTTPException(status_code=502, detail=f"Codex error: {error_detail}"[:400])
|
||||
|
||||
output_text = "".join(text_parts).strip()
|
||||
if not output_text:
|
||||
raise HTTPException(status_code=500, detail="No output from template provider")
|
||||
return output_text
|
||||
|
||||
|
||||
async def _call_google(
|
||||
*,
|
||||
model: str,
|
||||
system_prompt: str,
|
||||
user_text: str,
|
||||
image_bytes: Optional[bytes] = None,
|
||||
media_type: str = "image/png",
|
||||
) -> str:
|
||||
client = _get_google_client()
|
||||
parts = [google_types.Part.from_text(text=user_text)]
|
||||
if image_bytes:
|
||||
parts.append(google_types.Part.from_bytes(data=image_bytes, mime_type=media_type))
|
||||
|
||||
response = await asyncio.to_thread(
|
||||
client.models.generate_content,
|
||||
model=model,
|
||||
contents=[google_types.Content(role="user", parts=parts)],
|
||||
config=google_types.GenerateContentConfig(
|
||||
system_instruction=system_prompt,
|
||||
response_mime_type="text/plain",
|
||||
),
|
||||
)
|
||||
output_text = getattr(response, "text", None) or ""
|
||||
if not output_text:
|
||||
raise HTTPException(status_code=500, detail="No output from template provider")
|
||||
return output_text
|
||||
|
||||
|
||||
async def _call_anthropic(
|
||||
*,
|
||||
model: str,
|
||||
system_prompt: str,
|
||||
user_text: str,
|
||||
image_bytes: Optional[bytes] = None,
|
||||
media_type: str = "image/png",
|
||||
) -> str:
|
||||
client = _get_anthropic_client()
|
||||
content = [{"type": "text", "text": user_text}]
|
||||
if image_bytes:
|
||||
content.append(
|
||||
{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": media_type,
|
||||
"data": base64.b64encode(image_bytes).decode("utf-8"),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
response = await client.messages.create(
|
||||
model=model,
|
||||
max_tokens=8192,
|
||||
system=system_prompt,
|
||||
messages=[{"role": "user", "content": content}],
|
||||
)
|
||||
output_text = "".join(
|
||||
block.text for block in response.content if getattr(block, "type", None) == "text"
|
||||
)
|
||||
if not output_text:
|
||||
raise HTTPException(status_code=500, detail="No output from template provider")
|
||||
return output_text
|
||||
|
||||
|
||||
def _build_provider_call(
|
||||
*,
|
||||
spec: Optional[TemplateProviderSpec] = None,
|
||||
system_prompt: str,
|
||||
user_text: str,
|
||||
image_bytes: Optional[bytes] = None,
|
||||
media_type: str = "image/png",
|
||||
) -> PlainLLMProvider:
|
||||
spec = spec or get_template_provider_spec()
|
||||
|
||||
if spec.provider == LLMProvider.OPENAI:
|
||||
return PlainLLMProvider(
|
||||
name="OpenAI",
|
||||
call=lambda: _call_openai_like(
|
||||
client=_get_openai_client(),
|
||||
model=spec.model,
|
||||
system_prompt=system_prompt,
|
||||
user_text=user_text,
|
||||
image_bytes=image_bytes,
|
||||
media_type=media_type,
|
||||
),
|
||||
)
|
||||
if spec.provider == LLMProvider.CODEX:
|
||||
return PlainLLMProvider(
|
||||
name="Codex",
|
||||
call=lambda: _call_codex(
|
||||
model=spec.model,
|
||||
system_prompt=system_prompt,
|
||||
user_text=user_text,
|
||||
image_bytes=image_bytes,
|
||||
media_type=media_type,
|
||||
),
|
||||
)
|
||||
if spec.provider == LLMProvider.GOOGLE:
|
||||
return PlainLLMProvider(
|
||||
name="Google",
|
||||
call=lambda: _call_google(
|
||||
model=spec.model,
|
||||
system_prompt=system_prompt,
|
||||
user_text=user_text,
|
||||
image_bytes=image_bytes,
|
||||
media_type=media_type,
|
||||
),
|
||||
)
|
||||
if spec.provider == LLMProvider.ANTHROPIC:
|
||||
return PlainLLMProvider(
|
||||
name="Anthropic",
|
||||
call=lambda: _call_anthropic(
|
||||
model=spec.model,
|
||||
system_prompt=system_prompt,
|
||||
user_text=user_text,
|
||||
image_bytes=image_bytes,
|
||||
media_type=media_type,
|
||||
),
|
||||
)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Template generation only supports OpenAI, Codex, Google, or Anthropic.",
|
||||
)
|
||||
|
||||
|
||||
async def generate_slide_layout_code(
|
||||
*,
|
||||
system_prompt: str,
|
||||
user_text: str,
|
||||
image_bytes: bytes,
|
||||
media_type: str = "image/png",
|
||||
) -> str:
|
||||
provider = _build_provider_call(
|
||||
system_prompt=system_prompt,
|
||||
user_text=user_text,
|
||||
image_bytes=image_bytes,
|
||||
media_type=media_type,
|
||||
)
|
||||
return await run_plain_provider_buckets(providers=[provider])
|
||||
|
||||
|
||||
async def edit_slide_layout_code(
|
||||
*,
|
||||
system_prompt: str,
|
||||
user_text: str,
|
||||
) -> str:
|
||||
provider = _build_provider_call(
|
||||
system_prompt=system_prompt,
|
||||
user_text=user_text,
|
||||
)
|
||||
return await run_plain_provider_buckets(providers=[provider])
|
||||
65
electron/servers/fastapi/templates/router.py
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import uuid
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from templates.handler import (
|
||||
CreateSlideLayoutResponse,
|
||||
EditSlideLayoutResponse,
|
||||
EditSlideLayoutSectionResponse,
|
||||
FontsUploadAndSlidesPreviewResponse,
|
||||
GetTemplateLayoutsResponse,
|
||||
PresentationLayoutModel,
|
||||
SaveTemplateLayoutData,
|
||||
SaveTemplateResponse,
|
||||
TemplateDetail,
|
||||
TemplateExample,
|
||||
clone_slide_layout,
|
||||
clone_template,
|
||||
create_slide_layout,
|
||||
edit_slide_layout,
|
||||
edit_slide_layout_section,
|
||||
get_all_templates,
|
||||
get_layouts,
|
||||
get_template_by_id,
|
||||
get_template_example,
|
||||
init_create_template,
|
||||
save_slide_layout,
|
||||
save_template,
|
||||
update_template,
|
||||
upload_fonts_and_slides_preview,
|
||||
)
|
||||
|
||||
TEMPLATE_ROUTER = APIRouter(prefix="/template", tags=["Template"])
|
||||
|
||||
TEMPLATE_ROUTER.get("/all", response_model=list[TemplateDetail])(get_all_templates)
|
||||
TEMPLATE_ROUTER.get(
|
||||
"/{template_id}/layouts", response_model=GetTemplateLayoutsResponse
|
||||
)(get_layouts)
|
||||
TEMPLATE_ROUTER.get("/{id}", response_model=PresentationLayoutModel)(get_template_by_id)
|
||||
TEMPLATE_ROUTER.get("/{id}/example", response_model=TemplateExample)(
|
||||
get_template_example
|
||||
)
|
||||
TEMPLATE_ROUTER.post(
|
||||
"/fonts-upload-and-slides-preview",
|
||||
response_model=FontsUploadAndSlidesPreviewResponse,
|
||||
)(upload_fonts_and_slides_preview)
|
||||
TEMPLATE_ROUTER.post("/create/init", response_model=uuid.UUID)(init_create_template)
|
||||
TEMPLATE_ROUTER.post("/slide-layout/create", response_model=CreateSlideLayoutResponse)(
|
||||
create_slide_layout
|
||||
)
|
||||
TEMPLATE_ROUTER.post("/create/slide-layout", response_model=CreateSlideLayoutResponse)(
|
||||
create_slide_layout
|
||||
)
|
||||
TEMPLATE_ROUTER.post("/slide-layout/edit", response_model=EditSlideLayoutResponse)(
|
||||
edit_slide_layout
|
||||
)
|
||||
TEMPLATE_ROUTER.post(
|
||||
"/slide-layout/edit-section", response_model=EditSlideLayoutSectionResponse
|
||||
)(edit_slide_layout_section)
|
||||
TEMPLATE_ROUTER.post("/save", response_model=SaveTemplateResponse)(save_template)
|
||||
TEMPLATE_ROUTER.post("/clone", response_model=SaveTemplateResponse)(clone_template)
|
||||
TEMPLATE_ROUTER.put("/update", response_model=SaveTemplateResponse)(update_template)
|
||||
TEMPLATE_ROUTER.post("/slide-layout/save", status_code=200)(save_slide_layout)
|
||||
TEMPLATE_ROUTER.post("/slide-layout/clone", response_model=SaveTemplateLayoutData)(
|
||||
clone_slide_layout
|
||||
)
|
||||
|
|
@ -398,3 +398,38 @@ class TestImageGenerationEndpoint:
|
|||
|
||||
asyncio.run(run_test())
|
||||
|
||||
def test_search_stock_images_defaults_to_selected_pixabay(self, client, mock_images_directory):
|
||||
"""
|
||||
Test stock image search defaults to IMAGE_PROVIDER when provider query param is omitted
|
||||
- Sets IMAGE_PROVIDER to pixabay
|
||||
- Ensures /images/search uses Pixabay instead of returning provider validation error
|
||||
"""
|
||||
with patch.dict(os.environ, {"IMAGE_PROVIDER": "pixabay"}):
|
||||
with patch('api.v1.ppt.endpoints.images.get_images_directory', return_value=mock_images_directory):
|
||||
with patch('api.v1.ppt.endpoints.images.ImageGenerationService') as mock_service_class:
|
||||
mock_service_instance = Mock()
|
||||
mock_service_instance.get_image_from_pixabay = AsyncMock(
|
||||
return_value=["https://example.com/pixabay_image.jpg"]
|
||||
)
|
||||
mock_service_instance.get_image_from_pexels = AsyncMock(
|
||||
return_value=["https://example.com/pexels_image.jpg"]
|
||||
)
|
||||
mock_service_class.return_value = mock_service_instance
|
||||
|
||||
response = client.get("/images/search?query=business&limit=1")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == ["https://example.com/pixabay_image.jpg"]
|
||||
mock_service_instance.get_image_from_pixabay.assert_awaited_once()
|
||||
mock_service_instance.get_image_from_pexels.assert_not_called()
|
||||
|
||||
def test_search_stock_images_invalid_provider_returns_400(self, client):
|
||||
"""
|
||||
Test stock image search validates invalid provider values
|
||||
- Ensures unsupported providers return HTTP 400 with clear guidance
|
||||
"""
|
||||
response = client.get("/images/search?query=business&provider=invalid-provider")
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"] == "provider must be either 'pexels' or 'pixabay'"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,140 +0,0 @@
|
|||
import os
|
||||
import tempfile
|
||||
import zipfile
|
||||
from fastapi.testclient import TestClient
|
||||
from fastapi import UploadFile
|
||||
import pytest
|
||||
|
||||
from api.main import app
|
||||
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def create_sample_pptx():
|
||||
"""Create a minimal PPTX file for testing."""
|
||||
# This creates a very basic PPTX structure for testing
|
||||
pptx_content = {
|
||||
'[Content_Types].xml': '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
||||
<Default Extension="xml" ContentType="application/xml"/>
|
||||
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
||||
<Override PartName="/ppt/presentation.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"/>
|
||||
<Override PartName="/ppt/slides/slide1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>
|
||||
</Types>''',
|
||||
'_rels/.rels': '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="ppt/presentation.xml"/>
|
||||
</Relationships>''',
|
||||
'ppt/presentation.xml': '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<p:presentation xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
|
||||
<p:sldMasterIdLst/>
|
||||
<p:sldIdLst>
|
||||
<p:sldId id="256" r:id="rId2"/>
|
||||
</p:sldIdLst>
|
||||
<p:sldSz cx="9144000" cy="6858000"/>
|
||||
</p:presentation>''',
|
||||
'ppt/_rels/presentation.xml.rels': '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide1.xml"/>
|
||||
</Relationships>''',
|
||||
'ppt/slides/slide1.xml': '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<p:sld xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
|
||||
<p:cSld>
|
||||
<p:spTree>
|
||||
<p:nvGrpSpPr>
|
||||
<p:cNvPr id="1" name=""/>
|
||||
<p:cNvGrpSpPr/>
|
||||
<p:nvPr/>
|
||||
</p:nvGrpSpPr>
|
||||
<p:grpSpPr>
|
||||
<a:xfrm xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
|
||||
<a:off x="0" y="0"/>
|
||||
<a:ext cx="0" cy="0"/>
|
||||
<a:chOff x="0" y="0"/>
|
||||
<a:chExt cx="0" cy="0"/>
|
||||
</a:xfrm>
|
||||
</p:grpSpPr>
|
||||
</p:spTree>
|
||||
</p:cSld>
|
||||
<p:clrMapOvr>
|
||||
<a:masterClrMapping xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"/>
|
||||
</p:clrMapOvr>
|
||||
</p:sld>'''
|
||||
}
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix='.pptx', delete=False) as temp_file:
|
||||
with zipfile.ZipFile(temp_file.name, 'w') as zip_file:
|
||||
for path, content in pptx_content.items():
|
||||
zip_file.writestr(path, content)
|
||||
return temp_file.name
|
||||
|
||||
|
||||
def test_pptx_slides_processing():
|
||||
"""Test the PPTX slides processing endpoint."""
|
||||
|
||||
# Create a sample PPTX file
|
||||
pptx_path = create_sample_pptx()
|
||||
|
||||
try:
|
||||
with open(pptx_path, 'rb') as pptx_file:
|
||||
files = {'pptx_file': ('test.pptx', pptx_file, 'application/vnd.openxmlformats-officedocument.presentationml.presentation')}
|
||||
|
||||
response = client.post("/api/v1/ppt/pptx-slides/process", files=files)
|
||||
|
||||
# Check response
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
assert data['success'] == True
|
||||
assert 'slides' in data
|
||||
assert 'total_slides' in data
|
||||
assert data['total_slides'] > 0
|
||||
|
||||
# Check slide data structure
|
||||
if data['slides']:
|
||||
slide = data['slides'][0]
|
||||
assert 'slide_number' in slide
|
||||
assert 'screenshot_url' in slide
|
||||
assert 'xml_content' in slide
|
||||
assert slide['slide_number'] == 1
|
||||
assert slide['xml_content'] != ''
|
||||
|
||||
print(f"✅ Test passed! Processed {data['total_slides']} slides successfully")
|
||||
|
||||
finally:
|
||||
# Clean up
|
||||
if os.path.exists(pptx_path):
|
||||
os.unlink(pptx_path)
|
||||
|
||||
|
||||
def test_invalid_file_type():
|
||||
"""Test that non-PPTX files are rejected."""
|
||||
|
||||
# Create a text file and try to upload it
|
||||
with tempfile.NamedTemporaryFile(suffix='.txt', delete=False) as temp_file:
|
||||
temp_file.write(b"This is not a PPTX file")
|
||||
temp_file.flush()
|
||||
|
||||
try:
|
||||
with open(temp_file.name, 'rb') as txt_file:
|
||||
files = {'pptx_file': ('test.txt', txt_file, 'text/plain')}
|
||||
|
||||
response = client.post("/api/v1/ppt/pptx-slides/process", files=files)
|
||||
|
||||
# Should return 400 for invalid file type
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
assert 'Invalid file type' in data['detail']
|
||||
|
||||
print("✅ Invalid file type test passed!")
|
||||
|
||||
finally:
|
||||
os.unlink(temp_file.name)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Running PPTX slides processing tests...")
|
||||
test_pptx_slides_processing()
|
||||
test_invalid_file_type()
|
||||
print("🎉 All tests completed!")
|
||||
|
|
@ -1,189 +1,117 @@
|
|||
from unittest.mock import patch, AsyncMock, MagicMock
|
||||
import asyncio
|
||||
import uuid
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from fastapi import FastAPI
|
||||
from models.presentation_layout import PresentationLayoutModel
|
||||
from models.presentation_structure_model import PresentationStructureModel
|
||||
from api.v1.ppt.endpoints.presentation import PRESENTATION_ROUTER
|
||||
from fastapi import HTTPException
|
||||
from pydantic import ValidationError
|
||||
|
||||
class MockAiohttpResponse:
|
||||
def __init__(self, status=200, json_data=None):
|
||||
self.status = status
|
||||
self._json_data = json_data or {"path": "/tmp/exports/test.pdf"}
|
||||
from api.v1.ppt.endpoints.presentation import generate_presentation_sync
|
||||
from models.generate_presentation_request import GeneratePresentationRequest
|
||||
from models.presentation_and_path import PresentationPathAndEditPath
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
pass
|
||||
class FakeAsyncSession:
|
||||
async def get(self, *_args, **_kwargs):
|
||||
return None
|
||||
|
||||
async def json(self):
|
||||
return self._json_data
|
||||
def add(self, *_args, **_kwargs):
|
||||
return None
|
||||
|
||||
async def text(self):
|
||||
return str(self._json_data)
|
||||
def add_all(self, *_args, **_kwargs):
|
||||
return None
|
||||
|
||||
class MockAiohttpSession:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
async def commit(self):
|
||||
return None
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
pass
|
||||
|
||||
def post(self, *args, **kwargs):
|
||||
return MockAiohttpResponse()
|
||||
|
||||
def get(self, *args, **kwargs):
|
||||
pptx_model_data = {
|
||||
"slides": [],
|
||||
"title": "Test",
|
||||
"notes": [],
|
||||
"layout": {},
|
||||
"structure": {},
|
||||
}
|
||||
return MockAiohttpResponse(json_data=pptx_model_data)
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
app = FastAPI()
|
||||
app.include_router(PRESENTATION_ROUTER, prefix="/api/v1/ppt")
|
||||
return app
|
||||
|
||||
@pytest.fixture
|
||||
def client(app):
|
||||
return TestClient(app)
|
||||
|
||||
@pytest.fixture
|
||||
def mock_get_layout():
|
||||
async def _mock_get_layout_by_name(layout_name: str):
|
||||
mock_slide = MagicMock()
|
||||
mock_slide.name = "Mock Slide"
|
||||
mock_slide.json_schema = {"title": "Mock Slide Title"}
|
||||
mock_slide.description = "Mock slide description"
|
||||
mock_layout = MagicMock(spec=PresentationLayoutModel)
|
||||
mock_layout.name = layout_name
|
||||
mock_layout.ordered = True
|
||||
mock_layout.slides = [mock_slide]
|
||||
mock_layout.model_dump = lambda: {}
|
||||
mock_layout.to_presentation_structure = lambda: PresentationStructureModel(
|
||||
slides=[index for index in range(len(mock_layout.slides))]
|
||||
)
|
||||
def to_string():
|
||||
message = f"## Presentation Layout\n\n"
|
||||
for index, slide in enumerate(mock_layout.slides):
|
||||
message += f"### Slide Layout: {index}: \n"
|
||||
message += f"- Name: {slide.name or slide.json_schema.get('title')} \n"
|
||||
message += f"- Description: {slide.description} \n\n"
|
||||
return message
|
||||
mock_layout.to_string = to_string
|
||||
return mock_layout
|
||||
return _mock_get_layout_by_name
|
||||
|
||||
async def mock_generate_ppt_outline(*args, **kwargs):
|
||||
yield '{"title": "Test", "slides": [{"title": "Slide 1", "body": "Body 1"}], "notes": []}'
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def patch_presentation_api(monkeypatch, mock_get_layout):
|
||||
# Patch all dependencies used in the API
|
||||
patches = [
|
||||
patch('api.v1.ppt.endpoints.presentation.get_layout_by_name', new=AsyncMock(side_effect=mock_get_layout)),
|
||||
patch('api.v1.ppt.endpoints.presentation.TEMP_FILE_SERVICE.create_temp_dir', return_value='/tmp/mockdir'),
|
||||
patch('api.v1.ppt.endpoints.presentation.DocumentsLoader'),
|
||||
patch('api.v1.ppt.endpoints.presentation.generate_document_summary', new_callable=AsyncMock, return_value="mock_summary"),
|
||||
patch('api.v1.ppt.endpoints.presentation.generate_ppt_outline', side_effect=mock_generate_ppt_outline),
|
||||
patch('api.v1.ppt.endpoints.presentation.get_sql_session'),
|
||||
patch('api.v1.ppt.endpoints.presentation.get_slide_content_from_type_and_outline', new_callable=AsyncMock, return_value={"mock": "slide_content"}),
|
||||
patch('api.v1.ppt.endpoints.presentation.process_slide_and_fetch_assets', new_callable=AsyncMock),
|
||||
patch('api.v1.ppt.endpoints.presentation.get_exports_directory', return_value='/tmp/exports'),
|
||||
patch('api.v1.ppt.endpoints.presentation.PptxPresentationCreator'),
|
||||
patch('api.v1.ppt.endpoints.presentation.aiohttp.ClientSession', return_value=MockAiohttpSession()),
|
||||
]
|
||||
mocks = [p.start() for p in patches]
|
||||
|
||||
# Setup DocumentsLoader mock
|
||||
docs_loader = mocks[2]
|
||||
docs_loader.return_value.load_documents = AsyncMock()
|
||||
docs_loader.return_value.documents = []
|
||||
|
||||
# Setup PptxPresentationCreator mock for pptx test
|
||||
pptx_creator = mocks[9]
|
||||
pptx_creator.return_value.create_ppt = AsyncMock()
|
||||
pptx_creator.return_value.save = MagicMock()
|
||||
|
||||
yield
|
||||
|
||||
for p in patches:
|
||||
p.stop()
|
||||
|
||||
class TestPresentationGenerationAPI:
|
||||
def test_generate_presentation_export_as_pdf(self, client):
|
||||
response = client.post(
|
||||
"/api/v1/ppt/presentation/generate",
|
||||
json={
|
||||
"content": "Create a presentation about artificial intelligence and machine learning",
|
||||
"n_slides": 5,
|
||||
"language": "English",
|
||||
"export_as": "pdf",
|
||||
"layout": "general"
|
||||
}
|
||||
def test_generate_presentation_export_as_pdf(self):
|
||||
request = GeneratePresentationRequest(
|
||||
content="Create a presentation about artificial intelligence and machine learning",
|
||||
n_slides=5,
|
||||
language="English",
|
||||
export_as="pdf",
|
||||
template="general",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "presentation_id" in response.json()
|
||||
assert "pdf" in response.json()["path"]
|
||||
|
||||
def test_generate_presentation_export_as_pptx(self, client):
|
||||
response = client.post(
|
||||
"/api/v1/ppt/presentation/generate",
|
||||
json={
|
||||
"content": "Create a presentation about artificial intelligence and machine learning",
|
||||
"n_slides": 5,
|
||||
"language": "English",
|
||||
"export_as": "pptx",
|
||||
"layout": "general"
|
||||
}
|
||||
response_payload = PresentationPathAndEditPath(
|
||||
presentation_id=uuid.uuid4(),
|
||||
path="/tmp/exports/test.pdf",
|
||||
edit_path="/presentation?id=test",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "presentation_id" in response.json()
|
||||
assert "pptx" in response.json()["path"]
|
||||
|
||||
def test_generate_presentation_with_no_content(self, client):
|
||||
response = client.post(
|
||||
"/api/v1/ppt/presentation/generate",
|
||||
json={
|
||||
"n_slides": 5,
|
||||
"language": "English",
|
||||
"export_as": "pdf",
|
||||
"layout": "general"
|
||||
}
|
||||
with patch(
|
||||
"api.v1.ppt.endpoints.presentation.generate_presentation_handler",
|
||||
new=AsyncMock(return_value=response_payload),
|
||||
) as mock_handler:
|
||||
response = asyncio.run(
|
||||
generate_presentation_sync(request, sql_session=FakeAsyncSession())
|
||||
)
|
||||
|
||||
assert response == response_payload
|
||||
mock_handler.assert_awaited_once()
|
||||
|
||||
def test_generate_presentation_export_as_pptx(self):
|
||||
request = GeneratePresentationRequest(
|
||||
content="Create a presentation about artificial intelligence and machine learning",
|
||||
n_slides=5,
|
||||
language="English",
|
||||
export_as="pptx",
|
||||
template="general",
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_generate_presentation_with_n_slides_less_than_one(self, client):
|
||||
response = client.post(
|
||||
"/api/v1/ppt/presentation/generate",
|
||||
json={
|
||||
"content": "Create a presentation about artificial intelligence and machine learning",
|
||||
"n_slides": 0,
|
||||
"language": "English",
|
||||
"export_as": "pdf",
|
||||
"layout": "general"
|
||||
}
|
||||
response_payload = PresentationPathAndEditPath(
|
||||
presentation_id=uuid.uuid4(),
|
||||
path="/tmp/exports/test.pptx",
|
||||
edit_path="/presentation?id=test",
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
def test_generate_presentation_with_invalid_export_type(self, client):
|
||||
response = client.post(
|
||||
"/api/v1/ppt/presentation/generate",
|
||||
json={
|
||||
"content": "Create a presentation about artificial intelligence and machine learning",
|
||||
"n_slides": 5,
|
||||
"language": "English",
|
||||
"export_as": "invalid_type",
|
||||
"layout": "general"
|
||||
}
|
||||
with patch(
|
||||
"api.v1.ppt.endpoints.presentation.generate_presentation_handler",
|
||||
new=AsyncMock(return_value=response_payload),
|
||||
) as mock_handler:
|
||||
response = asyncio.run(
|
||||
generate_presentation_sync(request, sql_session=FakeAsyncSession())
|
||||
)
|
||||
|
||||
assert response == response_payload
|
||||
mock_handler.assert_awaited_once()
|
||||
|
||||
def test_generate_presentation_with_no_content(self):
|
||||
with pytest.raises(ValidationError):
|
||||
GeneratePresentationRequest.model_validate(
|
||||
{
|
||||
"n_slides": 5,
|
||||
"language": "English",
|
||||
"export_as": "pdf",
|
||||
"template": "general",
|
||||
}
|
||||
)
|
||||
|
||||
def test_generate_presentation_with_n_slides_less_than_one(self):
|
||||
request = GeneratePresentationRequest(
|
||||
content="Create a presentation about artificial intelligence and machine learning",
|
||||
n_slides=0,
|
||||
language="English",
|
||||
export_as="pdf",
|
||||
template="general",
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
asyncio.run(
|
||||
generate_presentation_sync(request, sql_session=FakeAsyncSession())
|
||||
)
|
||||
|
||||
assert exc.value.status_code == 400
|
||||
assert exc.value.detail == "Number of slides must be greater than 0"
|
||||
|
||||
def test_generate_presentation_with_invalid_export_type(self):
|
||||
with pytest.raises(ValidationError):
|
||||
GeneratePresentationRequest.model_validate(
|
||||
{
|
||||
"content": "Create a presentation about artificial intelligence and machine learning",
|
||||
"n_slides": 5,
|
||||
"language": "English",
|
||||
"export_as": "invalid_type",
|
||||
"template": "general",
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,115 +0,0 @@
|
|||
import pytest
|
||||
import os
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
# Import the main app
|
||||
from server import app
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def test_slide_to_html_endpoint():
|
||||
"""Test the slide-to-html endpoint with streaming API support."""
|
||||
|
||||
# Sample XML data (simplified version of OXML)
|
||||
test_xml = '''<?xml version="1.0" encoding="UTF-8"?>
|
||||
<p:sld xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
|
||||
<p:cSld>
|
||||
<p:bg>
|
||||
<p:bgPr>
|
||||
<a:solidFill xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
|
||||
<a:srgbClr val="FFFFFF"/>
|
||||
</a:solidFill>
|
||||
</p:bgPr>
|
||||
</p:bg>
|
||||
<p:spTree>
|
||||
<p:sp>
|
||||
<p:txBody>
|
||||
<a:p xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
|
||||
<a:r>
|
||||
<a:t>Test Slide</a:t>
|
||||
</a:r>
|
||||
</a:p>
|
||||
</p:txBody>
|
||||
</p:sp>
|
||||
</p:spTree>
|
||||
</p:cSld>
|
||||
</p:sld>'''
|
||||
|
||||
# Skip this test if ANTHROPIC_API_KEY is not set
|
||||
if not os.getenv('ANTHROPIC_API_KEY'):
|
||||
pytest.skip("ANTHROPIC_API_KEY not set - skipping API test")
|
||||
|
||||
# Use a placeholder image path (since we can't easily test with real files)
|
||||
test_data = {
|
||||
"image": "/static/images/placeholder.jpg",
|
||||
"xml": test_xml
|
||||
}
|
||||
|
||||
# Make the request with JSON
|
||||
response = client.post(
|
||||
"/api/v1/ppt/slide-to-html/",
|
||||
json=test_data
|
||||
)
|
||||
|
||||
# Check response (may take several minutes due to streaming)
|
||||
print("Note: This test may take several minutes due to Claude's streaming processing...")
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert "html" in data
|
||||
assert len(data["html"]) > 0
|
||||
print(f"Generated HTML preview: {data['html'][:200]}...")
|
||||
print("✅ Streaming API test completed successfully")
|
||||
else:
|
||||
print(f"Request failed with status {response.status_code}: {response.text}")
|
||||
# Don't fail the test if API key is missing or invalid
|
||||
if "ANTHROPIC_API_KEY" in response.text:
|
||||
pytest.skip("Invalid API key - skipping test")
|
||||
elif "Streaming is required" in response.text:
|
||||
print("✅ Streaming error handled correctly by endpoint")
|
||||
|
||||
|
||||
def test_slide_to_html_invalid_path():
|
||||
"""Test the endpoint with an invalid image path."""
|
||||
|
||||
test_data = {
|
||||
"image": "/app_data/images/nonexistent/image.png",
|
||||
"xml": "<simple>xml</simple>"
|
||||
}
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/ppt/slide-to-html/",
|
||||
json=test_data
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert "Image file not found" in response.json()["detail"]
|
||||
|
||||
|
||||
def test_slide_to_html_missing_xml():
|
||||
"""Test the endpoint with missing XML data."""
|
||||
|
||||
test_data = {
|
||||
"image": "/static/images/placeholder.jpg"
|
||||
# No XML data provided
|
||||
}
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/ppt/slide-to-html/",
|
||||
json=test_data
|
||||
)
|
||||
|
||||
assert response.status_code == 422 # Validation error
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Run a simple test
|
||||
test_slide_to_html_invalid_path()
|
||||
print("✅ Invalid path test passed")
|
||||
|
||||
test_slide_to_html_missing_xml()
|
||||
print("✅ Missing XML test passed")
|
||||
|
||||
print("🧪 Run full tests with: pytest test_slide_to_html.py")
|
||||
632
electron/servers/fastapi/tests/test_template_api.py
Normal file
|
|
@ -0,0 +1,632 @@
|
|||
import asyncio
|
||||
import base64
|
||||
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
|
||||
|
||||
from api.v1.ppt.router import API_V1_PPT_ROUTER
|
||||
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,
|
||||
EditSlideLayoutRequest,
|
||||
EditSlideLayoutSectionRequest,
|
||||
SaveSlideLayoutRequest,
|
||||
SaveTemplateLayoutData,
|
||||
SaveTemplateRequest,
|
||||
UpdateTemplateRequest,
|
||||
clone_slide_layout,
|
||||
create_slide_layout,
|
||||
edit_slide_layout,
|
||||
edit_slide_layout_section,
|
||||
init_create_template,
|
||||
save_slide_layout,
|
||||
save_template,
|
||||
update_template,
|
||||
upload_fonts_and_slides_preview,
|
||||
)
|
||||
from templates.preview import (
|
||||
FontCheckResponse,
|
||||
FontsUploadAndSlidesPreviewResponse,
|
||||
check_fonts_in_pptx_handler,
|
||||
)
|
||||
from templates.providers import (
|
||||
generate_slide_layout_code,
|
||||
get_template_provider_spec,
|
||||
)
|
||||
|
||||
|
||||
PNG_BYTES = base64.b64decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9s5WzxQAAAAASUVORK5CYII="
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api_client(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("APP_DATA_DIRECTORY", str(tmp_path / "app_data"))
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(API_V1_PPT_ROUTER)
|
||||
client = TestClient(app)
|
||||
yield client, tmp_path
|
||||
client.close()
|
||||
|
||||
|
||||
class FakeScalarResult:
|
||||
def __init__(self, items):
|
||||
self._items = list(items)
|
||||
|
||||
def all(self):
|
||||
return list(self._items)
|
||||
|
||||
|
||||
class FakeExecuteResult:
|
||||
def __init__(self, items):
|
||||
self._items = list(items)
|
||||
|
||||
def scalars(self):
|
||||
return FakeScalarResult(self._items)
|
||||
|
||||
def all(self):
|
||||
return list(self._items)
|
||||
|
||||
|
||||
class FakeAsyncSession:
|
||||
def __init__(self):
|
||||
self.template_infos: dict = {}
|
||||
self.templates: dict = {}
|
||||
self.layouts: list[PresentationLayoutCodeModel] = []
|
||||
self._next_layout_row_id = 1
|
||||
|
||||
async def get(self, model, key):
|
||||
if model is TemplateCreateInfoModel:
|
||||
return self.template_infos.get(key)
|
||||
if model is TemplateModel:
|
||||
return self.templates.get(key)
|
||||
return None
|
||||
|
||||
def add(self, obj):
|
||||
if isinstance(obj, TemplateCreateInfoModel):
|
||||
if getattr(obj, "created_at", None) is None:
|
||||
obj.created_at = datetime.now(timezone.utc)
|
||||
self.template_infos[obj.id] = obj
|
||||
elif isinstance(obj, TemplateModel):
|
||||
if getattr(obj, "created_at", None) is None:
|
||||
obj.created_at = datetime.now(timezone.utc)
|
||||
self.templates[obj.id] = obj
|
||||
elif isinstance(obj, PresentationLayoutCodeModel):
|
||||
if obj.id is None:
|
||||
obj.id = self._next_layout_row_id
|
||||
self._next_layout_row_id += 1
|
||||
if getattr(obj, "created_at", None) is None:
|
||||
obj.created_at = datetime.now(timezone.utc)
|
||||
if getattr(obj, "updated_at", None) is None:
|
||||
obj.updated_at = datetime.now(timezone.utc)
|
||||
self.layouts.append(obj)
|
||||
|
||||
def add_all(self, objects):
|
||||
for obj in objects:
|
||||
self.add(obj)
|
||||
|
||||
async def commit(self):
|
||||
return None
|
||||
|
||||
async def refresh(self, _obj):
|
||||
return None
|
||||
|
||||
async def scalar(self, statement):
|
||||
items = self._execute_select(statement)
|
||||
return items[0] if items else None
|
||||
|
||||
async def execute(self, statement):
|
||||
if isinstance(statement, Delete):
|
||||
self._execute_delete(statement)
|
||||
return FakeExecuteResult([])
|
||||
return FakeExecuteResult(self._execute_select(statement))
|
||||
|
||||
def _execute_select(self, statement):
|
||||
if not isinstance(statement, Select):
|
||||
return []
|
||||
entity = statement.column_descriptions[0].get("entity")
|
||||
if entity is not PresentationLayoutCodeModel:
|
||||
return []
|
||||
return [
|
||||
layout
|
||||
for layout in self.layouts
|
||||
if all(self._matches_clause(layout, clause) for clause in statement._where_criteria)
|
||||
]
|
||||
|
||||
def _execute_delete(self, statement):
|
||||
if statement.table.name != PresentationLayoutCodeModel.__tablename__:
|
||||
return
|
||||
self.layouts = [
|
||||
layout
|
||||
for layout in self.layouts
|
||||
if not all(self._matches_clause(layout, clause) for clause in statement._where_criteria)
|
||||
]
|
||||
|
||||
def _matches_clause(self, obj, clause):
|
||||
if hasattr(clause, "clauses"):
|
||||
return all(self._matches_clause(obj, child) for child in clause.clauses)
|
||||
left = getattr(clause.left, "key", None) or getattr(clause.left, "name", None)
|
||||
right = getattr(clause.right, "value", None)
|
||||
return getattr(obj, left) == right
|
||||
|
||||
|
||||
class SimpleUploadFile:
|
||||
def __init__(self, filename: str, content_type: str, data: bytes):
|
||||
self.filename = filename
|
||||
self.content_type = content_type
|
||||
self._data = data
|
||||
|
||||
async def read(self):
|
||||
return self._data
|
||||
|
||||
|
||||
def test_router_registration_replaces_old_routes(api_client):
|
||||
client, _ = api_client
|
||||
paths = {
|
||||
route.path
|
||||
for route in client.app.routes
|
||||
if hasattr(route, "path") and route.path.startswith("/api/v1/ppt")
|
||||
}
|
||||
|
||||
assert "/api/v1/ppt/template/all" in paths
|
||||
assert "/api/v1/ppt/template/create/init" in paths
|
||||
assert "/api/v1/ppt/template/slide-layout/create" in paths
|
||||
assert "/api/v1/ppt/template/fonts-upload-and-slides-preview" in paths
|
||||
assert "/api/v1/ppt/fonts/check" in paths
|
||||
|
||||
assert "/api/v1/ppt/fonts/upload" in paths
|
||||
assert "/api/v1/ppt/fonts/list" in paths
|
||||
assert "/api/v1/ppt/fonts/uploaded" in paths
|
||||
|
||||
assert "/api/v1/ppt/slide-to-html/" not in paths
|
||||
assert "/api/v1/ppt/html-to-react/" not in paths
|
||||
assert "/api/v1/ppt/html-edit/" not in paths
|
||||
assert "/api/v1/ppt/pptx-slides/process" not in paths
|
||||
assert "/api/v1/ppt/pdf-slides/process" not in paths
|
||||
assert "/api/v1/ppt/pptx-fonts/process" not in paths
|
||||
|
||||
|
||||
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(
|
||||
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": {
|
||||
"Inter": "https://fonts.googleapis.com/css2?family=Inter&display=swap"
|
||||
},
|
||||
},
|
||||
)(),
|
||||
sql_session=session,
|
||||
)
|
||||
)
|
||||
|
||||
template_info = session.template_infos[template_info_id]
|
||||
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)]
|
||||
|
||||
async def fake_to_thread(func, *args, **kwargs):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"templates.preview.get_available_and_unavailable_fonts_for_pptx",
|
||||
fake_font_check,
|
||||
)
|
||||
monkeypatch.setattr("templates.preview.asyncio.to_thread", fake_to_thread)
|
||||
|
||||
upload = SimpleUploadFile(
|
||||
filename="deck.pptx",
|
||||
content_type="application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
data=b"fake-pptx",
|
||||
)
|
||||
response = asyncio.run(check_fonts_in_pptx_handler(pptx_file=upload))
|
||||
|
||||
assert response == FontCheckResponse(
|
||||
available_fonts=[
|
||||
{
|
||||
"name": "Inter",
|
||||
"url": "https://fonts.googleapis.com/css2?family=Inter&display=swap",
|
||||
}
|
||||
],
|
||||
unavailable_fonts=[{"name": "Custom Font", "url": None}],
|
||||
)
|
||||
|
||||
|
||||
def test_fonts_upload_and_preview_route_uses_new_handler(monkeypatch):
|
||||
async def fake_preview_handler(**kwargs):
|
||||
assert kwargs["pptx_file"].filename == "deck.pptx"
|
||||
return FontsUploadAndSlidesPreviewResponse(
|
||||
slide_image_urls=["/app_data/images/1/slide_1.png"],
|
||||
pptx_url="/app_data/uploads/template-previews/1/presentation.pptx",
|
||||
modified_pptx_url="/app_data/uploads/template-previews/1/presentation.pptx",
|
||||
fonts={"Inter": "https://fonts.googleapis.com/css2?family=Inter&display=swap"},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"templates.handler.upload_fonts_and_slides_preview_handler",
|
||||
fake_preview_handler,
|
||||
)
|
||||
|
||||
upload = SimpleUploadFile(
|
||||
filename="deck.pptx",
|
||||
content_type="application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
data=b"fake-pptx",
|
||||
)
|
||||
response = asyncio.run(upload_fonts_and_slides_preview(pptx_file=upload))
|
||||
|
||||
assert response == FontsUploadAndSlidesPreviewResponse(
|
||||
slide_image_urls=["/app_data/images/1/slide_1.png"],
|
||||
pptx_url="/app_data/uploads/template-previews/1/presentation.pptx",
|
||||
modified_pptx_url="/app_data/uploads/template-previews/1/presentation.pptx",
|
||||
fonts={"Inter": "https://fonts.googleapis.com/css2?family=Inter&display=swap"},
|
||||
)
|
||||
|
||||
|
||||
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 == "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 == "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 == "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:
|
||||
get_template_provider_spec()
|
||||
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"
|
||||
image_path.write_bytes(PNG_BYTES)
|
||||
|
||||
template_info = TemplateCreateInfoModel(
|
||||
fonts={"Inter": "https://fonts.googleapis.com/css2?family=Inter&display=swap"},
|
||||
pptx_url="/app_data/uploads/template-previews/seed/presentation.pptx",
|
||||
slide_htmls=["<div>seed html</div>"],
|
||||
slide_image_urls=[str(image_path)],
|
||||
)
|
||||
session.add(template_info)
|
||||
|
||||
create_calls = []
|
||||
edit_calls = []
|
||||
|
||||
async def fake_generate_layout(**kwargs):
|
||||
create_calls.append(kwargs)
|
||||
return """```tsx
|
||||
import { z } from "zod";
|
||||
const layoutId = "title-image";
|
||||
const layoutName = "Title Image";
|
||||
const layoutDescription = "desc";
|
||||
function dynamicSlideLayout() { return <div>{image_url}{icon_url}{image_prompt}{icon_query}</div>; }
|
||||
export { layoutId };
|
||||
```"""
|
||||
|
||||
async def fake_edit_layout(**kwargs):
|
||||
edit_calls.append(kwargs)
|
||||
return "```tsx\nconst updatedLayout = true;\n```"
|
||||
|
||||
monkeypatch.setattr("templates.handler.generate_slide_layout_code", fake_generate_layout)
|
||||
monkeypatch.setattr("templates.handler.edit_slide_layout_code", fake_edit_layout)
|
||||
|
||||
create_response = asyncio.run(
|
||||
create_slide_layout(
|
||||
request=CreateSlideLayoutRequest(id=template_info.id, index=0),
|
||||
sql_session=session,
|
||||
)
|
||||
)
|
||||
assert "__image_url__" in create_response.react_component
|
||||
assert "__icon_url__" in create_response.react_component
|
||||
assert "__image_prompt__" in create_response.react_component
|
||||
assert "__icon_query__" in create_response.react_component
|
||||
assert "import " not in create_response.react_component
|
||||
assert "export " not in create_response.react_component
|
||||
assert re.search(r'layoutId\s*=\s*"title-image-\d{4}"', create_response.react_component)
|
||||
|
||||
assert create_calls
|
||||
assert create_calls[0]["system_prompt"]
|
||||
assert "#SLIDE HTML REFERENCE" in create_calls[0]["user_text"]
|
||||
assert create_calls[0]["media_type"] == "image/png"
|
||||
assert create_calls[0]["image_bytes"] == PNG_BYTES
|
||||
|
||||
edit_response = asyncio.run(
|
||||
edit_slide_layout(
|
||||
request=EditSlideLayoutRequest(
|
||||
react_component="const x = 1;",
|
||||
prompt="Move title up",
|
||||
)
|
||||
)
|
||||
)
|
||||
assert edit_response.react_component == "const updatedLayout = true;"
|
||||
|
||||
section_response = asyncio.run(
|
||||
edit_slide_layout_section(
|
||||
request=EditSlideLayoutSectionRequest(
|
||||
react_component="const x = 1;",
|
||||
section="header",
|
||||
prompt="Change spacing",
|
||||
)
|
||||
)
|
||||
)
|
||||
assert section_response.react_component == "const updatedLayout = true;"
|
||||
|
||||
assert len(edit_calls) == 2
|
||||
assert "#Prompt\nMove title up" in edit_calls[0]["user_text"]
|
||||
assert "#Section to make changes around\nheader" in edit_calls[1]["user_text"]
|
||||
|
||||
|
||||
def test_save_update_and_clone_template_flow():
|
||||
session = FakeAsyncSession()
|
||||
template_info = TemplateCreateInfoModel(
|
||||
fonts={"Inter": "https://fonts.googleapis.com/css2?family=Inter&display=swap"},
|
||||
pptx_url="/app_data/uploads/template-previews/seed/presentation.pptx",
|
||||
slide_htmls=["<div>seed html</div>"],
|
||||
slide_image_urls=["/app_data/images/seed/slide_1.png"],
|
||||
)
|
||||
session.add(template_info)
|
||||
|
||||
save_response = asyncio.run(
|
||||
save_template(
|
||||
request=SaveTemplateRequest(
|
||||
template_info_id=template_info.id,
|
||||
name="My Template",
|
||||
description="Saved from test",
|
||||
layouts=[
|
||||
SaveTemplateLayoutData(
|
||||
layout_id="title-image-1000",
|
||||
layout_name="Title Image",
|
||||
layout_code='const layoutId = "title-image-1000";',
|
||||
)
|
||||
],
|
||||
),
|
||||
sql_session=session,
|
||||
)
|
||||
)
|
||||
template_id = save_response.id
|
||||
|
||||
clone_layout_response = asyncio.run(
|
||||
clone_slide_layout(
|
||||
request=CloneSlideLayoutRequest(
|
||||
template_id=f"custom-{template_id}",
|
||||
layout_id="title-image-1000",
|
||||
layout_name="Cloned Layout",
|
||||
),
|
||||
sql_session=session,
|
||||
)
|
||||
)
|
||||
assert clone_layout_response.layout_name == "Cloned Layout"
|
||||
assert clone_layout_response.layout_id != "title-image-1000"
|
||||
|
||||
asyncio.run(
|
||||
save_slide_layout(
|
||||
request=SaveSlideLayoutRequest(
|
||||
template_id=template_id,
|
||||
layout_id="title-image-1000",
|
||||
layout_code='const layoutId = "title-image-1000"; const edited = true;',
|
||||
),
|
||||
sql_session=session,
|
||||
)
|
||||
)
|
||||
assert any("edited = true" in layout.layout_code for layout in session.layouts)
|
||||
|
||||
update_response = asyncio.run(
|
||||
update_template(
|
||||
request=UpdateTemplateRequest(
|
||||
id=template_id,
|
||||
layouts=[
|
||||
SaveTemplateLayoutData(
|
||||
layout_id="updated-layout-2000",
|
||||
layout_name="Updated Layout",
|
||||
layout_code='const layoutId = "updated-layout-2000";',
|
||||
)
|
||||
],
|
||||
),
|
||||
sql_session=session,
|
||||
)
|
||||
)
|
||||
assert update_response.id == template_id
|
||||
|
||||
layouts = [layout for layout in session.layouts if layout.presentation == template_id]
|
||||
assert session.templates[template_id] is not None
|
||||
assert len(layouts) == 1
|
||||
assert layouts[0].layout_id == "updated-layout-2000"
|
||||
assert layouts[0].fonts == {
|
||||
"Inter": "https://fonts.googleapis.com/css2?family=Inter&display=swap"
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -136,6 +136,18 @@ def get_codex_account_id_env():
|
|||
return os.getenv("CODEX_ACCOUNT_ID")
|
||||
|
||||
|
||||
def get_codex_username_env():
|
||||
return os.getenv("CODEX_USERNAME")
|
||||
|
||||
|
||||
def get_codex_email_env():
|
||||
return os.getenv("CODEX_EMAIL")
|
||||
|
||||
|
||||
def get_codex_is_pro_env():
|
||||
return os.getenv("CODEX_IS_PRO")
|
||||
|
||||
|
||||
def get_codex_model_env():
|
||||
return os.getenv("CODEX_MODEL")
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from models.llm_message import LLMSystemMessage, LLMUserMessage
|
||||
from models.presentation_layout import SlideLayoutModel
|
||||
from templates.presentation_layout import SlideLayoutModel
|
||||
from models.sql.slide import SlideModel
|
||||
from services.llm_client import LLMClient
|
||||
from utils.llm_client_error_handler import handle_llm_client_exceptions
|
||||
|
|
@ -9,6 +9,17 @@ from utils.llm_provider import get_model
|
|||
from utils.schema_utils import add_field_in_schema, remove_fields_from_schema
|
||||
|
||||
|
||||
def _resolve_prompt_language(language: Optional[str]) -> str:
|
||||
if language is None:
|
||||
return "auto-detect"
|
||||
s = str(language).strip()
|
||||
if not s:
|
||||
return "auto-detect"
|
||||
if s.lower() in {"auto", "auto-detect"}:
|
||||
return "auto-detect"
|
||||
return s
|
||||
|
||||
|
||||
def get_system_prompt(
|
||||
tone: Optional[str] = None,
|
||||
verbosity: Optional[str] = None,
|
||||
|
|
@ -40,6 +51,7 @@ def get_system_prompt(
|
|||
|
||||
|
||||
def get_user_prompt(prompt: str, slide_data: dict, language: str):
|
||||
display_language = _resolve_prompt_language(language)
|
||||
return f"""
|
||||
## Icon Query And Image Prompt Language
|
||||
English
|
||||
|
|
@ -48,7 +60,7 @@ def get_user_prompt(prompt: str, slide_data: dict, language: str):
|
|||
{datetime.now().strftime("%Y-%m-%d %H:%M:%S")}
|
||||
|
||||
## Slide Content Language
|
||||
{language}
|
||||
{display_language}
|
||||
|
||||
## Prompt
|
||||
{prompt}
|
||||
|
|
@ -61,7 +73,7 @@ def get_user_prompt(prompt: str, slide_data: dict, language: str):
|
|||
def get_messages(
|
||||
prompt: str,
|
||||
slide_data: dict,
|
||||
language: str,
|
||||
language: Optional[str],
|
||||
tone: Optional[str] = None,
|
||||
verbosity: Optional[str] = None,
|
||||
instructions: Optional[str] = None,
|
||||
|
|
@ -79,7 +91,7 @@ def get_messages(
|
|||
async def get_edited_slide_content(
|
||||
prompt: str,
|
||||
slide: SlideModel,
|
||||
language: str,
|
||||
language: Optional[str],
|
||||
slide_layout: SlideLayoutModel,
|
||||
tone: Optional[str] = None,
|
||||
verbosity: Optional[str] = None,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ from datetime import datetime
|
|||
from typing import Optional
|
||||
|
||||
from models.llm_message import LLMSystemMessage, LLMUserMessage
|
||||
from models.presentation_outline_model import PresentationOutlineModel
|
||||
from models.llm_tools import SearchWebTool
|
||||
from services.llm_client import LLMClient
|
||||
from utils.get_dynamic_models import get_presentation_outline_model_with_n_slides
|
||||
|
|
@ -14,77 +15,144 @@ def get_system_prompt(
|
|||
verbosity: Optional[str] = None,
|
||||
instructions: Optional[str] = None,
|
||||
include_title_slide: bool = True,
|
||||
include_table_of_contents: bool = False,
|
||||
):
|
||||
return f"""
|
||||
You are an expert presentation creator. Generate structured presentations based on user requirements and format them according to the specified JSON schema with markdown content.
|
||||
verbosity_instruction = (
|
||||
"Slide content should be abound 20 words but detailed enough to generate a good slide."
|
||||
if verbosity == "concise"
|
||||
else (
|
||||
"Slide content should be abound 60 words but detailed enough to generate a good slide."
|
||||
if verbosity == "text-heavy"
|
||||
else "Slide content should be abound 40 words but detailed enough to generate a good slide."
|
||||
)
|
||||
)
|
||||
|
||||
Try to use available tools for better results.
|
||||
title_slide_instruction = (
|
||||
"Include presenter name in first slide."
|
||||
if include_title_slide
|
||||
else "Do not include presenter name in any slides."
|
||||
)
|
||||
|
||||
{"# User Instruction:" if instructions else ""}
|
||||
{instructions or ""}
|
||||
toc_instruction = (
|
||||
"Include a table of contents slide in the outline sequence."
|
||||
if include_table_of_contents
|
||||
else ""
|
||||
)
|
||||
toc_block = f"{toc_instruction}\n" if toc_instruction else ""
|
||||
|
||||
{"# Tone:" if tone else ""}
|
||||
{tone or ""}
|
||||
slide_outline_structure = (
|
||||
"Each slide content:\n"
|
||||
" - Must have a ## title.\n"
|
||||
# " - Must have content either in multiple bullet points or table or both.\n"
|
||||
" - Must be in Markdown format.\n"
|
||||
" - Don't use **bold** and __italic__ text."
|
||||
" - First slide title must be the same as the presentation title."
|
||||
)
|
||||
|
||||
{"# Verbosity:" if verbosity else ""}
|
||||
{verbosity or ""}
|
||||
system = (
|
||||
"Generate presentation title and content for slides.\n"
|
||||
"Generate flow based on user **content** and use **context** just for reference.\n"
|
||||
"Presentation title should be plain text, not markdown. It should be a concise title for the presentation.\n"
|
||||
"Each slide content should contain the content for that slide.\n"
|
||||
f"{verbosity_instruction}\n"
|
||||
"Minimize repetitive content and make sure to use different words and phrases for different slides.\n"
|
||||
"Include numerical data, tables or code if required or asked by the user.\n"
|
||||
"If 'auto-detect' is used, figure it out from the content/context.\n"
|
||||
f"{title_slide_instruction}\n"
|
||||
f"{toc_block}"
|
||||
f"{slide_outline_structure}\n"
|
||||
"Slide content must not contain any presentation branding/styling information.\n"
|
||||
"Title slide must only contain title, presenter name, date and overview.\n"
|
||||
"Only include URLs if they appear in the provided content/context.\n"
|
||||
"Make sure data used is strictly from the provided content/context.\n"
|
||||
"Make sure data is consistent across all slides."
|
||||
)
|
||||
|
||||
- Provide content for each slide in markdown format.
|
||||
- Make sure that flow of the presentation is logical and consistent.
|
||||
- Place greater emphasis on numerical data.
|
||||
- If Additional Information is provided, divide it into slides.
|
||||
- Make sure no images are provided in the content.
|
||||
- Make sure that content follows language guidelines.
|
||||
- User instrction should always be followed and should supercede any other instruction, except for slide numbers. **Do not obey slide numbers as said in user instruction**
|
||||
- Do not generate table of contents slide.
|
||||
- Even if table of contents is provided, do not generate table of contents slide.
|
||||
{"- Always make first slide a title slide." if include_title_slide else "- Do not include title slide in the presentation."}
|
||||
return system
|
||||
|
||||
**Search web to get latest information about the topic**
|
||||
"""
|
||||
|
||||
def _resolve_prompt_language(language: Optional[str]) -> str:
|
||||
if language is None:
|
||||
return "auto-detect"
|
||||
s = str(language).strip()
|
||||
if not s:
|
||||
return "auto-detect"
|
||||
if s.lower() in {"auto", "auto-detect"}:
|
||||
return "auto-detect"
|
||||
return s
|
||||
|
||||
|
||||
def _resolve_prompt_n_slides(n_slides: Optional[int]) -> str:
|
||||
if n_slides is None:
|
||||
return "auto-detect"
|
||||
return str(n_slides)
|
||||
|
||||
|
||||
def get_user_prompt(
|
||||
content: str,
|
||||
n_slides: int,
|
||||
language: str,
|
||||
n_slides: Optional[int],
|
||||
language: Optional[str],
|
||||
additional_context: Optional[str] = None,
|
||||
tone: Optional[str] = None,
|
||||
instructions: Optional[str] = None,
|
||||
include_title_slide: bool = True,
|
||||
include_table_of_contents: bool = False,
|
||||
):
|
||||
return f"""
|
||||
**Input:**
|
||||
- User provided content: {content or "Create presentation"}
|
||||
- Output Language: {language}
|
||||
- Number of Slides: {n_slides}
|
||||
- Current Date and Time: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}
|
||||
- Additional Information: {additional_context or ""}
|
||||
"""
|
||||
display_language = _resolve_prompt_language(language)
|
||||
display_slides = _resolve_prompt_n_slides(n_slides)
|
||||
toc_text = f"Include Table Of Contents: {str(include_table_of_contents).lower()}\n"
|
||||
return (
|
||||
f"Content: {content or ''}\n"
|
||||
f"Number of Slides: {display_slides}\n"
|
||||
f"Language: {display_language}\n"
|
||||
f"Tone: {tone or ''}\n"
|
||||
f"Today's Date: {datetime.now().strftime('%Y-%m-%d')}\n"
|
||||
f"Include Title Slide: {include_title_slide}\n"
|
||||
f"{toc_text if include_table_of_contents else ''}"
|
||||
f"Instructions: {instructions or ''}\n"
|
||||
f"Context: {additional_context or ''}"
|
||||
)
|
||||
|
||||
|
||||
def get_messages(
|
||||
content: str,
|
||||
n_slides: int,
|
||||
language: str,
|
||||
n_slides: Optional[int],
|
||||
language: Optional[str],
|
||||
additional_context: Optional[str] = None,
|
||||
tone: Optional[str] = None,
|
||||
verbosity: Optional[str] = None,
|
||||
instructions: Optional[str] = None,
|
||||
include_title_slide: bool = True,
|
||||
include_table_of_contents: bool = False,
|
||||
):
|
||||
return [
|
||||
LLMSystemMessage(
|
||||
content=get_system_prompt(
|
||||
tone, verbosity, instructions, include_title_slide
|
||||
tone,
|
||||
verbosity,
|
||||
instructions,
|
||||
include_title_slide,
|
||||
include_table_of_contents,
|
||||
),
|
||||
),
|
||||
LLMUserMessage(
|
||||
content=get_user_prompt(content, n_slides, language, additional_context),
|
||||
content=get_user_prompt(
|
||||
content,
|
||||
n_slides,
|
||||
language,
|
||||
additional_context,
|
||||
tone,
|
||||
instructions,
|
||||
include_title_slide,
|
||||
include_table_of_contents,
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
async def generate_ppt_outline(
|
||||
content: str,
|
||||
n_slides: int,
|
||||
n_slides: Optional[int],
|
||||
language: Optional[str] = None,
|
||||
additional_context: Optional[str] = None,
|
||||
tone: Optional[str] = None,
|
||||
|
|
@ -92,9 +160,14 @@ async def generate_ppt_outline(
|
|||
instructions: Optional[str] = None,
|
||||
include_title_slide: bool = True,
|
||||
web_search: bool = False,
|
||||
include_table_of_contents: bool = False,
|
||||
):
|
||||
model = get_model()
|
||||
response_model = get_presentation_outline_model_with_n_slides(n_slides)
|
||||
response_model = (
|
||||
get_presentation_outline_model_with_n_slides(n_slides)
|
||||
if n_slides is not None
|
||||
else PresentationOutlineModel
|
||||
)
|
||||
|
||||
client = LLMClient()
|
||||
|
||||
|
|
@ -110,6 +183,7 @@ async def generate_ppt_outline(
|
|||
verbosity,
|
||||
instructions,
|
||||
include_title_slide,
|
||||
include_table_of_contents,
|
||||
),
|
||||
response_model.model_json_schema(),
|
||||
strict=True,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
from typing import Optional
|
||||
from typing import Optional, Dict
|
||||
|
||||
from models.llm_message import LLMSystemMessage, LLMUserMessage
|
||||
from models.presentation_layout import PresentationLayoutModel
|
||||
from templates.presentation_layout import PresentationLayoutModel
|
||||
from models.presentation_outline_model import PresentationOutlineModel
|
||||
from services.llm_client import LLMClient
|
||||
from utils.llm_client_error_handler import handle_llm_client_exceptions
|
||||
|
|
@ -9,58 +10,106 @@ from utils.get_dynamic_models import get_presentation_structure_model_with_n_sli
|
|||
from models.presentation_structure_model import PresentationStructureModel
|
||||
|
||||
|
||||
STRUCTURE_FROM_SLIDES_MARKDOWN_SYSTEM_PROMPT = """
|
||||
You will be given available slide layouts and content for each slide.
|
||||
You need to select a layout for each slide based on the mentioned guidelines.
|
||||
|
||||
# Steps
|
||||
1. Analyze all available slide layouts.
|
||||
2. Analyze content for each slide.
|
||||
3. Select a layout for each slide one by one by following the selection rules.
|
||||
|
||||
# Analyzing Slide Layouts
|
||||
- Identify what each layout contains based on provided schema markdown.
|
||||
|
||||
# Analyzing Content
|
||||
- Identify how the content is structured.
|
||||
- Identify if the content contains tables.
|
||||
|
||||
# Selection Rules
|
||||
- If content contains table, then select either table layout or graph layout.
|
||||
- Don't select layout with image unless content contains image.
|
||||
- Don't select table layout if content does not contain table.
|
||||
- You are allowed to select same layout for multiple slides.
|
||||
|
||||
# Table Layout Selection Rules
|
||||
- Must select table layout if the content contains table with text data.
|
||||
- Must only select a layout with table if the table only contains text data.
|
||||
|
||||
# Graph Layout Selection Rules
|
||||
- Must only select a layout with chart if the content contains table with numeric data.
|
||||
- Identify how many columns are present in the table.
|
||||
- Must select a layout that supports n-1 charts for n columns.
|
||||
- Must prioritize layouts that support multiple charts.
|
||||
- Don't select metrics layout for content containing table with numeric data.
|
||||
- For example, if content contains table with 3 columns, then select a layout that supports 2 charts.
|
||||
|
||||
{user_instructions}
|
||||
|
||||
# Output Rules:
|
||||
- One layout index for each slide.
|
||||
- Example: [0, 1, 2, 3, 4]
|
||||
|
||||
{presentation_layout}
|
||||
"""
|
||||
|
||||
|
||||
GET_MESSAGES_SYSTEM_PROMPT = """
|
||||
You're a professional presentation designer with creative freedom to design engaging presentations.
|
||||
|
||||
# DESIGN PHILOSOPHY
|
||||
- Create visually compelling and varied presentations
|
||||
- Match layout to content purpose and audience needs
|
||||
|
||||
# Layout Selection Guidelines
|
||||
1. **Content-driven choices**: Let the slide's purpose guide layout selection
|
||||
- Opening/closing → Title layouts
|
||||
- Processes/workflows → Visual process layouts
|
||||
- Comparisons/contrasts → Side-by-side layouts
|
||||
- Data/metrics → Chart/graph layouts
|
||||
- Concepts/ideas → Image + text layouts
|
||||
- Key insights → Emphasis layouts
|
||||
|
||||
2. **Visual variety**: Aim for diverse slide layouts across the presentation.
|
||||
- Don't use same layout for multiple slides unless necessary.
|
||||
- Mix text-heavy and visual-heavy slides naturally
|
||||
- Use your judgment on when repetition serves the content
|
||||
- Balance information density across slides
|
||||
- Adjacent slide layouts should be different unless instructed/necessary otherwise.
|
||||
|
||||
3. **Audience experience**: Consider how slides work together
|
||||
- Create natural transitions between topics
|
||||
|
||||
4. **Table of contents**:
|
||||
- Must only use table of contents layout if slide content contains table of contents.
|
||||
|
||||
{user_instruction_header}
|
||||
|
||||
User instruction should be taken into account while creating the presentation structure, except for number of slides.
|
||||
|
||||
Select layout index for each of the {n_slides} slides based on what will best serve the presentation's goals.
|
||||
|
||||
"""
|
||||
|
||||
|
||||
def get_messages(
|
||||
presentation_layout: PresentationLayoutModel,
|
||||
n_slides: int,
|
||||
data: str,
|
||||
instructions: Optional[str] = None,
|
||||
):
|
||||
system_prompt = GET_MESSAGES_SYSTEM_PROMPT.format(
|
||||
user_instruction_header="# User Instruction:" if instructions else "",
|
||||
n_slides=n_slides,
|
||||
)
|
||||
|
||||
return [
|
||||
LLMSystemMessage(
|
||||
content=f"""
|
||||
You're a professional presentation designer with creative freedom to design engaging presentations.
|
||||
|
||||
{presentation_layout.to_string()}
|
||||
|
||||
# DESIGN PHILOSOPHY
|
||||
- Create visually compelling and varied presentations
|
||||
- Match layout to content purpose and audience needs
|
||||
- Prioritize engagement over rigid formatting rules
|
||||
|
||||
# Layout Selection Guidelines
|
||||
1. **Content-driven choices**: Let the slide's purpose guide layout selection
|
||||
- Opening/closing → Title layouts
|
||||
- Processes/workflows → Visual process layouts
|
||||
- Comparisons/contrasts → Side-by-side layouts
|
||||
- Data/metrics → Chart/graph layouts
|
||||
- Concepts/ideas → Image + text layouts
|
||||
- Key insights → Emphasis layouts
|
||||
|
||||
2. **Visual variety**: Aim for diverse, engaging presentation flow
|
||||
- Mix text-heavy and visual-heavy slides naturally
|
||||
- Use your judgment on when repetition serves the content
|
||||
- Balance information density across slides
|
||||
|
||||
3. **Audience experience**: Consider how slides work together
|
||||
- Create natural transitions between topics
|
||||
- Use layouts that enhance comprehension
|
||||
- Design for maximum impact and retention
|
||||
|
||||
**Trust your design instincts. Focus on creating the most effective presentation for the content and audience.**
|
||||
|
||||
{"# User Instruction:" if instructions else ""}
|
||||
{instructions or ""}
|
||||
|
||||
User intruction should be taken into account while creating the presentation structure, except for number of slides.
|
||||
|
||||
Select layout index for each of the {n_slides} slides based on what will best serve the presentation's goals.
|
||||
""",
|
||||
),
|
||||
LLMUserMessage(
|
||||
content=f"""
|
||||
{data}
|
||||
""",
|
||||
),
|
||||
LLMSystemMessage(content=system_prompt),
|
||||
LLMUserMessage(content=(
|
||||
f"{presentation_layout.to_string()}\n\n"
|
||||
"--------------------------------------\n\n"
|
||||
f"{data}"
|
||||
)),
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -70,28 +119,18 @@ def get_messages_for_slides_markdown(
|
|||
data: str,
|
||||
instructions: Optional[str] = None,
|
||||
):
|
||||
system_prompt = STRUCTURE_FROM_SLIDES_MARKDOWN_SYSTEM_PROMPT.format(
|
||||
user_instructions=instructions or "",
|
||||
presentation_layout=presentation_layout.to_string(with_schema=True),
|
||||
)
|
||||
|
||||
return [
|
||||
LLMSystemMessage(
|
||||
content=f"""
|
||||
You're a professional presentation designer with creative freedom to design engaging presentations.
|
||||
|
||||
{"# User Instruction:" if instructions else ""}
|
||||
{instructions or ""}
|
||||
|
||||
{presentation_layout.to_string()}
|
||||
|
||||
Select layout that best matches the content of the slides.
|
||||
|
||||
User intruction should be taken into account while creating the presentation structure, except for number of slides.
|
||||
|
||||
Select layout index for each of the {n_slides} slides based on what will best serve the presentation's goals.
|
||||
""",
|
||||
content=system_prompt
|
||||
),
|
||||
LLMUserMessage(
|
||||
content=f"""
|
||||
{data}
|
||||
""",
|
||||
),
|
||||
content=data
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
from datetime import datetime
|
||||
import json
|
||||
from typing import Optional
|
||||
from models.llm_message import LLMSystemMessage, LLMUserMessage
|
||||
from models.presentation_layout import SlideLayoutModel
|
||||
from templates.presentation_layout import SlideLayoutModel
|
||||
from models.presentation_outline_model import SlideOutlineModel
|
||||
from services.llm_client import LLMClient
|
||||
from utils.llm_client_error_handler import handle_llm_client_exceptions
|
||||
|
|
@ -9,88 +10,136 @@ from utils.llm_provider import get_model
|
|||
from utils.schema_utils import add_field_in_schema, remove_fields_from_schema
|
||||
|
||||
|
||||
SLIDE_CONTENT_SYSTEM_PROMPT = """
|
||||
You will be given slide content and response schema.
|
||||
You need to generate structured content json based on the schema.
|
||||
|
||||
# Steps
|
||||
1. Analyze the content.
|
||||
2. Analyze the response schema.
|
||||
3. Generate structured content json based on the schema.
|
||||
4. Generate speaker note if required.
|
||||
5. Provide structured content json as output.
|
||||
|
||||
# General Rules
|
||||
- Make sure to follow language guidelines.
|
||||
- Speaker note should be normal text, not markdown.
|
||||
- Never ever go over the max character limit.
|
||||
- Do not add emoji in the content.
|
||||
- Don't provide $schema field in content json.
|
||||
{markdown_emphasis_rules}
|
||||
|
||||
{user_instructions}
|
||||
|
||||
{tone_instructions}
|
||||
|
||||
{verbosity_instructions}
|
||||
|
||||
{output_fields_instructions}
|
||||
"""
|
||||
|
||||
|
||||
SLIDE_CONTENT_USER_PROMPT = """
|
||||
# Current Date and Time:
|
||||
{current_date_time}
|
||||
|
||||
# Icon Query And Image Prompt Language:
|
||||
English
|
||||
|
||||
# Slide Language:
|
||||
{language}
|
||||
|
||||
# SLIDE CONTENT: START
|
||||
{content}
|
||||
# SLIDE CONTENT: END
|
||||
"""
|
||||
|
||||
|
||||
def _resolve_prompt_language(language: Optional[str]) -> str:
|
||||
if language is None:
|
||||
return "auto-detect"
|
||||
s = str(language).strip()
|
||||
if not s:
|
||||
return "auto-detect"
|
||||
if s.lower() in {"auto", "auto-detect"}:
|
||||
return "auto-detect"
|
||||
return s
|
||||
|
||||
|
||||
def _get_schema_markdown(response_schema: Optional[dict]) -> str:
|
||||
if not response_schema:
|
||||
return "- Follow the provided response schema strictly."
|
||||
try:
|
||||
schema_text = json.dumps(response_schema, ensure_ascii=False)
|
||||
except Exception:
|
||||
return "- Follow the provided response schema strictly."
|
||||
return f"- Follow this response schema exactly: {schema_text}"
|
||||
|
||||
|
||||
def get_system_prompt(
|
||||
tone: Optional[str] = None,
|
||||
verbosity: Optional[str] = None,
|
||||
instructions: Optional[str] = None,
|
||||
response_schema: Optional[dict] = None,
|
||||
):
|
||||
return f"""
|
||||
Generate structured slide based on provided outline, follow mentioned steps and notes and provide structured output.
|
||||
markdown_emphasis_rules = (
|
||||
"- Strictly use markdown to emphasize important points, by bolding or "
|
||||
"italicizing the part of text."
|
||||
)
|
||||
|
||||
{"# User Instructions:" if instructions else ""}
|
||||
{instructions or ""}
|
||||
user_instructions = f"# User Instructions:\n{instructions}" if instructions else ""
|
||||
tone_instructions = (
|
||||
f"# Tone Instructions:\nMake slide as {tone} as possible." if tone else ""
|
||||
)
|
||||
|
||||
{"# Tone:" if tone else ""}
|
||||
{tone or ""}
|
||||
verbosity_instructions = ""
|
||||
if verbosity:
|
||||
verbosity_instructions = "# Verbosity Instructions:\n"
|
||||
if verbosity == "concise":
|
||||
verbosity_instructions += "Make slide as concise as possible."
|
||||
elif verbosity == "standard":
|
||||
verbosity_instructions += "Make slide as standard as possible."
|
||||
elif verbosity == "text-heavy":
|
||||
verbosity_instructions += "Make slide as text-heavy as possible."
|
||||
|
||||
{"# Verbosity:" if verbosity else ""}
|
||||
{verbosity or ""}
|
||||
output_fields_instructions = "# Output Fields:\n" + _get_schema_markdown(
|
||||
response_schema
|
||||
)
|
||||
|
||||
# Steps
|
||||
1. Analyze the outline.
|
||||
2. Generate structured slide based on the outline.
|
||||
3. Generate speaker note that is simple, clear, concise and to the point.
|
||||
|
||||
# Notes
|
||||
- Slide body should not use words like "This slide", "This presentation".
|
||||
- Rephrase the slide body to make it flow naturally.
|
||||
- Only use markdown to highlight important points.
|
||||
- Make sure to follow language guidelines.
|
||||
- Speaker note should be normal text, not markdown.
|
||||
- Strictly follow the max and min character limit for every property in the slide.
|
||||
- Never ever go over the max character limit. Limit your narration to make sure you never go over the max character limit.
|
||||
- Number of items should not be more than max number of items specified in slide schema. If you have to put multiple points then merge them to obey max numebr of items.
|
||||
- Generate content as per the given tone.
|
||||
- Be very careful with number of words to generate for given field. As generating more than max characters will overflow in the design. So, analyze early and never generate more characters than allowed.
|
||||
- Do not add emoji in the content.
|
||||
- Metrics should be in abbreviated form with least possible characters. Do not add long sequence of words for metrics.
|
||||
- For verbosity:
|
||||
- If verbosity is 'concise', then generate description as 1/3 or lower of the max character limit. Don't worry if you miss content or context.
|
||||
- If verbosity is 'standard', then generate description as 2/3 of the max character limit.
|
||||
- If verbosity is 'text-heavy', then generate description as 3/4 or higher of the max character limit. Make sure it does not exceed the max character limit.
|
||||
|
||||
User instructions, tone and verbosity should always be followed and should supercede any other instruction, except for max and min character limit, slide schema and number of items.
|
||||
|
||||
- Provide output in json format and **don't include <parameters> tags**.
|
||||
|
||||
# Image and Icon Output Format
|
||||
image: {{
|
||||
__image_prompt__: string,
|
||||
}}
|
||||
icon: {{
|
||||
__icon_query__: string,
|
||||
}}
|
||||
|
||||
"""
|
||||
return SLIDE_CONTENT_SYSTEM_PROMPT.format(
|
||||
markdown_emphasis_rules=markdown_emphasis_rules,
|
||||
user_instructions=user_instructions,
|
||||
tone_instructions=tone_instructions,
|
||||
verbosity_instructions=verbosity_instructions,
|
||||
output_fields_instructions=output_fields_instructions,
|
||||
)
|
||||
|
||||
|
||||
def get_user_prompt(outline: str, language: str):
|
||||
return f"""
|
||||
## Current Date and Time
|
||||
{datetime.now().strftime("%Y-%m-%d %H:%M:%S")}
|
||||
|
||||
## Icon Query And Image Prompt Language
|
||||
English
|
||||
|
||||
## Slide Content Language
|
||||
{language}
|
||||
|
||||
## Slide Outline
|
||||
{outline}
|
||||
"""
|
||||
def get_user_prompt(outline: str, language: Optional[str]):
|
||||
return SLIDE_CONTENT_USER_PROMPT.format(
|
||||
current_date_time=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
language=_resolve_prompt_language(language),
|
||||
content=outline,
|
||||
)
|
||||
|
||||
|
||||
def get_messages(
|
||||
outline: str,
|
||||
language: str,
|
||||
language: Optional[str],
|
||||
tone: Optional[str] = None,
|
||||
verbosity: Optional[str] = None,
|
||||
instructions: Optional[str] = None,
|
||||
response_schema: Optional[dict] = None,
|
||||
):
|
||||
|
||||
return [
|
||||
LLMSystemMessage(
|
||||
content=get_system_prompt(tone, verbosity, instructions),
|
||||
content=get_system_prompt(
|
||||
tone,
|
||||
verbosity,
|
||||
instructions,
|
||||
response_schema,
|
||||
),
|
||||
),
|
||||
LLMUserMessage(
|
||||
content=get_user_prompt(outline, language),
|
||||
|
|
@ -101,7 +150,7 @@ def get_messages(
|
|||
async def get_slide_content_from_type_and_outline(
|
||||
slide_layout: SlideLayoutModel,
|
||||
outline: SlideOutlineModel,
|
||||
language: str,
|
||||
language: Optional[str],
|
||||
tone: Optional[str] = None,
|
||||
verbosity: Optional[str] = None,
|
||||
instructions: Optional[str] = None,
|
||||
|
|
@ -134,6 +183,7 @@ async def get_slide_content_from_type_and_outline(
|
|||
tone,
|
||||
verbosity,
|
||||
instructions,
|
||||
response_schema,
|
||||
),
|
||||
response_format=response_schema,
|
||||
strict=False,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
from models.llm_message import LLMSystemMessage, LLMUserMessage
|
||||
from models.presentation_layout import PresentationLayoutModel, SlideLayoutModel
|
||||
from templates.presentation_layout import PresentationLayoutModel, SlideLayoutModel
|
||||
from models.slide_layout_index import SlideLayoutIndex
|
||||
from models.sql.slide import SlideModel
|
||||
from services.llm_client import LLMClient
|
||||
|
|
|
|||
|
|
@ -244,6 +244,7 @@ class TokenSuccess:
|
|||
access: str
|
||||
refresh: str
|
||||
expires: int # Unix ms timestamp when the token expires
|
||||
id_token: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -261,6 +262,14 @@ class AuthorizationFlow:
|
|||
url: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class CodexAccountProfile:
|
||||
account_id: Optional[str] = None
|
||||
username: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
is_pro: Optional[bool] = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# JWT helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -296,6 +305,51 @@ def get_account_id(access_token: str) -> Optional[str]:
|
|||
return None
|
||||
|
||||
|
||||
def _as_non_empty_str(value) -> Optional[str]:
|
||||
if isinstance(value, str):
|
||||
stripped = value.strip()
|
||||
return stripped or None
|
||||
return None
|
||||
|
||||
|
||||
def get_account_profile(access_token: str, id_token: Optional[str] = None) -> CodexAccountProfile:
|
||||
"""Extract profile from exact observed JWT paths in access/id tokens."""
|
||||
access_payload = _decode_jwt_payload(access_token) or {}
|
||||
access_auth = access_payload.get(JWT_CLAIM_PATH)
|
||||
access_auth = access_auth if isinstance(access_auth, dict) else {}
|
||||
|
||||
access_profile = access_payload.get("https://api.openai.com/profile")
|
||||
access_profile = access_profile if isinstance(access_profile, dict) else {}
|
||||
|
||||
id_payload = _decode_jwt_payload(id_token) if id_token else None
|
||||
id_payload = id_payload if isinstance(id_payload, dict) else {}
|
||||
id_auth = id_payload.get(JWT_CLAIM_PATH)
|
||||
id_auth = id_auth if isinstance(id_auth, dict) else {}
|
||||
|
||||
account_id = _as_non_empty_str(access_auth.get("chatgpt_account_id")) or _as_non_empty_str(
|
||||
id_auth.get("chatgpt_account_id")
|
||||
)
|
||||
username = _as_non_empty_str(id_payload.get("name"))
|
||||
email = _as_non_empty_str(access_profile.get("email")) or _as_non_empty_str(
|
||||
id_payload.get("email")
|
||||
)
|
||||
|
||||
plan_type = _as_non_empty_str(access_auth.get("chatgpt_plan_type")) or _as_non_empty_str(
|
||||
id_auth.get("chatgpt_plan_type")
|
||||
)
|
||||
if plan_type:
|
||||
is_pro = plan_type.strip().lower() != "free"
|
||||
else:
|
||||
is_pro = None
|
||||
|
||||
return CodexAccountProfile(
|
||||
account_id=account_id,
|
||||
username=username,
|
||||
email=email,
|
||||
is_pro=is_pro,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Authorization URL + PKCE
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -474,6 +528,7 @@ def exchange_authorization_code(
|
|||
return TokenFailure(reason=f"HTTP {response.status_code}: {response.text[:200]}")
|
||||
|
||||
body = response.json()
|
||||
|
||||
access = body.get("access_token")
|
||||
refresh = body.get("refresh_token")
|
||||
expires_in = body.get("expires_in")
|
||||
|
|
@ -482,7 +537,9 @@ def exchange_authorization_code(
|
|||
return TokenFailure(reason=f"Token response missing fields: {list(body.keys())}")
|
||||
|
||||
expires_ms = int(time.time() * 1000) + int(expires_in) * 1000
|
||||
return TokenSuccess(access=access, refresh=refresh, expires=expires_ms)
|
||||
id_token = body.get("id_token")
|
||||
id_token = id_token if isinstance(id_token, str) else None
|
||||
return TokenSuccess(access=access, refresh=refresh, expires=expires_ms, id_token=id_token)
|
||||
except Exception as exc:
|
||||
return TokenFailure(reason=str(exc))
|
||||
|
||||
|
|
|
|||
205
electron/servers/fastapi/utils/outline_utils.py
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
import math
|
||||
import re
|
||||
from typing import Iterable, List, Optional
|
||||
|
||||
from models.presentation_outline_model import (
|
||||
PresentationOutlineModel,
|
||||
SlideOutlineModel,
|
||||
)
|
||||
|
||||
|
||||
HEADING_PATTERN = re.compile(r"^\s{0,3}#+\s*(.+)$", re.MULTILINE)
|
||||
FIRST_SENTENCE_PATTERN = re.compile(r"^\s*([^.?!]+?[.?!])", re.DOTALL)
|
||||
IMAGE_URL_PATTERN = re.compile(
|
||||
r"https?://[-\w./%~:!$&'()*+,;=]+?\.(?:jpe?g|png|webp)(?:\?[^\s\"\'\\]*)?",
|
||||
re.IGNORECASE | re.UNICODE,
|
||||
)
|
||||
|
||||
|
||||
def get_presentation_title_from_presentation_outline(
|
||||
presentation_outline: PresentationOutlineModel,
|
||||
) -> str:
|
||||
if not presentation_outline.slides:
|
||||
return "Untitled Presentation"
|
||||
|
||||
first_content = presentation_outline.slides[0].content or ""
|
||||
|
||||
if re.match(r"^\s*#{1,6}\s*Page\s+\d+\b", first_content):
|
||||
first_content = re.sub(
|
||||
r"^\s*#{1,6}\s*Page\s+\d+\b[\s,:\-]*",
|
||||
"",
|
||||
first_content,
|
||||
count=1,
|
||||
)
|
||||
|
||||
return (
|
||||
first_content[:100]
|
||||
.replace("#", "")
|
||||
.replace("/", "")
|
||||
.replace("\\", "")
|
||||
.replace("\n", " ")
|
||||
)
|
||||
|
||||
|
||||
def _get_toc_count_for_total_slides(total_slides: int, title_slide: bool) -> int:
|
||||
if total_slides <= 0:
|
||||
return 0
|
||||
|
||||
first_pass = math.ceil(((total_slides - 1) if title_slide else total_slides) / 10)
|
||||
return math.ceil((total_slides - first_pass) / 10)
|
||||
|
||||
|
||||
def get_no_of_toc_required_for_n_outlines(
|
||||
*,
|
||||
n_outlines: int,
|
||||
title_slide: bool,
|
||||
target_total_slides: Optional[int] = None,
|
||||
) -> int:
|
||||
if target_total_slides is not None:
|
||||
adjusted_total = max(target_total_slides, n_outlines)
|
||||
return _get_toc_count_for_total_slides(adjusted_total, title_slide)
|
||||
|
||||
if n_outlines <= 0:
|
||||
return 0
|
||||
|
||||
return math.ceil(((n_outlines - 1) if title_slide else n_outlines) / 10)
|
||||
|
||||
|
||||
def get_no_of_outlines_to_generate_for_n_slides(
|
||||
*,
|
||||
n_slides: int,
|
||||
toc: bool,
|
||||
title_slide: bool,
|
||||
) -> int:
|
||||
if toc:
|
||||
n_toc_1 = math.ceil(((n_slides - 1) if title_slide else n_slides) / 10)
|
||||
n_toc_2 = math.ceil((n_slides - n_toc_1) / 10)
|
||||
|
||||
return n_slides - n_toc_2
|
||||
|
||||
else:
|
||||
return n_slides
|
||||
|
||||
|
||||
def get_presentation_outline_model_with_toc(
|
||||
*,
|
||||
outline: PresentationOutlineModel,
|
||||
n_toc_slides: int,
|
||||
title_slide: bool,
|
||||
) -> PresentationOutlineModel:
|
||||
if n_toc_slides <= 0:
|
||||
return outline
|
||||
|
||||
outline_with_toc = outline.model_copy(deep=True)
|
||||
insertion_index = 1 if title_slide else 0
|
||||
|
||||
existing_outlines = outline_with_toc.slides
|
||||
outlines_for_toc = existing_outlines[insertion_index:]
|
||||
if not outlines_for_toc:
|
||||
return outline_with_toc
|
||||
|
||||
sections = _split_outlines_evenly(outlines_for_toc, n_toc_slides)
|
||||
if not sections:
|
||||
return outline_with_toc
|
||||
|
||||
toc_slides: List[SlideOutlineModel] = []
|
||||
outlines_before_toc = 1 if title_slide else 0
|
||||
total_toc_slides = len(sections)
|
||||
global_outline_index = 0
|
||||
|
||||
for section_index, section in enumerate(sections):
|
||||
section_lines = [
|
||||
"## Table of Contents",
|
||||
"",
|
||||
]
|
||||
|
||||
for outline in section:
|
||||
outline_title = _extract_outline_title(outline.content)
|
||||
page_number = (
|
||||
outlines_before_toc + total_toc_slides + global_outline_index + 1
|
||||
)
|
||||
section_lines.append(
|
||||
f"- Page number: {page_number}, Title: {outline_title}"
|
||||
)
|
||||
global_outline_index += 1
|
||||
|
||||
toc_slides.append(
|
||||
SlideOutlineModel(
|
||||
content="\n".join(
|
||||
line for line in section_lines if line is not None
|
||||
).strip()
|
||||
)
|
||||
)
|
||||
|
||||
for offset, toc_slide in enumerate(toc_slides):
|
||||
existing_outlines.insert(insertion_index + offset, toc_slide)
|
||||
|
||||
return outline_with_toc
|
||||
|
||||
|
||||
def _split_outlines_evenly(
|
||||
outlines: Iterable[SlideOutlineModel], n_sections: int
|
||||
) -> List[List[SlideOutlineModel]]:
|
||||
"""Split outlines into n contiguous sections with near-equal sizes."""
|
||||
outlines_list = list(outlines)
|
||||
if n_sections <= 0 or not outlines_list:
|
||||
return []
|
||||
|
||||
total = len(outlines_list)
|
||||
n_sections = max(1, n_sections)
|
||||
base_size = total // n_sections
|
||||
remainder = total % n_sections
|
||||
|
||||
sections: List[List[SlideOutlineModel]] = []
|
||||
start = 0
|
||||
for section_index in range(n_sections):
|
||||
current_size = base_size + (1 if section_index < remainder else 0)
|
||||
end = start + current_size
|
||||
sections.append(outlines_list[start:end])
|
||||
start = end
|
||||
|
||||
return sections
|
||||
|
||||
|
||||
def _extract_outline_title(content: str) -> str:
|
||||
"""Get a human-friendly title from an outline's markdown content."""
|
||||
text = content or ""
|
||||
|
||||
heading_match = HEADING_PATTERN.search(text)
|
||||
if heading_match:
|
||||
return heading_match.group(1).strip()
|
||||
|
||||
sentence_match = FIRST_SENTENCE_PATTERN.search(text.strip())
|
||||
if sentence_match:
|
||||
return sentence_match.group(1).strip()
|
||||
|
||||
for line in text.splitlines():
|
||||
stripped_line = line.strip()
|
||||
if stripped_line:
|
||||
return stripped_line
|
||||
|
||||
return "Slide"
|
||||
|
||||
|
||||
def get_images_for_slides_from_outline(
|
||||
slides: List[SlideOutlineModel],
|
||||
) -> List[List[str]]:
|
||||
"""
|
||||
Extract image URLs (png, jpg, jpeg, webp) from each slide's content in the outline.
|
||||
|
||||
Args:
|
||||
outline: PresentationOutlineModel containing slides with content
|
||||
|
||||
Returns:
|
||||
List of lists of image URLs, one list per slide
|
||||
"""
|
||||
result: List[List[str]] = []
|
||||
|
||||
for slide in slides:
|
||||
content = slide.content or ""
|
||||
image_urls = IMAGE_URL_PATTERN.findall(content)
|
||||
# Remove duplicates while preserving order
|
||||
unique_urls = list(dict.fromkeys(image_urls))
|
||||
result.append(unique_urls)
|
||||
|
||||
return result
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
from models.presentation_layout import PresentationLayoutModel
|
||||
from templates.presentation_layout import PresentationLayoutModel
|
||||
from models.presentation_outline_model import PresentationOutlineModel
|
||||
import re
|
||||
from typing import List
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import asyncio
|
||||
import os
|
||||
from typing import List, Tuple
|
||||
from typing import List, Optional, Tuple
|
||||
from models.image_prompt import ImagePrompt
|
||||
from models.sql.image_asset import ImageAsset
|
||||
from models.sql.slide import SlideModel
|
||||
|
|
@ -14,15 +14,27 @@ from utils.path_helpers import get_resource_path
|
|||
async def process_slide_and_fetch_assets(
|
||||
image_generation_service: ImageGenerationService,
|
||||
slide: SlideModel,
|
||||
outline_image_urls: Optional[List[str]] = None,
|
||||
) -> List[ImageAsset]:
|
||||
|
||||
async_tasks = []
|
||||
async_task_meta = []
|
||||
|
||||
image_paths = get_dict_paths_with_key(slide.content, "__image_prompt__")
|
||||
icon_paths = get_dict_paths_with_key(slide.content, "__icon_query__")
|
||||
|
||||
for image_path in image_paths:
|
||||
for image_index, image_path in enumerate(image_paths):
|
||||
__image_prompt__parent = get_dict_at_path(slide.content, image_path)
|
||||
|
||||
if (
|
||||
outline_image_urls
|
||||
and image_index < len(outline_image_urls)
|
||||
and outline_image_urls[image_index]
|
||||
):
|
||||
__image_prompt__parent["__image_url__"] = outline_image_urls[image_index]
|
||||
set_dict_at_path(slide.content, image_path, __image_prompt__parent)
|
||||
continue
|
||||
|
||||
async_tasks.append(
|
||||
image_generation_service.generate_image(
|
||||
ImagePrompt(
|
||||
|
|
@ -30,37 +42,37 @@ async def process_slide_and_fetch_assets(
|
|||
)
|
||||
)
|
||||
)
|
||||
async_task_meta.append(("image", image_path))
|
||||
|
||||
for icon_path in icon_paths:
|
||||
__icon_query__parent = get_dict_at_path(slide.content, icon_path)
|
||||
async_tasks.append(
|
||||
ICON_FINDER_SERVICE.search_icons(__icon_query__parent["__icon_query__"])
|
||||
)
|
||||
async_task_meta.append(("icon", icon_path))
|
||||
|
||||
results = await asyncio.gather(*async_tasks)
|
||||
results.reverse()
|
||||
results = await asyncio.gather(*async_tasks) if async_tasks else []
|
||||
|
||||
return_assets = []
|
||||
for image_path in image_paths:
|
||||
image_dict = get_dict_at_path(slide.content, image_path)
|
||||
result = results.pop()
|
||||
if isinstance(result, ImageAsset):
|
||||
return_assets.append(result)
|
||||
image_dict["__image_url__"] = result.file_url
|
||||
else:
|
||||
image_dict["__image_url__"] = result
|
||||
set_dict_at_path(slide.content, image_path, image_dict)
|
||||
for (task_type, asset_path), result in zip(async_task_meta, results):
|
||||
if task_type == "image":
|
||||
image_dict = get_dict_at_path(slide.content, asset_path)
|
||||
if isinstance(result, ImageAsset):
|
||||
return_assets.append(result)
|
||||
image_dict["__image_url__"] = result.file_url
|
||||
else:
|
||||
image_dict["__image_url__"] = result
|
||||
set_dict_at_path(slide.content, asset_path, image_dict)
|
||||
continue
|
||||
|
||||
for icon_path in icon_paths:
|
||||
icon_dict = get_dict_at_path(slide.content, icon_path)
|
||||
icon_result = results.pop()
|
||||
icon_dict = get_dict_at_path(slide.content, asset_path)
|
||||
# ICON_FINDER_SERVICE.search_icons returns a list of URLs
|
||||
if isinstance(icon_result, list) and icon_result:
|
||||
icon_dict["__icon_url__"] = icon_result[0]
|
||||
if isinstance(result, list) and result:
|
||||
icon_dict["__icon_url__"] = result[0]
|
||||
else:
|
||||
# Fallback to FastAPI static placeholder if no icon found
|
||||
icon_dict["__icon_url__"] = "/static/icons/placeholder.svg"
|
||||
set_dict_at_path(slide.content, icon_path, icon_dict)
|
||||
set_dict_at_path(slide.content, asset_path, icon_dict)
|
||||
|
||||
return return_assets
|
||||
|
||||
|
|
|
|||
|
|
@ -122,5 +122,17 @@ def set_codex_account_id_env(value: str):
|
|||
os.environ["CODEX_ACCOUNT_ID"] = value
|
||||
|
||||
|
||||
def set_codex_username_env(value: str):
|
||||
os.environ["CODEX_USERNAME"] = value
|
||||
|
||||
|
||||
def set_codex_email_env(value: str):
|
||||
os.environ["CODEX_EMAIL"] = value
|
||||
|
||||
|
||||
def set_codex_is_pro_env(value: str):
|
||||
os.environ["CODEX_IS_PRO"] = value
|
||||
|
||||
|
||||
def set_codex_model_env(value: str):
|
||||
os.environ["CODEX_MODEL"] = value
|
||||
|
|
|
|||
|
|
@ -32,6 +32,9 @@ from utils.get_env import (
|
|||
get_codex_refresh_token_env,
|
||||
get_codex_token_expires_env,
|
||||
get_codex_account_id_env,
|
||||
get_codex_username_env,
|
||||
get_codex_email_env,
|
||||
get_codex_is_pro_env,
|
||||
get_codex_model_env,
|
||||
)
|
||||
from utils.parsers import parse_bool_or_none
|
||||
|
|
@ -64,6 +67,9 @@ from utils.set_env import (
|
|||
set_codex_refresh_token_env,
|
||||
set_codex_token_expires_env,
|
||||
set_codex_account_id_env,
|
||||
set_codex_username_env,
|
||||
set_codex_email_env,
|
||||
set_codex_is_pro_env,
|
||||
set_codex_model_env,
|
||||
)
|
||||
|
||||
|
|
@ -133,6 +139,13 @@ def get_user_config():
|
|||
CODEX_REFRESH_TOKEN=existing_config.CODEX_REFRESH_TOKEN or get_codex_refresh_token_env(),
|
||||
CODEX_TOKEN_EXPIRES=existing_config.CODEX_TOKEN_EXPIRES or get_codex_token_expires_env(),
|
||||
CODEX_ACCOUNT_ID=existing_config.CODEX_ACCOUNT_ID or get_codex_account_id_env(),
|
||||
CODEX_USERNAME=existing_config.CODEX_USERNAME or get_codex_username_env(),
|
||||
CODEX_EMAIL=existing_config.CODEX_EMAIL or get_codex_email_env(),
|
||||
CODEX_IS_PRO=(
|
||||
existing_config.CODEX_IS_PRO
|
||||
if existing_config.CODEX_IS_PRO is not None
|
||||
else parse_bool_or_none(get_codex_is_pro_env())
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -196,6 +209,12 @@ def update_env_with_user_config():
|
|||
set_codex_token_expires_env(user_config.CODEX_TOKEN_EXPIRES)
|
||||
if user_config.CODEX_ACCOUNT_ID:
|
||||
set_codex_account_id_env(user_config.CODEX_ACCOUNT_ID)
|
||||
if user_config.CODEX_USERNAME:
|
||||
set_codex_username_env(user_config.CODEX_USERNAME)
|
||||
if user_config.CODEX_EMAIL:
|
||||
set_codex_email_env(user_config.CODEX_EMAIL)
|
||||
if user_config.CODEX_IS_PRO is not None:
|
||||
set_codex_is_pro_env(str(user_config.CODEX_IS_PRO))
|
||||
|
||||
|
||||
def save_codex_tokens_to_user_config() -> None:
|
||||
|
|
@ -220,6 +239,9 @@ def save_codex_tokens_to_user_config() -> None:
|
|||
existing["CODEX_REFRESH_TOKEN"] = get_codex_refresh_token_env()
|
||||
existing["CODEX_TOKEN_EXPIRES"] = get_codex_token_expires_env()
|
||||
existing["CODEX_ACCOUNT_ID"] = get_codex_account_id_env()
|
||||
existing["CODEX_USERNAME"] = get_codex_username_env()
|
||||
existing["CODEX_EMAIL"] = get_codex_email_env()
|
||||
existing["CODEX_IS_PRO"] = parse_bool_or_none(get_codex_is_pro_env())
|
||||
|
||||
try:
|
||||
with open(user_config_path, "w") as f:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,133 @@
|
|||
import * as z from "zod";
|
||||
|
||||
export const slideLayoutId = "product-overview-market-opportunity-slide";
|
||||
export const slideLayoutName = "Product Overview Market Opportunity Slide";
|
||||
export const slideLayoutDescription =
|
||||
"A market opportunity slide with title and intro text on the left, four bullet lines extending toward the right, and concentric value circles as the visual focal point.";
|
||||
|
||||
const BulletSchema = z.object({
|
||||
text: z.string().min(12).max(46).meta({
|
||||
description: "Bullet text shown on the left side of a line.",
|
||||
}),
|
||||
});
|
||||
|
||||
export const Schema = z.object({
|
||||
title: z.string().min(8).max(22).default("Market Opportunity").meta({
|
||||
description: "Main heading shown at the top-left.",
|
||||
}),
|
||||
subtitle: z.string().min(40).max(110).default(
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt."
|
||||
).meta({
|
||||
description: "Supporting text under the main heading.",
|
||||
}),
|
||||
bullets: z
|
||||
.array(BulletSchema)
|
||||
.min(4)
|
||||
.max(4)
|
||||
.default([
|
||||
{ text: "Ut enim ad minim veniam, quis" },
|
||||
{ text: "Ut enim ad minim veniam, quis" },
|
||||
{ text: "Ut enim ad minim veniam, quis" },
|
||||
{ text: "Ut enim ad minim veniam, quis" },
|
||||
])
|
||||
.meta({
|
||||
description: "Four bullet-line entries shown on the left.",
|
||||
}),
|
||||
values: z
|
||||
.array(z.string().min(2).max(6))
|
||||
.min(4)
|
||||
.max(4)
|
||||
.default(["$33", "$20", "$120", "$200"])
|
||||
.meta({
|
||||
description: "Four values shown from outer to inner circles.",
|
||||
}),
|
||||
});
|
||||
|
||||
export type SchemaType = z.infer<typeof Schema>;
|
||||
|
||||
|
||||
const COLORS = [
|
||||
"var(--graph-0,#5f7f79)",
|
||||
"var(--graph-1,#1f5a4f)",
|
||||
"var(--graph-2,#0d4f43)",
|
||||
"var(--graph-3,#06463d)",
|
||||
];
|
||||
|
||||
const MarketOpportunitySlide = ({ data }: { data: Partial<SchemaType> }) => {
|
||||
const { title, subtitle, bullets, values } = data;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative h-[720px] w-[1280px] overflow-hidden rounded-[24px]"
|
||||
style={{
|
||||
backgroundColor: "var(--background-color,#DAE1DE)",
|
||||
fontFamily: "var(--body-font-family,'Bricolage Grotesque')",
|
||||
}}
|
||||
>
|
||||
<div className="px-[56px] pt-[72px]">
|
||||
<h2
|
||||
className="text-[80px] font-semibold leading-[108.4%] tracking-[-2.419px] text-[#15342D]"
|
||||
style={{ color: "var(--primary-color,#15342D)" }}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
<p
|
||||
className="mt-[20px] w-[730px] text-[24px] font-normal text-[#15342DCC]"
|
||||
style={{ color: "var(--background-text,#15342DCC)" }}
|
||||
>
|
||||
{subtitle}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="absolute left-[56px] top-[368px] space-y-[42px]">
|
||||
{bullets?.map((bullet, index) => (
|
||||
<div key={index} className="relative flex items-center">
|
||||
<span
|
||||
className="mr-[14px] h-[14px] w-[14px] rounded-full bg-[#0a4a3f]"
|
||||
style={{ backgroundColor: "var(--graph-0,#0a4a3f)" }}
|
||||
/>
|
||||
<p
|
||||
className="w-[640px] text-[24px] font-normal text-[#15342DCC]"
|
||||
style={{ color: "var(--background-text,#15342DCC)" }}
|
||||
>
|
||||
{bullet.text}
|
||||
</p>
|
||||
<span
|
||||
className="ml-[8px] h-[2px] w-[80px] bg-[#8ea8a5]"
|
||||
style={{ backgroundColor: "var(--stroke,#8ea8a5)" }}
|
||||
/>
|
||||
<span
|
||||
className="h-[6px] w-[6px] rounded-full bg-[#edf2f1]"
|
||||
style={{ backgroundColor: "var(--primary-text,#edf2f1)" }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-[58px] right-[48px] h-[474px] w-[474px]">
|
||||
{values?.map((value, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="absolute rounded-full"
|
||||
style={{
|
||||
width: 237 + (index * 50),
|
||||
height: 237 + (index * 50),
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
backgroundColor: COLORS[index],
|
||||
}}
|
||||
>
|
||||
<p
|
||||
className="pt-[24px] text-center text-[24px] font-normal text-white"
|
||||
style={{ color: "var(--primary-text,#ffffff)" }}
|
||||
>
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarketOpportunitySlide;
|
||||
|
|
@ -175,7 +175,7 @@ const ImageProvider = ({ llmConfig, setLlmConfig }: { llmConfig: LLMConfig, setL
|
|||
<PopoverContent
|
||||
className="p-0"
|
||||
align="start"
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
style={{ width: "300px" }}
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="Search provider..." />
|
||||
|
|
|
|||
|
|
@ -137,12 +137,10 @@ const PrivacySettings = () => {
|
|||
<div className="w-full space-y-6">
|
||||
<div className="bg-[#F9F8F8] p-7 rounded-[20px]">
|
||||
<h4 className="text-sm font-semibold text-[#191919] mb-1">
|
||||
Anonymous Usage Tracking
|
||||
Usage analytics
|
||||
</h4>
|
||||
<p className="text-xs text-[#6B7280] mb-6 leading-relaxed max-w-lg">
|
||||
When enabled, Presenton collects anonymous usage data to help us
|
||||
understand how the app is used and improve your experience.<span className="font-bold"> No
|
||||
personal information or presentation content is ever collected.</span>
|
||||
Share anonymous usage data to help us improve Presenton. No personal information or presentation content is collected.
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-between gap-4 rounded-[10px] bg-white border border-[#EDEEEF] p-4">
|
||||
|
|
@ -151,12 +149,12 @@ const PrivacySettings = () => {
|
|||
htmlFor="tracking-toggle"
|
||||
className="text-sm font-medium text-[#191919] cursor-pointer select-none block"
|
||||
>
|
||||
{trackingEnabled ? "Tracking Enabled" : "Tracking Disabled"}
|
||||
Share anonymous usage data
|
||||
</label>
|
||||
<p className="text-xs text-[#9CA3AF] mt-0.5">
|
||||
{trackingEnabled
|
||||
? "Anonymous usage data is being sent."
|
||||
: "No usage data is being collected."}
|
||||
? "Anonymous usage data is being shared."
|
||||
: "Anonymous usage data is not being shared"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
|
|||
|
|
@ -21,6 +21,9 @@ import TextProvider from "./TextProvider";
|
|||
import ImageProvider from "./ImageProvider";
|
||||
import PrivacySettings from "./PrivacySettings";
|
||||
import { IMAGE_PROVIDERS, LLM_PROVIDERS } from "@/utils/providerConstants";
|
||||
import { ImagesApi } from "@/app/(presentation-generator)/services/api/images";
|
||||
|
||||
const STOCK_IMAGE_PROVIDERS = new Set(["pexels", "pixabay"]);
|
||||
|
||||
// Button state interface
|
||||
interface ButtonState {
|
||||
|
|
@ -72,6 +75,36 @@ const SettingsPage = () => {
|
|||
return 0;
|
||||
}, [downloadingModel?.downloaded, downloadingModel?.size]);
|
||||
|
||||
const ensureSelectedStockProviderReady = async (): Promise<boolean> => {
|
||||
if (llmConfig.DISABLE_IMAGE_GENERATION) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const provider = (llmConfig.IMAGE_PROVIDER || "").toLowerCase();
|
||||
if (!STOCK_IMAGE_PROVIDERS.has(provider)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const providerApiKey =
|
||||
provider === "pexels" ? llmConfig.PEXELS_API_KEY : llmConfig.PIXABAY_API_KEY;
|
||||
|
||||
try {
|
||||
await ImagesApi.searchStockImages("business", 1, {
|
||||
provider,
|
||||
apiKey: providerApiKey,
|
||||
strictApiKey: true,
|
||||
});
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
notify.error(
|
||||
"Cannot save settings",
|
||||
error?.message ||
|
||||
`Unable to reach ${provider} with the provided API key. Please verify your settings and try again.`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveConfig = async () => {
|
||||
trackEvent(MixpanelEvent.Settings_SaveConfiguration_Button_Clicked, { pathname });
|
||||
const validationError = getLLMConfigValidationError(llmConfig);
|
||||
|
|
@ -79,6 +112,12 @@ const SettingsPage = () => {
|
|||
notify.error("Cannot save settings", validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
const providerReady = await ensureSelectedStockProviderReady();
|
||||
if (!providerReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setButtonState(prev => ({
|
||||
...prev,
|
||||
|
|
|
|||
|
|
@ -9,30 +9,31 @@ const SettingSideBar = ({ mode, setMode, selectedProvider, setSelectedProvider }
|
|||
const textProviderIcon = LLM_PROVIDERS[llm_config.LLM as keyof typeof LLM_PROVIDERS]?.icon
|
||||
const imageProviderIcon = IMAGE_PROVIDERS[llm_config.IMAGE_PROVIDER as keyof typeof IMAGE_PROVIDERS]?.icon || '/providers/pexel.png'
|
||||
return (
|
||||
<div className='w-full max-w-[230px] h-screen px-4 pt-[22px] bg-[#F9FAFB] flex flex-col'>
|
||||
<div className='w-full max-w-[230px] h-screen px-3 pt-[22px] bg-[#F9FAFB] flex flex-col'>
|
||||
<p className='text-xs text-black font-medium border-b mt-[3.15rem] border-[#E1E1E5] pb-3.5'>FILTER BY:</p>
|
||||
<div className='mt-6 flex-1'>
|
||||
<p className='text-[#3A3A3A] text-xs font-medium pb-2.5'>Select Mode</p>
|
||||
<div className='p-1 rounded-[40px] bg-[#ffffff] w-fit border border-[#EDEEEF] flex items-center justify-center mb-[34px] '>
|
||||
<button className='px-3 py-2 text-xs font-medium text-[#3A3A3A] rounded-[70px]'
|
||||
<div className='p-0.5 rounded-[40px] bg-[#ffffff] w-fit border border-[#EDEEEF] flex items-center justify-center mb-[34px] '>
|
||||
<button className='px-3 font-syne h-[26px] text-[10px] font-medium text-[#3A3A3A] rounded-[70px]'
|
||||
onClick={() => setMode('presenton')}
|
||||
style={{
|
||||
background: mode === 'presenton' ? '#F4F3FF' : 'transparent',
|
||||
color: mode === 'presenton' ? '#5146E5' : '#3A3A3A'
|
||||
}}
|
||||
>Presenton</button>
|
||||
>Template Based
|
||||
</button>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className='mx-1' width="2" height="17" viewBox="0 0 2 17" fill="none">
|
||||
<path d="M1 0V16.5" stroke="#EDECEC" strokeWidth="2" />
|
||||
</svg>
|
||||
<div className='relative'>
|
||||
<button className='px-3 py-2 text-xs font-medium rounded-[70px] cursor-not-allowed opacity-60'
|
||||
<button className='px-3 font-syne h-[26px] text-[10px] font-medium rounded-[70px] cursor-not-allowed opacity-60'
|
||||
disabled
|
||||
style={{
|
||||
background: 'transparent',
|
||||
color: '#9CA3AF'
|
||||
}}
|
||||
>
|
||||
Nanobanana
|
||||
Image Based
|
||||
</button>
|
||||
<span className='absolute -top-2 -right-5 text-[7px] uppercase tracking-wide bg-[#F4F3FF] text-[#5146E5] border border-[#D9D6FE] rounded-full px-1.5 py-0.5 whitespace-nowrap'>
|
||||
Coming soon
|
||||
|
|
@ -43,15 +44,15 @@ const SettingSideBar = ({ mode, setMode, selectedProvider, setSelectedProvider }
|
|||
</div>
|
||||
<p className='text-[#3A3A3A] text-xs font-medium pb-2.5'>Select Provider</p>
|
||||
{mode === 'presenton' && <div className='space-y-2.5'>
|
||||
<button className={` w-full rounded-[6px] p-3 py-4 flex items-center gap-1.5 border ${selectedProvider === 'text-provider' ? 'bg-[#F4F3FF] border-[#D9D6FE]' : 'bg-white border-[#EDEEEF]'}`} onClick={() => setSelectedProvider('text-provider')}>
|
||||
<div className='relative w-6 h-6 rounded-full overflow-hidden border border-[#EDEEEF]'>
|
||||
<button className={` w-full rounded-[6px] px-3 py-4 flex items-center gap-1.5 border ${selectedProvider === 'text-provider' ? 'bg-[#F4F3FF] border-[#D9D6FE]' : 'bg-white border-[#EDEEEF]'}`} onClick={() => setSelectedProvider('text-provider')}>
|
||||
<div className='relative w-[18px] h-[18px] rounded-full overflow-hidden border border-[#EDEEEF]'>
|
||||
|
||||
<img src={textProviderIcon} className=' object-cover w-full h-full overflow-hidden' alt='google' />
|
||||
</div>
|
||||
<p className='text-[#191919] text-xs font-medium' >Text Provider</p>
|
||||
</button>
|
||||
<button className={` w-full rounded-[6px] p-3 py-4 flex items-center gap-1.5 border ${selectedProvider === 'image-provider' ? 'bg-[#F4F3FF] border-[#D9D6FE]' : 'bg-white border-[#EDEEEF]'}`} onClick={() => setSelectedProvider('image-provider')}>
|
||||
<div className='relative w-6 h-6 rounded-full overflow-hidden border border-[#EDEEEF]'>
|
||||
<button className={` w-full rounded-[6px] px-3 py-4 flex items-center gap-1.5 border ${selectedProvider === 'image-provider' ? 'bg-[#F4F3FF] border-[#D9D6FE]' : 'bg-white border-[#EDEEEF]'}`} onClick={() => setSelectedProvider('image-provider')}>
|
||||
<div className='relative w-[18px] h-[18px] rounded-full overflow-hidden border border-[#EDEEEF]'>
|
||||
<img src={imageProviderIcon} className=' object-cover w-full h-full overflow-hidden' alt='google' />
|
||||
</div>
|
||||
<p className='text-[#191919] text-xs font-medium' >Image Provider</p>
|
||||
|
|
@ -59,8 +60,8 @@ const SettingSideBar = ({ mode, setMode, selectedProvider, setSelectedProvider }
|
|||
</div>}
|
||||
{
|
||||
mode === 'nanobanana' && <div>
|
||||
<button className={` w-full rounded-[6px] p-3 py-4 flex items-center gap-1.5 border bg-[#F4F3FF] border-[#D9D6FE]`}>
|
||||
<div className='relative w-6 h-6 rounded-full overflow-hidden border border-[#EDEEEF]'>
|
||||
<button className={` w-full rounded-[6px] px-3 py-4 flex items-center gap-1.5 border bg-[#F4F3FF] border-[#D9D6FE]`}>
|
||||
<div className='relative w-[18px] h-[18px] rounded-full overflow-hidden border border-[#EDEEEF]'>
|
||||
|
||||
<img src='/providers/openai.png' className=' object-cover w-full h-full overflow-hidden' alt='google' />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -216,8 +216,8 @@ const TextProvider = ({
|
|||
return (
|
||||
<div className="space-y-6 bg-[#F9F8F8] p-7 rounded-[12px] ">
|
||||
{/* API Key Input */}
|
||||
<div className="mb-4 flex items-center justify-between rounded-[12px] bg-white pt-5 pb-10 px-10">
|
||||
<div className=" max-w-[290px] pb-[50px]">
|
||||
<div className="mb-4 flex items-end justify-between rounded-[12px] bg-white pt-5 pb-10 px-10">
|
||||
<div className=" max-w-[290px] ">
|
||||
<div className='w-[60px] h-[60px] rounded-[4px] flex items-center justify-center'
|
||||
style={{ backgroundColor: '#4C55541A' }}
|
||||
>
|
||||
|
|
@ -229,14 +229,13 @@ const TextProvider = ({
|
|||
</div>
|
||||
<h3 className="text-xl font-normal text-[#191919] py-2.5">Text Generation Settings</h3>
|
||||
<p className=" text-sm text-gray-500">
|
||||
Choosing where text contets come from
|
||||
Choosing where text content comes from
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className='flex flex-col justify-end items-end gap-4'>
|
||||
<div className={`flex gap-4 justify-end ${selectedProvider === 'codex' ? 'items-end' : 'items-start'}`}>
|
||||
<div className={`relative ${selectedProvider === 'codex' ? 'w-[240px]' : 'w-[222px]'}`}>
|
||||
<div className="flex flex-col justify-start ">
|
||||
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Select Text Provider
|
||||
</label>
|
||||
|
|
@ -265,7 +264,7 @@ const TextProvider = ({
|
|||
<PopoverContent
|
||||
className="p-0"
|
||||
align="start"
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
style={{ width: "300px" }}
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="Search provider..." />
|
||||
|
|
@ -311,8 +310,6 @@ const TextProvider = ({
|
|||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
<div className={`relative flex flex-col justify-end ${selectedProvider === 'codex' ? 'items-end w-[262px] max-w-full' : 'items-end w-[222px]'}`}>
|
||||
<div className="flex flex-col justify-start w-full ">
|
||||
|
|
@ -431,89 +428,86 @@ const TextProvider = ({
|
|||
"Check models"
|
||||
)}
|
||||
</button>
|
||||
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{/* Model Selection - only show if models are available */}
|
||||
{selectedProvider !== 'codex' && modelsChecked && availableModels.length > 0 ? (
|
||||
<div className="w-[222px]">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-3">
|
||||
{selectedProvider === 'ollama' ? 'Choose a supported model' : `Select ${modelLabel} Model`}
|
||||
</label>
|
||||
<div className="w-full">
|
||||
<Popover
|
||||
open={openModelSelect}
|
||||
onOpenChange={setOpenModelSelect}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={openModelSelect}
|
||||
className="w-full h-12 px-4 py-4 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors hover:border-gray-400 justify-between"
|
||||
>
|
||||
<span className="text-sm truncate font-medium text-gray-900">
|
||||
{currentModel
|
||||
? availableModels.find(model => model === currentModel) || currentModel
|
||||
: "Select a model"}
|
||||
</span>
|
||||
|
||||
<ChevronUp className="w-4 h-4 text-gray-500" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
align="start"
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
</div>
|
||||
{/* Model Selection - only show if models are available */}
|
||||
{selectedProvider !== 'codex' && modelsChecked && availableModels.length > 0 ? (
|
||||
<div className="w-[222px]">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-3">
|
||||
{selectedProvider === 'ollama' ? 'Choose a supported model' : `Select ${modelLabel} Model`}
|
||||
</label>
|
||||
<div className="w-full">
|
||||
<Popover
|
||||
open={openModelSelect}
|
||||
onOpenChange={setOpenModelSelect}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={openModelSelect}
|
||||
className="w-full h-12 px-4 py-4 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors hover:border-gray-400 justify-between"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="Search models..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No model found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{availableModels.map((model, index) => (
|
||||
<CommandItem
|
||||
key={index}
|
||||
value={model}
|
||||
onSelect={(value) => {
|
||||
if (currentModelField) {
|
||||
onInputChange(value, currentModelField);
|
||||
}
|
||||
setOpenModelSelect(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
currentModel === model
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<div className="flex gap-3 items-center">
|
||||
<div className="flex flex-col space-y-1 flex-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{model}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm truncate font-medium text-gray-900">
|
||||
{currentModel
|
||||
? availableModels.find(model => model === currentModel) || currentModel
|
||||
: "Select a model"}
|
||||
</span>
|
||||
|
||||
<ChevronUp className="w-4 h-4 text-gray-500" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
align="start"
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="Search models..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No model found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{availableModels.map((model, index) => (
|
||||
<CommandItem
|
||||
key={index}
|
||||
value={model}
|
||||
onSelect={(value) => {
|
||||
if (currentModelField) {
|
||||
onInputChange(value, currentModelField);
|
||||
}
|
||||
setOpenModelSelect(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
currentModel === model
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<div className="flex gap-3 items-center">
|
||||
<div className="flex flex-col space-y-1 flex-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{model}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{/* Show message if no models found */}
|
||||
|
|
@ -526,7 +520,7 @@ const TextProvider = ({
|
|||
)}
|
||||
|
||||
|
||||
{/* Web Grounding Toggle - show at the end, below models dropdown */}
|
||||
|
||||
<div className="bg-white flex justify-between items-center p-10 rounded-[12px]">
|
||||
<div className=' max-w-[290px]'>
|
||||
|
||||
|
|
@ -536,7 +530,6 @@ const TextProvider = ({
|
|||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
|
||||
<div className="w-[222px]">
|
||||
<div className="flex items-center mb-4 gap-2.5 ">
|
||||
<Switch
|
||||
|
|
@ -547,16 +540,9 @@ const TextProvider = ({
|
|||
Enable Web Grounding
|
||||
</label>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
{/* <div className="w-[295px]"></div> */}
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,76 +1,113 @@
|
|||
import { Card } from "@/components/ui/card";
|
||||
|
||||
export default function LoadingProfile() {
|
||||
function Shimmer({ className }: { className?: string }) {
|
||||
return (
|
||||
<div className="h-screen bg-gradient-to-b font-instrument_sans from-gray-50 to-white flex flex-col overflow-hidden">
|
||||
{/* Header Skeleton */}
|
||||
<div className="flex-shrink-0 bg-white border-b border-gray-200 p-4">
|
||||
<div className="container mx-auto max-w-3xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="h-8 w-32 bg-gray-200 animate-pulse rounded-md" />
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-8 w-8 bg-gray-200 animate-pulse rounded-full" />
|
||||
<div className="h-8 w-24 bg-gray-200 animate-pulse rounded-md" />
|
||||
<div
|
||||
className={`bg-[#E1E1E5] animate-pulse rounded-md ${className ?? ""}`}
|
||||
aria-hidden
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LoadingSettings() {
|
||||
return (
|
||||
<div className="h-screen font-syne flex flex-col overflow-hidden relative">
|
||||
<div
|
||||
className="fixed z-0 bottom-[-14.5rem] left-0 w-full h-full pointer-events-none"
|
||||
style={{
|
||||
height: "341px",
|
||||
borderRadius: "1440px",
|
||||
background:
|
||||
"radial-gradient(5.92% 104.69% at 50% 100%, rgba(122, 90, 248, 0.00) 0%, rgba(255, 255, 255, 0.00) 100%), radial-gradient(50% 50% at 50% 50%, rgba(122, 90, 248, 0.80) 0%, rgba(122, 90, 248, 0.00) 100%)",
|
||||
}}
|
||||
/>
|
||||
|
||||
<main className="w-full mx-auto gap-6 overflow-hidden flex">
|
||||
{/* SettingSideBar structure */}
|
||||
<div className="w-full max-w-[230px] h-screen px-4 pt-[22px] bg-[#F9FAFB] flex flex-col shrink-0">
|
||||
<div className="mt-[3.15rem] border-b border-[#E1E1E5] pb-3.5">
|
||||
<Shimmer className="h-3 w-16" />
|
||||
</div>
|
||||
<div className="mt-6 flex-1 min-h-0">
|
||||
<Shimmer className="h-3 w-24 mb-2.5" />
|
||||
<div className="p-0.5 rounded-[40px] bg-white w-full max-w-[210px] border border-[#EDEEEF] flex items-center mb-[34px] h-[30px]">
|
||||
<Shimmer className="h-[26px] flex-1 rounded-[70px] mx-0.5" />
|
||||
<Shimmer className="h-[26px] flex-1 rounded-[70px] mx-0.5 opacity-70" />
|
||||
</div>
|
||||
<Shimmer className="h-3 w-28 mb-2.5" />
|
||||
<div className="space-y-2.5">
|
||||
{[0, 1].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="w-full rounded-[6px] px-3 py-4 flex items-center gap-1.5 border border-[#EDEEEF] bg-white"
|
||||
>
|
||||
<Shimmer className="h-[18px] w-[18px] rounded-full shrink-0" />
|
||||
<Shimmer className="h-3 flex-1 max-w-[100px]" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-[#E1E1E5] py-5">
|
||||
<Shimmer className="h-3 w-12 mb-2.5" />
|
||||
<div className="w-full rounded-[6px] p-3 py-4 flex items-center gap-1.5 border border-[#EDEEEF] bg-white">
|
||||
<Shimmer className="h-6 w-6 rounded-full shrink-0" />
|
||||
<Shimmer className="h-3 w-16" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content Skeleton */}
|
||||
<main className="flex-1 container mx-auto px-4 max-w-3xl overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{/* LLM Selection Content Skeleton */}
|
||||
<div className="space-y-6 p-6">
|
||||
{/* Page Title */}
|
||||
<div className="space-y-2">
|
||||
<div className="h-8 w-48 bg-gray-200 animate-pulse rounded-md" />
|
||||
<div className="h-5 w-72 bg-gray-200 animate-pulse rounded-md" />
|
||||
{/* Main column — matches SettingPage + TextProvider default */}
|
||||
<div className="w-full min-w-0 flex flex-col">
|
||||
<div className="sticky top-0 right-0 z-50 py-[28px] backdrop-blur mb-4">
|
||||
<div className="flex gap-3 items-center flex-wrap">
|
||||
<Shimmer className="h-8 w-[132px] rounded-md" />
|
||||
<Shimmer className="h-[22px] w-[min(320px,55%)] rounded-[50px]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* LLM Provider Cards */}
|
||||
<div className="space-y-4">
|
||||
{[...Array(3)].map((_, index) => (
|
||||
<Card key={index} className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 bg-gray-200 animate-pulse rounded-md" />
|
||||
<div className="space-y-1">
|
||||
<div className="h-5 w-32 bg-gray-200 animate-pulse rounded-md" />
|
||||
<div className="h-4 w-48 bg-gray-200 animate-pulse rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-6 w-6 bg-gray-200 animate-pulse rounded-full" />
|
||||
</div>
|
||||
|
||||
{/* Configuration Fields */}
|
||||
<div className="space-y-4">
|
||||
{[...Array(2)].map((_, fieldIndex) => (
|
||||
<div key={fieldIndex} className="space-y-2">
|
||||
<div className="h-4 w-24 bg-gray-200 animate-pulse rounded-md" />
|
||||
<div className="h-10 w-full bg-gray-200 animate-pulse rounded-md" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Model Selection */}
|
||||
<Card className="p-6">
|
||||
<div className="space-y-4">
|
||||
<div className="h-5 w-32 bg-gray-200 animate-pulse rounded-md" />
|
||||
<div className="h-10 w-full bg-gray-200 animate-pulse rounded-md" />
|
||||
<div className="space-y-6 bg-[#F9F8F8] p-7 rounded-[12px] pr-4 sm:pr-7">
|
||||
{/* TextProvider top card: white panel, icon + copy left, controls right */}
|
||||
<div className="mb-4 flex flex-col lg:flex-row lg:items-end lg:justify-between gap-8 rounded-[12px] bg-white pt-5 pb-10 px-6 sm:px-10">
|
||||
<div className="max-w-[290px] shrink-0">
|
||||
<Shimmer className="w-[60px] h-[60px] rounded-[4px]" />
|
||||
<Shimmer className="h-6 w-48 mt-2.5 mb-2" />
|
||||
<Shimmer className="h-4 w-full max-w-[260px]" />
|
||||
<Shimmer className="h-4 w-40 mt-1.5" />
|
||||
</div>
|
||||
</Card>
|
||||
<div className="flex flex-col items-stretch lg:items-end gap-4 flex-1 min-w-0">
|
||||
<div className="flex flex-col sm:flex-row gap-4 sm:justify-end w-full">
|
||||
<div className="w-full sm:w-[222px]">
|
||||
<Shimmer className="h-4 w-36 mb-2" />
|
||||
<Shimmer className="h-12 w-full rounded-lg" />
|
||||
</div>
|
||||
<div className="w-full sm:w-[222px]">
|
||||
<Shimmer className="h-4 w-28 mb-2" />
|
||||
<Shimmer className="h-12 w-full rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full sm:w-[222px] sm:ml-auto">
|
||||
<Shimmer className="h-4 w-40 mb-2" />
|
||||
<Shimmer className="h-12 w-full rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TextProvider “Advanced” card */}
|
||||
<div className="bg-white flex flex-col sm:flex-row sm:justify-between sm:items-center gap-6 p-6 sm:p-10 rounded-[12px]">
|
||||
<div className="max-w-[290px] shrink-0">
|
||||
<Shimmer className="h-6 w-28 mb-2" />
|
||||
<Shimmer className="h-4 w-52" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5 w-full sm:w-[222px] sm:justify-start">
|
||||
<Shimmer className="h-6 w-11 rounded-full shrink-0" />
|
||||
<Shimmer className="h-4 flex-1 max-w-[160px]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Fixed Bottom Button Skeleton */}
|
||||
<div className="flex-shrink-0 bg-white border-t border-gray-200 p-4">
|
||||
<div className="container mx-auto max-w-3xl">
|
||||
<div className="h-12 w-full bg-gray-200 animate-pulse rounded-lg" />
|
||||
</div>
|
||||
{/* Fixed save button — matches SettingPage placement */}
|
||||
<div className="mx-auto fixed bottom-20 right-5 z-40">
|
||||
<Shimmer className="h-12 w-[200px] sm:w-[240px] rounded-[58px]" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ const CreateCustomTemplate = () => {
|
|||
trackEvent(MixpanelEvent.Templates_Build_Template_Clicked);
|
||||
router.push('/custom-template')
|
||||
}}
|
||||
className='w-full rounded-xl border border-[#EDEEEF] cursor-pointer font-syne'>
|
||||
className='w-full rounded-[22px] border border-[#EDEEEF] cursor-pointer font-syne'>
|
||||
<div className='relative h-[215px] flex justify-center items-center '>
|
||||
<img src="/card_bg.svg" alt="" className="absolute top-0 z-[1] left-0 w-full h-full object-cover" />
|
||||
<div className='w-[36px] h-[36px] relative z-[4] rounded-full bg-[#7A5AF8] flex items-center justify-center'
|
||||
|
|
@ -24,7 +24,7 @@ const CreateCustomTemplate = () => {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='px-5 py-4 bg-white flex items-center gap-4 border-t border-[#EDEEEF]'>
|
||||
<div className='px-5 py-4 bg-white flex items-center gap-4 overflow-hidden border-t border-[#EDEEEF]'>
|
||||
<div className='bg-[#7A5AF8] w-[45px] h-[45px] rounded-lg p-2 flex items-center justify-center'>
|
||||
|
||||
<Sparkles className='w-6 h-6 text-white' />
|
||||
|
|
|
|||
|
|
@ -2,23 +2,27 @@
|
|||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { ArrowUpRight, ChevronRight, ExternalLink, Loader2, Plus } from "lucide-react";
|
||||
import { ArrowUpRight, ChevronRight, Loader2 } from "lucide-react";
|
||||
import { templates } from "@/app/presentation-templates";
|
||||
import { TemplateWithData, TemplateLayoutsWithSettings } from "@/app/presentation-templates/utils";
|
||||
import { TemplateLayoutsWithSettings } from "@/app/presentation-templates/utils";
|
||||
import {
|
||||
useCustomTemplateSummaries,
|
||||
useCustomTemplatePreview,
|
||||
CustomTemplates,
|
||||
} from "@/app/hooks/useCustomTemplates";
|
||||
import { CompiledLayout } from "@/app/hooks/compileLayout";
|
||||
import CreateCustomTemplate from "./CreateCustomTemplate";
|
||||
import Link from "next/link";
|
||||
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
|
||||
import {
|
||||
TemplatePreviewStage,
|
||||
LayoutsBadge,
|
||||
InbuiltTemplatePreview,
|
||||
CustomTemplatePreview,
|
||||
} from "../../../components/TemplatePreviewComponents";
|
||||
|
||||
// Component for rendering custom template card with lazy-loaded previews
|
||||
export const CustomTemplateCard = React.memo(function CustomTemplateCard({ template }: { template: CustomTemplates }) {
|
||||
const router = useRouter();
|
||||
const { previewLayouts, loading, totalLayouts } = useCustomTemplatePreview(`${template.id}`);
|
||||
const { previewLayouts, loading } = useCustomTemplatePreview(`${template.id}`);
|
||||
const handleOpen = useCallback(() => {
|
||||
trackEvent(MixpanelEvent.Templates_Custom_Opened, { template_id: template.id, template_name: template.name });
|
||||
if (template.id.startsWith('custom-')) {
|
||||
|
|
@ -26,73 +30,29 @@ export const CustomTemplateCard = React.memo(function CustomTemplateCard({ templ
|
|||
} else {
|
||||
router.push(`/template-preview?slug=custom-${template.id}`)
|
||||
}
|
||||
}
|
||||
, [router, template.id, template.name]);
|
||||
}, [router, template.id, template.name]);
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="cursor-pointer flex flex-col justify-between shadow-none sm:shadow-none relative hover:shadow-lg transition-all duration-200 group overflow-hidden"
|
||||
className="cursor-pointer flex flex-col shadow-none sm:shadow-none relative hover:shadow-sm transition-all duration-200 group overflow-hidden rounded-[22px] border border-[#E8E9EC] bg-white"
|
||||
onClick={handleOpen}
|
||||
>
|
||||
|
||||
<img src="/card_bg.svg" alt="" className="absolute top-0 left-0 w-full h-full object-cover" />
|
||||
<span className="text-xs font-syne absolute top-2 flex gap-1 capitalize items-center left-2 rounded-[100px] px-2.5 py-1 bg-[#3A3A3AF5] text-white font-semibold z-40">
|
||||
{totalLayouts} {totalLayouts === 1 ? 'Layout' : 'Layouts'}
|
||||
</span>
|
||||
<div className="p-5">
|
||||
|
||||
{/* Layout previews */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{loading ? (
|
||||
// Loading placeholders
|
||||
[...Array(Math.min(4, template.layoutCount))].map((_, index) => (
|
||||
<div
|
||||
key={`${template.id}-loading-${index}`}
|
||||
className="relative bg-linear-to-br from-purple-50 to-blue-50 border border-gray-200 overflow-hidden aspect-video rounded flex items-center justify-center"
|
||||
>
|
||||
<Loader2 className="w-4 h-4 text-purple-300 animate-spin" />
|
||||
</div>
|
||||
))
|
||||
) : previewLayouts.length > 0 && (
|
||||
// Actual layout previews
|
||||
previewLayouts.slice(0, 4).map((layout: CompiledLayout, index: number) => {
|
||||
const LayoutComponent = layout.component;
|
||||
return (
|
||||
<div
|
||||
key={`${template.id}-preview-${index}`}
|
||||
className="relative bg-gray-100 border border-gray-200 overflow-hidden aspect-video rounded"
|
||||
>
|
||||
<div className="absolute inset-0 bg-transparent z-10" />
|
||||
<div
|
||||
className="transform scale-[0.12] origin-top-left"
|
||||
style={{ width: "833.33%", height: "833.33%" }}
|
||||
>
|
||||
<LayoutComponent data={layout.sampleData} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-5 bg-white border-t border-[#EDEEEF] relative z-40 ">
|
||||
<h3 className="text-sm font-bold w-[191px] text-gray-900">
|
||||
{template.name}
|
||||
</h3>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
<ArrowUpRight className="w-4 h-4 text-gray-400 group-hover:text-purple-600 transition-colors" />
|
||||
</div>
|
||||
<TemplatePreviewStage>
|
||||
<LayoutsBadge count={template.layoutCount} />
|
||||
<CustomTemplatePreview
|
||||
previewLayouts={previewLayouts}
|
||||
loading={loading}
|
||||
templateId={template.id}
|
||||
/>
|
||||
</TemplatePreviewStage>
|
||||
<div className="relative z-40 flex items-center justify-between border-t border-[#EDEEEF] bg-white px-6 py-5">
|
||||
<h3 className="max-w-[min(191px,65%)] text-base font-bold text-gray-900">{template.name}</h3>
|
||||
<ArrowUpRight className="h-4 w-4 shrink-0 text-gray-400 transition-colors group-hover:text-purple-600" />
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}, (prev, next) => {
|
||||
// Custom templates may be refetched, producing new object references; compare on fields we render/use.
|
||||
return (
|
||||
prev.template.id === next.template.id &&
|
||||
prev.template.id === next.template.id &&
|
||||
prev.template.name === next.template.name &&
|
||||
prev.template.layoutCount === next.template.layoutCount
|
||||
|
|
@ -106,54 +66,24 @@ const InbuiltTemplateCard = React.memo(function InbuiltTemplateCard({
|
|||
template: TemplateLayoutsWithSettings;
|
||||
onOpen: (id: string) => void;
|
||||
}) {
|
||||
const previewLayouts = useMemo(() => template.layouts.slice(0, 4), [template.layouts]);
|
||||
const handleOpen = useCallback(() => onOpen(template.id), [onOpen, template.id]);
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={template.id}
|
||||
className="cursor-pointer relative sm:shadow-none shadow-none hover:shadow-lg transition-all duration-200 group overflow-hidden"
|
||||
className="group relative cursor-pointer overflow-hidden rounded-[22px] border border-[#E8E9EC] bg-white shadow-none sm:shadow-none transition-all duration-200 hover:shadow-sm"
|
||||
onClick={handleOpen}
|
||||
>
|
||||
<span className="text-xs font-syne absolute top-2 flex gap-1 capitalize items-center left-2 rounded-[100px] px-2.5 py-1 bg-[#3A3A3AF5] text-white font-semibold z-40">
|
||||
{template.layouts.length} {template.layouts.length === 1 ? 'Layout' : 'Layouts'}
|
||||
</span>
|
||||
<img src="/card_bg.svg" alt="" className="absolute top-0 left-0 w-full h-full object-cover" />
|
||||
<div className="p-5">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{previewLayouts.map((layout: TemplateWithData, index: number) => {
|
||||
const LayoutComponent = layout.component;
|
||||
return (
|
||||
<div
|
||||
key={`${template.id}-preview-${index}`}
|
||||
className="relative bg-gray-100 border border-gray-200 overflow-hidden aspect-video rounded"
|
||||
>
|
||||
<div className="absolute inset-0 bg-transparent z-10" />
|
||||
<div
|
||||
className="transform scale-[0.12] origin-top-left"
|
||||
style={{ width: "833.33%", height: "833.33%" }}
|
||||
>
|
||||
<LayoutComponent data={layout.sampleData} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-5 bg-white border-t border-[#EDEEEF] relative z-40 ">
|
||||
<div className="w-[191px]">
|
||||
|
||||
<h3 className="text-sm font-bold text-gray-900 capitalize">
|
||||
{template.name}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-600 mb-4 line-clamp-2">
|
||||
{template.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
<ArrowUpRight className="w-4 h-4 text-gray-400 group-hover:text-blue-600 transition-colors" />
|
||||
<TemplatePreviewStage>
|
||||
<LayoutsBadge count={template.layouts.length} />
|
||||
<InbuiltTemplatePreview layouts={template.layouts} templateId={template.id} />
|
||||
</TemplatePreviewStage>
|
||||
<div className="relative z-40 flex items-center justify-between gap-4 border-t border-[#EDEEEF] bg-white px-6 py-5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-base font-bold capitalize text-gray-900">{template.name}</h3>
|
||||
<p className="mt-1 line-clamp-2 text-sm text-gray-500">{template.description}</p>
|
||||
</div>
|
||||
<ArrowUpRight className="h-4 w-4 shrink-0 text-gray-400 transition-colors group-hover:text-blue-600" />
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
|
@ -254,7 +184,7 @@ const LayoutPreview = () => {
|
|||
{/* Inbuilt Templates Section: non-neo first, then Report (neo) */}
|
||||
{tab === 'default' && (
|
||||
<section className="my-12 space-y-12">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{nonNeoInbuilt.map((template) => (
|
||||
<InbuiltTemplateCard
|
||||
key={template.id}
|
||||
|
|
@ -268,7 +198,7 @@ const LayoutPreview = () => {
|
|||
<h4 className="text-base font-semibold text-[#101828] mb-6 font-syne tracking-tight">
|
||||
Report
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{neoInbuilt.map((template) => (
|
||||
<InbuiltTemplateCard
|
||||
key={template.id}
|
||||
|
|
@ -290,7 +220,7 @@ const LayoutPreview = () => {
|
|||
<span className="ml-3 text-gray-600">Loading custom templates...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 items-center lg:grid-cols-4 gap-6">
|
||||
<CreateCustomTemplate />
|
||||
{customTemplateCards}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import ThemeApi from '@/app/(presentation-generator)/services/api/theme'
|
|||
import { useFontLoader } from '@/app/(presentation-generator)/hooks/useFontLoad'
|
||||
import Link from 'next/link'
|
||||
import { trackEvent, MixpanelEvent } from '@/utils/mixpanel'
|
||||
import { resolveBackendAssetUrl } from '@/utils/api'
|
||||
|
||||
// Fallback theme used before defaults are loaded from API (unified Theme type)
|
||||
const FALLBACK_THEME: Theme = {
|
||||
|
|
@ -153,7 +154,7 @@ const ThemePanel: React.FC = () => {
|
|||
setUserFonts({
|
||||
fonts: userFonts.fonts.map((font: any) => ({
|
||||
name: font.name,
|
||||
url: `${process.env.NEXT_PUBLIC_FAST_API}${font.url}`,
|
||||
url: resolveBackendAssetUrl(font.url),
|
||||
})),
|
||||
})
|
||||
|
||||
|
|
@ -458,14 +459,14 @@ const ThemePanel: React.FC = () => {
|
|||
setCustomFonts({
|
||||
textFont: {
|
||||
name: font_name,
|
||||
url: `${process.env.NEXT_PUBLIC_FAST_API}${font_url}`,
|
||||
url: resolveBackendAssetUrl(font_url),
|
||||
}
|
||||
})
|
||||
|
||||
// Add the newly uploaded font to userFonts if not already present
|
||||
if (!userFonts.fonts.find(f => f.name === font_name)) {
|
||||
setUserFonts(prev => ({
|
||||
fonts: [...prev.fonts, { name: font_name, url: `${process.env.NEXT_PUBLIC_FAST_API}${font_url}` }]
|
||||
fonts: [...prev.fonts, { name: font_name, url: resolveBackendAssetUrl(font_url) }]
|
||||
}))
|
||||
}
|
||||
toast.success(`Font "${font_name}" uploaded successfully`)
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import { Search } from "lucide-react";
|
|||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PresentationGenerationApi } from "../services/api/presentation-generation";
|
||||
import { getStaticFileUrl } from "../utils/others";
|
||||
import { resolveBackendAssetUrl } from "@/utils/api";
|
||||
import { toast } from "sonner";
|
||||
interface IconsEditorProps {
|
||||
icon_prompt?: string[] | null;
|
||||
|
|
@ -147,7 +147,7 @@ const IconsEditor = ({
|
|||
className="w-12 h-12 cursor-pointer group relative rounded-lg overflow-hidden hover:bg-gray-100 p-2 transition-colors"
|
||||
>
|
||||
<img
|
||||
src={iconSrc}
|
||||
src={resolveBackendAssetUrl(iconSrc)}
|
||||
alt={`Icon ${idx + 1}`}
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"use client";
|
||||
import React, { useEffect, useState, useRef } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
|
|
@ -9,7 +10,7 @@ import {
|
|||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Wand2, Upload, Loader2, Delete, Trash } from "lucide-react";
|
||||
import { Wand2, Upload, Loader2, Trash } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PresentationGenerationApi } from "../services/api/presentation-generation";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
|
@ -18,6 +19,10 @@ import { PreviousGeneratedImagesResponse } from "../services/api/params";
|
|||
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
|
||||
import { ImagesApi } from "../services/api/images";
|
||||
import { ImageAssetResponse } from "../services/api/types";
|
||||
import { RootState } from "@/store/store";
|
||||
|
||||
const STOCK_IMAGE_PROVIDERS = new Set(["pexels", "pixabay"]);
|
||||
|
||||
interface ImageEditorProps {
|
||||
initialImage: string | null;
|
||||
imageIdx?: number;
|
||||
|
|
@ -39,6 +44,10 @@ const ImageEditor = ({
|
|||
onFocusPointClick,
|
||||
onImageChange,
|
||||
}: ImageEditorProps) => {
|
||||
const llmConfig = useSelector((state: RootState) => state.userConfig.llm_config);
|
||||
const selectedImageProvider = (llmConfig?.IMAGE_PROVIDER || "").toLowerCase();
|
||||
const isStockImageProvider = STOCK_IMAGE_PROVIDERS.has(selectedImageProvider);
|
||||
|
||||
// State management
|
||||
const [previewImages, setPreviewImages] = useState(initialImage);
|
||||
const [previousGeneratedImages, setPreviousGeneratedImages] = useState<
|
||||
|
|
@ -53,6 +62,7 @@ const ImageEditor = ({
|
|||
const [isOpen, setIsOpen] = useState(true);
|
||||
const [uploadedImages, setUploadedImages] = useState<ImageAssetResponse[]>([]);
|
||||
const [uploadedImagesLoading, setUploadedImagesLoading] = useState(false);
|
||||
const [stockSearchResults, setStockSearchResults] = useState<string[]>([]);
|
||||
// Focus point and object fit for image editing
|
||||
const [isFocusPointMode, setIsFocusPointMode] = useState(false);
|
||||
const [focusPoint, setFocusPoint] = useState(
|
||||
|
|
@ -190,18 +200,40 @@ const ImageEditor = ({
|
|||
setError("Please enter a prompt");
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedPrompt = prompt.trim();
|
||||
if (!trimmedPrompt) {
|
||||
setError("Please enter a prompt");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsGenerating(true);
|
||||
setError(null);
|
||||
trackEvent(MixpanelEvent.ImageEditor_GenerateImage_API_Call);
|
||||
const response = await PresentationGenerationApi.generateImage({
|
||||
prompt: prompt,
|
||||
});
|
||||
|
||||
setPreviewImages(response);
|
||||
if (isStockImageProvider) {
|
||||
const providerApiKey =
|
||||
selectedImageProvider === "pexels"
|
||||
? llmConfig?.PEXELS_API_KEY
|
||||
: llmConfig?.PIXABAY_API_KEY;
|
||||
const results = await ImagesApi.searchStockImages(trimmedPrompt, 12, {
|
||||
provider: selectedImageProvider,
|
||||
apiKey: providerApiKey,
|
||||
});
|
||||
setStockSearchResults(results);
|
||||
if (results.length > 0) {
|
||||
setPreviewImages(results[0]);
|
||||
}
|
||||
} else {
|
||||
const response = await PresentationGenerationApi.generateImage({
|
||||
prompt: trimmedPrompt,
|
||||
});
|
||||
setPreviewImages(response);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("Error in image generation", err);
|
||||
setError(err.message || "Failed to generate image. Please try again.");
|
||||
setError(err.message || "Failed to fetch images. Please try again.");
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
|
|
@ -286,7 +318,7 @@ const ImageEditor = ({
|
|||
<Tabs defaultValue="generate" className="w-full" onValueChange={handleTabChange}>
|
||||
<TabsList className="grid bg-blue-100 border border-blue-300 w-full grid-cols-3 mx-auto">
|
||||
<TabsTrigger className="font-medium" value="generate">
|
||||
AI Generate
|
||||
{isStockImageProvider ? "Stock Search" : "AI Generate"}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger className="font-medium" value="upload">
|
||||
Upload
|
||||
|
|
@ -305,10 +337,14 @@ const ImageEditor = ({
|
|||
|
||||
<div>
|
||||
<h3 className="text-base font-medium mb-2">
|
||||
Image Description
|
||||
{isStockImageProvider ? "Image Keyword" : "Image Description"}
|
||||
</h3>
|
||||
<Textarea
|
||||
placeholder="Describe the image you want to generate..."
|
||||
placeholder={
|
||||
isStockImageProvider
|
||||
? "Enter a keyword to search stock images..."
|
||||
: "Describe the image you want to generate..."
|
||||
}
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
className="min-h-[100px]"
|
||||
|
|
@ -321,35 +357,62 @@ const ImageEditor = ({
|
|||
disabled={!prompt || isGenerating}
|
||||
>
|
||||
<Wand2 className="w-4 h-4 mr-2" />
|
||||
{isGenerating ? "Generating..." : "Generate Image"}
|
||||
{isGenerating
|
||||
? (isStockImageProvider ? "Searching..." : "Generating...")
|
||||
: (isStockImageProvider ? "Search Images" : "Generate Image")}
|
||||
</Button>
|
||||
|
||||
{error && <p className="text-red-500 text-sm">{error}</p>}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{isGenerating || !previewImages ? (
|
||||
{isGenerating ? (
|
||||
Array.from({ length: 4 }).map((_, index) => (
|
||||
<Skeleton
|
||||
key={index}
|
||||
className="aspect-[4/3] w-full rounded-lg"
|
||||
/>
|
||||
))
|
||||
) : isStockImageProvider ? (
|
||||
stockSearchResults.length > 0 ? (
|
||||
stockSearchResults.map((imageUrl, index) => (
|
||||
<div
|
||||
key={`${imageUrl}-${index}`}
|
||||
onClick={() => handleImageChange(imageUrl)}
|
||||
className="aspect-[4/3] w-full overflow-hidden rounded-lg border cursor-pointer hover:border-blue-500 transition-colors"
|
||||
>
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={`Stock result ${index + 1}`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-sm text-gray-500 col-span-2">Search with a keyword to view stock images.</p>
|
||||
)
|
||||
) : (
|
||||
<div
|
||||
onClick={() => handleImageChange(previewImages)}
|
||||
className="aspect-[4/3] w-full overflow-hidden rounded-lg border cursor-pointer hover:border-blue-500 transition-colors"
|
||||
>
|
||||
{previewImages && (
|
||||
!previewImages ? (
|
||||
Array.from({ length: 4 }).map((_, index) => (
|
||||
<Skeleton
|
||||
key={index}
|
||||
className="aspect-[4/3] w-full rounded-lg"
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div
|
||||
onClick={() => handleImageChange(previewImages)}
|
||||
className="aspect-[4/3] w-full overflow-hidden rounded-lg border cursor-pointer hover:border-blue-500 transition-colors"
|
||||
>
|
||||
<img
|
||||
src={previewImages}
|
||||
alt={`Preview`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
{previousGeneratedImages.length > 0 && (
|
||||
{!isStockImageProvider && previousGeneratedImages.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<h3 className="text-sm font-medium mb-2">
|
||||
Previous Generated Images
|
||||
|
|
|
|||
|
|
@ -0,0 +1,125 @@
|
|||
"use client";
|
||||
import React, { memo, useMemo } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { TemplateWithData } from "@/app/presentation-templates/utils";
|
||||
import { CompiledLayout } from "@/app/hooks/compileLayout";
|
||||
|
||||
|
||||
|
||||
|
||||
export function TemplatePreviewStage({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="relative overflow-hidden px-5 pb-5 pt-5 h-[230px]">
|
||||
<img
|
||||
src="/card_bg.svg"
|
||||
alt=""
|
||||
className="absolute top-0 left-0 w-full h-full object-cover"
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const LayoutsBadge = memo(function LayoutsBadge({ count }: { count: number }) {
|
||||
return (
|
||||
<span className="text-xs font-syne absolute top-3.5 left-4 z-40 inline-flex items-center rounded-full bg-[#333333] px-3 py-1 font-semibold text-white">
|
||||
Layouts-{count}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
|
||||
export const ScaledSlidePreview = memo(function ScaledSlidePreview({
|
||||
children,
|
||||
id,
|
||||
index,
|
||||
isOutline = false,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
id: string;
|
||||
index: number;
|
||||
isOutline?: boolean;
|
||||
}) {
|
||||
const PREVIEW_SCALE = isOutline ? 0.2 : 0.24;
|
||||
const SLIDE_HEIGHT = 720 * PREVIEW_SCALE;
|
||||
const SLIDE_WIDTH = 1280;
|
||||
const SLIDE_NATIVE_HEIGHT = 720;
|
||||
return (
|
||||
<div
|
||||
key={`${id}-preview-${index}`}
|
||||
className="relative"
|
||||
style={{ height: `${SLIDE_HEIGHT}px`, overflow: "hidden" }}
|
||||
>
|
||||
<div
|
||||
className={`absolute top-0 ${isOutline ? "left-0" : "left-8"} pointer-events-none`}
|
||||
style={{
|
||||
width: SLIDE_WIDTH,
|
||||
height: SLIDE_NATIVE_HEIGHT,
|
||||
transformOrigin: "top left",
|
||||
transform: `scale(${PREVIEW_SCALE})`,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const InbuiltTemplatePreview = memo(function InbuiltTemplatePreview({
|
||||
layouts,
|
||||
templateId,
|
||||
isOutline = false,
|
||||
}: {
|
||||
layouts: TemplateWithData[];
|
||||
templateId: string;
|
||||
isOutline?: boolean;
|
||||
}) {
|
||||
const previewLayouts = useMemo(() => layouts.slice(0, 2), [layouts]);
|
||||
return (
|
||||
<div className="relative z-10 flex flex-col gap-3 overflow-hidden">
|
||||
{previewLayouts.map((layout, index) => {
|
||||
const LayoutComponent = layout.component;
|
||||
return (
|
||||
<ScaledSlidePreview key={`${templateId}-preview-${index}`} id={templateId} index={index} isOutline={isOutline}>
|
||||
<LayoutComponent data={layout.sampleData} />
|
||||
</ScaledSlidePreview>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const CustomTemplatePreview = memo(function CustomTemplatePreview({
|
||||
previewLayouts,
|
||||
loading,
|
||||
templateId,
|
||||
isOutline = false,
|
||||
}: {
|
||||
previewLayouts: CompiledLayout[];
|
||||
loading: boolean;
|
||||
templateId: string;
|
||||
isOutline?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative z-10 flex flex-col gap-3">
|
||||
{loading ? (
|
||||
[...Array(2)].map((_, index) => (
|
||||
<div
|
||||
key={`${templateId}-loading-${index}`}
|
||||
className="relative w-full aspect-video flex items-center justify-center"
|
||||
>
|
||||
<Loader2 className="h-4 w-4 animate-spin text-slate-300" />
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
previewLayouts.slice(0, 2).map((layout, index) => {
|
||||
const LayoutComponent = layout.component;
|
||||
return (
|
||||
<ScaledSlidePreview key={`${templateId}-preview-${index}`} id={templateId} index={index} isOutline={isOutline}>
|
||||
<LayoutComponent data={layout.sampleData} />
|
||||
</ScaledSlidePreview>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,276 @@
|
|||
"use client";
|
||||
|
||||
|
||||
|
||||
import React, { useEffect, useCallback, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
|
||||
|
||||
import { useFileUpload } from "./hooks/useFileUpload";
|
||||
import { useTemplateCreation } from "./hooks/useTemplateCreation";
|
||||
import { useLayoutSaving } from "./hooks/useLayoutSaving";
|
||||
|
||||
import { ProcessedSlide } from "./types";
|
||||
import { TAILWIND_CDN_URL } from "./constants";
|
||||
import { TemplateStudioHeader } from "./components/TemplateStudioHeader";
|
||||
import { TemplateCreationProgress } from "./components/TemplateCreationProgress";
|
||||
import { Step2FontManagement } from "./components/steps/Step2FontManagement";
|
||||
import { Step3SlidePreview } from "./components/steps/Step3SlidePreview";
|
||||
import { Step4TemplateCreation } from "./components/steps/Step4TemplateCreation";
|
||||
import { SaveLayoutButton } from "./components/SaveLayoutButton";
|
||||
import { SaveLayoutModal } from "./components/SaveLayoutModal";
|
||||
import { FileUploadSection } from "./components/FileUploadSection";
|
||||
|
||||
import { useFontLoader } from "../hooks/useFontLoad";
|
||||
import Header from "@/app/(presentation-generator)/(dashboard)/dashboard/components/Header";
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
const CustomTemplatePage = () => {
|
||||
const router = useRouter();
|
||||
|
||||
const [schemaEditorSlideIndex, setSchemaEditorSlideIndex] = useState<number | null>(null);
|
||||
const [schemaPreviewData, setSchemaPreviewData] = useState<Record<number, Record<string, any>>>({});
|
||||
|
||||
const { selectedFile, handleFileSelect, removeFile } = useFileUpload();
|
||||
|
||||
|
||||
const {
|
||||
state,
|
||||
uploadedFonts,
|
||||
slides,
|
||||
setSlides,
|
||||
completedSlides,
|
||||
checkFonts,
|
||||
uploadFont,
|
||||
removeFont,
|
||||
fontUploadAndPreview,
|
||||
initTemplateCreation,
|
||||
retrySlide,
|
||||
} = useTemplateCreation();
|
||||
|
||||
// Layout saving hook
|
||||
const {
|
||||
isSavingLayout,
|
||||
isModalOpen,
|
||||
openSaveModal,
|
||||
closeSaveModal,
|
||||
saveLayout,
|
||||
} = useLayoutSaving(slides);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const existingScript = document.querySelector('script[src*="tailwindcss.com"]');
|
||||
if (!existingScript) {
|
||||
const script = document.createElement("script");
|
||||
script.src = TAILWIND_CDN_URL;
|
||||
script.async = true;
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
/**
|
||||
* Step 1: Check fonts in uploaded PPTX
|
||||
*/
|
||||
const handleCheckFonts = useCallback(async () => {
|
||||
|
||||
|
||||
if (selectedFile) {
|
||||
await checkFonts(selectedFile);
|
||||
}
|
||||
}, [selectedFile, checkFonts]);
|
||||
|
||||
/**
|
||||
* Step 2: Upload fonts and generate preview
|
||||
*/
|
||||
const handleFontUploadAndPreview = useCallback(async () => {
|
||||
if (selectedFile) {
|
||||
const data = await fontUploadAndPreview(selectedFile);
|
||||
if (data) {
|
||||
useFontLoader(data.fonts);
|
||||
}
|
||||
}
|
||||
}, [selectedFile, fontUploadAndPreview]);
|
||||
|
||||
/**
|
||||
* Step 5: Save template with metadata
|
||||
*/
|
||||
const handleSaveTemplate = useCallback(async (
|
||||
layoutName: string,
|
||||
description: string,
|
||||
template_info_id: string
|
||||
): Promise<string | null> => {
|
||||
const id = await saveLayout(layoutName, description, template_info_id);
|
||||
if (id) {
|
||||
router.push(`/template-preview?slug=custom-${id}`);
|
||||
}
|
||||
return id;
|
||||
}, [saveLayout, router]);
|
||||
|
||||
/**
|
||||
* Update a specific slide's data
|
||||
*/
|
||||
const handleSlideUpdate = useCallback((index: number, updatedSlideData: Partial<ProcessedSlide>) => {
|
||||
setSlides((prevSlides) =>
|
||||
prevSlides.map((s, i) =>
|
||||
i === index
|
||||
? { ...s, ...updatedSlideData, modified: true }
|
||||
: s
|
||||
)
|
||||
);
|
||||
}, [setSlides]);
|
||||
|
||||
|
||||
/**
|
||||
* Open schema editor for a specific slide
|
||||
*/
|
||||
const handleOpenSchemaEditor = useCallback((index: number | null) => {
|
||||
setSchemaEditorSlideIndex(index);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Close schema editor
|
||||
*/
|
||||
const handleCloseSchemaEditor = useCallback(() => {
|
||||
setSchemaEditorSlideIndex(null);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Save changes from schema editor
|
||||
*/
|
||||
const handleSchemaEditorSave = useCallback((updatedReact: string) => {
|
||||
if (schemaEditorSlideIndex !== null) {
|
||||
setSlides(prev => prev.map((s, i) =>
|
||||
i === schemaEditorSlideIndex ? { ...s, react: updatedReact } : s
|
||||
));
|
||||
}
|
||||
setSchemaEditorSlideIndex(null);
|
||||
}, [schemaEditorSlideIndex, setSlides]);
|
||||
|
||||
/**
|
||||
* Update schema preview content (for AI fill)
|
||||
*/
|
||||
const handleSchemaPreviewContent = useCallback((content: Record<string, any>) => {
|
||||
if (schemaEditorSlideIndex !== null) {
|
||||
setSchemaPreviewData(prev => ({
|
||||
...prev,
|
||||
[schemaEditorSlideIndex]: content
|
||||
}));
|
||||
}
|
||||
}, [schemaEditorSlideIndex]);
|
||||
|
||||
/**
|
||||
* Clear schema preview data for a specific slide
|
||||
*/
|
||||
const handleClearSchemaPreview = useCallback((slideIndex: number) => {
|
||||
setSchemaPreviewData(prev => {
|
||||
const newData = { ...prev };
|
||||
delete newData[slideIndex];
|
||||
return newData;
|
||||
});
|
||||
}, []);
|
||||
|
||||
|
||||
|
||||
const showFileUpload = state.step === 'file-upload';
|
||||
const showFontManager = state.step === 'font-check' || state.step === 'font-upload';
|
||||
const showPreview = state.step === 'slides-preview';
|
||||
const showSlides = state.step === 'template-creation' || state.step === 'completed';
|
||||
const isProcessingCompleted = state.step === 'completed';
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100">
|
||||
|
||||
<Header />
|
||||
<TemplateStudioHeader />
|
||||
{showFileUpload ? (
|
||||
<div className="pb-24">
|
||||
<FileUploadSection
|
||||
selectedFile={selectedFile}
|
||||
handleFileSelect={handleFileSelect}
|
||||
removeFile={removeFile}
|
||||
CheckFonts={handleCheckFonts}
|
||||
isProcessingPptx={state.isLoading}
|
||||
slides={[]}
|
||||
completedSlides={0}
|
||||
/>
|
||||
|
||||
</div>
|
||||
) : (
|
||||
<div className="mx-auto min-h-[600px] px-6 pb-24">
|
||||
|
||||
<TemplateCreationProgress
|
||||
currentStep={state.step}
|
||||
totalSlides={state.totalSlides}
|
||||
processedSlides={completedSlides}
|
||||
/>
|
||||
|
||||
{/* Step 2: Font Management */}
|
||||
{showFontManager && (
|
||||
<Step2FontManagement
|
||||
fontsData={state.fontsData}
|
||||
uploadedFonts={uploadedFonts}
|
||||
uploadFont={uploadFont}
|
||||
removeFont={removeFont}
|
||||
onContinue={handleFontUploadAndPreview}
|
||||
isUploading={state.isLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Step 3: Slide Preview */}
|
||||
{showPreview && (
|
||||
<Step3SlidePreview
|
||||
previewData={state.previewData}
|
||||
onInitTemplate={initTemplateCreation}
|
||||
isLoading={state.isLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Step 4: Template Creation & Editing */}
|
||||
{showSlides && slides.length > 0 && (
|
||||
<Step4TemplateCreation
|
||||
slides={slides}
|
||||
setSlides={setSlides}
|
||||
retrySlide={retrySlide}
|
||||
onSlideUpdate={handleSlideUpdate}
|
||||
schemaEditorSlideIndex={schemaEditorSlideIndex}
|
||||
onOpenSchemaEditor={handleOpenSchemaEditor}
|
||||
onCloseSchemaEditor={handleCloseSchemaEditor}
|
||||
onSchemaEditorSave={handleSchemaEditorSave}
|
||||
schemaPreviewData={schemaPreviewData}
|
||||
onSchemaPreviewContent={handleSchemaPreviewContent}
|
||||
onClearSchemaPreview={handleClearSchemaPreview}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Floating Save Template Button */}
|
||||
{isProcessingCompleted && slides.some((s) => s.processed) && (
|
||||
<SaveLayoutButton
|
||||
onSave={openSaveModal}
|
||||
isSaving={isSavingLayout}
|
||||
isProcessing={slides.some((s) => s.processing)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Save Template Modal */}
|
||||
<SaveLayoutModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={closeSaveModal}
|
||||
onSave={handleSaveTemplate}
|
||||
isSaving={isSavingLayout}
|
||||
template_info_id={state.templateId || ''}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomTemplatePage;
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
import React from "react";
|
||||
import Header from "@/app/(presentation-generator)/(dashboard)/dashboard/components/Header";
|
||||
|
||||
export const APIKeyWarning: React.FC = () => {
|
||||
return (
|
||||
<div className="min-h-screen font-roboto bg-gradient-to-br from-slate-50 to-slate-100">
|
||||
<Header />
|
||||
<div className="flex items-center justify-center aspect-video mx-auto px-6">
|
||||
<div className="text-center space-y-2 my-6 bg-white p-10 rounded-lg shadow-lg">
|
||||
<h1 className="text-xl font-bold text-gray-900">
|
||||
Please add "GOOGLE_API_KEY" to enable template creation via AI.
|
||||
</h1>
|
||||
<h1 className="text-xl font-bold text-gray-900">Please add your OpenAI API Key to process the layout</h1>
|
||||
<p className="text-lg text-gray-600 max-w-2xl mx-auto">
|
||||
This feature requires an OpenAI model GPT-5. Configure your key in settings or via environment variables.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,167 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Pencil, Eraser, RotateCcw, SendHorizontal, X } from "lucide-react";
|
||||
import { EditControlsProps } from "../../types";
|
||||
|
||||
export const EditControls: React.FC<EditControlsProps> = ({
|
||||
isEditMode,
|
||||
prompt,
|
||||
isUpdating,
|
||||
strokeWidth,
|
||||
strokeColor,
|
||||
eraserMode,
|
||||
onPromptChange,
|
||||
onSave,
|
||||
onCancel,
|
||||
onStrokeWidthChange,
|
||||
onStrokeColorChange,
|
||||
onEraserModeChange,
|
||||
onClearCanvas,
|
||||
}) => {
|
||||
const colors = [
|
||||
"#000000",
|
||||
"#FF0000",
|
||||
"#00FF00",
|
||||
"#0000FF",
|
||||
"#FFFF00",
|
||||
"#FF00FF",
|
||||
"#00FFFF",
|
||||
"#FFA500",
|
||||
];
|
||||
|
||||
const strokeWidths = [1, 3, 5, 8, 12];
|
||||
|
||||
if (!isEditMode) return null;
|
||||
|
||||
return (
|
||||
<div className="border-2 max-w-[1280px] mx-auto border-blue-200 rounded-lg p-4 bg-blue-50 space-y-4">
|
||||
{/* Drawing Tools */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
{/* Drawing Tools */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant={!eraserMode ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => onEraserModeChange(false)}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
Draw
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={eraserMode ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => onEraserModeChange(true)}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<Eraser size={14} />
|
||||
Erase
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Color Picker */}
|
||||
{!eraserMode && (
|
||||
<div className="flex items-center gap-1">
|
||||
{colors.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
className={`w-5 h-5 rounded-full border-2 ${
|
||||
strokeColor === color
|
||||
? "border-gray-800"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
style={{ backgroundColor: color }}
|
||||
onClick={() => onStrokeColorChange(color)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stroke Width */}
|
||||
<div className="flex items-center gap-1">
|
||||
{strokeWidths.map((width) => (
|
||||
<button
|
||||
key={width}
|
||||
className={`w-7 h-7 rounded border flex items-center justify-center ${
|
||||
strokeWidth === width
|
||||
? "bg-blue-100 border-blue-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
onClick={() => onStrokeWidthChange(width)}
|
||||
>
|
||||
<div
|
||||
className="rounded-full bg-gray-800"
|
||||
style={{
|
||||
width: `${width + 1}px`,
|
||||
height: `${width + 1}px`,
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onClearCanvas}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onCancel}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<X size={14} />
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Prompt Section */}
|
||||
<div className="space-y-2 mt-2">
|
||||
<label
|
||||
htmlFor="edit-prompt"
|
||||
className="text-sm font-medium font-inter text-gray-700"
|
||||
>
|
||||
Describe the changes you want to make:
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Textarea
|
||||
id="edit-prompt"
|
||||
placeholder="Enter your prompt here... (e.g., 'Change the title color to blue', 'Add a border to the image', etc.)"
|
||||
value={prompt}
|
||||
onChange={(e) => onPromptChange(e.target.value)}
|
||||
className="flex-1 font-inter duration-300 h-[70px] border-blue-200 border-2 rounded-lg outline-none focus:border-blue-500 focus:ring-0 max-h-[70px] resize-none"
|
||||
disabled={isUpdating}
|
||||
/>
|
||||
<div>
|
||||
<Button
|
||||
onClick={onSave}
|
||||
disabled={isUpdating || !prompt.trim()}
|
||||
className="flex flex-col w-28 font-inter font-semibold items-center gap-1 h-full bg-green-600 hover:bg-green-700 px-4"
|
||||
>
|
||||
{isUpdating ? (
|
||||
"Updating..."
|
||||
) : (
|
||||
<>
|
||||
<SendHorizontal size={14} />
|
||||
Update
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
'use client'
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Save, X, Code } from "lucide-react";
|
||||
import { ProcessedSlide } from "../../types";
|
||||
import Editor from 'react-simple-code-editor';
|
||||
import { highlight, languages } from 'prismjs';
|
||||
import 'prismjs/components/prism-clike';
|
||||
import 'prismjs/components/prism-javascript';
|
||||
import 'prismjs/components/prism-markup';
|
||||
import 'prismjs/components/prism-jsx';
|
||||
import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
|
||||
interface HtmlEditorProps {
|
||||
slide: ProcessedSlide;
|
||||
isHtmlEditMode: boolean;
|
||||
onSave: (html: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export const HtmlEditor: React.FC<HtmlEditorProps> = ({
|
||||
slide,
|
||||
isHtmlEditMode,
|
||||
onSave,
|
||||
onCancel,
|
||||
}) => {
|
||||
const [htmlContent, setHtmlContent] = useState(slide.html || "");
|
||||
|
||||
useEffect(() => {
|
||||
setHtmlContent(slide.html || "");
|
||||
}, [slide.html]);
|
||||
|
||||
if (!isHtmlEditMode) return null;
|
||||
|
||||
const handleSave = () => {
|
||||
onSave(htmlContent);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setHtmlContent(slide.html || "");
|
||||
onCancel();
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet open={isHtmlEditMode} onOpenChange={(open) => { if (!open) handleCancel(); }}>
|
||||
<SheetContent side="right" className="w-full sm:max-w-[860px] p-0">
|
||||
<SheetHeader className="px-6 py-4 border-b">
|
||||
<SheetTitle className="flex items-center justify-between w-full">
|
||||
<span className="flex items-center gap-2 text-purple-800">
|
||||
<Code className="w-5 h-5 text-purple-600" />
|
||||
HTML Editor
|
||||
</span>
|
||||
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="space-y-4 px-2 overflow-y-auto h-[85%]">
|
||||
<div className="container__content_area">
|
||||
<Editor
|
||||
value={htmlContent}
|
||||
onValueChange={html => setHtmlContent(html)}
|
||||
highlight={code => highlight(code, languages.jsx!, 'jsx')}
|
||||
padding={10}
|
||||
id="html-editor"
|
||||
name="html-editor"
|
||||
className="container__editor"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<SheetFooter className="px-6 py-4 border-b">
|
||||
<SheetTitle className="flex items-center justify-between w-full">
|
||||
<div></div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCancel}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<X size={14} />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
className="flex items-center gap-1 bg-purple-600 hover:bg-purple-700"
|
||||
size="sm"
|
||||
>
|
||||
<Save size={14} />
|
||||
Save HTML
|
||||
</Button>
|
||||
</div>
|
||||
</SheetTitle>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
};
|
||||