Merge pull request #445 from presenton/feat/export-issue
Feat/export issue
This commit is contained in:
commit
7e79dbed42
28 changed files with 2401 additions and 654 deletions
|
|
@ -1,124 +1,163 @@
|
|||
import { ipcMain } from "electron";
|
||||
import { appDataDir, baseDir, downloadsDir, tempDir } from "../utils/constants";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
import { showFileDownloadedDialog } from "../utils/dialog";
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { spawn } from "child_process";
|
||||
|
||||
export function setupExportHandlers() {
|
||||
ipcMain.handle("file-downloaded", async (_, filePath: string): Promise<IPCStatus> => {
|
||||
const fileName = path.basename(filePath);
|
||||
const destinationPath = path.join(downloadsDir, fileName);
|
||||
|
||||
await fs.promises.rename(filePath, destinationPath);
|
||||
const success = await showFileDownloadedDialog(destinationPath);
|
||||
return { success };
|
||||
});
|
||||
|
||||
ipcMain.handle("export-presentation", async (_, id: string, title: string, exportAs: "pptx" | "pdf" | "png") => {
|
||||
try {
|
||||
const pptUrl = `${process.env.NEXT_PUBLIC_URL}/pdf-maker?id=${id}`;
|
||||
|
||||
let exportTask = {
|
||||
type: "export",
|
||||
url: pptUrl,
|
||||
format: exportAs,
|
||||
title: title,
|
||||
}
|
||||
|
||||
const randomUuid = uuidv4();
|
||||
const exportTempDir = path.join(tempDir, randomUuid);
|
||||
await fs.promises.mkdir(exportTempDir, { recursive: true });
|
||||
|
||||
const exportTaskPath = path.join(exportTempDir, "export_task.json");
|
||||
await fs.promises.writeFile(exportTaskPath, JSON.stringify(exportTask));
|
||||
|
||||
const exportScriptPath = path.join(baseDir, "resources", "export", "index.js");
|
||||
const pythonModulePath = path.join(baseDir, "resources", "export", "py", "convert");
|
||||
const exportTaskProcess = spawn("node", [exportScriptPath, exportTaskPath], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: {
|
||||
...process.env,
|
||||
TEMP_DIRECTORY: tempDir,
|
||||
APP_DATA_DIRECTORY: appDataDir,
|
||||
BUILT_PYTHON_MODULE_PATH: pythonModulePath,
|
||||
},
|
||||
});
|
||||
|
||||
exportTaskProcess.stdout.on("data", (data: Buffer) => {
|
||||
console.log(`[Export] ${data.toString()}`);
|
||||
});
|
||||
exportTaskProcess.stderr.on("data", (data: Buffer) => {
|
||||
console.error(`[Export] ${data.toString()}`);
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
exportTaskProcess.on("error", reject);
|
||||
exportTaskProcess.on("exit", (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Export process exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const responsePath = exportTaskPath.replace(".json", ".response.json");
|
||||
const responseRaw = await fs.promises.readFile(responsePath, "utf8");
|
||||
const responseData = JSON.parse(responseRaw);
|
||||
const exportFilePath = resolveExportedFilePath(responseData);
|
||||
|
||||
if (!exportFilePath) {
|
||||
return { success: false, message: "Export finished but output file was not found." };
|
||||
}
|
||||
|
||||
const destinationPath = path.join(downloadsDir, path.basename(exportFilePath));
|
||||
await moveFile(exportFilePath, destinationPath);
|
||||
const success = await showFileDownloadedDialog(destinationPath);
|
||||
return { success, message: success ? "Export completed." : "Export completed but dialog failed." };
|
||||
} catch (error: any) {
|
||||
console.error("[Export] Error exporting presentation:", error);
|
||||
return { success: false, message: error?.message ?? "Export failed." };
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
function resolveExportedFilePath(responseData: any): string | null {
|
||||
if (responseData?.path && typeof responseData.path === "string") {
|
||||
return path.isAbsolute(responseData.path)
|
||||
? responseData.path
|
||||
: path.join(appDataDir, responseData.path);
|
||||
}
|
||||
|
||||
if (responseData?.url && typeof responseData.url === "string") {
|
||||
try {
|
||||
const parsed = new URL(responseData.url);
|
||||
if (parsed.protocol === "file:") {
|
||||
const filePath = decodeURIComponent(parsed.pathname);
|
||||
if (process.platform === "win32" && filePath.startsWith("/")) {
|
||||
return filePath.slice(1);
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function moveFile(sourcePath: string, destinationPath: string) {
|
||||
try {
|
||||
await fs.promises.rename(sourcePath, destinationPath);
|
||||
} catch (error: any) {
|
||||
if (error?.code !== "EXDEV") {
|
||||
throw error;
|
||||
}
|
||||
await fs.promises.copyFile(sourcePath, destinationPath);
|
||||
await fs.promises.unlink(sourcePath);
|
||||
}
|
||||
import { ipcMain } from "electron";
|
||||
import { appDataDir, baseDir, downloadsDir, tempDir } from "../utils/constants";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
import { showFileDownloadedDialog } from "../utils/dialog";
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { spawn } from "child_process";
|
||||
import { getPuppeteerExecutablePath } from "../utils/puppeteer-check";
|
||||
|
||||
export function setupExportHandlers() {
|
||||
ipcMain.handle("file-downloaded", async (_, filePath: string): Promise<IPCStatus> => {
|
||||
const fileName = path.basename(filePath);
|
||||
const destinationPath = path.join(downloadsDir, fileName);
|
||||
|
||||
await fs.promises.rename(filePath, destinationPath);
|
||||
const success = await showFileDownloadedDialog(destinationPath);
|
||||
return { success };
|
||||
});
|
||||
|
||||
ipcMain.handle("export-presentation", async (_, id: string, title: string, exportAs: "pptx" | "pdf" | "png") => {
|
||||
try {
|
||||
const pptUrl = `${process.env.NEXT_PUBLIC_URL}/pdf-maker?id=${id}`;
|
||||
|
||||
let exportTask = {
|
||||
type: "export",
|
||||
url: pptUrl,
|
||||
format: exportAs,
|
||||
title: title,
|
||||
fastapiUrl: process.env.NEXT_PUBLIC_FAST_API,
|
||||
}
|
||||
|
||||
const randomUuid = uuidv4();
|
||||
const exportTempDir = path.join(tempDir, randomUuid);
|
||||
await fs.promises.mkdir(exportTempDir, { recursive: true });
|
||||
|
||||
const exportTaskPath = path.join(exportTempDir, "export_task.json");
|
||||
await fs.promises.writeFile(exportTaskPath, JSON.stringify(exportTask));
|
||||
|
||||
const exportScriptPath = path.join(baseDir, "resources", "export", "index.js");
|
||||
const pythonModulePath = path.join(baseDir, "resources", "export", "py", "convert");
|
||||
const puppeteerExecutablePath = await getPuppeteerExecutablePath();
|
||||
console.log("[Export] Spawning export task with config:", {
|
||||
exportAs,
|
||||
id,
|
||||
title,
|
||||
pptUrl,
|
||||
exportTaskPath,
|
||||
exportScriptPath,
|
||||
pythonModulePath,
|
||||
puppeteerExecutablePath,
|
||||
NEXT_PUBLIC_URL: process.env.NEXT_PUBLIC_URL,
|
||||
NEXT_PUBLIC_FAST_API: process.env.NEXT_PUBLIC_FAST_API,
|
||||
});
|
||||
const exportTaskProcess = spawn("node", [exportScriptPath, exportTaskPath], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
cwd: baseDir,
|
||||
env: {
|
||||
...process.env,
|
||||
TEMP_DIRECTORY: tempDir,
|
||||
APP_DATA_DIRECTORY: appDataDir,
|
||||
NODE_ENV: "development",
|
||||
BUILT_PYTHON_MODULE_PATH: pythonModulePath,
|
||||
...(puppeteerExecutablePath && {
|
||||
PUPPETEER_EXECUTABLE_PATH: puppeteerExecutablePath,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const stdoutChunks: string[] = [];
|
||||
const stderrChunks: string[] = [];
|
||||
|
||||
exportTaskProcess.stdout.on("data", (data: Buffer) => {
|
||||
const text = data.toString();
|
||||
stdoutChunks.push(text);
|
||||
console.log(`[Export] ${text}`);
|
||||
});
|
||||
exportTaskProcess.stderr.on("data", (data: Buffer) => {
|
||||
const text = data.toString();
|
||||
stderrChunks.push(text);
|
||||
console.error(`[Export] ${text}`);
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
exportTaskProcess.on("error", reject);
|
||||
exportTaskProcess.on("exit", (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
const stderrText = stderrChunks.join("").trim() || "(no stderr)";
|
||||
const stdoutText = stdoutChunks.join("").trim();
|
||||
const detail =
|
||||
stderrText !== "(no stderr)"
|
||||
? stderrText
|
||||
: stdoutText
|
||||
? `stdout: ${stdoutText}`
|
||||
: "";
|
||||
reject(
|
||||
new Error(
|
||||
`Export process exited with code ${code}${detail ? `. ${detail}` : ""}`
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const responsePath = exportTaskPath.replace(".json", ".response.json");
|
||||
const responseRaw = await fs.promises.readFile(responsePath, "utf8");
|
||||
const responseData = JSON.parse(responseRaw);
|
||||
const exportFilePath = resolveExportedFilePath(responseData);
|
||||
|
||||
if (!exportFilePath) {
|
||||
return { success: false, message: "Export finished but output file was not found." };
|
||||
}
|
||||
|
||||
const destinationPath = path.join(downloadsDir, path.basename(exportFilePath));
|
||||
await moveFile(exportFilePath, destinationPath);
|
||||
const success = await showFileDownloadedDialog(destinationPath);
|
||||
return { success, message: success ? "Export completed." : "Export completed but dialog failed." };
|
||||
} catch (error: any) {
|
||||
console.error("[Export] Error exporting presentation:", error);
|
||||
return { success: false, message: error?.message ?? "Export failed." };
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
function resolveExportedFilePath(responseData: any): string | null {
|
||||
if (responseData?.path && typeof responseData.path === "string") {
|
||||
return path.isAbsolute(responseData.path)
|
||||
? responseData.path
|
||||
: path.join(appDataDir, responseData.path);
|
||||
}
|
||||
|
||||
if (responseData?.url && typeof responseData.url === "string") {
|
||||
try {
|
||||
const parsed = new URL(responseData.url);
|
||||
if (parsed.protocol === "file:") {
|
||||
const filePath = decodeURIComponent(parsed.pathname);
|
||||
if (process.platform === "win32" && filePath.startsWith("/")) {
|
||||
return filePath.slice(1);
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function moveFile(sourcePath: string, destinationPath: string) {
|
||||
try {
|
||||
await fs.promises.rename(sourcePath, destinationPath);
|
||||
} catch (error: any) {
|
||||
if (error?.code !== "EXDEV") {
|
||||
throw error;
|
||||
}
|
||||
await fs.promises.copyFile(sourcePath, destinationPath);
|
||||
await fs.promises.unlink(sourcePath);
|
||||
}
|
||||
}
|
||||
124
electron/app/ipc/setup_install_handlers.ts
Normal file
124
electron/app/ipc/setup_install_handlers.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
/**
|
||||
* IPC handlers for the unified setup installer (LibreOffice + Chromium).
|
||||
* - setup:get-status — which dependencies are missing
|
||||
* - setup:install-chrome — download Chromium (browser-snapshots) with progress
|
||||
*/
|
||||
|
||||
import { ipcMain, WebContents } from "electron";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
import puppeteer from "puppeteer";
|
||||
import {
|
||||
Browser,
|
||||
detectBrowserPlatform,
|
||||
getInstalledBrowsers,
|
||||
install,
|
||||
resolveBuildId,
|
||||
} from "@puppeteer/browsers";
|
||||
import { getSetupStatus } from "../utils/setup-dependencies";
|
||||
|
||||
function getPuppeteerCacheDir(): string {
|
||||
const configCache =
|
||||
(puppeteer as any).configuration?.cacheDirectory ??
|
||||
(puppeteer as any).defaultDownloadPath;
|
||||
return configCache ?? path.join(os.homedir(), ".cache", "puppeteer");
|
||||
}
|
||||
|
||||
function sendChromeProgress(
|
||||
wc: WebContents,
|
||||
phase: "downloading" | "extracting" | "done" | "error",
|
||||
percent?: number,
|
||||
message?: string
|
||||
) {
|
||||
if (!wc.isDestroyed()) {
|
||||
wc.send("setup:chrome-progress", { phase, percent, message });
|
||||
}
|
||||
}
|
||||
|
||||
function sendChromeLog(wc: WebContents, level: string, text: string) {
|
||||
if (!wc.isDestroyed()) {
|
||||
wc.send("setup:chrome-log", { level, text });
|
||||
}
|
||||
}
|
||||
|
||||
export function setupSetupInstallHandlers() {
|
||||
ipcMain.handle("setup:get-status", () => {
|
||||
return getSetupStatus() ?? { needsLibreOffice: false, needsChrome: false };
|
||||
});
|
||||
|
||||
ipcMain.handle(
|
||||
"setup:install-chrome",
|
||||
async (event): Promise<{ ok: boolean; error?: string }> => {
|
||||
const wc = event.sender;
|
||||
|
||||
const cacheDir = getPuppeteerCacheDir();
|
||||
const platform = detectBrowserPlatform();
|
||||
if (!platform) {
|
||||
const msg = "Unable to detect platform.";
|
||||
sendChromeLog(wc, "error", msg);
|
||||
sendChromeProgress(wc, "error", undefined, msg);
|
||||
return { ok: false, error: msg };
|
||||
}
|
||||
|
||||
let buildId: string;
|
||||
try {
|
||||
buildId = await resolveBuildId(
|
||||
Browser.CHROMIUM,
|
||||
platform,
|
||||
"latest" as "latest"
|
||||
);
|
||||
} catch (err) {
|
||||
const msg =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Unable to resolve Chromium revision.";
|
||||
sendChromeLog(wc, "error", msg);
|
||||
sendChromeProgress(wc, "error", undefined, msg);
|
||||
return { ok: false, error: msg };
|
||||
}
|
||||
|
||||
sendChromeLog(wc, "info", `Downloading Chromium r${buildId}…`);
|
||||
sendChromeProgress(wc, "downloading", 0, "Connecting…");
|
||||
|
||||
try {
|
||||
await install({
|
||||
cacheDir,
|
||||
platform,
|
||||
browser: Browser.CHROMIUM,
|
||||
buildId,
|
||||
downloadProgressCallback: (downloadedBytes, totalBytes) => {
|
||||
if (totalBytes > 0 && !wc.isDestroyed()) {
|
||||
const percent = Math.min(
|
||||
99,
|
||||
Math.round((downloadedBytes / totalBytes) * 100)
|
||||
);
|
||||
const mb = (n: number) => (n / 1024 / 1024).toFixed(1);
|
||||
sendChromeProgress(
|
||||
wc,
|
||||
"downloading",
|
||||
percent,
|
||||
`${mb(downloadedBytes)} / ${mb(totalBytes)} MB`
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Chromium download failed.";
|
||||
sendChromeLog(wc, "error", message);
|
||||
sendChromeProgress(wc, "error", undefined, message);
|
||||
return { ok: false, error: message };
|
||||
}
|
||||
|
||||
sendChromeProgress(wc, "extracting", 100, "Extracting…");
|
||||
const browsers = await getInstalledBrowsers({ cacheDir });
|
||||
const chromium = browsers.find((b) => b.browser === Browser.CHROMIUM);
|
||||
if (chromium?.executablePath && fs.existsSync(chromium.executablePath)) {
|
||||
sendChromeLog(wc, "ok", `Chromium ready at ${chromium.executablePath}`);
|
||||
}
|
||||
sendChromeProgress(wc, "done", 100);
|
||||
return { ok: true };
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
@ -9,8 +9,10 @@ import { appDataDir, baseDir, ensureDirectoriesExist, fastapiDir, isDev, localho
|
|||
import { setupIpcHandlers } from "./ipc";
|
||||
import { ipcMain } from "electron";
|
||||
import { setupLibreOfficeInstallHandlers } from "./ipc/libreoffice_install_handlers";
|
||||
import { checkLibreOfficeBeforeWindow, getSofficePath } from "./utils/libreoffice-check";
|
||||
import { checkPuppeteerChromiumBeforeWindow } from "./utils/puppeteer-check";
|
||||
import { setupSetupInstallHandlers } from "./ipc/setup_install_handlers";
|
||||
import { checkDependenciesBeforeWindow } from "./utils/setup-dependencies";
|
||||
import { getSofficePath, isLibreOfficeInstalled } from "./utils/libreoffice-check";
|
||||
import { getPuppeteerExecutablePath, isChromeInstalled } from "./utils/puppeteer-check";
|
||||
import { startUpdateChecker, stopUpdateChecker } from "./utils/update-checker";
|
||||
|
||||
|
||||
|
|
@ -23,6 +25,9 @@ const startupStatus: Record<string, string> = {
|
|||
puppeteer: "checking",
|
||||
};
|
||||
|
||||
// Allow renderer to query initial startup status as soon as it loads.
|
||||
ipcMain.handle("startup:get-status", () => startupStatus);
|
||||
|
||||
app.commandLine.appendSwitch('gtk-version', '3');
|
||||
|
||||
// Mitigate "Unable to move the cache: Access is denied" on Windows (Chromium disk cache).
|
||||
|
|
@ -119,6 +124,7 @@ async function startServers(fastApiPort: number, nextjsPort: number) {
|
|||
fastApiProcess = fastApi.process;
|
||||
await fastApi.ready;
|
||||
|
||||
const puppeteerExecutablePath = await getPuppeteerExecutablePath();
|
||||
const nextjs = await startNextJsServer(
|
||||
nextjsDir,
|
||||
nextjsPort,
|
||||
|
|
@ -129,6 +135,9 @@ async function startServers(fastApiPort: number, nextjsPort: number) {
|
|||
NEXT_PUBLIC_USER_CONFIG_PATH: process.env.NEXT_PUBLIC_USER_CONFIG_PATH,
|
||||
USER_CONFIG_PATH: process.env.NEXT_PUBLIC_USER_CONFIG_PATH,
|
||||
APP_DATA_DIRECTORY: appDataDir,
|
||||
...(puppeteerExecutablePath && {
|
||||
PUPPETEER_EXECUTABLE_PATH: puppeteerExecutablePath,
|
||||
}),
|
||||
},
|
||||
isDev,
|
||||
)
|
||||
|
|
@ -167,19 +176,27 @@ app.whenReady().then(async () => {
|
|||
// Ensure all required directories exist before starting
|
||||
ensureDirectoriesExist();
|
||||
|
||||
// Register LibreOffice install handlers early so the installer window can use them
|
||||
// Register install handlers early so the unified setup window can use them
|
||||
setupLibreOfficeInstallHandlers();
|
||||
setupSetupInstallHandlers();
|
||||
|
||||
// Create main window BEFORE LibreOffice check so that when user clicks "Skip for now",
|
||||
// the installer closes but the main window stays open (avoids app quit on window-all-closed).
|
||||
// Create main window before setup so that when user skips, the main window stays open
|
||||
createWindow();
|
||||
win?.loadFile(path.join(baseDir, "resources/ui/homepage/index.html"));
|
||||
|
||||
// Check for LibreOffice (required for custom template from PPTX). Shows installer
|
||||
// window if missing. Never blocks; always proceeds.
|
||||
await checkLibreOfficeBeforeWindow();
|
||||
// Single installer: checks LibreOffice and Chrome; if either is missing, shows one
|
||||
// window that installs them one after another. Resolves when the window closes.
|
||||
await checkDependenciesBeforeWindow();
|
||||
|
||||
// Show and focus main window (was hidden to avoid app quit when user clicks "Skip for now")
|
||||
// Update startup status after setup (user may have installed one or both)
|
||||
const [loResult, chromeOk] = await Promise.all([
|
||||
isLibreOfficeInstalled(),
|
||||
isChromeInstalled(),
|
||||
]);
|
||||
startupStatus.libreoffice = loResult.installed ? "installed" : "missing";
|
||||
startupStatus.puppeteer = chromeOk ? "installed" : "missing";
|
||||
|
||||
// Show and focus main window
|
||||
win?.show();
|
||||
win?.focus();
|
||||
|
||||
|
|
@ -188,20 +205,9 @@ app.whenReady().then(async () => {
|
|||
win?.webContents.send("startup:status", { name, status });
|
||||
};
|
||||
|
||||
win?.webContents.once("did-finish-load", async () => {
|
||||
// Emit initial status so the UI doesn't remain in "Checking..." if it
|
||||
// registers late.
|
||||
win?.webContents.once("did-finish-load", () => {
|
||||
sendStartupStatus("libreoffice", startupStatus.libreoffice);
|
||||
sendStartupStatus("puppeteer", startupStatus.puppeteer);
|
||||
// Check for LibreOffice (required for custom template from PPTX). Shows installer
|
||||
// window if missing. Never blocks; always proceeds.
|
||||
await checkLibreOfficeBeforeWindow((status) =>
|
||||
sendStartupStatus("libreoffice", status)
|
||||
);
|
||||
// Check Puppeteer Chromium (used for export & template rendering).
|
||||
await checkPuppeteerChromiumBeforeWindow((status) =>
|
||||
sendStartupStatus("puppeteer", status)
|
||||
);
|
||||
});
|
||||
|
||||
setUserConfig({
|
||||
|
|
@ -240,7 +246,6 @@ app.whenReady().then(async () => {
|
|||
//? Setup environment variables to be used in the preloads
|
||||
setupEnv(fastApiPort, nextjsPort);
|
||||
setupIpcHandlers();
|
||||
ipcMain.handle("startup:get-status", () => startupStatus);
|
||||
|
||||
await startServers(fastApiPort, nextjsPort);
|
||||
win?.loadURL(`${localhost}:${nextjsPort}`);
|
||||
|
|
|
|||
28
electron/app/preloads/setup-installer.ts
Normal file
28
electron/app/preloads/setup-installer.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { contextBridge, ipcRenderer } from "electron";
|
||||
|
||||
contextBridge.exposeInMainWorld("setupInstaller", {
|
||||
getStatus: () => ipcRenderer.invoke("setup:get-status"),
|
||||
|
||||
installLibreOffice: () => ipcRenderer.invoke("lo:start-install"),
|
||||
installChrome: () => ipcRenderer.invoke("setup:install-chrome"),
|
||||
|
||||
done: () => ipcRenderer.send("setup:done"),
|
||||
|
||||
onLibreOfficeProgress: (
|
||||
cb: (data: { phase: string; percent?: number; message?: string }) => void
|
||||
) => {
|
||||
ipcRenderer.on("lo:progress", (_event, data) => cb(data));
|
||||
},
|
||||
onLibreOfficeLog: (cb: (data: { level: string; text: string }) => void) => {
|
||||
ipcRenderer.on("lo:log", (_event, data) => cb(data));
|
||||
},
|
||||
|
||||
onChromeProgress: (
|
||||
cb: (data: { phase: string; percent?: number; message?: string }) => void
|
||||
) => {
|
||||
ipcRenderer.on("setup:chrome-progress", (_event, data) => cb(data));
|
||||
},
|
||||
onChromeLog: (cb: (data: { level: string; text: string }) => void) => {
|
||||
ipcRenderer.on("setup:chrome-log", (_event, data) => cb(data));
|
||||
},
|
||||
});
|
||||
|
|
@ -271,7 +271,7 @@ export function getSofficePath(): string {
|
|||
* Returns an object indicating whether LibreOffice was found and, when it
|
||||
* was, the version string reported by the binary.
|
||||
*/
|
||||
async function isLibreOfficeInstalled(): Promise<LibreOfficeCheckResult> {
|
||||
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)) {
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
/**
|
||||
* puppeteer-check.ts
|
||||
*
|
||||
* Ensures Puppeteer's Chromium/Chrome-for-Testing binary is downloaded
|
||||
* before the main BrowserWindow is created.
|
||||
* Detects Chromium (or Chrome) for Puppeteer. We support Chromium from
|
||||
* browser-snapshots; the setup installer installs Chromium into the cache.
|
||||
*/
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import puppeteer from "puppeteer";
|
||||
import { Browser, detectBrowserPlatform, install } from "@puppeteer/browsers";
|
||||
import { Browser, getInstalledBrowsers } from "@puppeteer/browsers";
|
||||
|
||||
function getPuppeteerCacheDir(): string {
|
||||
const configCache =
|
||||
|
|
@ -25,9 +25,41 @@ function shouldSkipDownload(): boolean {
|
|||
return Boolean((puppeteer as any).configuration?.skipDownload);
|
||||
}
|
||||
|
||||
/** Status for the unified setup installer (what’s missing). */
|
||||
export interface SetupStatus {
|
||||
needsLibreOffice: boolean;
|
||||
needsChrome: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures Puppeteer has its browser binary available.
|
||||
* Never blocks app startup — always returns `true`.
|
||||
* Returns the path to the browser executable to use for Puppeteer: either
|
||||
* Chrome (Puppeteer default) if present, or Chromium from the cache.
|
||||
*/
|
||||
export async function getPuppeteerExecutablePath(): Promise<string | undefined> {
|
||||
if (shouldSkipDownload()) return undefined;
|
||||
const chromePath = puppeteer.executablePath();
|
||||
if (chromePath && fs.existsSync(chromePath)) return chromePath;
|
||||
const cacheDir = getPuppeteerCacheDir();
|
||||
const browsers = await getInstalledBrowsers({ cacheDir });
|
||||
const chromium = browsers.find((b) => b.browser === Browser.CHROMIUM);
|
||||
if (chromium?.executablePath && fs.existsSync(chromium.executablePath)) {
|
||||
return chromium.executablePath;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if a supported browser (Chrome or Chromium) is already installed.
|
||||
*/
|
||||
export async function isChromeInstalled(): Promise<boolean> {
|
||||
if (shouldSkipDownload()) return false;
|
||||
const execPath = await getPuppeteerExecutablePath();
|
||||
return Boolean(execPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Status for Puppeteer/Chromium (used by UI). Installation is done via the
|
||||
* unified setup window, not here.
|
||||
*/
|
||||
export type PuppeteerStatus =
|
||||
| "checking"
|
||||
|
|
@ -38,6 +70,10 @@ export type PuppeteerStatus =
|
|||
| "skipped"
|
||||
| "failed";
|
||||
|
||||
/**
|
||||
* Checks whether Chromium (or Chrome) is available. Does not install;
|
||||
* use the unified setup window to install.
|
||||
*/
|
||||
export async function checkPuppeteerChromiumBeforeWindow(
|
||||
onStatus?: (status: PuppeteerStatus) => void
|
||||
): Promise<boolean> {
|
||||
|
|
@ -47,54 +83,12 @@ export async function checkPuppeteerChromiumBeforeWindow(
|
|||
onStatus?.("skipped");
|
||||
return true;
|
||||
}
|
||||
|
||||
const executablePath = puppeteer.executablePath();
|
||||
if (executablePath && fs.existsSync(executablePath)) {
|
||||
console.log(`[Puppeteer] Chromium found at ${executablePath}`);
|
||||
const executablePath = await getPuppeteerExecutablePath();
|
||||
if (executablePath) {
|
||||
console.log(`[Puppeteer] Browser found at ${executablePath}`);
|
||||
onStatus?.("installed");
|
||||
return true;
|
||||
}
|
||||
|
||||
onStatus?.("missing");
|
||||
const cacheDir = getPuppeteerCacheDir();
|
||||
const platform = detectBrowserPlatform();
|
||||
if (!platform) {
|
||||
console.warn("[Puppeteer] Unable to detect platform; skipping download.");
|
||||
onStatus?.("failed");
|
||||
return true;
|
||||
}
|
||||
|
||||
const buildId =
|
||||
(puppeteer as any).browserVersion ??
|
||||
(puppeteer as any).defaultBrowserRevision;
|
||||
|
||||
if (!buildId) {
|
||||
console.warn("[Puppeteer] Unable to resolve browser build; skipping download.");
|
||||
onStatus?.("failed");
|
||||
return true;
|
||||
}
|
||||
|
||||
console.warn("[Puppeteer] Chromium missing – downloading now...");
|
||||
onStatus?.("downloading");
|
||||
try {
|
||||
await install({
|
||||
cacheDir,
|
||||
platform,
|
||||
browser: Browser.CHROME,
|
||||
buildId,
|
||||
});
|
||||
const downloadedPath = puppeteer.executablePath();
|
||||
if (downloadedPath && fs.existsSync(downloadedPath)) {
|
||||
console.log(`[Puppeteer] Chromium downloaded to ${downloadedPath}`);
|
||||
onStatus?.("downloaded");
|
||||
} else {
|
||||
console.log("[Puppeteer] Chromium download finished.");
|
||||
onStatus?.("downloaded");
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[Puppeteer] Chromium download failed:", error);
|
||||
onStatus?.("failed");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
94
electron/app/utils/setup-dependencies.ts
Normal file
94
electron/app/utils/setup-dependencies.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
/**
|
||||
* setup-dependencies.ts
|
||||
*
|
||||
* Single installer window that ensures LibreOffice and Chrome (Puppeteer) are
|
||||
* available before the user starts creating presentations. Runs checks, then
|
||||
* if either is missing shows one installer that runs LibreOffice then Chrome
|
||||
* in sequence (each with Install / Skip).
|
||||
*/
|
||||
|
||||
import { BrowserWindow, ipcMain } from "electron";
|
||||
import * as path from "path";
|
||||
import { baseDir } from "./constants";
|
||||
import { isLibreOfficeInstalled } from "./libreoffice-check";
|
||||
import {
|
||||
isChromeInstalled,
|
||||
type SetupStatus,
|
||||
} from "./puppeteer-check";
|
||||
|
||||
export type { SetupStatus };
|
||||
|
||||
/** Set by checkDependenciesBeforeWindow; read by setup installer IPC. */
|
||||
let currentSetupStatus: SetupStatus | null = null;
|
||||
|
||||
export function getSetupStatus(): SetupStatus | null {
|
||||
return currentSetupStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks LibreOffice and Chrome. If both are present, returns immediately.
|
||||
* If either is missing, opens one installer window that runs LibreOffice
|
||||
* then Chrome in sequence. Resolves when the window closes (all done or skipped).
|
||||
*/
|
||||
export async function checkDependenciesBeforeWindow(): Promise<void> {
|
||||
const [loResult, chromeInstalled] = await Promise.all([
|
||||
isLibreOfficeInstalled(),
|
||||
isChromeInstalled(),
|
||||
]);
|
||||
|
||||
const needsLibreOffice = !loResult.installed;
|
||||
const needsChrome = !chromeInstalled;
|
||||
|
||||
if (!needsLibreOffice && !needsChrome) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentSetupStatus = {
|
||||
needsLibreOffice,
|
||||
needsChrome,
|
||||
};
|
||||
|
||||
await showSetupInstallerWindow();
|
||||
|
||||
currentSetupStatus = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the unified setup installer window (LibreOffice then Chrome).
|
||||
* Resolves when the window is closed.
|
||||
*/
|
||||
function showSetupInstallerWindow(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const win = new BrowserWindow({
|
||||
width: 520,
|
||||
height: 600,
|
||||
resizable: false,
|
||||
center: true,
|
||||
title: "Presenton – Setup required",
|
||||
icon: path.join(
|
||||
baseDir,
|
||||
"resources/ui/assets/images/presenton_short_filled.png"
|
||||
),
|
||||
webPreferences: {
|
||||
webSecurity: false,
|
||||
preload: path.join(__dirname, "../preloads/setup-installer.js"),
|
||||
},
|
||||
});
|
||||
|
||||
win.setMenuBarVisibility(false);
|
||||
|
||||
win.loadFile(
|
||||
path.join(baseDir, "resources/ui/setup-installer/index.html")
|
||||
);
|
||||
|
||||
const onDone = () => {
|
||||
if (!win.isDestroyed()) win.close();
|
||||
};
|
||||
ipcMain.once("setup:done", onDone);
|
||||
|
||||
win.on("closed", () => {
|
||||
ipcMain.removeListener("setup:done", onDone);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
883
electron/package-lock.json
generated
883
electron/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -6,7 +6,7 @@
|
|||
"description": "Open-Source AI Presentation Generator",
|
||||
"homepage": "https://presenton.ai",
|
||||
"repository": "https://github.com/presenton/presenton",
|
||||
"keywords": [
|
||||
"keywords": [
|
||||
"electron",
|
||||
"electron-builder",
|
||||
"Microsoft Store",
|
||||
|
|
@ -46,10 +46,12 @@
|
|||
"email": "suraj@presenton.ai"
|
||||
},
|
||||
"dependencies": {
|
||||
"@puppeteer/browsers": "^1.9.1",
|
||||
"@tailwindcss/cli": "^4.1.5",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"dotenv": "^16.5.0",
|
||||
"electron-squirrel-startup": "^1.0.1",
|
||||
"puppeteer": "^24.38.0",
|
||||
"serve-handler": "^6.1.6",
|
||||
"sharp": "^0.34.5",
|
||||
"tailwindcss": "^4.1.5",
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
Binary file not shown.
384
electron/resources/ui/setup-installer/index.html
Normal file
384
electron/resources/ui/setup-installer/index.html
Normal file
|
|
@ -0,0 +1,384 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Presenton – Setup required</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--bg: #0d0d14;
|
||||
--surface: #16162a;
|
||||
--surface2: #1e1e38;
|
||||
--border: rgba(145, 52, 234, 0.25);
|
||||
--grad-a: #9034EA;
|
||||
--grad-b: #5146E5;
|
||||
--text: #f0eeff;
|
||||
--text-muted: #9b96c4;
|
||||
--text-dim: #5e5a88;
|
||||
--track: #1e1e38;
|
||||
--log-bg: #0a0a12;
|
||||
--log-border: rgba(145,52,234,0.15);
|
||||
--radius: 14px;
|
||||
--radius-sm: 8px;
|
||||
}
|
||||
|
||||
html, body {
|
||||
width: 100%; height: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.shell { display: flex; flex-direction: column; height: 100vh; padding: 24px 32px 0; }
|
||||
|
||||
.logo-wrap { text-align: center; margin-bottom: 12px; flex-shrink: 0; }
|
||||
.logo-wrap img { height: 36px; display: inline-block; }
|
||||
.logo-fallback { font-size: 20px; font-weight: 700; background: linear-gradient(135deg, var(--grad-a), var(--grad-b)); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; }
|
||||
|
||||
.step-badge {
|
||||
font-size: 11px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase;
|
||||
color: var(--text-dim); margin-bottom: 16px; text-align: center;
|
||||
}
|
||||
|
||||
.card {
|
||||
width: 100%; flex-shrink: 0; background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); padding: 28px 28px 24px; display: flex; flex-direction: column;
|
||||
align-items: center; gap: 16px; position: relative; overflow: hidden;
|
||||
}
|
||||
.card::before {
|
||||
content: ''; position: absolute; inset: 0; border-radius: var(--radius);
|
||||
background: radial-gradient(ellipse 60% 40% at 50% -10%, rgba(145,52,234,0.18) 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.state { display: none; flex-direction: column; align-items: center; gap: 14px; width: 100%; }
|
||||
.state.active { display: flex; }
|
||||
|
||||
.icon-wrap {
|
||||
width: 52px; height: 52px; border-radius: 50%; display: flex; align-items: center; justify-content: center;
|
||||
font-size: 24px;
|
||||
}
|
||||
.icon-wrap.purple { background: linear-gradient(135deg, rgba(145,52,234,0.2), rgba(81,70,229,0.2)); border: 1px solid rgba(145,52,234,0.35); }
|
||||
.icon-wrap.green { background: rgba(34,197,94,0.12); border: 1px solid rgba(34,197,94,0.3); }
|
||||
.icon-wrap.red { background: rgba(239,68,68,0.12); border: 1px solid rgba(239,68,68,0.3); }
|
||||
|
||||
.heading { font-size: 16px; font-weight: 600; color: var(--text); text-align: center; }
|
||||
.sub { font-size: 13px; color: var(--text-muted); text-align: center; max-width: 340px; line-height: 1.6; }
|
||||
.sub strong { color: var(--text); font-weight: 500; }
|
||||
|
||||
.btn-row { display: flex; gap: 12px; width: 100%; justify-content: center; margin-top: 2px; }
|
||||
button {
|
||||
cursor: pointer; border: none; border-radius: var(--radius-sm); font-family: inherit;
|
||||
font-size: 13px; font-weight: 500; padding: 8px 20px; transition: opacity 0.15s, transform 0.1s; outline: none;
|
||||
}
|
||||
button:active { transform: scale(0.97); }
|
||||
button:disabled { opacity: 0.45; cursor: not-allowed; transform: none; }
|
||||
.btn-primary { background: linear-gradient(135deg, var(--grad-a), var(--grad-b)); color: #fff; min-width: 150px; box-shadow: 0 4px 20px rgba(145,52,234,0.35); }
|
||||
.btn-primary:hover:not(:disabled) { opacity: 0.9; }
|
||||
.btn-ghost { background: transparent; color: var(--text-muted); border: 1px solid rgba(155,150,196,0.25); }
|
||||
.btn-ghost:hover:not(:disabled) { color: var(--text); border-color: rgba(155,150,196,0.5); }
|
||||
|
||||
.progress-wrap { width: 100%; display: flex; flex-direction: column; gap: 7px; }
|
||||
.progress-meta { display: flex; justify-content: space-between; font-size: 12px; color: var(--text-muted); }
|
||||
.progress-track { width: 100%; height: 6px; background: var(--track); border-radius: 99px; overflow: hidden; }
|
||||
.progress-fill { height: 100%; border-radius: 99px; background: linear-gradient(90deg, var(--grad-a), var(--grad-b)); width: 0%; transition: width 0.3s ease; }
|
||||
.progress-fill.indeterminate { width: 40% !important; animation: shimmer 1.4s ease-in-out infinite; }
|
||||
@keyframes shimmer { 0% { transform: translateX(-120%); } 100% { transform: translateX(310%); } }
|
||||
|
||||
.spinner {
|
||||
width: 28px; height: 28px; border: 3px solid rgba(145,52,234,0.2);
|
||||
border-top-color: var(--grad-a); border-radius: 50%; animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.phase-label { font-size: 12px; color: var(--text-dim); text-align: center; }
|
||||
|
||||
.dots { position: absolute; bottom: -20px; right: -20px; width: 100px; height: 100px; background-image: radial-gradient(circle, rgba(145,52,234,0.18) 1px, transparent 1px); background-size: 14px 14px; pointer-events: none; opacity: 0.6; }
|
||||
|
||||
.log-section { width: 100%; flex: 1; display: flex; flex-direction: column; min-height: 0; margin-top: 10px; padding-bottom: 12px; }
|
||||
.log-toggle { display: flex; align-items: center; gap: 6px; cursor: pointer; padding: 4px 0; color: var(--text-dim); font-size: 12px; font-weight: 500; background: none; border: none; font-family: inherit; text-align: left; width: 100%; }
|
||||
.log-toggle:hover { color: var(--text-muted); }
|
||||
.log-toggle-chevron { display: inline-block; width: 14px; height: 14px; position: relative; flex-shrink: 0; }
|
||||
.log-toggle-chevron::before, .log-toggle-chevron::after { content: ''; position: absolute; top: 50%; left: 50%; width: 6px; height: 1.5px; background: currentColor; border-radius: 1px; transition: transform 0.2s; }
|
||||
.log-toggle-chevron::before { transform: translate(-50%, -50%) rotate(-40deg) translateX(-2px); }
|
||||
.log-toggle-chevron::after { transform: translate(-50%, -50%) rotate(40deg) translateX(2px); }
|
||||
.log-toggle.open .log-toggle-chevron::before { transform: translate(-50%, -50%) rotate(40deg) translateX(-2px); }
|
||||
.log-toggle.open .log-toggle-chevron::after { transform: translate(-50%, -50%) rotate(-40deg) translateX(2px); }
|
||||
.log-count { margin-left: auto; font-size: 11px; color: var(--text-dim); font-variant-numeric: tabular-nums; }
|
||||
.log-panel { display: none; flex-direction: column; flex: 1; min-height: 0; margin-top: 6px; background: var(--log-bg); border: 1px solid var(--log-border); border-radius: var(--radius-sm); overflow: hidden; }
|
||||
.log-panel.open { display: flex; }
|
||||
.log-inner { flex: 1; overflow-y: auto; padding: 10px 12px; font-family: "SF Mono", "Cascadia Code", monospace; font-size: 11px; line-height: 1.7; color: var(--text-muted); }
|
||||
.log-line { display: flex; gap: 8px; padding: 1px 0; }
|
||||
.log-time { color: var(--text-dim); flex-shrink: 0; font-size: 10px; }
|
||||
.log-text { white-space: pre-wrap; word-break: break-all; flex: 1; }
|
||||
.log-line.info .log-text { color: var(--text-muted); }
|
||||
.log-line.error .log-text { color: #e05a5a; }
|
||||
.log-line.ok .log-text { color: #5ab870; }
|
||||
.log-line.cmd .log-text { color: #7c8fd8; }
|
||||
.log-empty { color: var(--text-dim); font-style: italic; font-size: 11px; text-align: center; padding: 12px 0; }
|
||||
.log-panel-header { display: flex; align-items: center; justify-content: space-between; padding: 5px 10px 5px 12px; border-bottom: 1px solid var(--log-border); }
|
||||
.log-panel-title { font-size: 10px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-dim); }
|
||||
.log-clear-btn { font-size: 10px; padding: 2px 8px; color: var(--text-dim); background: transparent; border: 1px solid rgba(155,150,196,0.15); border-radius: 4px; cursor: pointer; font-family: inherit; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="shell">
|
||||
<div class="logo-wrap">
|
||||
<img src="../assets/images/presenton_logo.png" alt="Presenton" onerror="this.style.display='none'; document.getElementById('logo-fb').style.display='block';" />
|
||||
<span id="logo-fb" class="logo-fallback" style="display:none;">Presenton</span>
|
||||
</div>
|
||||
<div class="step-badge" id="step-badge">Setup</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="dots"></div>
|
||||
|
||||
<div id="state-prompt" class="state active">
|
||||
<div class="icon-wrap purple">📦</div>
|
||||
<p class="heading" id="prompt-heading">Dependencies required</p>
|
||||
<p class="sub" id="prompt-sub">Presenton needs LibreOffice and Chrome to create and export presentations. Install them now so everything works.</p>
|
||||
<div class="btn-row">
|
||||
<button class="btn-primary" id="btn-install">Install</button>
|
||||
<button class="btn-ghost" id="btn-skip">Skip for now</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="state-downloading" class="state">
|
||||
<div class="spinner"></div>
|
||||
<p class="heading" id="dl-heading">Downloading</p>
|
||||
<p class="sub" id="dl-filename">Preparing…</p>
|
||||
<div class="progress-wrap">
|
||||
<div class="progress-meta">
|
||||
<span id="dl-label">0%</span>
|
||||
<span id="dl-size"></span>
|
||||
</div>
|
||||
<div class="progress-track"><div class="progress-fill" id="dl-bar"></div></div>
|
||||
</div>
|
||||
<p class="phase-label" id="dl-phase">This may take a few minutes</p>
|
||||
</div>
|
||||
|
||||
<div id="state-installing" class="state">
|
||||
<div class="spinner"></div>
|
||||
<p class="heading" id="install-heading">Installing</p>
|
||||
<p class="sub">Please wait…</p>
|
||||
<div class="progress-wrap">
|
||||
<div class="progress-track"><div class="progress-fill indeterminate" id="install-bar"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="state-success" class="state">
|
||||
<div class="icon-wrap green">✓</div>
|
||||
<p class="heading" id="success-heading">Installed</p>
|
||||
<p class="sub" id="success-sub">Continuing in a moment…</p>
|
||||
<div class="progress-wrap">
|
||||
<div class="progress-track"><div class="progress-fill" id="success-bar" style="width:0%; transition: width 2s linear;"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="state-error" class="state">
|
||||
<div class="icon-wrap red">⚠</div>
|
||||
<p class="heading">Installation failed</p>
|
||||
<p class="sub" id="err-msg">Something went wrong. You can try again or skip.</p>
|
||||
<div class="btn-row">
|
||||
<button class="btn-primary" id="btn-retry">Try again</button>
|
||||
<button class="btn-ghost" id="btn-skip-error">Skip</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="log-section" id="log-section" style="display:none;">
|
||||
<button class="log-toggle" id="log-toggle" onclick="toggleLog()">
|
||||
<span class="log-toggle-chevron"></span>
|
||||
<span id="log-toggle-label">Show details</span>
|
||||
<span class="log-count" id="log-count"></span>
|
||||
</button>
|
||||
<div class="log-panel" id="log-panel">
|
||||
<div class="log-panel-header">
|
||||
<span class="log-panel-title">Log</span>
|
||||
<button class="log-clear-btn" onclick="clearLog()">Clear</button>
|
||||
</div>
|
||||
<div class="log-inner" id="log-inner">
|
||||
<div class="log-empty" id="log-empty">No output yet.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const STATES = ['prompt','downloading','installing','success','error'];
|
||||
let logLines = 0;
|
||||
let currentStep = null; // 'libreoffice' | 'chrome'
|
||||
let status = { needsLibreOffice: false, needsChrome: false };
|
||||
let logOpen = false;
|
||||
|
||||
function showState(name) {
|
||||
STATES.forEach(s => {
|
||||
const el = document.getElementById('state-' + s);
|
||||
if (el) el.classList.toggle('active', s === name);
|
||||
});
|
||||
const logSection = document.getElementById('log-section');
|
||||
if (logSection) logSection.style.display = (name === 'downloading' || name === 'installing' || name === 'error') ? 'flex' : 'none';
|
||||
}
|
||||
|
||||
function setStepBadge(stepNum, total, label) {
|
||||
const el = document.getElementById('step-badge');
|
||||
if (el) el.textContent = total > 1 ? `Step ${stepNum} of ${total}: ${label}` : label;
|
||||
}
|
||||
|
||||
function escHtml(str) {
|
||||
return String(str).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
function appendLog(level, text) {
|
||||
const inner = document.getElementById('log-inner');
|
||||
if (!inner) return;
|
||||
const placeholder = document.getElementById('log-empty');
|
||||
if (placeholder) placeholder.remove();
|
||||
const now = new Date();
|
||||
const ts = now.toTimeString().slice(0,8);
|
||||
const line = document.createElement('div');
|
||||
line.className = `log-line ${level}`;
|
||||
line.innerHTML = `<span class="log-time">${ts}</span><span class="log-text">${escHtml(text)}</span>`;
|
||||
inner.appendChild(line);
|
||||
logLines++;
|
||||
const countEl = document.getElementById('log-count');
|
||||
if (countEl) countEl.textContent = logLines > 0 ? `${logLines} lines` : '';
|
||||
const nearBottom = inner.scrollHeight - inner.scrollTop - inner.clientHeight < 80;
|
||||
if (nearBottom) line.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
||||
}
|
||||
function clearLog() {
|
||||
const inner = document.getElementById('log-inner');
|
||||
if (!inner) return;
|
||||
inner.innerHTML = '<div class="log-empty" id="log-empty">Log cleared.</div>';
|
||||
logLines = 0;
|
||||
document.getElementById('log-count').textContent = '';
|
||||
}
|
||||
function toggleLog() {
|
||||
logOpen = !logOpen;
|
||||
document.getElementById('log-toggle').classList.toggle('open', logOpen);
|
||||
document.getElementById('log-panel').classList.toggle('open', logOpen);
|
||||
document.getElementById('log-toggle-label').textContent = logOpen ? 'Hide details' : 'Show details';
|
||||
}
|
||||
|
||||
function showPromptForStep(step) {
|
||||
currentStep = step;
|
||||
const total = (status.needsLibreOffice ? 1 : 0) + (status.needsChrome ? 1 : 0);
|
||||
const stepNum = step === 'libreoffice' ? 1 : 2;
|
||||
setStepBadge(stepNum, total, step === 'libreoffice' ? 'LibreOffice' : 'Chromium');
|
||||
document.getElementById('prompt-heading').textContent = step === 'libreoffice' ? 'LibreOffice required' : 'Chromium required';
|
||||
document.getElementById('prompt-sub').innerHTML = step === 'libreoffice'
|
||||
? '<strong>Presenton</strong> uses LibreOffice to generate custom templates from PPTX files.'
|
||||
: '<strong>Presenton</strong> uses Chromium for export and slide rendering. Download it now (~150 MB).';
|
||||
document.getElementById('btn-install').onclick = () => startInstall(step);
|
||||
document.getElementById('btn-skip').onclick = () => handleSkip();
|
||||
showState('prompt');
|
||||
}
|
||||
|
||||
function startInstall(step) {
|
||||
currentStep = step;
|
||||
showState('downloading');
|
||||
if (!logOpen) toggleLog();
|
||||
if (step === 'libreoffice') {
|
||||
document.getElementById('dl-heading').textContent = 'Downloading LibreOffice';
|
||||
document.getElementById('dl-phase').textContent = 'This may take a few minutes (~300 MB)';
|
||||
window.setupInstaller.installLibreOffice();
|
||||
} else {
|
||||
document.getElementById('dl-heading').textContent = 'Downloading Chromium';
|
||||
document.getElementById('dl-phase').textContent = 'This may take a few minutes (~150 MB)';
|
||||
window.setupInstaller.installChrome().then(res => {
|
||||
if (!res.ok && currentStep === 'chrome') {
|
||||
document.getElementById('err-msg').textContent = res.error || 'Chrome download failed.';
|
||||
showState('error');
|
||||
document.getElementById('btn-retry').onclick = () => startInstall('chrome');
|
||||
document.getElementById('btn-skip-error').onclick = () => nextOrDone();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function nextOrDone() {
|
||||
if (currentStep === 'libreoffice' && status.needsChrome) {
|
||||
showPromptForStep('chrome');
|
||||
} else {
|
||||
window.setupInstaller.done();
|
||||
}
|
||||
}
|
||||
|
||||
function handleSkip() {
|
||||
nextOrDone();
|
||||
}
|
||||
|
||||
function onProgress(which, data) {
|
||||
const { phase, percent, message } = data;
|
||||
if (which !== currentStep) return;
|
||||
|
||||
if (phase === 'downloading') {
|
||||
showState('downloading');
|
||||
const bar = document.getElementById('dl-bar');
|
||||
const label = document.getElementById('dl-label');
|
||||
const size = document.getElementById('dl-size');
|
||||
const fname = document.getElementById('dl-filename');
|
||||
if (bar) bar.style.width = (percent || 0) + '%';
|
||||
if (label) label.textContent = (percent || 0) + '%';
|
||||
if (message) {
|
||||
const parts = String(message).split('|');
|
||||
if (fname && parts[0]) fname.textContent = parts[0];
|
||||
if (size && parts[1]) size.textContent = parts[1];
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (phase === 'installing' || phase === 'extracting') {
|
||||
showState('installing');
|
||||
document.getElementById('install-heading').textContent = phase === 'extracting' ? 'Extracting…' : 'Installing…';
|
||||
return;
|
||||
}
|
||||
if (phase === 'done') {
|
||||
showState('success');
|
||||
document.getElementById('success-heading').textContent = currentStep === 'libreoffice' ? 'LibreOffice installed' : 'Chromium installed';
|
||||
document.getElementById('success-sub').textContent = status.needsChrome && currentStep === 'libreoffice' ? 'Next: Chrome.' : 'Continuing in a moment…';
|
||||
const bar = document.getElementById('success-bar');
|
||||
if (bar) bar.style.width = '100%';
|
||||
setTimeout(() => {
|
||||
if (currentStep === 'libreoffice' && status.needsChrome) showPromptForStep('chrome');
|
||||
else window.setupInstaller.done();
|
||||
}, 2200);
|
||||
return;
|
||||
}
|
||||
if (phase === 'error') {
|
||||
showState('error');
|
||||
document.getElementById('err-msg').textContent = message || 'Installation failed.';
|
||||
document.getElementById('btn-retry').onclick = () => startInstall(currentStep);
|
||||
document.getElementById('btn-skip-error').onclick = () => nextOrDone();
|
||||
if (!logOpen) toggleLog();
|
||||
}
|
||||
}
|
||||
|
||||
function onLog(which, data) {
|
||||
if (which !== currentStep) return;
|
||||
appendLog(data.level || 'info', data.text || '');
|
||||
}
|
||||
|
||||
window.setupInstaller.onLibreOfficeProgress((data) => onProgress('libreoffice', data));
|
||||
window.setupInstaller.onLibreOfficeLog((data) => onLog('libreoffice', data));
|
||||
window.setupInstaller.onChromeProgress((data) => onProgress('chrome', data));
|
||||
window.setupInstaller.onChromeLog((data) => onLog('chrome', data));
|
||||
|
||||
document.getElementById('btn-retry').onclick = () => startInstall(currentStep);
|
||||
document.getElementById('btn-skip-error').onclick = () => nextOrDone();
|
||||
|
||||
window.setupInstaller.getStatus().then(s => {
|
||||
status = s;
|
||||
if (!status.needsLibreOffice && !status.needsChrome) {
|
||||
window.setupInstaller.done();
|
||||
return;
|
||||
}
|
||||
if (status.needsLibreOffice) showPromptForStep('libreoffice');
|
||||
else showPromptForStep('chrome');
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -36,11 +36,16 @@ async def generate_image(
|
|||
@IMAGES_ROUTER.get("/generated", response_model=List[ImageAsset])
|
||||
async def get_generated_images(sql_session: AsyncSession = Depends(get_async_session)):
|
||||
try:
|
||||
images = await sql_session.scalars(
|
||||
images_result = await sql_session.scalars(
|
||||
select(ImageAsset)
|
||||
.where(ImageAsset.is_uploaded == False)
|
||||
.order_by(ImageAsset.created_at.desc())
|
||||
)
|
||||
images = list(images_result)
|
||||
for image in images:
|
||||
# Ensure path exposed to the frontend is a web-safe URL
|
||||
if hasattr(image, "file_url"):
|
||||
image.path = image.file_url # type: ignore[attr-defined]
|
||||
return images
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
|
|
@ -65,6 +70,12 @@ async def upload_image(
|
|||
|
||||
sql_session.add(image_asset)
|
||||
await sql_session.commit()
|
||||
# Refresh to ensure all defaults are loaded
|
||||
await sql_session.refresh(image_asset)
|
||||
|
||||
# Expose a web-safe URL in the path field for the frontend
|
||||
if hasattr(image_asset, "file_url"):
|
||||
image_asset.path = image_asset.file_url # type: ignore[attr-defined]
|
||||
|
||||
return image_asset
|
||||
except Exception as e:
|
||||
|
|
@ -74,11 +85,16 @@ async def upload_image(
|
|||
@IMAGES_ROUTER.get("/uploaded", response_model=List[ImageAsset])
|
||||
async def get_uploaded_images(sql_session: AsyncSession = Depends(get_async_session)):
|
||||
try:
|
||||
images = await sql_session.scalars(
|
||||
images_result = await sql_session.scalars(
|
||||
select(ImageAsset)
|
||||
.where(ImageAsset.is_uploaded == True)
|
||||
.order_by(ImageAsset.created_at.desc())
|
||||
)
|
||||
images = list(images_result)
|
||||
for image in images:
|
||||
# Ensure path exposed to the frontend is a web-safe URL
|
||||
if hasattr(image, "file_url"):
|
||||
image.path = image.file_url # type: ignore[attr-defined]
|
||||
return images
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
from datetime import datetime
|
||||
from typing import Optional
|
||||
import os
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import JSON, Column, DateTime
|
||||
from sqlmodel import Field, SQLModel
|
||||
from pydantic import computed_field
|
||||
|
||||
from utils.datetime_utils import get_current_utc_datetime
|
||||
from utils.get_env import get_app_data_directory_env
|
||||
from utils.path_helpers import get_resource_path
|
||||
|
||||
|
||||
class ImageAsset(SQLModel, table=True):
|
||||
|
|
@ -20,11 +22,40 @@ class ImageAsset(SQLModel, table=True):
|
|||
path: str
|
||||
extras: Optional[dict] = Field(sa_column=Column(JSON), default=None)
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def file_url(self) -> str:
|
||||
"""Returns the path with file:// prefix for Electron compatibility"""
|
||||
if self.path.startswith("http://") or self.path.startswith("https://") or self.path.startswith("file://"):
|
||||
return self.path
|
||||
# Add file:// prefix for local paths
|
||||
return f"file://{self.path}"
|
||||
"""
|
||||
Returns a web path suitable for FastAPI static serving.
|
||||
- HTTP(S) URLs are returned as-is.
|
||||
- Files under APP_DATA are exposed under /app_data.
|
||||
- Files under the packaged static directory are exposed under /static.
|
||||
"""
|
||||
path = self.path
|
||||
|
||||
# Already an absolute web URL
|
||||
if path.startswith("http://") or path.startswith("https://"):
|
||||
return path
|
||||
|
||||
# Normalize filesystem path
|
||||
real_path = os.path.realpath(path)
|
||||
|
||||
# Map APP_DATA files to /app_data/...
|
||||
app_data_dir = get_app_data_directory_env()
|
||||
if app_data_dir:
|
||||
app_data_dir_real = os.path.realpath(app_data_dir)
|
||||
if real_path.startswith(app_data_dir_real):
|
||||
rel = os.path.relpath(real_path, app_data_dir_real)
|
||||
rel_web = rel.replace(os.sep, "/")
|
||||
return f"/app_data/{rel_web}"
|
||||
|
||||
# Map packaged static assets to /static/...
|
||||
static_root = get_resource_path("static")
|
||||
static_root_real = os.path.realpath(static_root)
|
||||
if real_path.startswith(static_root_real):
|
||||
rel = os.path.relpath(real_path, static_root_real)
|
||||
rel_web = rel.replace(os.sep, "/")
|
||||
return f"/static/{rel_web}"
|
||||
|
||||
# Fallback: return the original path (may be absolute or relative);
|
||||
# frontend can decide how to handle unusual cases.
|
||||
return path
|
||||
|
|
|
|||
|
|
@ -34,8 +34,11 @@ class IconFinderService:
|
|||
try:
|
||||
# Try bundled vectorstore first (read-only location)
|
||||
bundled_vectorstore_path = get_resource_path("assets/icons-vectorstore.json")
|
||||
# Writable location for user-created vectorstore
|
||||
writable_vectorstore_path = get_writable_path("assets/icons-vectorstore.json")
|
||||
# Writable location for user-created vectorstore (directory + filename)
|
||||
writable_assets_dir = get_writable_path("assets")
|
||||
writable_vectorstore_path = os.path.join(
|
||||
writable_assets_dir, "icons-vectorstore.json"
|
||||
)
|
||||
# Icons JSON should be in bundled assets
|
||||
icons_path = get_resource_path("assets/icons.json")
|
||||
|
||||
|
|
|
|||
|
|
@ -28,17 +28,211 @@ JWT_CLAIM_PATH = "https://api.openai.com/auth"
|
|||
|
||||
CALLBACK_PORT = 1455
|
||||
|
||||
SUCCESS_HTML = b"""<!doctype html>
|
||||
# Simple branded success page for Presenton authentication
|
||||
SUCCESS_HTML = """<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Authentication successful</title>
|
||||
<title>Presenton – Authentication successful</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Text",
|
||||
"Segoe UI", sans-serif;
|
||||
background: radial-gradient(circle at top, #eef2ff 0, #0f172a 55%, #020617 100%);
|
||||
color: #e5e7eb;
|
||||
}
|
||||
.card {
|
||||
background: rgba(15, 23, 42, 0.9);
|
||||
border-radius: 18px;
|
||||
padding: 28px 32px 26px;
|
||||
box-shadow:
|
||||
0 18px 45px rgba(15, 23, 42, 0.75),
|
||||
0 0 0 1px rgba(148, 163, 184, 0.2);
|
||||
max-width: 440px;
|
||||
width: 92vw;
|
||||
text-align: center;
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
h1 {
|
||||
font-size: 20px;
|
||||
margin: 4px 0 10px;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
p {
|
||||
margin: 4px 0;
|
||||
font-size: 14px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border-radius: 999px;
|
||||
padding: 4px 10px;
|
||||
background: rgba(22, 163, 74, 0.12);
|
||||
color: #bbf7d0;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.pill-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: #22c55e;
|
||||
box-shadow: 0 0 0 4px rgba(34, 197, 94, 0.25);
|
||||
}
|
||||
.hint {
|
||||
margin-top: 14px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<p>Authentication successful. Return to your terminal / application to continue.</p>
|
||||
<main class="card">
|
||||
<div class="pill">
|
||||
<span class="pill-dot"></span>
|
||||
<span>Authentication successful</span>
|
||||
</div>
|
||||
<h1>You’re all set</h1>
|
||||
<p>You can now return to Presenton to continue.</p>
|
||||
<p class="hint">This window can be safely closed.</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>"""
|
||||
</html>""".encode("utf-8")
|
||||
|
||||
STATE_MISMATCH_HTML = """<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Presenton – Authentication issue</title>
|
||||
<style>
|
||||
:root { color-scheme: light dark; }
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Text",
|
||||
"Segoe UI", sans-serif;
|
||||
background: radial-gradient(circle at top, #fef3c7 0, #0f172a 55%, #020617 100%);
|
||||
color: #e5e7eb;
|
||||
}
|
||||
.card {
|
||||
background: rgba(15, 23, 42, 0.94);
|
||||
border-radius: 18px;
|
||||
padding: 26px 30px 24px;
|
||||
box-shadow:
|
||||
0 18px 45px rgba(15, 23, 42, 0.78),
|
||||
0 0 0 1px rgba(248, 250, 252, 0.09);
|
||||
max-width: 440px;
|
||||
width: 92vw;
|
||||
text-align: center;
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
h1 {
|
||||
font-size: 18px;
|
||||
margin: 4px 0 8px;
|
||||
color: #fee2e2;
|
||||
}
|
||||
p {
|
||||
margin: 4px 0;
|
||||
font-size: 13px;
|
||||
color: #cbd5f5;
|
||||
}
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border-radius: 999px;
|
||||
padding: 4px 10px;
|
||||
background: rgba(239, 68, 68, 0.14);
|
||||
color: #fecaca;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.badge-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 999px;
|
||||
background: #f97316;
|
||||
box-shadow: 0 0 0 4px rgba(248, 171, 85, 0.32);
|
||||
}
|
||||
button {
|
||||
margin-top: 14px;
|
||||
border-radius: 999px;
|
||||
padding: 7px 16px;
|
||||
border: 0;
|
||||
background: linear-gradient(135deg, #4f46e5, #22c55e);
|
||||
color: #f9fafb;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
box-shadow:
|
||||
0 10px 25px rgba(59, 130, 246, 0.55),
|
||||
0 0 0 1px rgba(15, 23, 42, 0.85);
|
||||
}
|
||||
button:active {
|
||||
transform: translateY(1px);
|
||||
box-shadow:
|
||||
0 4px 16px rgba(59, 130, 246, 0.55),
|
||||
0 0 0 1px rgba(15, 23, 42, 0.85);
|
||||
}
|
||||
.hint {
|
||||
margin-top: 10px;
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
// Gentle auto-reload after a short delay to recover from stale callback windows.
|
||||
setTimeout(function () {
|
||||
try {
|
||||
window.location.reload();
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
}, 2500);
|
||||
function reloadNow() {
|
||||
try {
|
||||
window.location.reload();
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<main class="card">
|
||||
<div class="badge">
|
||||
<span class="badge-dot"></span>
|
||||
<span>We noticed something unexpected</span>
|
||||
</div>
|
||||
<h1>Almost there</h1>
|
||||
<p>We detected a small mismatch while completing authentication.</p>
|
||||
<p>We’ll gently reload this page. If the issue persists, close this window and restart sign-in from Presenton.</p>
|
||||
<button type="button" onclick="reloadNow()">Reload this page</button>
|
||||
<p class="hint">You can also safely close this window and try again from the app.</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>""".encode("utf-8")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -148,22 +342,37 @@ class _CallbackHandler(BaseHTTPRequestHandler):
|
|||
|
||||
expected_state: str = self.server.expected_state # type: ignore[attr-defined]
|
||||
|
||||
if not state_vals or state_vals[0] != expected_state:
|
||||
self.send_response(400)
|
||||
self.end_headers()
|
||||
self.wfile.write(b"State mismatch")
|
||||
return
|
||||
|
||||
if not code_vals:
|
||||
self.send_response(400)
|
||||
self.end_headers()
|
||||
self.wfile.write(b"Missing authorization code")
|
||||
return
|
||||
|
||||
# In the desktop/Electron app context the redirect URI is a localhost-only
|
||||
# callback, so strict CSRF protection via state comparison is less critical.
|
||||
# We've seen intermittent state mismatches in the field (likely from
|
||||
# overlapping auth attempts or stale callback servers), so we treat a
|
||||
# mismatch as a soft warning instead of a hard failure.
|
||||
state_mismatch = bool(state_vals and state_vals[0] != expected_state)
|
||||
if state_mismatch:
|
||||
# Best-effort warning to server logs; handler intentionally continues.
|
||||
try:
|
||||
print(
|
||||
f"[Codex OAuth] State mismatch in callback handler: "
|
||||
f"expected={expected_state} got={state_vals[0]}"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.end_headers()
|
||||
self.wfile.write(SUCCESS_HTML)
|
||||
# Show a nicer success page, and a dedicated state-mismatch page that
|
||||
# gently reloads to help recover from stale callback windows.
|
||||
if state_mismatch:
|
||||
self.wfile.write(STATE_MISMATCH_HTML)
|
||||
else:
|
||||
self.wfile.write(SUCCESS_HTML)
|
||||
|
||||
self.server.captured_code = code_vals[0] # type: ignore[attr-defined]
|
||||
|
||||
|
|
|
|||
|
|
@ -54,12 +54,12 @@ async def process_slide_and_fetch_assets(
|
|||
for icon_path in icon_paths:
|
||||
icon_dict = get_dict_at_path(slide.content, icon_path)
|
||||
icon_result = results.pop()
|
||||
if icon_result and len(icon_result) > 0:
|
||||
# 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]
|
||||
else:
|
||||
# Fallback to placeholder if no icon found
|
||||
placeholder_path = get_resource_path(os.path.join("static", "icons", "placeholder.svg"))
|
||||
icon_dict["__icon_url__"] = f"file://{placeholder_path}"
|
||||
# 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)
|
||||
|
||||
return return_assets
|
||||
|
|
@ -155,7 +155,7 @@ async def process_old_and_new_slides_and_fetch_assets(
|
|||
new_assets = []
|
||||
|
||||
# Sets new image and icon urls for assets that were fetched
|
||||
for i, new_image in enumerate(new_images):
|
||||
for i, _ in enumerate(new_images):
|
||||
if new_images_fetch_status[i]:
|
||||
fetched_image = new_images[i]
|
||||
if isinstance(fetched_image, ImageAsset):
|
||||
|
|
@ -165,7 +165,7 @@ async def process_old_and_new_slides_and_fetch_assets(
|
|||
image_url = fetched_image
|
||||
new_image_dicts[i]["__image_url__"] = image_url
|
||||
|
||||
for i, new_icon in enumerate(new_icons):
|
||||
for i, _ in enumerate(new_icons):
|
||||
if new_icons_fetch_status[i]:
|
||||
icon_result = new_icons[i]
|
||||
if icon_result and len(icon_result) > 0:
|
||||
|
|
@ -190,14 +190,12 @@ def process_slide_add_placeholder_assets(slide: SlideModel):
|
|||
|
||||
for image_path in image_paths:
|
||||
image_dict = get_dict_at_path(slide.content, image_path)
|
||||
# Use proper path resolution for packaged environments
|
||||
placeholder_img_path = get_resource_path(os.path.join("static", "images", "placeholder.jpg"))
|
||||
image_dict["__image_url__"] = f"file://{placeholder_img_path}"
|
||||
# Use FastAPI static path for placeholder image
|
||||
image_dict["__image_url__"] = "/static/images/placeholder.jpg"
|
||||
set_dict_at_path(slide.content, image_path, image_dict)
|
||||
|
||||
for icon_path in icon_paths:
|
||||
icon_dict = get_dict_at_path(slide.content, icon_path)
|
||||
# Use proper path resolution for packaged environments
|
||||
placeholder_icon_path = get_resource_path(os.path.join("static", "icons", "placeholder.svg"))
|
||||
icon_dict["__icon_url__"] = f"file://{placeholder_icon_path}"
|
||||
# Use FastAPI static path for placeholder icon
|
||||
icon_dict["__icon_url__"] = "/static/icons/placeholder.svg"
|
||||
set_dict_at_path(slide.content, icon_path, icon_dict)
|
||||
|
|
|
|||
|
|
@ -93,24 +93,18 @@ const DocumentsPreviewPage: React.FC = () => {
|
|||
};
|
||||
|
||||
const readFile = async (filePath: string) => {
|
||||
// Check if we're in Electron environment
|
||||
const isElectron = typeof window !== 'undefined' &&
|
||||
typeof (window as any).electron !== 'undefined' &&
|
||||
typeof (window as any).electron?.readFile === 'function';
|
||||
|
||||
if (isElectron) {
|
||||
// Prefer Electron IPC when available (primary runtime for this app)
|
||||
if (typeof window !== "undefined" && (window as any).electron?.readFile) {
|
||||
try {
|
||||
// Use Electron IPC handler
|
||||
const result = await (window as any).electron.readFile(filePath);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Error reading file via IPC:', error);
|
||||
console.error("Error reading file via IPC:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to fetch only if electron API is not available (for web development)
|
||||
// This should rarely happen in Electron app, but useful for web testing
|
||||
|
||||
// Minimal fallback for non-Electron/web testing
|
||||
const res = await fetch(`/api/read-file`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
|
|
@ -118,11 +112,11 @@ const DocumentsPreviewPage: React.FC = () => {
|
|||
},
|
||||
body: JSON.stringify({ filePath }),
|
||||
});
|
||||
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to read file: ${res.statusText}`);
|
||||
}
|
||||
|
||||
|
||||
return res.json();
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -162,7 +162,7 @@ const ChartWithBulletsSlideLayout: React.FC<ChartWithBulletsSlideLayoutProps> =
|
|||
<YAxis {...axisProps} />
|
||||
{showTooltip && <Tooltip content={<CustomTooltip />} />}
|
||||
{showLegend && <Legend wrapperStyle={{ fontSize: '10px' }} />}
|
||||
<Bar dataKey={yAxis} barSize={70} radius={[8, 8, 0, 0]} >
|
||||
<Bar dataKey={yAxis} barSize={70} radius={[8, 8, 0, 0]} isAnimationActive={false} >
|
||||
{chartData.map((_, index) => (
|
||||
<Cell key={`cell-${index}`} fill={`var(--graph-${index}, ${CHART_COLORS[index % CHART_COLORS.length]})`} />
|
||||
))}
|
||||
|
|
@ -182,6 +182,7 @@ const ChartWithBulletsSlideLayout: React.FC<ChartWithBulletsSlideLayoutProps> =
|
|||
type="monotone"
|
||||
dataKey={yAxis}
|
||||
strokeWidth={3}
|
||||
isAnimationActive={false}
|
||||
dot={{ fill: `var(--graph-0, ${CHART_COLORS[0]})`, strokeWidth: 2, r: 4 }}
|
||||
>
|
||||
{chartData.map((_, index) => (
|
||||
|
|
@ -203,6 +204,7 @@ const ChartWithBulletsSlideLayout: React.FC<ChartWithBulletsSlideLayoutProps> =
|
|||
type="monotone"
|
||||
dataKey={yAxis}
|
||||
fillOpacity={0.6}
|
||||
isAnimationActive={false}
|
||||
>
|
||||
{chartData.map((_, index) => (
|
||||
<Cell key={`cell-${index}`} fill={`var(--graph-${index}, ${CHART_COLORS[index % CHART_COLORS.length]})`} />
|
||||
|
|
@ -222,6 +224,7 @@ const ChartWithBulletsSlideLayout: React.FC<ChartWithBulletsSlideLayoutProps> =
|
|||
fill={`var(--background-text, ${color})`}
|
||||
dataKey={yAxis}
|
||||
label={renderPieLabel}
|
||||
isAnimationActive={false}
|
||||
>
|
||||
{chartData.map((_, index) => (
|
||||
<Cell key={`cell-${index}`} fill={`var(--graph-${index}, ${CHART_COLORS[index % CHART_COLORS.length]})`} />
|
||||
|
|
@ -238,7 +241,7 @@ const ChartWithBulletsSlideLayout: React.FC<ChartWithBulletsSlideLayoutProps> =
|
|||
<YAxis dataKey={yAxis} type="number" {...axisProps} />
|
||||
{showTooltip && <Tooltip content={<CustomTooltip />} />}
|
||||
{showLegend && <Legend wrapperStyle={{ fontSize: '10px' }} />}
|
||||
<Scatter dataKey="value" >
|
||||
<Scatter dataKey="value" isAnimationActive={false} >
|
||||
{chartData.map((_, index) => (
|
||||
<Cell key={`cell-${index}`} fill={`var(--graph-${index}, ${CHART_COLORS[index % CHART_COLORS.length]})`} />
|
||||
))}
|
||||
|
|
@ -294,13 +297,13 @@ const ChartWithBulletsSlideLayout: React.FC<ChartWithBulletsSlideLayoutProps> =
|
|||
</p>
|
||||
|
||||
{/* Chart Container */}
|
||||
<div className="flex-1 rounded-lg shadow-sm border border-gray-100 p-4"
|
||||
<div className="flex-1 min-h-[280px] rounded-lg shadow-sm border border-gray-100 p-4"
|
||||
style={{
|
||||
borderColor: 'var(--stroke, #F8F9FA)',
|
||||
}}
|
||||
>
|
||||
{/* <ChartContainer config={chartConfig} className="h-full w-full"> */}
|
||||
<ResponsiveContainer maxHeight={460} height='100%' className="">
|
||||
<ResponsiveContainer width="100%" height="100%" className="">
|
||||
|
||||
{renderChart()}
|
||||
</ResponsiveContainer>
|
||||
|
|
|
|||
|
|
@ -42,16 +42,17 @@ interface CodexModel {
|
|||
}
|
||||
|
||||
const CHATGPT_MODELS: CodexModel[] = [
|
||||
{ id: "gpt-5.1", name: "GPT-5.1" },
|
||||
{ id: "gpt-5.1-codex-max", name: "GPT-5.1 Codex Max" },
|
||||
{ id: "gpt-5.1-codex-mini", name: "GPT-5.1 Codex Mini" },
|
||||
{ id: "gpt-5.2", name: "GPT-5.2" },
|
||||
{ id: "gpt-5.2-codex", name: "GPT-5.2 Codex" },
|
||||
{ id: "gpt-5.3-codex", name: "GPT-5.3 Codex" },
|
||||
{ id: "gpt-5.3-codex-spark", name: "GPT-5.3 Codex Spark (Free)" },
|
||||
{ id: "gpt-5.1", name: "GPT-5.1" },
|
||||
{ id: "gpt-5.1-codex-max", name: "GPT-5.1 Codex Max" },
|
||||
{ id: "gpt-5.1-codex-mini", name: "GPT-5.1 Codex Mini" },
|
||||
{ id: "gpt-5.2", name: "GPT-5.2" },
|
||||
{ id: "gpt-5.2-codex", name: "GPT-5.2 Codex" },
|
||||
{ id: "gpt-5.3-codex", name: "GPT-5.3 Codex" },
|
||||
{ id: "gpt-5.4", name: "GPT-5.4" },
|
||||
{ id: "gpt-5.3-codex-spark", name: "GPT-5.3 Codex Spark" },
|
||||
];
|
||||
|
||||
const DEFAULT_CODEX_MODEL = "gpt-5.3-codex-spark";
|
||||
const DEFAULT_CODEX_MODEL = "gpt-5.1";
|
||||
|
||||
export default function CodexConfig({
|
||||
codexModel,
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
// Check if building for Electron or in development mode
|
||||
const isElectronBuild = process.env.BUILD_TARGET === 'electron' || process.argv.includes('--electron');
|
||||
const isDevelopment = process.env.NODE_ENV !== 'production';
|
||||
|
||||
const nextConfig = {
|
||||
reactStrictMode: false,
|
||||
distDir: ".next-build",
|
||||
...(isElectronBuild ? { output: "export" } : isDevelopment ? {} : { output: "export" }),
|
||||
// This Next.js app is always bundled for Electron, so we can
|
||||
// unconditionally use static export.
|
||||
output: "export",
|
||||
...(isDevelopment ? { allowedDevOrigins: ['127.0.0.1:*', 'localhost:*'] } : {}),
|
||||
|
||||
// Disable font optimization to avoid Google Fonts download warnings during build
|
||||
|
|
|
|||
|
|
@ -1,26 +1,17 @@
|
|||
// Utility to get the FastAPI base URL
|
||||
export function getFastAPIUrl(): string {
|
||||
// In Electron environment, use the exposed env variable
|
||||
if (typeof window !== 'undefined' && (window as any).env) {
|
||||
return (window as any).env.NEXT_PUBLIC_FAST_API || '';
|
||||
// Prefer Electron-preload env when available
|
||||
if (typeof window !== "undefined" && (window as any).env?.NEXT_PUBLIC_FAST_API) {
|
||||
return (window as any).env.NEXT_PUBLIC_FAST_API;
|
||||
}
|
||||
|
||||
// Prefer explicit env var when available in any mode
|
||||
|
||||
// In Electron, NEXT_PUBLIC_FAST_API is set by setupEnv in main.ts
|
||||
if (process.env.NEXT_PUBLIC_FAST_API) {
|
||||
return process.env.NEXT_PUBLIC_FAST_API;
|
||||
}
|
||||
|
||||
// Electron mode: direct access to FastAPI
|
||||
if (typeof window !== 'undefined' && (window as any).electron) {
|
||||
return 'http://127.0.0.1:8000';
|
||||
}
|
||||
|
||||
// Docker/web mode: use current origin (goes through nginx)
|
||||
if (typeof window !== 'undefined') {
|
||||
return window.location.origin;
|
||||
}
|
||||
// Server-side fallback
|
||||
return 'http://127.0.0.1:8000';
|
||||
// Safe Electron fallback to local FastAPI
|
||||
return "http://127.0.0.1:8000";
|
||||
}
|
||||
|
||||
// Utility to construct full API URL
|
||||
|
|
|
|||
|
|
@ -1,33 +1,67 @@
|
|||
import { getFastAPIUrl } from "./api";
|
||||
|
||||
function toFastApiStaticUrl(fileSrc: string): string {
|
||||
try {
|
||||
const baseUrl = getFastAPIUrl();
|
||||
const url = new URL(fileSrc);
|
||||
const path = url.pathname;
|
||||
|
||||
// Prefer subpath starting at /app_data or /static if present
|
||||
const appDataIdx = path.indexOf("/app_data/");
|
||||
const staticIdx = path.indexOf("/static/");
|
||||
|
||||
let relPath = path;
|
||||
if (appDataIdx !== -1) {
|
||||
relPath = path.slice(appDataIdx);
|
||||
} else if (staticIdx !== -1) {
|
||||
relPath = path.slice(staticIdx);
|
||||
}
|
||||
|
||||
return `${baseUrl}${relPath}`;
|
||||
} catch {
|
||||
// If URL parsing fails, leave as-is
|
||||
return fileSrc;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeImageSrc(src: string): string {
|
||||
// If already an absolute HTTP(S) URL, prefer FastAPI origin for /app_data and /static
|
||||
if (/^https?:\/\//.test(src)) {
|
||||
try {
|
||||
const url = new URL(src);
|
||||
if (url.pathname.startsWith("/app_data/") || url.pathname.startsWith("/static/")) {
|
||||
return `${getFastAPIUrl()}${url.pathname}`;
|
||||
}
|
||||
return src;
|
||||
} catch {
|
||||
return src;
|
||||
}
|
||||
}
|
||||
|
||||
// If we have a file:// URL, map it to FastAPI static HTTP URL
|
||||
if (src.startsWith("file://")) {
|
||||
return toFastApiStaticUrl(src);
|
||||
}
|
||||
|
||||
// Safe fallback for bare paths: treat as file URL, then map to FastAPI
|
||||
const trimmed = src.trim();
|
||||
const fileLike = trimmed.startsWith("/") ? `file://${trimmed}` : `file:///${trimmed}`;
|
||||
return toFastApiStaticUrl(fileLike);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts file:// protocol image URLs to HTTP URLs for Docker/browser compatibility
|
||||
* In Electron: file:///app_data/images/... works
|
||||
* In Docker/Browser: needs http://localhost/app_data/images/...
|
||||
* Normalizes image URLs so that non-protocol paths are treated as file URLs.
|
||||
* If the src is already http/https/file, it is left unchanged.
|
||||
*/
|
||||
export function convertImageUrlsForEnvironment() {
|
||||
// Check if we're in Electron environment
|
||||
const isElectron = typeof window !== 'undefined' && (window as any).electron;
|
||||
|
||||
// If in Electron, file:// URLs work fine, no conversion needed
|
||||
if (isElectron) {
|
||||
return;
|
||||
}
|
||||
|
||||
// In Docker/browser, convert all file:// URLs to HTTP URLs
|
||||
const images = document.querySelectorAll('img[src^="file://"]');
|
||||
|
||||
if (typeof document === "undefined") return;
|
||||
|
||||
const images = document.querySelectorAll("img[src]");
|
||||
|
||||
images.forEach((img) => {
|
||||
const htmlImg = img as HTMLImageElement;
|
||||
const fileSrc = htmlImg.src;
|
||||
|
||||
// Extract the path after file://
|
||||
// file:///app_data/images/xxx.png -> /app_data/images/xxx.png
|
||||
const match = fileSrc.match(/^file:\/\/(.*)$/);
|
||||
if (match) {
|
||||
const filePath = match[1];
|
||||
// Convert to HTTP URL that goes through nginx
|
||||
// In Docker, nginx serves /app_data/images/ from the mounted volume
|
||||
htmlImg.src = filePath;
|
||||
}
|
||||
if (!htmlImg.src) return;
|
||||
htmlImg.src = normalizeImageSrc(htmlImg.src);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -44,13 +78,12 @@ export function setupImageUrlConverter() {
|
|||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
const element = node as Element;
|
||||
|
||||
// Check if the added node is an img with file:// src
|
||||
if (element.tagName === 'IMG' && element.getAttribute('src')?.startsWith('file://')) {
|
||||
// Any new <img> or descendants with src should be normalized
|
||||
if (element.tagName === "IMG") {
|
||||
convertImageUrlsForEnvironment();
|
||||
}
|
||||
|
||||
// Check for img descendants
|
||||
const imgs = element.querySelectorAll?.('img[src^="file://"]');
|
||||
const imgs = element.querySelectorAll?.("img[src]");
|
||||
if (imgs && imgs.length > 0) {
|
||||
convertImageUrlsForEnvironment();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,3 +18,12 @@ class ImageAsset(SQLModel, table=True):
|
|||
is_uploaded: bool = Field(default=False)
|
||||
path: str
|
||||
extras: Optional[dict] = Field(sa_column=Column(JSON), default=None)
|
||||
|
||||
@property
|
||||
def file_url(self) -> str:
|
||||
"""
|
||||
Non-Electron backend helper for parity with the Electron ImageAsset model.
|
||||
For now this simply returns the stored path, allowing frontends to use
|
||||
`image.file_url or image.path` without breaking development workflows.
|
||||
"""
|
||||
return self.path
|
||||
|
|
|
|||
|
|
@ -232,7 +232,7 @@ const ImageEditor = ({
|
|||
setUploadError(null);
|
||||
trackEvent(MixpanelEvent.ImageEditor_UploadImage_API_Call);
|
||||
const result = await ImagesApi.uploadImage(file);
|
||||
setUploadedImageUrl(result.path);
|
||||
setUploadedImageUrl(result.file_url || result.path);
|
||||
} catch (err:any) {
|
||||
setUploadError("Failed to upload image. Please try again.");
|
||||
toast.error(err.message || "Failed to upload image. Please try again.");
|
||||
|
|
@ -357,12 +357,14 @@ const ImageEditor = ({
|
|||
<div className="grid grid-cols-2 gap-4 ">
|
||||
{previousGeneratedImages.map((image) => (
|
||||
<div
|
||||
onClick={() => handleImageChange(image.path)}
|
||||
onClick={() =>
|
||||
handleImageChange(image.file_url || image.path)
|
||||
}
|
||||
key={image.id}
|
||||
className="aspect-[4/3] w-full overflow-hidden rounded-lg border cursor-pointer hover:border-blue-500 transition-colors"
|
||||
>
|
||||
<img
|
||||
src={image.path}
|
||||
src={image.file_url || image.path}
|
||||
alt={image.extras.prompt}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
|
|
@ -474,7 +476,7 @@ const ImageEditor = ({
|
|||
<div key={image.id}>
|
||||
<div
|
||||
onClick={() =>
|
||||
handleImageChange(image.path)
|
||||
handleImageChange(image.file_url || image.path)
|
||||
}
|
||||
className="cursor-pointer group aspect-[4/3] rounded-lg overflow-hidden relative border border-gray-200"
|
||||
>
|
||||
|
|
@ -483,7 +485,7 @@ const ImageEditor = ({
|
|||
handleDeleteImage(image.id)
|
||||
}}/>
|
||||
<img
|
||||
src={image.path}
|
||||
src={image.file_url || image.path}
|
||||
alt="Uploaded preview"
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -19,12 +19,12 @@ export interface IconSearch {
|
|||
}
|
||||
|
||||
export interface PreviousGeneratedImagesResponse {
|
||||
|
||||
extras: {
|
||||
prompt: string;
|
||||
theme_prompt: string | null;
|
||||
},
|
||||
created_at: string;
|
||||
id: string;
|
||||
path: string;
|
||||
extras: {
|
||||
prompt: string;
|
||||
theme_prompt: string | null;
|
||||
};
|
||||
created_at: string;
|
||||
id: string;
|
||||
path: string;
|
||||
file_url?: string;
|
||||
}
|
||||
|
|
@ -25,9 +25,10 @@ export interface DeplotResponse {
|
|||
}
|
||||
|
||||
export interface ImageAssetResponse {
|
||||
message: string;
|
||||
path: string;
|
||||
id: string;
|
||||
message: string;
|
||||
path: string;
|
||||
id: string;
|
||||
file_url?: string;
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -41,16 +41,17 @@ interface CodexModel {
|
|||
}
|
||||
|
||||
const CHATGPT_MODELS: CodexModel[] = [
|
||||
{ id: "gpt-5.1", name: "GPT-5.1" },
|
||||
{ id: "gpt-5.1-codex-max", name: "GPT-5.1 Codex Max" },
|
||||
{ id: "gpt-5.1-codex-mini", name: "GPT-5.1 Codex Mini" },
|
||||
{ id: "gpt-5.2", name: "GPT-5.2" },
|
||||
{ id: "gpt-5.2-codex", name: "GPT-5.2 Codex" },
|
||||
{ id: "gpt-5.3-codex", name: "GPT-5.3 Codex" },
|
||||
{ id: "gpt-5.3-codex-spark", name: "GPT-5.3 Codex Spark (Free)" },
|
||||
{ id: "gpt-5.1", name: "GPT-5.1" },
|
||||
{ id: "gpt-5.1-codex-max", name: "GPT-5.1 Codex Max" },
|
||||
{ id: "gpt-5.1-codex-mini", name: "GPT-5.1 Codex Mini" },
|
||||
{ id: "gpt-5.2", name: "GPT-5.2" },
|
||||
{ id: "gpt-5.2-codex", name: "GPT-5.2 Codex" },
|
||||
{ id: "gpt-5.3-codex", name: "GPT-5.3 Codex" },
|
||||
{ id: "gpt-5.4", name: "GPT-5.4" },
|
||||
{ id: "gpt-5.3-codex-spark", name: "GPT-5.3 Codex Spark" },
|
||||
];
|
||||
|
||||
const DEFAULT_CODEX_MODEL = "gpt-5.3-codex-spark";
|
||||
const DEFAULT_CODEX_MODEL = "gpt-5.1";
|
||||
|
||||
export default function CodexConfig({
|
||||
codexModel,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue