Merge pull request #419 from presenton/feat/electron-support

Feature added Electron Application and CI/CD Workflow
This commit is contained in:
Sudip Parajuli 2026-02-22 08:30:17 +05:45 committed by GitHub
commit b30f365869
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2082 changed files with 1318328 additions and 11 deletions

62
.github/workflows/README.md vendored Normal file
View file

@ -0,0 +1,62 @@
# GitHub Actions Workflows
## Test All Applications (`test-all.yml`)
This workflow runs comprehensive tests for all parts of the application:
- **Main FastAPI** - Python tests for the main backend
- **Electron FastAPI** - Python tests for the Electron-compatible backend
- **Main Next.js** - Lint, build, and Cypress component tests
- **Electron Next.js** - Lint, build, and Cypress component tests
- **Docker Build** - Verifies Docker image builds successfully
## Testing Locally
Before pushing, you can test everything locally using the provided script:
```bash
./test-local.sh
```
This script runs the same tests that GitHub Actions will run, so you can catch issues early.
## Manual Testing
If you prefer to test individual components:
### FastAPI Tests
```bash
# Main FastAPI
cd servers/fastapi
export APP_DATA_DIRECTORY=/tmp/app_data
export TEMP_DIRECTORY=/tmp/presenton
export DATABASE_URL=sqlite+aiosqlite:///./test.db
export DISABLE_ANONYMOUS_TRACKING=true
export DISABLE_IMAGE_GENERATION=true
export PYTHONPATH=$(pwd)
pytest tests/ -v
# Electron FastAPI
cd electron/servers/fastapi
# Same environment variables as above
pytest tests/ -v
```
### Next.js Tests
```bash
# Main Next.js
cd servers/nextjs
npm run lint
npm run build
# Electron Next.js
cd electron/servers/nextjs
npm run lint
npm run build
```
### Docker Build
```bash
docker build -t presenton:test -f Dockerfile .
docker images | grep presenton:test
```

188
.github/workflows/test-all.yml vendored Normal file
View file

@ -0,0 +1,188 @@
name: Test All Applications
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
workflow_dispatch:
jobs:
test-fastapi:
name: Test Main FastAPI
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./servers/fastapi
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: 'pip'
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
libreoffice \
fontconfig \
chromium \
chromium-driver
- name: Install Python dependencies
run: |
python -m pip install --upgrade pip
# Install package in editable mode if pyproject.toml exists, otherwise install dependencies directly
if [ -f "pyproject.toml" ]; then
pip install -e .
else
pip install aiohttp aiomysql aiosqlite asyncpg fastapi[standard] \
pathvalidate pdfplumber chromadb sqlmodel \
anthropic google-genai openai fastmcp dirtyjson
fi
- name: Set up environment variables
run: |
echo "APP_DATA_DIRECTORY=/tmp/app_data" >> $GITHUB_ENV
echo "TEMP_DIRECTORY=/tmp/presenton" >> $GITHUB_ENV
echo "PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium" >> $GITHUB_ENV
echo "DATABASE_URL=sqlite+aiosqlite:///./test.db" >> $GITHUB_ENV
echo "DISABLE_ANONYMOUS_TRACKING=true" >> $GITHUB_ENV
echo "DISABLE_IMAGE_GENERATION=true" >> $GITHUB_ENV
test-electron-fastapi:
name: Test Electron FastAPI
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./electron/servers/fastapi
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: 'pip'
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
libreoffice \
fontconfig \
chromium \
chromium-driver
- name: Install Python dependencies
run: |
python -m pip install --upgrade pip
# Install package in editable mode if pyproject.toml exists, otherwise install dependencies directly
if [ -f "pyproject.toml" ]; then
pip install -e .
else
pip install aiohttp aiomysql aiosqlite asyncpg fastapi[standard] \
pathvalidate pdfplumber chromadb sqlmodel \
anthropic google-genai openai fastmcp dirtyjson
fi
- name: Set up environment variables
run: |
echo "APP_DATA_DIRECTORY=/tmp/app_data" >> $GITHUB_ENV
echo "TEMP_DIRECTORY=/tmp/presenton" >> $GITHUB_ENV
echo "PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium" >> $GITHUB_ENV
echo "DATABASE_URL=sqlite+aiosqlite:///./test.db" >> $GITHUB_ENV
echo "DISABLE_ANONYMOUS_TRACKING=true" >> $GITHUB_ENV
echo "DISABLE_IMAGE_GENERATION=true" >> $GITHUB_ENV
- name: Build FastAPI binary
run: pyinstaller server.spec
test-nextjs:
name: Test Main Next.js
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./servers/nextjs
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: servers/nextjs/package-lock.json
- name: Install dependencies
run: npm ci
- name: Build application
env:
NEXT_PUBLIC_FAST_API: http://localhost:8000
NEXT_PUBLIC_URL: http://localhost:3000
run: npm run build
- name: Run Cypress component tests
continue-on-error: true
run: |
# Check if Cypress tests exist
if [ -d "cypress" ] || [ -n "$(find . -name '*.cy.*' -type f 2>/dev/null)" ]; then
echo "Running Cypress component tests..."
npx cypress run --component
else
echo "No Cypress tests found, skipping component tests"
fi
test-electron-nextjs:
name: Test Electron Next.js
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./electron/servers/nextjs
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: electron/servers/nextjs/package-lock.json
- name: Install dependencies
run: npm ci
- name: Build application
env:
BUILD_TARGET: electron
NEXT_PUBLIC_FAST_API: http://localhost:8000
NEXT_PUBLIC_URL: http://localhost:3000
run: npm run build
- name: Run Cypress component tests
continue-on-error: true
run: |
# Check if Cypress tests exist
if [ -d "cypress" ] || [ -n "$(find . -name '*.cy.*' -type f 2>/dev/null)" ]; then
echo "Running Cypress component tests..."
npx cypress run --component
else
echo "No Cypress tests found, skipping component tests"
fi

1
.gitignore vendored
View file

@ -1,6 +1,5 @@
.env
.venv
build
__pycache__
.pytest_cache
.next

24
electron/.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
.env
.venv
__pycache__
.pytest_cache
.next
node_modules
out
user_data
app_data
tmp
debug
.fastembed_cache
generated_models
nltk
chroma
container.db
.next-build
.cursor
app_dist
resources/fastapi
resources/nextjs
dist
servers/fastapi/fastembed_cache/

View file

@ -0,0 +1,169 @@
import { app, ipcMain } from "electron";
import fs from "fs";
import path from "path";
export function setupApiHandlers() {
// Handler for can-change-keys API
ipcMain.handle("api:can-change-keys", async () => {
const canChangeKeys = process.env.CAN_CHANGE_KEYS !== "false";
return { canChange: canChangeKeys };
});
// Handler for has-required-key API
ipcMain.handle("api:has-required-key", async () => {
const userConfigPath = process.env.USER_CONFIG_PATH;
let keyFromFile = "";
if (userConfigPath && fs.existsSync(userConfigPath)) {
try {
const raw = fs.readFileSync(userConfigPath, "utf-8");
const cfg = JSON.parse(raw || "{}");
keyFromFile = cfg?.OPENAI_API_KEY || "";
} catch {
// Silent error handling
}
}
const keyFromEnv = process.env.OPENAI_API_KEY || "";
const hasKey = Boolean((keyFromFile || keyFromEnv).trim());
return { hasKey };
});
// Handler for telemetry-status API
ipcMain.handle("api:telemetry-status", async () => {
const isDisabled = process.env.DISABLE_ANONYMOUS_TELEMETRY === 'true' || process.env.DISABLE_ANONYMOUS_TELEMETRY === 'True';
const telemetryEnabled = !isDisabled;
return { telemetryEnabled };
});
// Handler for save-layout API
ipcMain.handle("api:save-layout", async (event, { layout_name, components }) => {
try {
if (!layout_name || !components || !Array.isArray(components)) {
throw new Error("Invalid request body. Expected layout_name and components array.");
}
// Define the layouts directory path
const layoutsDir = path.join(process.cwd(), "app_data", "layouts", layout_name);
// Create the directory if it doesn't exist
if (!fs.existsSync(layoutsDir)) {
fs.mkdirSync(layoutsDir, { recursive: true });
}
// Save each component as a separate file
const savedFiles = [];
for (const component of components) {
const { slide_number, component_code, component_name } = component;
if (!component_code || !component_name) {
console.warn(
`Skipping component for slide ${slide_number}: missing code or name`
);
continue;
}
const fileName = `${component_name}.tsx`;
const filePath = path.join(layoutsDir, fileName);
const cleanComponentCode = component_code
.replace(/```tsx/g, "")
.replace(/```/g, "");
fs.writeFileSync(filePath, cleanComponentCode, "utf8");
savedFiles.push({
slide_number,
component_name,
file_path: filePath,
file_name: fileName,
});
}
return {
success: true,
layout_name,
path: layoutsDir,
saved_files: savedFiles.length,
components: savedFiles,
};
} catch (error) {
console.error("Error saving layout:", error);
throw new Error("Failed to save layout components");
}
});
// Handler for templates API (static list)
ipcMain.handle("api:templates", async () => {
try {
// In development, use servers/nextjs/presentation-templates
// In production, use resources/nextjs/presentation-templates
const baseDir = app.getAppPath();
const isDev = !app.isPackaged;
const templatesPath = isDev
? path.join(baseDir, "servers", "nextjs", "presentation-templates")
: path.join(baseDir, "resources", "nextjs", "presentation-templates");
if (!fs.existsSync(templatesPath)) {
return [];
}
const items = fs.readdirSync(templatesPath, { withFileTypes: true });
const templateDirectories = items
.filter(item => item.isDirectory())
.map(dir => dir.name);
const allLayouts: Array<{templateName: string; templateID: string; files: string[]; settings: any }> = [];
// Scan each template directory for layout files and settings
for (const templateName of templateDirectories) {
try {
const templatePath = path.join(templatesPath, templateName);
const templateFiles = fs.readdirSync(templatePath);
// Filter for .tsx files and exclude any non-layout files
const layoutFiles = templateFiles.filter(file =>
file.endsWith('.tsx') &&
!file.startsWith('.') &&
!file.includes('.test.') &&
!file.includes('.spec.') &&
file !== 'settings.json'
);
// Read settings.json if it exists
let settings: any = null;
const settingsPath = path.join(templatePath, 'settings.json');
try {
const settingsContent = fs.readFileSync(settingsPath, 'utf-8');
settings = JSON.parse(settingsContent);
} catch (settingsError) {
console.warn(`No settings.json found for template ${templateName} or invalid JSON`);
// Provide default settings if settings.json is missing or invalid
settings = {
description: `${templateName} presentation layouts`,
ordered: false,
default: false
};
}
if (layoutFiles.length > 0) {
allLayouts.push({
templateName: templateName,
templateID: templateName,
files: layoutFiles,
settings: settings
});
}
} catch (error) {
console.error(`Error reading template directory ${templateName}:`, error);
// Continue with other templates even if one fails
}
}
return allLayouts;
} catch (error) {
console.error("Error reading templates:", error);
return [];
}
});
}

View file

@ -0,0 +1,58 @@
import { BrowserWindow, ipcMain, } from "electron";
import { baseDir, downloadsDir } from "../utils/constants";
import fs from "fs";
import path from "path";
import { showFileDownloadedDialog } from "../utils/dialog";
import { sanitizeFilename } from "../utils";
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-as-pdf", async (_, id: string, title: string) => {
const ppt_url = `${process.env.NEXT_PUBLIC_URL}/pdf-maker?id=${id}`;
const browser = new BrowserWindow({
width: 1280,
height: 720,
icon: path.join(baseDir, "resources/ui/assets/images/presenton_short_filled.png"),
webPreferences: {
webSecurity: false,
preload: path.join(__dirname, '../preloads/index.js'),
},
show: false,
});
browser.loadURL(ppt_url);
const success = await new Promise((resolve, _) => {
browser.webContents.on('did-finish-load', async () => {
// Wait for 1 second to make sure the page is loaded
await new Promise((resolve, _) => {
setTimeout(resolve, 1000);
});
const pdfBuffer = await browser.webContents.printToPDF({
printBackground: true,
pageSize: { width: 1280 / 96, height: 720 / 96 },
margins: { top: 0, right: 0, bottom: 0, left: 0 }
});
browser.close();
const sanitizedTitle = sanitizeFilename(title);
const destinationPath = path.join(downloadsDir, `${sanitizedTitle}.pdf`);
await fs.promises.writeFile(destinationPath, pdfBuffer);
const success = await showFileDownloadedDialog(destinationPath);
resolve(success);
});
});
return { success };
})
}

View file

@ -0,0 +1,32 @@
import { ipcMain } from 'electron';
import { settingsStore } from '../services/settings-store';
const FOOTER_KEY = 'footer';
export function setupFooterHandlers() {
ipcMain.handle('get-footer', async () => {
try {
const properties = settingsStore.get(FOOTER_KEY);
return { properties };
} catch (error) {
console.error('Error retrieving footer properties:', error);
throw error;
}
});
ipcMain.handle('set-footer', async (_, properties: any) => {
try {
if (!properties) {
throw new Error('Properties are required');
}
settingsStore.set(FOOTER_KEY, properties);
return { success: true };
} catch (error) {
console.error('Error saving footer properties:', error);
throw error;
}
});
}

25
electron/app/ipc/index.ts Normal file
View file

@ -0,0 +1,25 @@
import { setupExportHandlers } from "./export_handlers";
import { setupUserConfigHandlers } from "./user_config_handlers";
import { setupSlideMetadataHandlers } from "./slide_metadata";
import { setupReadFile } from "./read_file";
import { setupFooterHandlers } from "./footer_handlers";
import { setupThemeHandlers } from "./theme_handlers";
import { setupUploadImage } from "./upload_image";
import { setupLogHandler } from "./log_handler";
import { setupApiHandlers } from "./api_handlers";
import { setupTemplateHandlers } from "./template_api_handlers";
import { setupPresentationToPptxModelHandlers } from "./presentation_to_pptx_model_handlers";
export function setupIpcHandlers() {
setupExportHandlers();
setupUserConfigHandlers();
setupSlideMetadataHandlers();
setupReadFile();
setupFooterHandlers();
setupThemeHandlers();
setupUploadImage();
setupLogHandler();
setupApiHandlers();
setupTemplateHandlers();
setupPresentationToPptxModelHandlers();
}

View file

@ -0,0 +1,50 @@
import { ipcMain } from 'electron';
import * as fs from 'fs';
import * as path from 'path';
import { userDataDir } from '../utils/constants';
export function setupLogHandler() {
// Ensure logs directory exists
const logsDir = path.join(userDataDir, 'logs');
if (!fs.existsSync(logsDir)) {
fs.mkdirSync(logsDir, { recursive: true });
}
const logFilePath = path.join(logsDir, 'nextjs.log');
// Handle log writing through IPC - non-blocking
ipcMain.handle('write-nextjs-log', (_, logData: string) => {
try {
const timestamp = new Date().toISOString();
const logEntry = `[${timestamp}] ${logData}\n`;
// Use non-blocking write
fs.appendFile(logFilePath, logEntry, (err) => {
if (err) {
console.error('Error writing to log file:', err);
}
});
return { success: true };
} catch (error) {
console.error('Error in log handler:', error);
return { success: false, error: (error as Error).message };
}
});
// Handle log clearing
ipcMain.handle('clear-nextjs-logs', () => {
try {
// Create a new empty file, effectively clearing the old one
fs.writeFile(logFilePath, '', (err) => {
if (err) {
console.error('Error clearing log file:', err);
}
});
return { success: true };
} catch (error) {
console.error('Error in clear logs handler:', error);
return { success: false, error: (error as Error).message };
}
});
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,15 @@
import { ipcMain } from "electron";
import fs from "fs";
import path from "path";
export function setupReadFile() {
ipcMain.handle("read-file", async (_, filePath: string) => {
try {
const normalizedPath = path.normalize(filePath);
const content = fs.readFileSync(normalizedPath, 'utf-8');
return { content };
} catch (error) {
console.error('Error reading file:', error);
throw error;
}
});
}

View file

@ -0,0 +1,351 @@
import { BrowserWindow, ipcMain } from "electron";
import fs from 'fs';
import path from 'path';
import { tempDir } from "../utils/constants";
interface Position {
left: number;
top: number;
width: number;
height: number;
}
interface FontStyles {
name: string;
size: number;
bold: boolean;
weight: number;
color: string;
}
interface TextElement {
position: Position;
paragraphs: {
alignment: number;
text: string;
font: FontStyles;
}[];
}
interface PictureElement {
position: Position;
picture: {
is_network: boolean;
path: string;
};
shape: string | null;
object_fit: {
fit: string | null;
focus: number[];
};
overlay: string | null;
border_radius: number[];
}
interface BoxElement {
position: Position;
type: number;
fill: {
color: string;
};
border_radius: number;
stroke: {
color: string;
thickness: number;
};
shadow: {
radius: number;
color: string;
offset: number;
opacity: number;
angle: number;
};
}
interface LineElement {
position: Position;
lineType: number;
thickness: string;
color: string;
}
interface GraphElement {
position: Position;
picture: {
is_network: boolean;
path: string;
};
border_radius: number[];
}
type SlideElement = TextElement | PictureElement | BoxElement | LineElement | GraphElement;
export function setupSlideMetadataHandlers() {
ipcMain.handle("get-slide-metadata", async (_, url: string, theme: string, customColors?: any) => {
let win: BrowserWindow | null = null;
try {
win = new BrowserWindow({
width: 1920,
height: 1080,
webPreferences: {
webSecurity: false,
preload: path.join(__dirname, '../preloads/index.js'),
},
show: false,
});
await win.loadURL(url, { userAgent: 'electron' });
await win.webContents.executeJavaScript(`
new Promise((resolve) => {
const check = () => {
const el = document.querySelector('[data-element-type="slide-container"]');
if (el) return resolve(true);
setTimeout(check, 200);
};
check();
});
`);
const metadata = await win.webContents.executeJavaScript(
`
(() => {
const rgbToHex = (color) => {
if (!color || color === "transparent" || color === "none") return "000000";
if (color.startsWith("#")) return color.replace("#", "");
const matches = color.match(/\\d+/g);
if (!matches) return "000000";
const [r, g, b] = matches.map(x => parseInt(x));
return [r, g, b].map(x => x.toString(16).padStart(2, "0")).join("");
};
const slidesMetadata = [];
const slideContainers = document.querySelectorAll('[data-element-type="slide-container"]');
slideContainers.forEach((container) => {
const containerEl = container;
containerEl.style.width = "1280px";
containerEl.style.height = "720px";
containerEl.style.transform = "none";
const containerRect = containerEl.getBoundingClientRect();
const slideIndex = parseInt(containerEl.getAttribute("data-slide-index") || "0");
const backgroundColor = rgbToHex(window.getComputedStyle(containerEl).backgroundColor);
const elements = [];
const slideElements = containerEl.querySelectorAll('[data-slide-element]:not([data-element-type="slide-container"])');
slideElements.forEach((element) => {
const el = element;
const elementRect = el.getBoundingClientRect();
const computedStyle = window.getComputedStyle(el);
const position = {
left: Math.round(elementRect.left - containerRect.left),
top: Math.round(elementRect.top - containerRect.top),
width: Math.round(elementRect.width),
height: Math.round(elementRect.height),
};
const elementType = el.getAttribute("data-element-type");
if (!elementType) return;
switch (elementType) {
case "text":
elements.push({
position,
paragraphs: [{
alignment: el.getAttribute("data-is-align") === 'true' ? 2 : 1,
text: el.getAttribute("data-text-content") || el.textContent || "",
font: {
name: computedStyle.fontFamily.split('_')[2] || 'Inter',
size: parseInt(computedStyle.fontSize),
bold: parseInt(computedStyle.fontWeight) >= 500,
weight: parseInt(computedStyle.fontWeight),
color: rgbToHex(computedStyle.color),
},
}],
});
break;
case "picture":
const imgEl = el.tagName.toLowerCase() === "img" ? el : el.querySelector("img");
if (imgEl) {
elements.push({
position,
picture: {
is_network: imgEl.src.startsWith("http"),
path: imgEl.src || imgEl.getAttribute("data-image-path") || "",
},
shape: imgEl.getAttribute('data-image-type'),
object_fit: {
fit: imgEl.getAttribute('data-object-fit'),
focus: [
parseFloat(imgEl.getAttribute('data-focial-point-x') || '0'),
parseFloat(imgEl.getAttribute('data-focial-point-y') || '0'),
],
},
overlay: el.getAttribute("data-is-icon") ? "ffffff" : null,
border_radius: Array(4).fill(parseInt(computedStyle.borderRadius) || 0),
});
}
break;
case "graph":
elements.push({
position,
picture: {
is_network: true,
path: \`__GRAPH_PLACEHOLDER__\${el.getAttribute("data-element-id")}\`,
},
border_radius: [0, 0, 0, 0],
});
break;
case "slide-box":
case "filledbox":
const boxShadow = computedStyle.boxShadow;
let shadowRadius = 0;
let shadowColor = "000000";
let shadowOffsetX = 0;
let shadowOffsetY = 0;
let shadowOpacity = 0;
if (boxShadow && boxShadow !== "none") {
const boxShadowRegex =
/rgba?\\((\\d+),\\s*(\\d+),\\s*(\\d+),?\\s*([\\d.]+)?\\)?\\s+(-?\\d+)px\\s+(-?\\d+)px\\s+(-?\\d+)px/;
const match = boxShadow.match(boxShadowRegex);
if (match) {
const r = match[1];
const g = match[2];
const b = match[3];
const rgbStr = "rgb(" + r + ", " + g + ", " + b + ")";
shadowColor = rgbToHex(rgbStr);
shadowOpacity = match[4] ? parseFloat(match[4]) : 1;
shadowOffsetX = parseInt(match[5]);
shadowOffsetY = parseInt(match[6]);
shadowRadius = parseInt(match[7]);
}
}
elements.push({
position,
type:
computedStyle.borderRadius === "9999px" ||
computedStyle.borderRadius === "50%"
? 9
: 5,
fill: {
color: rgbToHex(computedStyle.backgroundColor),
},
border_radius: parseInt(computedStyle.borderRadius) || 0,
stroke: {
color: rgbToHex(computedStyle.borderColor),
thickness: parseInt(computedStyle.borderWidth) || 0,
},
shadow: {
radius: shadowRadius,
color: shadowColor,
offset: Math.sqrt(
shadowOffsetX * shadowOffsetX +
shadowOffsetY * shadowOffsetY
),
opacity: shadowOpacity,
angle: Math.round(
(Math.atan2(shadowOffsetY, shadowOffsetX) * 180) / Math.PI
),
},
});
break;
case "line":
elements.push({
position,
lineType: 1,
thickness: computedStyle.borderWidth || computedStyle.height,
color: rgbToHex(
computedStyle.borderColor || computedStyle.backgroundColor
),
});
break;
}
});
slidesMetadata.push({ slideIndex, backgroundColor, elements });
});
return slidesMetadata;
})();
`
)
// ✅ Handle Graphs: capture each graph element as an image
const graphIds: { id: string; bounds: Electron.Rectangle }[] = await win.webContents.executeJavaScript(`
(() => {
return Array.from(document.querySelectorAll('[data-element-type="graph"]')).map(el => el.getAttribute("data-element-id"));
})();
`);
for (const id of graphIds) {
try {
// Scroll into view first
await win.webContents.executeJavaScript(`
document.querySelector('[data-element-id="${id}"]').scrollIntoView({ behavior: 'instant', block: 'center' });
`);
// Wait a bit for any animations/rendering to complete
await new Promise((r) => setTimeout(r, 2000));
const bounds: Electron.Rectangle = await win.webContents.executeJavaScript(`
(() => {
const el = document.querySelector('[data-element-id="${id}"]');
if (!el) return null;
const rect = el.getBoundingClientRect();
return {
x: Math.round(rect.left),
y: Math.round(rect.top),
width: Math.round(rect.width),
height: Math.round(rect.height),
};
})();
`);
const image = await win.webContents.capturePage(bounds);
const buffer = image.toJPEG(100);
if (buffer.length === 0) {
console.error("Empty buffer! Graph not captured.");
continue;
}
const filePath = path.join(tempDir, `chart-${id}-${Date.now()}.jpeg`);
fs.writeFileSync(filePath, buffer);
// Update metadata
metadata.forEach((slide: any) => {
slide.elements.forEach((element: any) => {
if ("picture" in element && element.picture.path === `__GRAPH_PLACEHOLDER__${id}`) {
element.picture.path = filePath;
}
});
});
} catch (err) {
console.error(`Failed to capture or save chart-${id}:`, err);
}
}
return metadata;
} catch (error) {
console.error("Error during page preparation:", error);
throw error;
} finally {
// if (browser) await browser.close();
if (win) win.close();
}
});
}

View file

@ -0,0 +1,94 @@
import { ipcMain, dialog } from "electron";
import puppeteer from "puppeteer";
export function setupTemplateHandlers() {
// Handler for template API (with puppeteer)
ipcMain.handle("api:template", async (event, { group }: { group: string }) => {
if (!group) {
throw new Error("Missing group name");
}
const schemaPageUrl = `http://localhost/schema?group=${encodeURIComponent(group)}`;
let browser;
try {
browser = await puppeteer.launch({
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH,
headless: true,
args: [
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-dev-shm-usage",
"--disable-gpu",
"--disable-web-security",
"--disable-background-timer-throttling",
"--disable-backgrounding-occluded-windows",
"--disable-renderer-backgrounding",
"--disable-features=TranslateUI",
"--disable-ipc-flooding-protection",
],
});
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 720 });
page.setDefaultNavigationTimeout(300000);
page.setDefaultTimeout(300000);
await page.goto(schemaPageUrl, {
waitUntil: "networkidle0",
timeout: 300000,
});
await page.waitForSelector("[data-layouts]", { timeout: 300000 });
await page.waitForSelector("[data-settings]", { timeout: 300000 });
const { dataLayouts, dataGroupSettings } = await page.$eval(
"[data-layouts]",
(el) => ({
dataLayouts: el.getAttribute("data-layouts"),
dataGroupSettings: el.getAttribute("data-settings"),
})
);
if (!dataLayouts || !dataGroupSettings) {
throw new Error("Could not find layouts or settings data");
}
const layouts = JSON.parse(dataLayouts);
const groupSettings = JSON.parse(dataGroupSettings);
return {
layouts,
groupSettings,
};
} catch (error) {
console.error("Error getting template:", error);
throw new Error(`Failed to get template: ${error instanceof Error ? error.message : String(error)}`);
} finally {
if (browser) {
await browser.close();
}
}
});
// Handler for presentation_to_pptx_model API (simplified version)
ipcMain.handle("api:presentation-to-pptx-model", async (event, { id }: { id?: string }) => {
// Note: This is a simplified version since the full implementation is quite complex
// and involves puppeteer operations that might be better handled differently in Electron
try {
if (!id) {
throw new Error("Missing presentation ID");
}
// For now, return a placeholder response or implement a simplified version
// The full implementation would require significant adaptation for Electron context
return {
error: "This endpoint requires server-side implementation",
message: "Use the FastAPI backend for presentation to PPTX conversion"
};
} catch (error) {
console.error("Error in presentation-to-pptx-model:", error);
throw new Error(`Failed to convert presentation: ${error instanceof Error ? error.message : String(error)}`);
}
});
}

View file

@ -0,0 +1,32 @@
import { ipcMain } from 'electron';
import { settingsStore } from '../services/settings-store';
const THEME_KEY = 'theme';
export function setupThemeHandlers() {
ipcMain.handle('get-theme', async () => {
try {
const theme = settingsStore.get(THEME_KEY);
return { theme };
} catch (error) {
console.error('Error retrieving theme:', error);
throw error;
}
});
ipcMain.handle('set-theme', async (_, themeData: any) => {
try {
if (!themeData) {
throw new Error('Theme data is required');
}
settingsStore.set(THEME_KEY, themeData);
return { success: true };
} catch (error) {
console.error('Error saving theme:', error);
throw error;
}
});
}

View file

@ -0,0 +1,28 @@
import { ipcMain } from "electron";
import path from "path";
import fs from "fs";
import crypto from "crypto";
import { userDataDir } from "../utils/constants";
export function setupUploadImage() {
ipcMain.handle("upload-image", async (_, file: Buffer) => {
try {
// Create uploads directory if it doesn't exist
const uploadsDir = path.join(userDataDir, "uploads");
fs.mkdirSync(uploadsDir, { recursive: true });
// Generate unique filename
const filename = `${crypto.randomBytes(16).toString('hex')}.png`;
const filePath = path.join(uploadsDir, filename);
// Write file to disk
await fs.writeFileSync(filePath, file);
// Return the path with file:// protocol for Electron
return `file://${filePath}`;
} catch (error) {
console.error("Error saving image:", error);
throw error;
}
});
}

View file

@ -0,0 +1,16 @@
import { ipcMain } from "electron";
import { getUserConfig, setUserConfig } from "../utils";
export function setupUserConfigHandlers() {
ipcMain.handle("get-user-config", async (_, __) => {
return getUserConfig();
});
ipcMain.handle("set-user-config", async (_, userConfig: UserConfig) => {
setUserConfig(userConfig);
});
ipcMain.handle("get-can-change-keys", async (_, __) => {
return process.env.CAN_CHANGE_KEYS !== "false";
});
}

151
electron/app/main.ts Normal file
View file

@ -0,0 +1,151 @@
require("dotenv").config();
import { app, BrowserWindow } from "electron";
import path from "path";
import { findUnusedPorts, killProcess, setupEnv, setUserConfig } from "./utils";
import { startFastApiServer, startNextJsServer } from "./utils/servers";
import { ChildProcessByStdio } from "child_process";
import { appDataDir, baseDir, ensureDirectoriesExist, fastapiDir, isDev, localhost, nextjsDir, tempDir, userConfigPath, userDataDir } from "./utils/constants";
import { setupIpcHandlers } from "./ipc";
var win: BrowserWindow | undefined;
var fastApiProcess: ChildProcessByStdio<any, any, any> | undefined;
var nextjsProcess: any;
app.commandLine.appendSwitch('gtk-version', '3');
const createWindow = () => {
win = new BrowserWindow({
width: 1280,
height: 720,
icon: path.join(baseDir, "resources/ui/assets/images/presenton_short_filled.png"),
webPreferences: {
webSecurity: false,
preload: path.join(__dirname, 'preloads/index.js'),
},
});
};
async function startServers(fastApiPort: number, nextjsPort: number) {
try {
fastApiProcess = await startFastApiServer(
fastapiDir,
fastApiPort,
{
DEBUG: isDev ? "True" : "False",
CAN_CHANGE_KEYS: process.env.CAN_CHANGE_KEYS,
LLM: process.env.LLM,
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
OPENAI_MODEL: process.env.OPENAI_MODEL,
GOOGLE_API_KEY: process.env.GOOGLE_API_KEY,
GOOGLE_MODEL: process.env.GOOGLE_MODEL,
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
ANTHROPIC_MODEL: process.env.ANTHROPIC_MODEL,
OLLAMA_URL: process.env.OLLAMA_URL,
OLLAMA_MODEL: process.env.OLLAMA_MODEL,
CUSTOM_LLM_URL: process.env.CUSTOM_LLM_URL,
CUSTOM_LLM_API_KEY: process.env.CUSTOM_LLM_API_KEY,
CUSTOM_MODEL: process.env.CUSTOM_MODEL,
PEXELS_API_KEY: process.env.PEXELS_API_KEY,
PIXABAY_API_KEY: process.env.PIXABAY_API_KEY,
IMAGE_PROVIDER: process.env.IMAGE_PROVIDER,
DISABLE_IMAGE_GENERATION: process.env.DISABLE_IMAGE_GENERATION,
EXTENDED_REASONING: process.env.EXTENDED_REASONING,
TOOL_CALLS: process.env.TOOL_CALLS,
DISABLE_THINKING: process.env.DISABLE_THINKING,
WEB_GROUNDING: process.env.WEB_GROUNDING,
DATABASE_URL: process.env.DATABASE_URL,
DISABLE_ANONYMOUS_TRACKING: process.env.DISABLE_ANONYMOUS_TRACKING,
COMFYUI_URL: process.env.COMFYUI_URL,
COMFYUI_WORKFLOW: process.env.COMFYUI_WORKFLOW,
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,
TEMP_DIRECTORY: tempDir,
USER_CONFIG_PATH: userConfigPath,
},
isDev,
);
nextjsProcess = await startNextJsServer(
nextjsDir,
nextjsPort,
{
NEXT_PUBLIC_FAST_API: process.env.NEXT_PUBLIC_FAST_API,
TEMP_DIRECTORY: process.env.TEMP_DIRECTORY,
NEXT_PUBLIC_URL: process.env.NEXT_PUBLIC_URL,
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,
},
isDev,
)
} catch (error) {
console.error("Server startup error:", error);
}
}
async function stopServers() {
if (fastApiProcess?.pid) {
await killProcess(fastApiProcess.pid);
}
if (nextjsProcess) {
if (isDev) {
await killProcess(nextjsProcess.pid);
} else {
nextjsProcess.close();
}
}
}
app.whenReady().then(async () => {
// Ensure all required directories exist before starting
ensureDirectoriesExist();
createWindow();
win?.loadFile(path.join(baseDir, "resources/ui/homepage/index.html"));
setUserConfig({
CAN_CHANGE_KEYS: process.env.CAN_CHANGE_KEYS,
LLM: process.env.LLM,
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
OPENAI_MODEL: process.env.OPENAI_MODEL,
GOOGLE_API_KEY: process.env.GOOGLE_API_KEY,
GOOGLE_MODEL: process.env.GOOGLE_MODEL,
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
ANTHROPIC_MODEL: process.env.ANTHROPIC_MODEL,
OLLAMA_URL: process.env.OLLAMA_URL,
OLLAMA_MODEL: process.env.OLLAMA_MODEL,
CUSTOM_LLM_URL: process.env.CUSTOM_LLM_URL,
CUSTOM_LLM_API_KEY: process.env.CUSTOM_LLM_API_KEY,
CUSTOM_MODEL: process.env.CUSTOM_MODEL,
PEXELS_API_KEY: process.env.PEXELS_API_KEY,
PIXABAY_API_KEY: process.env.PIXABAY_API_KEY,
IMAGE_PROVIDER: process.env.IMAGE_PROVIDER,
DISABLE_IMAGE_GENERATION: process.env.DISABLE_IMAGE_GENERATION,
EXTENDED_REASONING: process.env.EXTENDED_REASONING,
TOOL_CALLS: process.env.TOOL_CALLS,
DISABLE_THINKING: process.env.DISABLE_THINKING,
WEB_GROUNDING: process.env.WEB_GROUNDING,
DATABASE_URL: process.env.DATABASE_URL,
DISABLE_ANONYMOUS_TRACKING: process.env.DISABLE_ANONYMOUS_TRACKING,
COMFYUI_URL: process.env.COMFYUI_URL,
COMFYUI_WORKFLOW: process.env.COMFYUI_WORKFLOW,
DALL_E_3_QUALITY: process.env.DALL_E_3_QUALITY,
GPT_IMAGE_1_5_QUALITY: process.env.GPT_IMAGE_1_5_QUALITY,
})
const [fastApiPort, nextjsPort] = await findUnusedPorts();
console.log(`FastAPI port: ${fastApiPort}, NextJS port: ${nextjsPort}`);
//? Setup environment variables to be used in the preloads
setupEnv(fastApiPort, nextjsPort);
setupIpcHandlers();
await startServers(fastApiPort, nextjsPort);
win?.loadURL(`${localhost}:${nextjsPort}`);
});
app.on("window-all-closed", async () => {
await stopServers();
app.quit();
});

View file

@ -0,0 +1,32 @@
import { contextBridge, ipcRenderer } from 'electron';
contextBridge.exposeInMainWorld('env', {
NEXT_PUBLIC_FAST_API: process.env.NEXT_PUBLIC_FAST_API || '',
NEXT_PUBLIC_URL: process.env.NEXT_PUBLIC_URL || '',
TEMP_DIRECTORY: process.env.TEMP_DIRECTORY || '',
NEXT_PUBLIC_USER_CONFIG_PATH: process.env.NEXT_PUBLIC_USER_CONFIG_PATH || '',
});
contextBridge.exposeInMainWorld('electron', {
fileDownloaded: (filePath: string) => ipcRenderer.invoke("file-downloaded", filePath),
exportAsPDF: (id: string, title: string) => ipcRenderer.invoke("export-as-pdf", id, title),
getUserConfig: () => ipcRenderer.invoke("get-user-config"),
setUserConfig: (userConfig: UserConfig) => ipcRenderer.invoke("set-user-config", userConfig),
getCanChangeKeys: () => ipcRenderer.invoke("get-can-change-keys"),
readFile: (filePath: string) => ipcRenderer.invoke("read-file", filePath),
getSlideMetadata: (url: string, theme: string, customColors?: any, tempDirectory?: string) =>
ipcRenderer.invoke("get-slide-metadata", url, theme, customColors, tempDirectory),
getFooter: (userId: string) => ipcRenderer.invoke("get-footer", userId),
setFooter: (userId: string, properties: any) => ipcRenderer.invoke("set-footer", userId, properties),
getTheme: (userId: string) => ipcRenderer.invoke("get-theme", userId),
setTheme: (userId: string, themeData: any) => ipcRenderer.invoke("set-theme", userId, themeData),
uploadImage: (file: Buffer) => ipcRenderer.invoke("upload-image", file),
writeNextjsLog: (logData: string) => ipcRenderer.invoke("write-nextjs-log", logData),
clearNextjsLogs: () => ipcRenderer.invoke("clear-nextjs-logs"),
// API handlers
hasRequiredKey: () => ipcRenderer.invoke("api:has-required-key"),
telemetryStatus: () => ipcRenderer.invoke("api:telemetry-status"),
getTemplates: () => ipcRenderer.invoke("api:templates"),
getPresentationPptxModel: (presentationId: string) => ipcRenderer.invoke("presentation-to-pptx-model", presentationId),
});

View file

@ -0,0 +1,12 @@
// Preload script for PPTX export browser window
// This script runs before the page loads and injects environment variables
// Expose environment variables to the window
(window as any).env = {
NEXT_PUBLIC_FAST_API: process.env.NEXT_PUBLIC_FAST_API || '',
NEXT_PUBLIC_URL: process.env.NEXT_PUBLIC_URL || '',
TEMP_DIRECTORY: process.env.TEMP_DIRECTORY || '',
NEXT_PUBLIC_USER_CONFIG_PATH: process.env.NEXT_PUBLIC_USER_CONFIG_PATH || '',
};
console.log('[PPTX Export Preload] Environment variables set:', (window as any).env);

View file

@ -0,0 +1,68 @@
import path from 'path';
import fs from 'fs';
import { userDataDir } from '../utils/constants';
class SettingsStore {
private settingsPath: string;
private settings: { [key: string]: any };
constructor() {
this.settingsPath = path.join(userDataDir, 'settings.json');
this.settings = {};
this.loadSettings();
}
private loadSettings() {
try {
if (fs.existsSync(this.settingsPath)) {
const data = fs.readFileSync(this.settingsPath, 'utf-8');
this.settings = JSON.parse(data);
} else {
this.settings = {};
this.saveSettings();
}
} catch (error) {
console.error('Error loading settings:', error);
this.settings = {};
}
}
private saveSettings() {
try {
fs.writeFileSync(this.settingsPath, JSON.stringify(this.settings, null, 2));
} catch (error) {
console.error('Error saving settings:', error);
throw error;
}
}
get(key: string, defaultValue: any = null): any {
const value = this.settings[key];
return value || defaultValue;
}
set(key: string, value: any): void {
this.settings[key] = value;
this.saveSettings();
}
// Helper method to check if settings exist
has(key: string): boolean {
return key in this.settings;
}
// Helper method to delete a setting
delete(key: string): void {
delete this.settings[key];
this.saveSettings();
}
}
// Export a singleton instance
export const settingsStore = new SettingsStore();

77
electron/app/types/index.d.ts vendored Normal file
View file

@ -0,0 +1,77 @@
interface FastApiEnv {
DEBUG?: string,
CAN_CHANGE_KEYS?: string,
LLM?: string,
OPENAI_API_KEY?: string,
OPENAI_MODEL?: string,
GOOGLE_API_KEY?: string,
GOOGLE_MODEL?: string,
ANTHROPIC_API_KEY?: string,
ANTHROPIC_MODEL?: string,
OLLAMA_URL?: string,
OLLAMA_MODEL?: string,
CUSTOM_LLM_URL?: string,
CUSTOM_LLM_API_KEY?: string,
CUSTOM_MODEL?: string,
PEXELS_API_KEY?: string,
PIXABAY_API_KEY?: string,
IMAGE_PROVIDER?: string,
DISABLE_IMAGE_GENERATION?: string,
EXTENDED_REASONING?: string,
TOOL_CALLS?: string,
DISABLE_THINKING?: string,
WEB_GROUNDING?: string,
DATABASE_URL?: string,
DISABLE_ANONYMOUS_TRACKING?: string,
COMFYUI_URL?: string,
COMFYUI_WORKFLOW?: string,
DALL_E_3_QUALITY?: string,
GPT_IMAGE_1_5_QUALITY?: string,
APP_DATA_DIRECTORY?: string,
TEMP_DIRECTORY?: string,
USER_CONFIG_PATH?: string,
}
interface NextJsEnv {
NEXT_PUBLIC_FAST_API?: string,
TEMP_DIRECTORY?: string,
NEXT_PUBLIC_URL?: string,
NEXT_PUBLIC_USER_CONFIG_PATH?: string,
USER_CONFIG_PATH?: string,
APP_DATA_DIRECTORY?: string,
}
interface UserConfig {
CAN_CHANGE_KEYS?: string,
LLM?: string,
OPENAI_API_KEY?: string,
OPENAI_MODEL?: string,
GOOGLE_API_KEY?: string,
GOOGLE_MODEL?: string,
ANTHROPIC_API_KEY?: string,
ANTHROPIC_MODEL?: string,
OLLAMA_URL?: string,
OLLAMA_MODEL?: string,
CUSTOM_LLM_URL?: string,
CUSTOM_LLM_API_KEY?: string,
CUSTOM_MODEL?: string,
PEXELS_API_KEY?: string,
PIXABAY_API_KEY?: string,
IMAGE_PROVIDER?: string,
DISABLE_IMAGE_GENERATION?: string,
EXTENDED_REASONING?: string,
TOOL_CALLS?: string,
DISABLE_THINKING?: string,
WEB_GROUNDING?: string,
DATABASE_URL?: string,
DISABLE_ANONYMOUS_TRACKING?: string,
COMFYUI_URL?: string,
COMFYUI_WORKFLOW?: string,
DALL_E_3_QUALITY?: string,
GPT_IMAGE_1_5_QUALITY?: string,
}
interface IPCStatus {
success: boolean,
message?: string,
}

View file

@ -0,0 +1,26 @@
import { app } from "electron"
import path from "path"
import fs from "fs"
export const localhost = "http://127.0.0.1"
export const isDev = !app.isPackaged;
export const baseDir = app.getAppPath();
export const fastapiDir = isDev ? path.join(baseDir, "servers/fastapi") : path.join(baseDir, "resources/fastapi");
export const nextjsDir = isDev ? path.join(baseDir, "servers/nextjs") : path.join(baseDir, "resources/nextjs");
export const tempDir = path.join(app.getPath("temp"), "presenton")
export const userDataDir = app.getPath("userData")
export const appDataDir = isDev ? path.join(baseDir, "app_data") : app.getPath("userData")
export const downloadsDir = app.getPath("downloads")
export const userConfigPath = path.join(userDataDir, "userConfig.json")
export const logsDir = path.join(userDataDir, "logs")
// Ensure required directories exist
export function ensureDirectoriesExist() {
fs.mkdirSync(userDataDir, { recursive: true });
fs.mkdirSync(appDataDir, { recursive: true });
fs.mkdirSync(logsDir, { recursive: true });
fs.mkdirSync(tempDir, { recursive: true });
}

View file

@ -0,0 +1,31 @@
import { shell } from "electron";
import { dialog } from "electron";
import path from "path";
export async function showFileDownloadedDialog(filePath: string): Promise<boolean> {
try {
const { response } = await dialog.showMessageBox({
type: 'question',
buttons: ['Open File', 'Open Folder', 'Cancel'],
defaultId: 0,
title: 'File Downloaded',
message: 'What would you like to do?'
});
// Open file/folder in background without awaiting to prevent blocking
if (response === 0) {
shell.openPath(filePath).catch(err =>
console.error('Error opening file:', err)
);
} else if (response === 1) {
shell.openPath(path.dirname(filePath)).catch(err =>
console.error('Error opening folder:', err)
);
}
return true;
} catch (error: any) {
console.error('Error handling downloaded file:', error);
return false;
}
}

114
electron/app/utils/index.ts Normal file
View file

@ -0,0 +1,114 @@
import net from 'net'
import treeKill from 'tree-kill'
import fs from 'fs'
import { localhost, tempDir, userConfigPath } from './constants'
export function setUserConfig(userConfig: UserConfig) {
let existingConfig: UserConfig = {}
if (fs.existsSync(userConfigPath)) {
const configData = fs.readFileSync(userConfigPath, 'utf-8')
existingConfig = JSON.parse(configData)
}
const mergedConfig: UserConfig = {
CAN_CHANGE_KEYS: userConfig.CAN_CHANGE_KEYS || existingConfig.CAN_CHANGE_KEYS,
LLM: userConfig.LLM || existingConfig.LLM,
OPENAI_API_KEY: userConfig.OPENAI_API_KEY || existingConfig.OPENAI_API_KEY,
OPENAI_MODEL: userConfig.OPENAI_MODEL || existingConfig.OPENAI_MODEL,
GOOGLE_API_KEY: userConfig.GOOGLE_API_KEY || existingConfig.GOOGLE_API_KEY,
GOOGLE_MODEL: userConfig.GOOGLE_MODEL || existingConfig.GOOGLE_MODEL,
ANTHROPIC_API_KEY: userConfig.ANTHROPIC_API_KEY || existingConfig.ANTHROPIC_API_KEY,
ANTHROPIC_MODEL: userConfig.ANTHROPIC_MODEL || existingConfig.ANTHROPIC_MODEL,
OLLAMA_URL: userConfig.OLLAMA_URL || existingConfig.OLLAMA_URL,
OLLAMA_MODEL: userConfig.OLLAMA_MODEL || existingConfig.OLLAMA_MODEL,
CUSTOM_LLM_URL: userConfig.CUSTOM_LLM_URL || existingConfig.CUSTOM_LLM_URL,
CUSTOM_LLM_API_KEY: userConfig.CUSTOM_LLM_API_KEY || existingConfig.CUSTOM_LLM_API_KEY,
CUSTOM_MODEL: userConfig.CUSTOM_MODEL || existingConfig.CUSTOM_MODEL,
PEXELS_API_KEY: userConfig.PEXELS_API_KEY || existingConfig.PEXELS_API_KEY,
PIXABAY_API_KEY: userConfig.PIXABAY_API_KEY || existingConfig.PIXABAY_API_KEY,
IMAGE_PROVIDER: userConfig.IMAGE_PROVIDER || existingConfig.IMAGE_PROVIDER,
DISABLE_IMAGE_GENERATION: userConfig.DISABLE_IMAGE_GENERATION || existingConfig.DISABLE_IMAGE_GENERATION,
EXTENDED_REASONING: userConfig.EXTENDED_REASONING || existingConfig.EXTENDED_REASONING,
TOOL_CALLS: userConfig.TOOL_CALLS || existingConfig.TOOL_CALLS,
DISABLE_THINKING: userConfig.DISABLE_THINKING || existingConfig.DISABLE_THINKING,
WEB_GROUNDING: userConfig.WEB_GROUNDING || existingConfig.WEB_GROUNDING,
DATABASE_URL: userConfig.DATABASE_URL || existingConfig.DATABASE_URL,
DISABLE_ANONYMOUS_TRACKING: userConfig.DISABLE_ANONYMOUS_TRACKING || existingConfig.DISABLE_ANONYMOUS_TRACKING,
COMFYUI_URL: userConfig.COMFYUI_URL || existingConfig.COMFYUI_URL,
COMFYUI_WORKFLOW: userConfig.COMFYUI_WORKFLOW || existingConfig.COMFYUI_WORKFLOW,
DALL_E_3_QUALITY: userConfig.DALL_E_3_QUALITY || existingConfig.DALL_E_3_QUALITY,
GPT_IMAGE_1_5_QUALITY: userConfig.GPT_IMAGE_1_5_QUALITY || existingConfig.GPT_IMAGE_1_5_QUALITY,
}
fs.writeFileSync(userConfigPath, JSON.stringify(mergedConfig))
}
export function getUserConfig(): UserConfig {
if (!fs.existsSync(userConfigPath)) {
return {}
}
const configData = fs.readFileSync(userConfigPath, 'utf-8')
return JSON.parse(configData)
}
export function setupEnv(fastApiPort: number, nextjsPort: number) {
process.env.NEXT_PUBLIC_FAST_API = `${localhost}:${fastApiPort}`;
process.env.TEMP_DIRECTORY = tempDir;
process.env.NEXT_PUBLIC_USER_CONFIG_PATH = userConfigPath;
process.env.NEXT_PUBLIC_URL = `${localhost}:${nextjsPort}`;
// Set environment variables for NextJS API routes
process.env.USER_CONFIG_PATH = userConfigPath;
// Read CAN_CHANGE_KEYS from existing env or default to true
if (process.env.CAN_CHANGE_KEYS === undefined) {
process.env.CAN_CHANGE_KEYS = "true";
}
}
export function killProcess(pid: number) {
return new Promise((resolve, reject) => {
treeKill(pid, "SIGTERM", (err: any) => {
if (err) {
console.error(`Error killing process ${pid}:`, err)
reject(err)
} else {
console.log(`Process ${pid} killed`)
resolve(true)
}
})
})
}
export async function findUnusedPorts(startPort: number = 40000, count: number = 2): Promise<number[]> {
const ports: number[] = [];
console.log(`Finding ${count} unused ports starting from ${startPort}`);
const isPortAvailable = (port: number): Promise<boolean> => {
return new Promise((resolve) => {
const server = net.createServer();
server.once('error', () => {
resolve(false);
});
server.once('listening', () => {
server.close();
resolve(true);
});
server.listen(port);
});
};
let currentPort = startPort;
while (ports.length < count) {
if (await isPortAvailable(currentPort)) {
ports.push(currentPort);
}
currentPort++;
}
return ports;
}
export function sanitizeFilename(filename: string): string {
return filename.replace(/[\\/:*?"<>|]/g, '_');
}

View file

@ -0,0 +1,121 @@
import { spawn } from "child_process";
import { localhost, logsDir, userDataDir } from "./constants";
import http from "http";
import fs from "fs";
// @ts-ignore
import handler from "serve-handler";
import path from "path";
export async function startFastApiServer(
directory: string,
port: number,
env: FastApiEnv,
isDev: boolean,
) {
// Start FastAPI server
let command: string;
let args: string[];
if (isDev) {
command = "uv";
args = ["run", "python", "server.py", "--port", port.toString(), "--reload", "true"];
} else {
const binary = process.platform === "win32" ? "fastapi.exe" : "fastapi";
command = path.join(directory, binary);
args = ["--port", port.toString()];
}
const fastApiProcess = spawn(
command,
args,
{
cwd: directory,
stdio: ["inherit", "pipe", "pipe"],
env: { ...process.env, ...env },
}
);
fastApiProcess.stdout.on("data", (data: any) => {
fs.appendFileSync(path.join(logsDir, "fastapi-server.log"), data);
console.log(`FastAPI: ${data}`);
});
fastApiProcess.stderr.on("data", (data: any) => {
fs.appendFileSync(path.join(logsDir, "fastapi-server.log"), data);
console.error(`FastAPI: ${data}`);
});
// Wait for FastAPI server to start
await waitForServer(`${localhost}:${port}/docs`);
return fastApiProcess;
}
export async function startNextJsServer(
directory: string,
port: number,
env: NextJsEnv,
isDev: boolean,
) {
let nextjsProcess;
if (isDev) {
// Start NextJS development server
nextjsProcess = spawn(
"npm",
["run", "dev", "--", "-p", port.toString()],
{
cwd: directory,
stdio: ["inherit", "pipe", "pipe"],
env: { ...process.env, ...env },
}
);
nextjsProcess.stdout.on("data", (data: any) => {
fs.appendFileSync(path.join(logsDir, "nextjs-server.log"), data);
console.log(`NextJS: ${data}`);
});
nextjsProcess.stderr.on("data", (data: any) => {
fs.appendFileSync(path.join(logsDir, "nextjs-server.log"), data);
console.error(`NextJS: ${data}`);
});
} else {
// Start NextJS build server
nextjsProcess = startNextjsBuildServer(directory, port);
}
// Wait for NextJS server to start
await waitForServer(`${localhost}:${port}`);
return nextjsProcess;
}
async function startNextjsBuildServer(directory: string, port: number) {
const server = http.createServer((req, res) => {
return handler(req, res, {
public: directory,
cleanUrls: true,
});
});
server.listen(port);
return server;
}
async function waitForServer(url: string, timeout = 30000): Promise<void> {
const startTime = Date.now();
while (Date.now() - startTime < timeout) {
try {
await new Promise<void>((resolve, reject) => {
http.get(url, (res) => {
if (res.statusCode === 200 || res.statusCode === 304) {
resolve();
} else {
reject(new Error(`Unexpected status code: ${res.statusCode}`));
}
}).on('error', reject);
});
return;
} catch (error) {
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
throw new Error(`Server did not start within ${timeout}ms`);
}

81
electron/build.js Normal file
View file

@ -0,0 +1,81 @@
const builder = require("electron-builder")
const fs = require("fs")
const path = require("path")
// AfterPack hook: set executable permissions on macOS; no-op on Windows
const afterPack = async (context) => {
if (context.electronPlatformName === "darwin") {
const appPath = context.appOutDir
const fastapiPath = path.join(appPath, "Presenton.app/Contents/Resources/app/resources/fastapi/fastapi")
console.log("Setting executable permissions for FastAPI binary...")
console.log("FastAPI path:", fastapiPath)
if (fs.existsSync(fastapiPath)) {
fs.chmodSync(fastapiPath, 0o755)
console.log("✓ Execute permissions set for FastAPI")
} else {
console.warn("⚠ FastAPI binary not found at:", fastapiPath)
}
const fastapiDir = path.join(appPath, "Presenton.app/Contents/Resources/app/resources/fastapi")
if (fs.existsSync(fastapiDir)) {
console.log("FastAPI directory contents:", fs.readdirSync(fastapiDir))
}
}
}
const config = {
appId: "PresentonAI.Presenton",
asar: false,
copyright: "Copyright © 2026 Presenton",
directories: {
output: "dist",
buildResources: "build",
},
files: [
"resources",
"app_dist",
"node_modules",
"NOTICE"
],
afterPack,
mac: {
artifactName: "Presenton-${version}.${ext}",
target: ["dmg"],
category: "public.app-category.productivity",
icon: "resources/ui/assets/images/presenton_short_filled.png",
},
linux: {
artifactName: "Presenton-${version}.${ext}",
target: ["AppImage"],
icon: "resources/ui/assets/images/presenton_short_filled.png",
},
win: {
target: ["nsis", "appx"],
icon: "build/icon.ico",
artifactName: "Presenton-${version}.${ext}",
executableName: "Presenton",
},
nsis: {
oneClick: false,
perMachine: false,
allowToChangeInstallationDirectory: true,
allowElevation: true,
installerIcon: "build/icon.ico",
uninstallerIcon: "build/icon.ico",
installerHeaderIcon: "build/icon.ico",
createDesktopShortcut: true,
createStartMenuShortcut: true,
shortcutName: "Presenton",
uninstallDisplayName: "Presenton",
},
appx: {
identityName: "PresentonAI.Presenton",
publisher: "CN=8A2C57B5-F1C6-473A-93EE-2E9B72134341",
publisherDisplayName: "Presenton AI",
applicationId: "PresentonAI.Presenton",
},
}
builder.build({ config })

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 446 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
electron/build/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

7174
electron/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

68
electron/package.json Normal file
View file

@ -0,0 +1,68 @@
{
"name": "presenton",
"productName": "Presenton Open Source",
"version": "0.6.0-beta",
"main": "app_dist/main.js",
"description": "Open-Source AI Presentation Generator",
"homepage": "https://presenton.ai",
"repository": "https://github.com/presenton/presenton",
"keywords": [
"electron",
"electron-builder",
"Microsoft Store",
"AI presentation generator",
"open-source",
"presentation software",
"tailwindcss",
"typescript",
"fastapi",
"nextjs",
"puppeteer",
"sharp",
"template management",
"slide generation",
"modern UI",
"automation"
],
"scripts": {
"start": "electron .",
"dist": "electron-builder",
"postinstall": "electron-builder install-app-deps",
"dev": "rm -rf app_dist && tsc && electron .",
"setup:env": "npm install && cd servers/fastapi && uv sync && cd ../../servers/nextjs && npm install",
"install:pyinstaller": "cd servers/fastapi && echo 'pyinstaller already in dependencies'",
"build:ts": "rm -rf app_dist && tsc",
"build:css": "tailwindcss -i ./resources/ui/assets/css/tailwind.import.css -o ./resources/ui/assets/css/tailwind.css --watch",
"build:vectorstore": "cd servers/fastapi && uv run python build_vectorstore.py",
"build:nextjs": "rm -rf resources/nextjs && cd servers/nextjs && cross-env BUILD_TARGET=electron npm run build && cp -r .next-build ../../resources/nextjs && cp -r app/presentation-templates ../../resources/nextjs/presentation-templates",
"build:fastapi": "rm -rf resources/fastapi && npm run build:vectorstore && cd servers/fastapi && uv run python -m PyInstaller --distpath ../../resources server.spec",
"build:electron": "rm -rf app_dist && tsc && node build.js",
"build:all": "npm run clean:build && npm run setup:env && npm run build:ts && npm run install:pyinstaller && npm run build:nextjs && npm run build:fastapi && npm run build:electron",
"clean:build": "rm -rf resources/nextjs && rm -rf resources/fastapi && rm -rf app_dist"
},
"author": {
"name": "Presenton",
"email": "suraj@presenton.ai"
},
"dependencies": {
"@tailwindcss/cli": "^4.1.5",
"@types/uuid": "^10.0.0",
"dotenv": "^16.5.0",
"electron-squirrel-startup": "^1.0.1",
"puppeteer": "^24.8.2",
"serve-handler": "^6.1.6",
"sharp": "^0.34.5",
"tailwindcss": "^4.1.5",
"tree-kill": "^1.2.2",
"uuid": "^13.0.0"
},
"devDependencies": {
"cross-env": "^7.0.3",
"electron": "^36.1.0",
"electron-builder": "^26.0.12",
"typescript": "^5.8.3"
},
"peerDependencies": {
"typescript": "^5.0.0"
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1 @@
@import 'tailwindcss';

Binary file not shown.

After

Width:  |  Height:  |  Size: 861 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View file

@ -0,0 +1,32 @@
<!DOCTYPE html>
<html>
<head>
<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;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</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-20 text-lg">Just a moment...</p>
<div class="loading-circle mt-10"></div>
</body>
</html>

View file

View file

@ -0,0 +1,333 @@
#!/usr/bin/env python3
import json
import os
import re
import sys
from pathlib import Path
from typing import Dict, List, Optional, Tuple
REPO_ROOT = Path(__file__).resolve().parent.parent
FASTAPI_DIR = REPO_ROOT / "servers" / "fastapi"
NEXT_DIR = REPO_ROOT / "servers" / "nextjs"
NOTICE_PATH = REPO_ROOT / "NOTICE"
PY_LICENSE_CANDIDATES = [
"LICENSE",
"LICENSE.txt",
"LICENSE.md",
"LICENCE",
"COPYING",
"COPYING.txt",
"NOTICE",
"NOTICE.txt",
]
NODE_LICENSE_CANDIDATES = [
"LICENSE",
"LICENSE.txt",
"LICENSE.md",
"LICENCE",
"LICENCE.txt",
"COPYING",
"COPYING.txt",
"NOTICE",
"NOTICE.txt",
]
def read_text_safe(path: Path) -> str:
try:
return path.read_text(encoding="utf-8", errors="replace").strip()
except Exception:
return ""
def parse_rfc822_metadata(text: str) -> Dict[str, str]:
data: Dict[str, str] = {}
key: Optional[str] = None
for raw_line in text.splitlines():
if not raw_line:
key = None
continue
if raw_line[0] in " \t" and key:
data[key] += "\n" + raw_line.strip()
continue
if ":" in raw_line:
k, v = raw_line.split(":", 1)
key = k.strip()
data[key] = v.strip()
return data
def find_python_site_packages(venv_dir: Path) -> Optional[Path]:
# Linux/mac
lib_dir = venv_dir / "lib"
if lib_dir.exists():
for child in lib_dir.iterdir():
if child.is_dir() and child.name.startswith("python"):
sp = child / "site-packages"
if sp.exists():
return sp
# Windows
sp = venv_dir / "Lib" / "site-packages"
if sp.exists():
return sp
return None
def detect_python_venv() -> Optional[Path]:
env_path = os.environ.get("NOTICE_PYTHON_VENV")
if env_path:
v = Path(env_path)
if v.exists():
return v
default = FASTAPI_DIR / ".venv"
if default.exists():
return default
active = os.environ.get("VIRTUAL_ENV")
if active and FASTAPI_DIR.as_posix() in Path(active).as_posix():
return Path(active)
return None
def scan_python_packages(site_packages_dir: Path) -> List[Dict[str, str]]:
entries: List[Dict[str, str]] = []
dist_infos = sorted(site_packages_dir.glob("*.dist-info"))
for dist in dist_infos:
metadata_path = dist / "METADATA"
if not metadata_path.exists():
continue
meta = parse_rfc822_metadata(read_text_safe(metadata_path))
name = meta.get("Name", "").strip()
version = meta.get("Version", "").strip()
license_name = meta.get("License", "").strip()
if not name:
# Fallback to folder name pattern
# e.g., requests-2.32.3.dist-info
base = dist.name[:-10]
if "-" in base:
parts = base.rsplit("-", 1)
if len(parts) == 2:
name = parts[0]
version = version or parts[1]
author = meta.get("Author", meta.get("Maintainer", meta.get("Author-email", ""))).strip()
# License text candidates inside dist-info
license_text = ""
for cand in PY_LICENSE_CANDIDATES:
p = dist / cand
if p.exists():
license_text = read_text_safe(p)
if license_text:
break
# Search via RECORD for license files elsewhere
if not license_text:
record = dist / "RECORD"
if record.exists():
for line in read_text_safe(record).splitlines():
path_part = line.split(",", 1)[0]
lower = path_part.lower()
if any(token in lower for token in ["license", "licence", "copying", "notice"]):
target = site_packages_dir / path_part
if target.exists():
license_text = read_text_safe(target)
if license_text:
break
# As last resort, embed the License: field content
if not license_text and license_name:
license_text = f"License field from METADATA:\n{license_name}"
entries.append({
"name": name or dist.name,
"version": version,
"license": license_name,
"author": author,
"license_text": license_text,
})
# Sort by name for stability
entries.sort(key=lambda e: (e["name"].lower(), e["version"]))
return entries
def find_license_file_in_dir(base_dir: Path, depth_limit: int = 2) -> Optional[Path]:
# First, try immediate candidates
for cand in NODE_LICENSE_CANDIDATES:
p = base_dir / cand
if p.exists():
return p
# case-insensitive check
for child in base_dir.iterdir():
if child.is_file() and child.name.lower() == cand.lower():
return child
# Recursive limited-depth scan excluding nested node_modules
def walk(dir_path: Path, depth: int) -> Optional[Path]:
if depth > depth_limit:
return None
try:
it = list(dir_path.iterdir())
except Exception:
return None
for child in it:
name_lower = child.name.lower()
if child.is_dir():
if child.name == "node_modules" or child.name.startswith('.'):
continue
found = walk(child, depth + 1)
if found:
return found
else:
if any(tok in name_lower for tok in ["license", "licence", "copying", "notice"]):
return child
return None
return walk(base_dir, 0)
def scan_node_modules(node_modules_dir: Path) -> List[Dict[str, str]]:
entries: List[Dict[str, str]] = []
seen: set[str] = set()
def visit_pkg(pkg_dir: Path):
pkg_json = pkg_dir / "package.json"
if not pkg_json.exists():
return
try:
data = json.loads(read_text_safe(pkg_json) or "{}")
except Exception:
return
name = data.get("name") or pkg_dir.name
version = str(data.get("version") or "")
key = f"{name}@{version}"
if key in seen:
return
seen.add(key)
license_name = ""
lic_field = data.get("license")
if isinstance(lic_field, str):
license_name = lic_field
elif isinstance(lic_field, dict):
license_name = lic_field.get("type", "")
elif isinstance(data.get("licenses"), list):
license_name = ", ".join([str(x.get("type", "")) for x in data["licenses"] if isinstance(x, dict)])
author = ""
a = data.get("author")
if isinstance(a, str):
author = a
elif isinstance(a, dict):
author = a.get("name", "")
license_text = ""
lic_file = find_license_file_in_dir(pkg_dir, depth_limit=2)
if lic_file:
license_text = read_text_safe(lic_file)
entries.append({
"name": name,
"version": version,
"license": license_name,
"author": author,
"license_text": license_text,
})
def walk_node_modules(base: Path):
if not base.exists():
return
for entry in base.iterdir():
if not entry.is_dir():
continue
if entry.name == ".bin":
continue
if entry.name.startswith("@"): # scoped packages
for scoped in entry.iterdir():
if scoped.is_dir():
visit_pkg(scoped)
# nested node_modules inside the package
nested = scoped / "node_modules"
walk_node_modules(nested)
continue
visit_pkg(entry)
nested = entry / "node_modules"
walk_node_modules(nested)
walk_node_modules(node_modules_dir)
# Sort by package name
entries.sort(key=lambda e: (e["name"].lower(), e["version"]))
return entries
def format_section(title: str, entries: List[Dict[str, str]]) -> str:
header = [
"-------------------------------------",
title,
"-------------------------------------",
"",
]
lines: List[str] = ["\n".join(header)]
for e in entries:
block = [
e.get("name", "").strip(),
e.get("version", "").strip(),
e.get("license", "").strip(),
e.get("author", "").strip(),
"",
(e.get("license_text", "") or "LICENSE TEXT NOT FOUND").strip(),
"",
"",
]
lines.append("\n".join(block))
return "".join(lines).rstrip() + "\n"
def main():
# Optional CLI overrides
import argparse
parser = argparse.ArgumentParser(description="Rebuild NOTICE from installed packages")
parser.add_argument("--python-venv", dest="python_venv", default=None, help="Path to Python venv to scan")
parser.add_argument("--node-modules", dest="node_modules", default=None, help="Path to node_modules to scan")
args = parser.parse_args()
python_entries: List[Dict[str, str]] = []
node_entries: List[Dict[str, str]] = []
# Python scan
venv = Path(args.python_venv) if args.python_venv else detect_python_venv()
if venv:
sp = find_python_site_packages(venv)
if sp and sp.exists():
python_entries = scan_python_packages(sp)
else:
print(f"Warning: site-packages not found under {venv}", file=sys.stderr)
else:
print("Warning: Python venv not found. Set NOTICE_PYTHON_VENV or create servers/fastapi/.venv", file=sys.stderr)
# Node scan
node_modules_dir = Path(args.node_modules or os.environ.get("NOTICE_NODE_MODULES") or (NEXT_DIR / "node_modules"))
if node_modules_dir.exists():
node_entries = scan_node_modules(node_modules_dir)
else:
print(f"Warning: node_modules not found at {node_modules_dir}", file=sys.stderr)
# Build NOTICE content
parts: List[str] = []
if python_entries:
parts.append(format_section("PYTHON PACKAGES", python_entries))
if node_entries:
parts.append(format_section("NODE PACKAGES", node_entries))
if not parts:
print("Error: No sections generated. Ensure .venv and node_modules exist.", file=sys.stderr)
sys.exit(1)
content = "\n".join(parts)
NOTICE_PATH.write_text(content, encoding="utf-8")
print("NOTICE rebuilt from installed packages")
if __name__ == "__main__":
main()

View file

@ -0,0 +1 @@
3.11

View file

View file

@ -0,0 +1,23 @@
from contextlib import asynccontextmanager
import os
from fastapi import FastAPI
from services.database import create_db_and_tables
from utils.get_env import get_app_data_directory_env
from utils.model_availability import (
check_llm_and_image_provider_api_or_model_availability,
)
@asynccontextmanager
async def app_lifespan(_: FastAPI):
"""
Lifespan context manager for FastAPI application.
Initializes the application data directory and checks LLM model availability.
"""
os.makedirs(get_app_data_directory_env(), exist_ok=True)
await create_db_and_tables()
await check_llm_and_image_provider_api_or_model_availability()
yield

View file

@ -0,0 +1,49 @@
import os
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from api.lifespan import app_lifespan
from api.middlewares import UserConfigEnvUpdateMiddleware
from api.v1.ppt.router import API_V1_PPT_ROUTER
from api.v1.webhook.router import API_V1_WEBHOOK_ROUTER
from api.v1.mock.router import API_V1_MOCK_ROUTER
from utils.get_env import get_app_data_directory_env
from utils.path_helpers import get_resource_path
app = FastAPI(lifespan=app_lifespan)
# Routers
app.include_router(API_V1_PPT_ROUTER)
app.include_router(API_V1_WEBHOOK_ROUTER)
app.include_router(API_V1_MOCK_ROUTER)
# Mount app_data directory as static files
app_data_dir = get_app_data_directory_env()
if app_data_dir:
os.makedirs(app_data_dir, exist_ok=True)
app.mount("/app_data", StaticFiles(directory=app_data_dir), name="app_data")
# Mount static directory for icons, placeholder images, etc.
static_dir = get_resource_path("static")
print(f"[FastAPI] Static directory path: {static_dir}")
print(f"[FastAPI] Static directory exists: {os.path.exists(static_dir)}")
if os.path.exists(static_dir):
app.mount("/static", StaticFiles(directory=static_dir), name="static")
print(f"[FastAPI] Static files mounted successfully from {static_dir}")
else:
print(f"[FastAPI] WARNING: Static directory not found at {static_dir}")
# Middlewares
origins = ["*"]
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.add_middleware(UserConfigEnvUpdateMiddleware)

View file

@ -0,0 +1,12 @@
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
from utils.get_env import get_can_change_keys_env
from utils.user_config import update_env_with_user_config
class UserConfigEnvUpdateMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
if get_can_change_keys_env() != "false":
update_env_with_user_config()
return await call_next(request)

View file

@ -0,0 +1,34 @@
import uuid
from fastapi import APIRouter
from models.api_error_model import APIErrorModel
from models.presentation_and_path import PresentationPathAndEditPath
from typing import List
API_V1_MOCK_ROUTER = APIRouter(prefix="/api/v1/mock", tags=["Mock"])
@API_V1_MOCK_ROUTER.get(
"/presentation-generation-completed",
response_model=List[PresentationPathAndEditPath],
)
async def mock_presentation_generation_completed():
return [
PresentationPathAndEditPath(
presentation_id=uuid.uuid4(),
path="/app_data/exports/test.pdf",
edit_path="/presentation?id=123",
)
]
@API_V1_MOCK_ROUTER.get(
"/presentation-generation-failed",
response_model=List[APIErrorModel],
)
async def mock_presentation_generation_completed():
return [
APIErrorModel(
status_code=500,
detail="Presentation generation failed",
)
]

View file

@ -0,0 +1,76 @@
from datetime import datetime
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from models.ollama_model_status import OllamaModelStatus
from models.sql.ollama_pull_status import OllamaPullStatus
from services.database import get_container_db_async_session
from utils.ollama import pull_ollama_model
async def pull_ollama_model_background_task(model: str):
saved_model_status = OllamaModelStatus(
name=model,
status="pulling",
done=False,
)
log_event_count = 0
session = await get_container_db_async_session().__anext__()
try:
async for event in pull_ollama_model(model):
log_event_count += 1
if log_event_count != 1 and log_event_count % 20 != 0:
continue
if "completed" in event:
saved_model_status.downloaded = event["completed"]
if not saved_model_status.size and "total" in event:
saved_model_status.size = event["total"]
if "status" in event:
saved_model_status.status = event["status"]
await upsert_ollama_pull_status(session, model, saved_model_status)
except Exception as e:
saved_model_status.status = "error"
saved_model_status.done = True
await upsert_ollama_pull_status(session, model, saved_model_status)
await session.close()
raise HTTPException(
status_code=500,
detail=f"Failed to pull model: {e}",
)
saved_model_status.done = True
saved_model_status.status = "pulled"
saved_model_status.downloaded = saved_model_status.size
await upsert_ollama_pull_status(session, model, saved_model_status)
await session.close()
async def upsert_ollama_pull_status(
session: AsyncSession, model: str, model_status: OllamaModelStatus
):
stmt = select(OllamaPullStatus).where(OllamaPullStatus.id == model)
result = await session.execute(stmt)
existing_record = result.scalar_one_or_none()
if existing_record:
existing_record.status = model_status.model_dump(mode="json")
existing_record.last_updated = datetime.now()
else:
new_record = OllamaPullStatus(
id=model,
status=model_status.model_dump(mode="json"),
last_updated=datetime.now(),
)
session.add(new_record)
await session.commit()
await session.flush()

View file

@ -0,0 +1,16 @@
from typing import Annotated, List
from fastapi import APIRouter, Body, HTTPException
from utils.available_models import list_available_anthropic_models
ANTHROPIC_ROUTER = APIRouter(prefix="/anthropic", tags=["Anthropic"])
@ANTHROPIC_ROUTER.post("/models/available", response_model=List[str])
async def get_available_models(
api_key: Annotated[str, Body(embed=True)],
):
try:
return await list_available_anthropic_models(api_key)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

View file

@ -0,0 +1,87 @@
from http.client import HTTPException
import os
from typing import Annotated, List, Optional
from fastapi import APIRouter, Body, File, UploadFile
from constants.documents import UPLOAD_ACCEPTED_FILE_TYPES
from models.decomposed_file_info import DecomposedFileInfo
from services.temp_file_service import TEMP_FILE_SERVICE
from services.documents_loader import DocumentsLoader
import uuid
from utils.validators import validate_files
FILES_ROUTER = APIRouter(prefix="/files", tags=["Files"])
@FILES_ROUTER.post("/upload", response_model=List[str])
async def upload_files(files: Optional[List[UploadFile]]):
if not files:
raise HTTPException(400, "Documents are required")
temp_dir = TEMP_FILE_SERVICE.create_temp_dir(str(uuid.uuid4()))
validate_files(files, True, True, 100, UPLOAD_ACCEPTED_FILE_TYPES)
temp_files: List[str] = []
if files:
for each_file in files:
temp_path = TEMP_FILE_SERVICE.create_temp_file_path(
each_file.filename, temp_dir
)
with open(temp_path, "wb") as f:
content = await each_file.read()
f.write(content)
temp_files.append(temp_path)
return temp_files
@FILES_ROUTER.post("/decompose", response_model=List[DecomposedFileInfo])
async def decompose_files(file_paths: Annotated[List[str], Body(embed=True)]):
temp_dir = TEMP_FILE_SERVICE.create_temp_dir(str(uuid.uuid4()))
txt_files = []
other_files = []
for file_path in file_paths:
if file_path.endswith(".txt"):
txt_files.append(file_path)
else:
other_files.append(file_path)
documents_loader = DocumentsLoader(file_paths=other_files)
await documents_loader.load_documents(temp_dir)
parsed_documents = documents_loader.documents
response = []
for index, parsed_doc in enumerate(parsed_documents):
file_path = TEMP_FILE_SERVICE.create_temp_file_path(
f"{uuid.uuid4()}.txt", temp_dir
)
parsed_doc = parsed_doc.replace("<br>", "\n")
with open(file_path, "w", encoding="utf-8") as text_file:
text_file.write(parsed_doc)
response.append(
DecomposedFileInfo(
name=os.path.basename(other_files[index]), file_path=file_path
)
)
# Return the txt documents as it is
for each_file in txt_files:
response.append(
DecomposedFileInfo(name=os.path.basename(each_file), file_path=each_file)
)
return response
@FILES_ROUTER.post("/update")
async def update_files(
file_path: Annotated[str, Body()],
file: Annotated[UploadFile, File()],
):
with open(file_path, "wb") as f:
f.write(await file.read())
return {"message": "File updated successfully"}

View file

@ -0,0 +1,290 @@
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 utils.asset_directory_utils import get_app_data_directory_env
import uuid
try:
from fontTools.ttLib import TTFont
from fontTools.ttLib.tables._n_a_m_e import table__n_a_m_e
FONTTOOLS_AVAILABLE = True
except ImportError:
FONTTOOLS_AVAILABLE = False
FONTS_ROUTER = APIRouter(prefix="/fonts", tags=["fonts"])
# Supported font file extensions
SUPPORTED_FONT_EXTENSIONS = {
'.ttf': 'font/ttf',
'.otf': 'font/otf',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.eot': 'application/vnd.ms-fontobject'
}
class FontUploadResponse(BaseModel):
success: bool
font_name: str
font_url: str
font_path: str
message: Optional[str] = None
class FontListResponse(BaseModel):
success: bool
fonts: List[dict]
message: Optional[str] = None
def get_fonts_directory() -> str:
"""Get the fonts directory path, create if it doesn't exist"""
app_data_dir = get_app_data_directory_env() or "/tmp/presenton"
fonts_dir = os.path.join(app_data_dir, "fonts")
os.makedirs(fonts_dir, exist_ok=True)
return fonts_dir
def is_valid_font_file(file: UploadFile) -> bool:
"""Validate font file by extension and MIME type"""
if not file.filename:
return False
file_ext = os.path.splitext(file.filename)[1].lower()
if file_ext not in SUPPORTED_FONT_EXTENSIONS:
return False
# Check MIME type
content_type = file.content_type or ""
valid_mime_types = [
"font/ttf", "font/otf", "font/woff", "font/woff2",
"application/font-ttf", "application/font-otf",
"application/font-woff", "application/font-woff2",
"application/x-font-ttf", "application/x-font-otf",
"font/truetype", "font/opentype"
]
return content_type in valid_mime_types
def extract_font_name_from_file(file_path: str) -> str:
"""Extract the actual font family name from font file metadata"""
if not FONTTOOLS_AVAILABLE:
# Fallback to filename parsing if fonttools not available
filename = os.path.basename(file_path)
base_name = os.path.splitext(filename)[0]
if '_' in filename and len(filename.split('_')[-1].split('.')[0]) == 8:
# Remove UUID part
parts = filename.split('_')
if len(parts) > 1:
return '_'.join(parts[:-1])
return base_name
try:
font = TTFont(file_path)
# Try to get font family name from name table
if 'name' in font:
name_table = font['name']
# Preferred order: Family name (ID 1), then Full name (ID 4), then PostScript name (ID 6)
for name_id in [1, 4, 6]:
for record in name_table.names:
if record.nameID == name_id:
# Prefer English names
if record.langID == 0x409 or record.langID == 0: # English
font_name = record.toUnicode().strip()
if font_name:
font.close()
return font_name
# If no English name found, use any available family name
for record in name_table.names:
if record.nameID == 1: # Family name
font_name = record.toUnicode().strip()
if font_name:
font.close()
return font_name
font.close()
except Exception as e:
# If font parsing fails, fallback to filename
print(f"Error reading font metadata from {file_path}: {e}")
# Fallback to filename parsing
filename = os.path.basename(file_path)
base_name = os.path.splitext(filename)[0]
if '_' in filename and len(filename.split('_')[-1].split('.')[0]) == 8:
# Remove UUID part
parts = filename.split('_')
if len(parts) > 1:
return '_'.join(parts[:-1])
return base_name
@FONTS_ROUTER.post("/upload", response_model=FontUploadResponse)
async def upload_font(
font_file: UploadFile = File(..., description="Font file to upload (.ttf, .otf, .woff, .woff2, .eot)")
):
"""
Upload a font file and save it to the fonts directory.
Args:
font_file: Uploaded font file
Returns:
FontUploadResponse with font details and accessible URL
Raises:
HTTPException: If file validation fails or upload error occurs
"""
try:
# Validate file
if not font_file.filename:
raise HTTPException(
status_code=400,
detail="No file name provided"
)
if not is_valid_font_file(font_file):
raise HTTPException(
status_code=400,
detail=f"Invalid font file. Supported formats: {', '.join(SUPPORTED_FONT_EXTENSIONS.keys())}"
)
# Generate unique filename to avoid conflicts
file_ext = os.path.splitext(font_file.filename)[1].lower()
base_name = os.path.splitext(font_file.filename)[0]
unique_filename = f"{base_name}_{str(uuid.uuid4())[:8]}{file_ext}"
# Get fonts directory
fonts_dir = get_fonts_directory()
font_path = os.path.join(fonts_dir, unique_filename)
# Save the uploaded file
with open(font_path, "wb") as buffer:
shutil.copyfileobj(font_file.file, buffer)
# Generate accessible URL
font_url = f"/app_data/fonts/{unique_filename}"
return FontUploadResponse(
success=True,
font_name=base_name,
font_url=font_url,
font_path=font_path,
message=f"Font '{base_name}' uploaded successfully"
)
except HTTPException:
# Re-raise HTTP exceptions as-is
raise
except Exception as e:
print(f"Error uploading font: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"Error uploading font: {str(e)}"
)
@FONTS_ROUTER.get("/list", response_model=FontListResponse)
async def list_fonts():
"""
List all uploaded fonts with their accessible URLs.
Returns:
FontListResponse with list of available fonts
"""
try:
fonts_dir = get_fonts_directory()
fonts = []
# Get all font files in the directory
if os.path.exists(fonts_dir):
for filename in os.listdir(fonts_dir):
file_path = os.path.join(fonts_dir, filename)
if os.path.isfile(file_path):
file_ext = os.path.splitext(filename)[1].lower()
if file_ext in SUPPORTED_FONT_EXTENSIONS:
# Get the real font name from file metadata
font_name = extract_font_name_from_file(file_path)
# Extract original name (remove UUID suffix for display)
base_name = filename
if '_' in filename and len(filename.split('_')[-1].split('.')[0]) == 8:
# Remove UUID part for original_name display
parts = filename.split('_')
if len(parts) > 1:
base_name = '_'.join(parts[:-1]) + file_ext
fonts.append({
"filename": filename,
"font_name": font_name, # Real font family name from metadata
"original_name": base_name,
"font_url": f"/app_data/fonts/{filename}",
"font_type": SUPPORTED_FONT_EXTENSIONS.get(file_ext, 'unknown'),
"file_size": os.path.getsize(file_path)
})
return FontListResponse(
success=True,
fonts=fonts,
message=f"Found {len(fonts)} font files"
)
except Exception as e:
print(f"Error listing fonts: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"Error listing fonts: {str(e)}"
)
@FONTS_ROUTER.delete("/delete/{filename}")
async def delete_font(filename: str):
"""
Delete a font file from the fonts directory.
Args:
filename: Name of the font file to delete
Returns:
Success message
"""
try:
fonts_dir = get_fonts_directory()
font_path = os.path.join(fonts_dir, filename)
if not os.path.exists(font_path):
raise HTTPException(
status_code=404,
detail=f"Font file '{filename}' not found"
)
# Validate it's actually a font file before deleting
file_ext = os.path.splitext(filename.lower())[1]
if file_ext not in SUPPORTED_FONT_EXTENSIONS:
raise HTTPException(
status_code=400,
detail="File is not a recognized font format"
)
os.remove(font_path)
return {
"success": True,
"message": f"Font '{filename}' deleted successfully"
}
except HTTPException:
raise
except Exception as e:
print(f"Error deleting font: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"Error deleting font: {str(e)}"
)

View file

@ -0,0 +1,14 @@
from typing import Annotated, List
from fastapi import APIRouter, Body, HTTPException
from utils.available_models import list_available_google_models
GOOGLE_ROUTER = APIRouter(prefix="/google", tags=["Google"])
@GOOGLE_ROUTER.post("/models/available", response_model=List[str])
async def get_available_models(api_key: Annotated[str, Body(embed=True)]):
try:
return await list_available_google_models(api_key)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

View file

@ -0,0 +1,10 @@
from typing import List
from fastapi import APIRouter
from services.icon_finder_service import ICON_FINDER_SERVICE
ICONS_ROUTER = APIRouter(prefix="/icons", tags=["Icons"])
@ICONS_ROUTER.get("/search", response_model=List[str])
async def search_icons(query: str, limit: int = 20):
return await ICON_FINDER_SERVICE.search_icons(query, limit)

View file

@ -0,0 +1,105 @@
from typing import List
from fastapi import APIRouter, Depends, File, UploadFile, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import select
from models.image_prompt import ImagePrompt
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
import os
import uuid
from utils.file_utils import get_file_name_with_random_uuid
IMAGES_ROUTER = APIRouter(prefix="/images", tags=["Images"])
@IMAGES_ROUTER.get("/generate")
async def generate_image(
prompt: str, sql_session: AsyncSession = Depends(get_async_session)
):
images_directory = get_images_directory()
image_prompt = ImagePrompt(prompt=prompt)
image_generation_service = ImageGenerationService(images_directory)
image = await image_generation_service.generate_image(image_prompt)
if not isinstance(image, ImageAsset):
return image
sql_session.add(image)
await sql_session.commit()
return image.file_url
@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(
select(ImageAsset)
.where(ImageAsset.is_uploaded == False)
.order_by(ImageAsset.created_at.desc())
)
return images
except Exception as e:
raise HTTPException(
status_code=500, detail=f"Failed to retrieve generated images: {str(e)}"
)
@IMAGES_ROUTER.post("/upload")
async def upload_image(
file: UploadFile = File(...), sql_session: AsyncSession = Depends(get_async_session)
):
try:
new_filename = get_file_name_with_random_uuid(file)
image_path = os.path.join(
get_images_directory(), os.path.basename(new_filename)
)
with open(image_path, "wb") as f:
f.write(await file.read())
image_asset = ImageAsset(path=image_path, is_uploaded=True)
sql_session.add(image_asset)
await sql_session.commit()
return image_asset
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to upload image: {str(e)}")
@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(
select(ImageAsset)
.where(ImageAsset.is_uploaded == True)
.order_by(ImageAsset.created_at.desc())
)
return images
except Exception as e:
raise HTTPException(
status_code=500, detail=f"Failed to retrieve uploaded images: {str(e)}"
)
@IMAGES_ROUTER.delete("/{id}", status_code=204)
async def delete_uploaded_image_by_id(
id: uuid.UUID, sql_session: AsyncSession = Depends(get_async_session)
):
try:
# Fetch the asset to get its actual file path
image = await sql_session.get(ImageAsset, id)
if not image:
raise HTTPException(status_code=404, detail="Image not found")
os.remove(image.path)
await sql_session.delete(image)
await sql_session.commit()
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to delete image: {str(e)}")

View file

@ -0,0 +1,27 @@
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
LAYOUTS_ROUTER = APIRouter(prefix="/layouts", tags=["Layouts"])
@LAYOUTS_ROUTER.get("/", summary="Get available layouts")
async def get_layouts():
url = "http://localhost:3000/api/layouts" # Adjust port if needed
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
if response.status != 200:
error_text = await response.text()
raise HTTPException(
status_code=response.status,
detail=f"Failed to fetch layouts: {error_text}"
)
layouts_json = await response.json()
# Optionally, parse into a Pydantic model if you have one matching the structure
return layouts_json
@LAYOUTS_ROUTER.get("/{layout_name}", summary="Get layout details by ID")
async def get_layout_detail(layout_name: str) -> PresentationLayoutModel:
return await get_layout_by_name(layout_name)

View file

@ -0,0 +1,85 @@
from datetime import datetime, timedelta
import json
from typing import List
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from api.v1.ppt.background_tasks import pull_ollama_model_background_task
from constants.supported_ollama_models import SUPPORTED_OLLAMA_MODELS
from models.ollama_model_metadata import OllamaModelMetadata
from models.ollama_model_status import OllamaModelStatus
from models.sql.ollama_pull_status import OllamaPullStatus
from services.database import get_container_db_async_session
from utils.ollama import list_pulled_ollama_models
OLLAMA_ROUTER = APIRouter(prefix="/ollama", tags=["Ollama"])
@OLLAMA_ROUTER.get("/models/supported", response_model=List[OllamaModelMetadata])
def get_supported_models():
return SUPPORTED_OLLAMA_MODELS.values()
@OLLAMA_ROUTER.get("/models/available", response_model=List[OllamaModelStatus])
async def get_available_models():
return await list_pulled_ollama_models()
@OLLAMA_ROUTER.get("/model/pull", response_model=OllamaModelStatus)
async def pull_model(
model: str,
background_tasks: BackgroundTasks,
session: AsyncSession = Depends(get_container_db_async_session),
):
if model not in SUPPORTED_OLLAMA_MODELS:
raise HTTPException(
status_code=400,
detail=f"Model {model} is not supported",
)
try:
pulled_models = await list_pulled_ollama_models()
filtered_models = [
pulled_model for pulled_model in pulled_models if pulled_model.name == model
]
if filtered_models:
return filtered_models[0]
except HTTPException as e:
raise e
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Failed to check pulled models: {e}",
)
saved_pull_status = None
saved_model_status = None
try:
saved_pull_status = await session.get(OllamaPullStatus, model)
saved_model_status = saved_pull_status.status
except Exception as e:
pass
# If the model is being pulled, return the model
if saved_model_status:
# If the model is being pulled, return the model
# ? If the model status is pulled in database but was not found while listing pulled models,
# ? it means the model was deleted and we need to pull it again
if (
saved_model_status["status"] == "error"
or saved_model_status["status"] == "pulled"
or saved_pull_status.last_updated < (datetime.now() - timedelta(seconds=10))
):
await session.delete(saved_pull_status)
else:
return saved_model_status
# If the model is not being pulled, pull the model
background_tasks.add_task(pull_ollama_model_background_task, model)
return OllamaModelStatus(
name=model,
status="pulling",
done=False,
)

View file

@ -0,0 +1,17 @@
from typing import Annotated, List
from fastapi import APIRouter, Body, HTTPException
from utils.available_models import list_available_openai_compatible_models
OPENAI_ROUTER = APIRouter(prefix="/openai", tags=["OpenAI"])
@OPENAI_ROUTER.post("/models/available", response_model=List[str])
async def get_available_models(
url: Annotated[str, Body()],
api_key: Annotated[str, Body()],
):
try:
return await list_available_openai_compatible_models(url, api_key)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

View file

@ -0,0 +1,113 @@
import asyncio
import json
import math
import traceback
import uuid
import dirtyjson
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from models.presentation_outline_model import PresentationOutlineModel
from models.sql.presentation import PresentationModel
from models.sse_response import (
SSECompleteResponse,
SSEErrorResponse,
SSEResponse,
SSEStatusResponse,
)
from services.temp_file_service import TEMP_FILE_SERVICE
from services.database import get_async_session
from services.documents_loader import DocumentsLoader
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"])
@OUTLINES_ROUTER.get("/stream/{id}")
async def stream_outlines(
id: uuid.UUID, sql_session: AsyncSession = Depends(get_async_session)
):
presentation = await sql_session.get(PresentationModel, id)
if not presentation:
raise HTTPException(status_code=404, detail="Presentation not found")
temp_dir = TEMP_FILE_SERVICE.create_temp_dir()
async def inner():
yield SSEStatusResponse(
status="Generating presentation outlines..."
).to_string()
additional_context = ""
if presentation.file_paths:
documents_loader = DocumentsLoader(file_paths=presentation.file_paths)
await documents_loader.load_documents(temp_dir)
documents = documents_loader.documents
if documents:
additional_context = "\n\n".join(documents)
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
)
async for chunk in generate_ppt_outline(
presentation.content,
n_slides_to_generate,
presentation.language,
additional_context,
presentation.tone,
presentation.verbosity,
presentation.instructions,
presentation.include_title_slide,
presentation.web_search,
):
# Give control to the event loop
await asyncio.sleep(0)
if isinstance(chunk, HTTPException):
yield SSEErrorResponse(detail=chunk.detail).to_string()
return
yield SSEResponse(
event="response",
data=json.dumps({"type": "chunk", "chunk": chunk}),
).to_string()
presentation_outlines_text += chunk
try:
presentation_outlines_json = dict(
dirtyjson.loads(presentation_outlines_text)
)
except Exception as e:
traceback.print_exc()
yield SSEErrorResponse(
detail=f"Failed to generate presentation outlines. Please try again. {str(e)}",
).to_string()
return
presentation_outlines = PresentationOutlineModel(**presentation_outlines_json)
presentation_outlines.slides = presentation_outlines.slides[
:n_slides_to_generate
]
presentation.outlines = presentation_outlines.model_dump()
presentation.title = get_presentation_title_from_outlines(presentation_outlines)
sql_session.add(presentation)
await sql_session.commit()
yield SSECompleteResponse(
key="presentation", value=presentation.model_dump(mode="json")
).to_string()
return StreamingResponse(inner(), media_type="text/event-stream")

View file

@ -0,0 +1,116 @@
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)}"
)

View file

@ -0,0 +1,613 @@
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 POWERPOINT_TYPES
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 POWERPOINT_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 POWERPOINT_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(
[
"libreoffice",
"--headless",
"--convert-to",
"pdf",
"--outdir",
screenshots_dir,
pptx_path,
],
check=True,
capture_output=True,
text=True,
timeout=500,
env=env,
)
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)}")

View file

@ -0,0 +1,956 @@
import asyncio
from datetime import datetime
import json
import math
import os
import random
import traceback
from typing import Annotated, List, Literal, Optional, Tuple
import dirtyjson
from fastapi import APIRouter, BackgroundTasks, Body, Depends, HTTPException, Path
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 enums.webhook_event import WebhookEvent
from models.api_error_model import APIErrorModel
from models.generate_presentation_request import GeneratePresentationRequest
from models.presentation_and_path import PresentationPathAndEditPath
from models.presentation_from_template import EditPresentationRequest
from models.presentation_outline_model import (
PresentationOutlineModel,
SlideOutlineModel,
)
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
from utils.llm_calls.generate_presentation_outlines import generate_ppt_outline
from models.sql.slide import SlideModel
from models.sse_response import SSECompleteResponse, SSEErrorResponse, SSEResponse
from services.database import get_async_session
from services.temp_file_service import TEMP_FILE_SERVICE
from services.concurrent_service import CONCURRENT_SERVICE
from models.sql.presentation import PresentationModel
from services.pptx_presentation_creator import PptxPresentationCreator
from models.sql.async_presentation_generation_status import (
AsyncPresentationGenerationTaskModel,
)
from utils.asset_directory_utils import get_exports_directory, get_images_directory
from utils.llm_calls.generate_presentation_structure import (
generate_presentation_structure,
)
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.process_slides import (
process_slide_add_placeholder_assets,
process_slide_and_fetch_assets,
)
import uuid
PRESENTATION_ROUTER = APIRouter(prefix="/presentation", tags=["Presentation"])
@PRESENTATION_ROUTER.get("/all", response_model=List[PresentationWithSlides])
async def get_all_presentations(sql_session: AsyncSession = Depends(get_async_session)):
presentations_with_slides = []
query = (
select(PresentationModel, SlideModel)
.join(
SlideModel,
(SlideModel.presentation == PresentationModel.id) & (SlideModel.index == 0),
)
.order_by(PresentationModel.created_at.desc())
)
results = await sql_session.execute(query)
rows = results.all()
presentations_with_slides = [
PresentationWithSlides(
**presentation.model_dump(),
slides=[first_slide],
)
for presentation, first_slide in rows
]
return presentations_with_slides
@PRESENTATION_ROUTER.get("/{id}", response_model=PresentationWithSlides)
async def get_presentation(
id: uuid.UUID, sql_session: AsyncSession = Depends(get_async_session)
):
presentation = await sql_session.get(PresentationModel, id)
if not presentation:
raise HTTPException(404, "Presentation not found")
slides = await sql_session.scalars(
select(SlideModel)
.where(SlideModel.presentation == id)
.order_by(SlideModel.index)
)
return PresentationWithSlides(
**presentation.model_dump(),
slides=slides,
)
@PRESENTATION_ROUTER.delete("/{id}", status_code=204)
async def delete_presentation(
id: uuid.UUID, sql_session: AsyncSession = Depends(get_async_session)
):
presentation = await sql_session.get(PresentationModel, id)
if not presentation:
raise HTTPException(404, "Presentation not found")
await sql_session.delete(presentation)
await sql_session.commit()
@PRESENTATION_ROUTER.post("/create", response_model=PresentationModel)
async def create_presentation(
content: Annotated[str, Body()],
n_slides: Annotated[int, Body()],
language: Annotated[str, Body()],
file_paths: Annotated[Optional[List[str]], Body()] = None,
tone: Annotated[Tone, Body()] = Tone.DEFAULT,
verbosity: Annotated[Verbosity, Body()] = Verbosity.STANDARD,
instructions: Annotated[Optional[str], Body()] = None,
include_table_of_contents: Annotated[bool, Body()] = False,
include_title_slide: Annotated[bool, Body()] = True,
web_search: Annotated[bool, Body()] = False,
sql_session: AsyncSession = Depends(get_async_session),
):
if include_table_of_contents 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()
presentation = PresentationModel(
id=presentation_id,
content=content,
n_slides=n_slides,
language=language,
file_paths=file_paths,
tone=tone.value,
verbosity=verbosity.value,
instructions=instructions,
include_table_of_contents=include_table_of_contents,
include_title_slide=include_title_slide,
web_search=web_search,
)
sql_session.add(presentation)
await sql_session.commit()
return presentation
@PRESENTATION_ROUTER.post("/prepare", response_model=PresentationModel)
async def prepare_presentation(
presentation_id: Annotated[uuid.UUID, Body()],
outlines: Annotated[List[SlideOutlineModel], Body()],
layout: Annotated[PresentationLayoutModel, Body()],
title: Annotated[Optional[str], Body()] = None,
sql_session: AsyncSession = Depends(get_async_session),
):
if not outlines:
raise HTTPException(status_code=400, detail="Outlines are required")
presentation = await sql_session.get(PresentationModel, presentation_id)
if not presentation:
raise HTTPException(status_code=404, detail="Presentation not found")
presentation_outline_model = PresentationOutlineModel(slides=outlines)
total_slide_layouts = len(layout.slides)
total_outlines = len(outlines)
if layout.ordered:
presentation_structure = layout.to_presentation_structure()
else:
presentation_structure: PresentationStructureModel = (
await generate_presentation_structure(
presentation_outline=presentation_outline_model,
presentation_layout=layout,
instructions=presentation.instructions,
)
)
presentation_structure.slides = presentation_structure.slides[: len(outlines)]
for index in range(total_outlines):
random_slide_index = random.randint(0, total_slide_layouts - 1)
if index >= total_outlines:
presentation_structure.slides.append(random_slide_index)
continue
if presentation_structure.slides[index] >= total_slide_layouts:
presentation_structure.slides[index] = random_slide_index
if presentation.include_table_of_contents:
n_toc_slides = presentation.n_slides - total_outlines
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,
),
)
sql_session.add(presentation)
presentation.outlines = presentation_outline_model.model_dump(mode="json")
presentation.title = title or presentation.title
presentation.set_layout(layout)
presentation.set_structure(presentation_structure)
await sql_session.commit()
return presentation
@PRESENTATION_ROUTER.get("/stream/{id}", response_model=PresentationWithSlides)
async def stream_presentation(
id: uuid.UUID, sql_session: AsyncSession = Depends(get_async_session)
):
presentation = await sql_session.get(PresentationModel, id)
if not presentation:
raise HTTPException(status_code=404, detail="Presentation not found")
if not presentation.structure:
raise HTTPException(
status_code=400,
detail="Presentation not prepared for stream",
)
if not presentation.outlines:
raise HTTPException(
status_code=400,
detail="Outlines can not be empty",
)
image_generation_service = ImageGenerationService(get_images_directory())
async def inner():
structure = presentation.get_structure()
layout = presentation.get_layout()
outline = presentation.get_presentation_outline()
# These tasks will be gathered and awaited after all slides are generated
async_assets_generation_tasks = []
slides: List[SlideModel] = []
yield SSEResponse(
event="response",
data=json.dumps({"type": "chunk", "chunk": '{ "slides": [ '}),
).to_string()
for i, slide_layout_index in enumerate(structure.slides):
slide_layout = layout.slides[slide_layout_index]
try:
slide_content = await get_slide_content_from_type_and_outline(
slide_layout,
outline.slides[i],
presentation.language,
presentation.tone,
presentation.verbosity,
presentation.instructions,
)
except HTTPException as e:
yield SSEErrorResponse(detail=e.detail).to_string()
return
slide = SlideModel(
presentation=id,
layout_group=layout.name,
layout=slide_layout.id,
index=i,
speaker_note=slide_content.get("__speaker_note__", ""),
content=slide_content,
)
slides.append(slide)
# This will mutate slide and add placeholder assets
process_slide_add_placeholder_assets(slide)
# This will mutate slide
async_assets_generation_tasks.append(
process_slide_and_fetch_assets(image_generation_service, slide)
)
yield SSEResponse(
event="response",
data=json.dumps({"type": "chunk", "chunk": slide.model_dump_json()}),
).to_string()
yield SSEResponse(
event="response",
data=json.dumps({"type": "chunk", "chunk": " ] }"}),
).to_string()
generated_assets_lists = await asyncio.gather(*async_assets_generation_tasks)
generated_assets = []
for assets_list in generated_assets_lists:
generated_assets.extend(assets_list)
# Moved this here to make sure new slides are generated before deleting the old ones
await sql_session.execute(
delete(SlideModel).where(SlideModel.presentation == id)
)
await sql_session.commit()
sql_session.add(presentation)
sql_session.add_all(slides)
sql_session.add_all(generated_assets)
await sql_session.commit()
response = PresentationWithSlides(
**presentation.model_dump(),
slides=slides,
)
yield SSECompleteResponse(
key="presentation",
value=response.model_dump(mode="json"),
).to_string()
return StreamingResponse(inner(), media_type="text/event-stream")
@PRESENTATION_ROUTER.patch("/update", response_model=PresentationWithSlides)
async def update_presentation(
id: Annotated[uuid.UUID, Body()],
n_slides: Annotated[Optional[int], Body()] = None,
title: Annotated[Optional[str], Body()] = None,
slides: Annotated[Optional[List[SlideModel]], Body()] = None,
sql_session: AsyncSession = Depends(get_async_session),
):
presentation = await sql_session.get(PresentationModel, id)
if not presentation:
raise HTTPException(status_code=404, detail="Presentation not found")
presentation_update_dict = {}
if n_slides:
presentation_update_dict["n_slides"] = n_slides
if title:
presentation_update_dict["title"] = title
if n_slides or title:
presentation.sqlmodel_update(presentation_update_dict)
if slides:
# Just to make sure id is UUID
for slide in slides:
slide.presentation = uuid.UUID(slide.presentation)
slide.id = uuid.UUID(slide.id)
await sql_session.execute(
delete(SlideModel).where(SlideModel.presentation == presentation.id)
)
sql_session.add_all(slides)
await sql_session.commit()
return PresentationWithSlides(
**presentation.model_dump(),
slides=slides or [],
)
@PRESENTATION_ROUTER.post("/export/pptx", response_model=str)
async def export_presentation_as_pptx(
pptx_model: Annotated[PptxPresentationModel, Body()],
):
temp_dir = TEMP_FILE_SERVICE.create_temp_dir()
pptx_creator = PptxPresentationCreator(pptx_model, temp_dir)
await pptx_creator.create_ppt()
export_directory = get_exports_directory()
pptx_path = os.path.join(
export_directory, f"{pptx_model.name or uuid.uuid4()}.pptx"
)
pptx_creator.save(pptx_path)
return pptx_path
@PRESENTATION_ROUTER.post("/export", response_model=PresentationPathAndEditPath)
async def export_presentation_as_pptx_or_pdf(
id: Annotated[uuid.UUID, Body(description="Presentation ID to export")],
export_as: Annotated[
Literal["pptx", "pdf"], Body(description="Format to export the presentation as")
] = "pptx",
sql_session: AsyncSession = Depends(get_async_session),
):
"""
Export a presentation as PPTX or PDF.
This Api is used to export via the nextjs app i.e using the puppeteer to export the presentation.
"""
presentation = await sql_session.get(PresentationModel, id)
if not presentation:
raise HTTPException(status_code=404, detail="Presentation not found")
presentation_and_path = await export_presentation(
id,
presentation.title or str(uuid.uuid4()),
export_as,
)
return PresentationPathAndEditPath(
**presentation_and_path.model_dump(),
edit_path=f"/presentation?id={id}",
)
async def check_if_api_request_is_valid(
request: GeneratePresentationRequest,
sql_session: AsyncSession = Depends(get_async_session),
) -> Tuple[uuid.UUID,]:
presentation_id = uuid.uuid4()
print(f"Presentation ID: {presentation_id}")
# Making sure either content, slides markdown or files is provided
if not (request.content or request.slides_markdown or request.files):
raise HTTPException(
status_code=400,
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:
raise HTTPException(
status_code=400,
detail="Number of slides must be greater than 0",
)
# Checking if template is valid
if request.template not in DEFAULT_TEMPLATES:
request.template = request.template.lower()
if not request.template.startswith("custom-"):
raise HTTPException(
status_code=400,
detail="Template not found. Please use a valid template.",
)
template_id = request.template.replace("custom-", "")
try:
template = await sql_session.get(TemplateModel, uuid.UUID(template_id))
if not template:
raise Exception()
except Exception:
raise HTTPException(
status_code=400,
detail="Template not found. Please use a valid template.",
)
return (presentation_id,)
async def generate_presentation_handler(
request: GeneratePresentationRequest,
presentation_id: uuid.UUID,
async_status: Optional[AsyncPresentationGenerationTaskModel],
sql_session: AsyncSession = Depends(get_async_session),
):
try:
using_slides_markdown = False
if request.slides_markdown:
using_slides_markdown = True
request.n_slides = len(request.slides_markdown)
if not using_slides_markdown:
additional_context = ""
# Updating async status
if async_status:
async_status.message = "Generating presentation outlines"
async_status.updated_at = datetime.now()
sql_session.add(async_status)
await sql_session.commit()
if request.files:
documents_loader = DocumentsLoader(file_paths=request.files)
await documents_loader.load_documents()
documents = documents_loader.documents
if documents:
additional_context = "\n\n".join(documents)
# 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
)
/ 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,
additional_context,
request.tone.value,
request.verbosity.value,
request.instructions,
request.include_title_slide,
request.web_search,
):
if isinstance(chunk, HTTPException):
raise chunk
presentation_outlines_text += chunk
try:
presentation_outlines_json = dict(
dirtyjson.loads(presentation_outlines_text)
)
except Exception:
traceback.print_exc()
raise HTTPException(
status_code=400,
detail="Failed to generate presentation outlines. Please try again.",
)
presentation_outlines = PresentationOutlineModel(
**presentation_outlines_json
)
total_outlines = n_slides_to_generate
else:
# Setting outlines to slides markdown
presentation_outlines = PresentationOutlineModel(
slides=[
SlideOutlineModel(content=slide)
for slide in request.slides_markdown
]
)
total_outlines = len(request.slides_markdown)
# Updating async status
if async_status:
async_status.message = "Selecting layout for each slide"
async_status.updated_at = datetime.now()
sql_session.add(async_status)
await sql_session.commit()
print("-" * 40)
print(f"Generated {total_outlines} outlines for the presentation")
# Parse Layouts
layout_model = await get_layout_by_name(request.template)
total_slide_layouts = len(layout_model.slides)
# Generate Structure
if layout_model.ordered:
presentation_structure = layout_model.to_presentation_structure()
else:
presentation_structure: PresentationStructureModel = (
await generate_presentation_structure(
presentation_outlines,
layout_model,
request.instructions,
using_slides_markdown,
)
)
presentation_structure.slides = presentation_structure.slides[:total_outlines]
for index in range(total_outlines):
random_slide_index = random.randint(0, total_slide_layouts - 1)
if index >= total_outlines:
presentation_structure.slides.append(random_slide_index)
continue
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
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
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,
),
)
# 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),
outlines=presentation_outlines.model_dump(),
layout=layout_model.model_dump(),
structure=presentation_structure.model_dump(),
tone=request.tone.value,
verbosity=request.verbosity.value,
instructions=request.instructions,
)
# Updating async status
if async_status:
async_status.message = "Generating slides"
async_status.updated_at = datetime.now()
sql_session.add(async_status)
await sql_session.commit()
image_generation_service = ImageGenerationService(get_images_directory())
async_assets_generation_tasks = []
# 7. Generate slide content concurrently (batched), then build slides and fetch assets
slides: List[SlideModel] = []
slide_layout_indices = presentation_structure.slides
slide_layouts = [layout_model.slides[idx] for idx in slide_layout_indices]
# Schedule slide content generation and asset fetching in batches of 10
batch_size = 10
for start in range(0, len(slide_layouts), batch_size):
end = min(start + batch_size, len(slide_layouts))
print(f"Generating slides from {start} to {end}")
# Generate contents for this batch concurrently
content_tasks = [
get_slide_content_from_type_and_outline(
slide_layouts[i],
presentation_outlines.slides[i],
request.language,
request.tone.value,
request.verbosity.value,
request.instructions,
)
for i in range(start, end)
]
batch_contents: List[dict] = await asyncio.gather(*content_tasks)
# Build slides for this batch
batch_slides: List[SlideModel] = []
for offset, slide_content in enumerate(batch_contents):
i = start + offset
slide_layout = slide_layouts[i]
slide = SlideModel(
presentation=presentation_id,
layout_group=layout_model.name,
layout=slide_layout.id,
index=i,
speaker_note=slide_content.get("__speaker_note__"),
content=slide_content,
)
slides.append(slide)
batch_slides.append(slide)
# Start asset fetch tasks for just-generated slides so they run while next batch is processed
asset_tasks = [
process_slide_and_fetch_assets(image_generation_service, slide)
for slide in batch_slides
]
async_assets_generation_tasks.extend(asset_tasks)
if async_status:
async_status.message = "Fetching assets for slides"
async_status.updated_at = datetime.now()
sql_session.add(async_status)
await sql_session.commit()
# Run all asset tasks concurrently while batches may still be generating content
generated_assets_list = await asyncio.gather(*async_assets_generation_tasks)
generated_assets = []
for assets_list in generated_assets_list:
generated_assets.extend(assets_list)
# 8. Save PresentationModel and Slides
sql_session.add(presentation)
sql_session.add_all(slides)
sql_session.add_all(generated_assets)
await sql_session.commit()
if async_status:
async_status.message = "Exporting presentation"
async_status.updated_at = datetime.now()
sql_session.add(async_status)
# 9. Export
presentation_and_path = await export_presentation(
presentation_id, presentation.title or str(uuid.uuid4()), request.export_as
)
response = PresentationPathAndEditPath(
**presentation_and_path.model_dump(),
edit_path=f"/presentation?id={presentation_id}",
)
if async_status:
async_status.message = "Presentation generation completed"
async_status.status = "completed"
async_status.data = response.model_dump(mode="json")
async_status.updated_at = datetime.now()
sql_session.add(async_status)
await sql_session.commit()
# Triggering webhook on success
CONCURRENT_SERVICE.run_task(
None,
WebhookService.send_webhook,
WebhookEvent.PRESENTATION_GENERATION_COMPLETED,
response.model_dump(mode="json"),
)
return response
except Exception as e:
if not isinstance(e, HTTPException):
traceback.print_exc()
e = HTTPException(status_code=500, detail="Presentation generation failed")
api_error_model = APIErrorModel.from_exception(e)
# Triggering webhook on failure
CONCURRENT_SERVICE.run_task(
None,
WebhookService.send_webhook,
WebhookEvent.PRESENTATION_GENERATION_FAILED,
api_error_model.model_dump(mode="json"),
)
if async_status:
async_status.status = "error"
async_status.message = "Presentation generation failed"
async_status.updated_at = datetime.now()
async_status.error = api_error_model.model_dump(mode="json")
sql_session.add(async_status)
await sql_session.commit()
else:
raise e
@PRESENTATION_ROUTER.post("/generate", response_model=PresentationPathAndEditPath)
async def generate_presentation_sync(
request: GeneratePresentationRequest,
sql_session: AsyncSession = Depends(get_async_session),
):
try:
(presentation_id,) = await check_if_api_request_is_valid(request, sql_session)
return await generate_presentation_handler(
request, presentation_id, None, sql_session
)
except Exception:
traceback.print_exc()
raise HTTPException(status_code=500, detail="Presentation generation failed")
@PRESENTATION_ROUTER.post(
"/generate/async", response_model=AsyncPresentationGenerationTaskModel
)
async def generate_presentation_async(
request: GeneratePresentationRequest,
background_tasks: BackgroundTasks,
sql_session: AsyncSession = Depends(get_async_session),
):
try:
(presentation_id,) = await check_if_api_request_is_valid(request, sql_session)
async_status = AsyncPresentationGenerationTaskModel(
status="pending",
message="Queued for generation",
data=None,
)
sql_session.add(async_status)
await sql_session.commit()
background_tasks.add_task(
generate_presentation_handler,
request,
presentation_id,
async_status=async_status,
sql_session=sql_session,
)
return async_status
except Exception as e:
if not isinstance(e, HTTPException):
print(e)
e = HTTPException(status_code=500, detail="Presentation generation failed")
raise e
@PRESENTATION_ROUTER.get(
"/status/{id}", response_model=AsyncPresentationGenerationTaskModel
)
async def check_async_presentation_generation_status(
id: str = Path(description="ID of the presentation generation task"),
sql_session: AsyncSession = Depends(get_async_session),
):
status = await sql_session.get(AsyncPresentationGenerationTaskModel, id)
if not status:
raise HTTPException(
status_code=404, detail="No presentation generation task found"
)
return status
@PRESENTATION_ROUTER.post("/edit", response_model=PresentationPathAndEditPath)
async def edit_presentation_with_new_content(
data: Annotated[EditPresentationRequest, Body()],
sql_session: AsyncSession = Depends(get_async_session),
):
presentation = await sql_session.get(PresentationModel, data.presentation_id)
if not presentation:
raise HTTPException(status_code=404, detail="Presentation not found")
slides = await sql_session.scalars(
select(SlideModel).where(SlideModel.presentation == data.presentation_id)
)
new_slides = []
slides_to_delete = []
for each_slide in slides:
updated_content = None
new_slide_data = list(
filter(lambda x: x.index == each_slide.index, data.slides)
)
if new_slide_data:
updated_content = deep_update(each_slide.content, new_slide_data[0].content)
new_slides.append(
each_slide.get_new_slide(presentation.id, updated_content)
)
slides_to_delete.append(each_slide.id)
await sql_session.execute(
delete(SlideModel).where(SlideModel.id.in_(slides_to_delete))
)
sql_session.add_all(new_slides)
await sql_session.commit()
presentation_and_path = await export_presentation(
presentation.id, presentation.title or str(uuid.uuid4()), data.export_as
)
return PresentationPathAndEditPath(
**presentation_and_path.model_dump(),
edit_path=f"/presentation?id={presentation.id}",
)
@PRESENTATION_ROUTER.post("/derive", response_model=PresentationPathAndEditPath)
async def derive_presentation_from_existing_one(
data: Annotated[EditPresentationRequest, Body()],
sql_session: AsyncSession = Depends(get_async_session),
):
presentation = await sql_session.get(PresentationModel, data.presentation_id)
if not presentation:
raise HTTPException(status_code=404, detail="Presentation not found")
slides = await sql_session.scalars(
select(SlideModel).where(SlideModel.presentation == data.presentation_id)
)
new_presentation = presentation.get_new_presentation()
new_slides = []
for each_slide in slides:
updated_content = None
new_slide_data = list(
filter(lambda x: x.index == each_slide.index, data.slides)
)
if new_slide_data:
updated_content = deep_update(each_slide.content, new_slide_data[0].content)
new_slides.append(
each_slide.get_new_slide(new_presentation.id, updated_content)
)
sql_session.add(new_presentation)
sql_session.add_all(new_slides)
await sql_session.commit()
presentation_and_path = await export_presentation(
new_presentation.id, new_presentation.title or str(uuid.uuid4()), data.export_as
)
return PresentationPathAndEditPath(
**presentation_and_path.model_dump(),
edit_path=f"/presentation?id={new_presentation.id}",
)

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,90 @@
from typing import Annotated, Optional
from fastapi import APIRouter, Body, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
import uuid
from models.sql.presentation import PresentationModel
from models.sql.slide import SlideModel
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.llm_calls.edit_slide import get_edited_slide_content
from utils.llm_calls.edit_slide_html import get_edited_slide_html
from utils.llm_calls.select_slide_type_on_edit import get_slide_layout_from_prompt
from utils.process_slides import process_old_and_new_slides_and_fetch_assets
import uuid
SLIDE_ROUTER = APIRouter(prefix="/slide", tags=["Slide"])
@SLIDE_ROUTER.post("/edit")
async def edit_slide(
id: Annotated[uuid.UUID, Body()],
prompt: Annotated[str, Body()],
sql_session: AsyncSession = Depends(get_async_session),
):
slide = await sql_session.get(SlideModel, id)
if not slide:
raise HTTPException(status_code=404, detail="Slide not found")
presentation = await sql_session.get(PresentationModel, slide.presentation)
if not presentation:
raise HTTPException(status_code=404, detail="Presentation not found")
presentation_layout = presentation.get_layout()
slide_layout = await get_slide_layout_from_prompt(
prompt, presentation_layout, slide
)
edited_slide_content = await get_edited_slide_content(
prompt, slide, presentation.language, slide_layout
)
image_generation_service = ImageGenerationService(get_images_directory())
# This will mutate edited_slide_content
new_assets = await process_old_and_new_slides_and_fetch_assets(
image_generation_service,
slide.content,
edited_slide_content,
)
# Always assign a new unique id to the slide
slide.id = uuid.uuid4()
sql_session.add(slide)
slide.content = edited_slide_content
slide.layout = slide_layout.id
slide.speaker_note = edited_slide_content.get("__speaker_note__", "")
sql_session.add_all(new_assets)
await sql_session.commit()
return slide
@SLIDE_ROUTER.post("/edit-html", response_model=SlideModel)
async def edit_slide_html(
id: Annotated[uuid.UUID, Body()],
prompt: Annotated[str, Body()],
html: Annotated[Optional[str], Body()] = None,
sql_session: AsyncSession = Depends(get_async_session),
):
slide = await sql_session.get(SlideModel, id)
if not slide:
raise HTTPException(status_code=404, detail="Slide not found")
html_to_edit = html or slide.html_content
if not html_to_edit:
raise HTTPException(status_code=400, detail="No HTML to edit")
edited_slide_html = await get_edited_slide_html(prompt, html_to_edit)
# Always assign a new unique id to the slide
# This is to ensure that the nextjs can track slide updates
slide.id = uuid.uuid4()
sql_session.add(slide)
slide.html_content = edited_slide_html
await sql_session.commit()
return slide

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,39 @@
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
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.pptx_slides import PPTX_FONTS_ROUTER
API_V1_PPT_ROUTER = APIRouter(prefix="/api/v1/ppt")
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(IMAGES_ROUTER)
API_V1_PPT_ROUTER.include_router(ICONS_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(PPTX_FONTS_ROUTER)

View file

@ -0,0 +1,53 @@
from typing import Optional
from fastapi import APIRouter, Body, Depends, HTTPException, Path
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from enums.webhook_event import WebhookEvent
from models.sql.webhook_subscription import WebhookSubscription
from services.database import get_async_session
API_V1_WEBHOOK_ROUTER = APIRouter(prefix="/api/v1/webhook", tags=["Webhook"])
class SubscribeToWebhookRequest(BaseModel):
url: str = Field(description="The URL to send the webhook to")
secret: Optional[str] = Field(None, description="The secret to use for the webhook")
event: WebhookEvent = Field(description="The event to subscribe to")
class SubscribeToWebhookResponse(BaseModel):
id: str
@API_V1_WEBHOOK_ROUTER.post(
"/subscribe", response_model=SubscribeToWebhookResponse, status_code=201
)
async def subscribe_to_webhook(
body: SubscribeToWebhookRequest,
sql_session: AsyncSession = Depends(get_async_session),
):
webhook_subscription = WebhookSubscription(
url=body.url,
secret=body.secret,
event=body.event,
)
sql_session.add(webhook_subscription)
await sql_session.commit()
return SubscribeToWebhookResponse(id=webhook_subscription.id)
@API_V1_WEBHOOK_ROUTER.delete("/unsubscribe", status_code=204)
async def unsubscribe_to_webhook(
id: str = Body(
embed=True, description="The ID of the webhook subscription to unsubscribe from"
),
sql_session: AsyncSession = Depends(get_async_session),
):
webhook_subscription = await sql_session.get(WebhookSubscription, id)
if not webhook_subscription:
raise HTTPException(404, "Webhook subscription not found")
await sql_session.delete(webhook_subscription)
await sql_session.commit()

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,69 @@
('/home/sudipnext/Documents/presenton-3/electron/servers/fastapi/build/server/fastapi',
True,
False,
True,
None,
None,
False,
False,
None,
True,
False,
None,
None,
None,
'/home/sudipnext/Documents/presenton-3/electron/servers/fastapi/build/server/fastapi.pkg',
[('pyi-contents-directory _internal', '', 'OPTION'),
('PYZ-00.pyz',
'/home/sudipnext/Documents/presenton-3/electron/servers/fastapi/build/server/PYZ-00.pyz',
'PYZ'),
('struct',
'/home/sudipnext/Documents/presenton-3/electron/servers/fastapi/build/server/localpycs/struct.pyc',
'PYMODULE'),
('pyimod01_archive',
'/home/sudipnext/Documents/presenton-3/electron/servers/fastapi/build/server/localpycs/pyimod01_archive.pyc',
'PYMODULE'),
('pyimod02_importers',
'/home/sudipnext/Documents/presenton-3/electron/servers/fastapi/build/server/localpycs/pyimod02_importers.pyc',
'PYMODULE'),
('pyimod03_ctypes',
'/home/sudipnext/Documents/presenton-3/electron/servers/fastapi/build/server/localpycs/pyimod03_ctypes.pyc',
'PYMODULE'),
('pyiboot01_bootstrap',
'/home/sudipnext/Documents/presenton-3/electron/servers/fastapi/.venv/lib/python3.11/site-packages/PyInstaller/loader/pyiboot01_bootstrap.py',
'PYSOURCE'),
('runtime_hook_docling',
'/home/sudipnext/Documents/presenton-3/electron/servers/fastapi/runtime_hook_docling.py',
'PYSOURCE'),
('pyi_rth_pkgutil',
'/home/sudipnext/Documents/presenton-3/electron/servers/fastapi/.venv/lib/python3.11/site-packages/PyInstaller/hooks/rthooks/pyi_rth_pkgutil.py',
'PYSOURCE'),
('pyi_rth_multiprocessing',
'/home/sudipnext/Documents/presenton-3/electron/servers/fastapi/.venv/lib/python3.11/site-packages/PyInstaller/hooks/rthooks/pyi_rth_multiprocessing.py',
'PYSOURCE'),
('pyi_rth_inspect',
'/home/sudipnext/Documents/presenton-3/electron/servers/fastapi/.venv/lib/python3.11/site-packages/PyInstaller/hooks/rthooks/pyi_rth_inspect.py',
'PYSOURCE'),
('pyi_rth_setuptools',
'/home/sudipnext/Documents/presenton-3/electron/servers/fastapi/.venv/lib/python3.11/site-packages/PyInstaller/hooks/rthooks/pyi_rth_setuptools.py',
'PYSOURCE'),
('pyi_rth_pkgres',
'/home/sudipnext/Documents/presenton-3/electron/servers/fastapi/.venv/lib/python3.11/site-packages/PyInstaller/hooks/rthooks/pyi_rth_pkgres.py',
'PYSOURCE'),
('pyi_rth_cryptography_openssl',
'/home/sudipnext/Documents/presenton-3/electron/servers/fastapi/.venv/lib/python3.11/site-packages/_pyinstaller_hooks_contrib/rthooks/pyi_rth_cryptography_openssl.py',
'PYSOURCE'),
('pyi_rth_nltk',
'/home/sudipnext/Documents/presenton-3/electron/servers/fastapi/.venv/lib/python3.11/site-packages/_pyinstaller_hooks_contrib/rthooks/pyi_rth_nltk.py',
'PYSOURCE'),
('server',
'/home/sudipnext/Documents/presenton-3/electron/servers/fastapi/server.py',
'PYSOURCE')],
[],
False,
False,
1771514382,
[('run',
'/home/sudipnext/Documents/presenton-3/electron/servers/fastapi/.venv/lib/python3.11/site-packages/PyInstaller/bootloader/Linux-64bit-intel/run',
'EXECUTABLE')],
'/lib/x86_64-linux-gnu/libpython3.11.so.1.0')

View file

@ -0,0 +1,64 @@
('/home/sudipnext/Documents/presenton-3/electron/servers/fastapi/build/server/fastapi.pkg',
{'BINARY': True,
'DATA': True,
'EXECUTABLE': True,
'EXTENSION': True,
'PYMODULE': True,
'PYSOURCE': True,
'PYZ': False,
'SPLASH': True,
'SYMLINK': False},
[('pyi-contents-directory _internal', '', 'OPTION'),
('PYZ-00.pyz',
'/home/sudipnext/Documents/presenton-3/electron/servers/fastapi/build/server/PYZ-00.pyz',
'PYZ'),
('struct',
'/home/sudipnext/Documents/presenton-3/electron/servers/fastapi/build/server/localpycs/struct.pyc',
'PYMODULE'),
('pyimod01_archive',
'/home/sudipnext/Documents/presenton-3/electron/servers/fastapi/build/server/localpycs/pyimod01_archive.pyc',
'PYMODULE'),
('pyimod02_importers',
'/home/sudipnext/Documents/presenton-3/electron/servers/fastapi/build/server/localpycs/pyimod02_importers.pyc',
'PYMODULE'),
('pyimod03_ctypes',
'/home/sudipnext/Documents/presenton-3/electron/servers/fastapi/build/server/localpycs/pyimod03_ctypes.pyc',
'PYMODULE'),
('pyiboot01_bootstrap',
'/home/sudipnext/Documents/presenton-3/electron/servers/fastapi/.venv/lib/python3.11/site-packages/PyInstaller/loader/pyiboot01_bootstrap.py',
'PYSOURCE'),
('runtime_hook_docling',
'/home/sudipnext/Documents/presenton-3/electron/servers/fastapi/runtime_hook_docling.py',
'PYSOURCE'),
('pyi_rth_pkgutil',
'/home/sudipnext/Documents/presenton-3/electron/servers/fastapi/.venv/lib/python3.11/site-packages/PyInstaller/hooks/rthooks/pyi_rth_pkgutil.py',
'PYSOURCE'),
('pyi_rth_multiprocessing',
'/home/sudipnext/Documents/presenton-3/electron/servers/fastapi/.venv/lib/python3.11/site-packages/PyInstaller/hooks/rthooks/pyi_rth_multiprocessing.py',
'PYSOURCE'),
('pyi_rth_inspect',
'/home/sudipnext/Documents/presenton-3/electron/servers/fastapi/.venv/lib/python3.11/site-packages/PyInstaller/hooks/rthooks/pyi_rth_inspect.py',
'PYSOURCE'),
('pyi_rth_setuptools',
'/home/sudipnext/Documents/presenton-3/electron/servers/fastapi/.venv/lib/python3.11/site-packages/PyInstaller/hooks/rthooks/pyi_rth_setuptools.py',
'PYSOURCE'),
('pyi_rth_pkgres',
'/home/sudipnext/Documents/presenton-3/electron/servers/fastapi/.venv/lib/python3.11/site-packages/PyInstaller/hooks/rthooks/pyi_rth_pkgres.py',
'PYSOURCE'),
('pyi_rth_cryptography_openssl',
'/home/sudipnext/Documents/presenton-3/electron/servers/fastapi/.venv/lib/python3.11/site-packages/_pyinstaller_hooks_contrib/rthooks/pyi_rth_cryptography_openssl.py',
'PYSOURCE'),
('pyi_rth_nltk',
'/home/sudipnext/Documents/presenton-3/electron/servers/fastapi/.venv/lib/python3.11/site-packages/_pyinstaller_hooks_contrib/rthooks/pyi_rth_nltk.py',
'PYSOURCE'),
('server',
'/home/sudipnext/Documents/presenton-3/electron/servers/fastapi/server.py',
'PYSOURCE')],
'libpython3.11.so.1.0',
True,
False,
False,
[],
None,
None,
None)

Binary file not shown.

File diff suppressed because it is too large Load diff

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,94 @@
#!/usr/bin/env python3
"""
Pre-build the icons vectorstore for distribution.
This script should be run before packaging the application to ensure
the vectorstore is included in the bundle, eliminating first-run delays.
"""
import json
import os
# Windows: resolve ORT_DYLIB_PATH before fastembed_vectorstore is imported
import utils.onnx_windows_bootstrap # noqa: F401
from fastembed_vectorstore import FastembedVectorstore
from utils.embedding_config import get_embedding_model
def build_vectorstore():
"""Build the icons vectorstore from icons.json"""
print("Building icons vectorstore...")
# Paths
assets_dir = os.path.join(os.path.dirname(__file__), "assets")
icons_path = os.path.join(assets_dir, "icons.json")
vectorstore_path = os.path.join(assets_dir, "icons-vectorstore.json")
cache_dir = os.path.join(os.path.dirname(__file__), "fastembed_cache")
print(f"Icons JSON: {icons_path}")
print(f"Vectorstore output: {vectorstore_path}")
print(f"Cache directory: {cache_dir}")
# Ensure directories exist
os.makedirs(assets_dir, exist_ok=True)
os.makedirs(cache_dir, exist_ok=True)
# Check if icons.json exists
if not os.path.exists(icons_path):
print(f"ERROR: icons.json not found at {icons_path}")
return False
try:
# Load icons
with open(icons_path, "r", encoding="utf-8") as f:
icons = json.load(f)
print(f"Loaded {len(icons.get('icons', []))} icons from JSON")
# Windows: BGESmallENV15 (AllMiniLML6V2 can fail there); macOS/Linux: AllMiniLML6V2
model = get_embedding_model()
vectorstore = FastembedVectorstore(model, cache_directory=cache_dir)
# Prepare documents
documents = []
for each in icons["icons"]:
# Only include 'bold' variants
if each["name"].split("-")[-1] == "bold":
doc_text = f"{each['name']}||{each['tags']}"
documents.append(doc_text)
print(f"Embedding {len(documents)} icon documents...")
# Embed documents
success = vectorstore.embed_documents(documents)
if success:
print(f"Successfully embedded {len(documents)} icons")
# Save vectorstore
vectorstore.save(vectorstore_path)
print(f"Vectorstore saved to {vectorstore_path}")
# Verify the file was created
if os.path.exists(vectorstore_path):
file_size = os.path.getsize(vectorstore_path)
print(f"Vectorstore file size: {file_size / 1024:.2f} KB")
print("Vectorstore built successfully!")
return True
else:
print("ERROR: Vectorstore file was not created")
return False
else:
print("ERROR: Failed to embed documents")
return False
except Exception as e:
print(f"ERROR: Failed to build vectorstore: {e}")
import traceback
traceback.print_exc()
return False
if __name__ == "__main__":
success = build_vectorstore()
exit(0 if success else 1)

View file

@ -0,0 +1,20 @@
PDF_MIME_TYPES = ["application/pdf"]
TEXT_MIME_TYPES = ["text/plain"]
POWERPOINT_TYPES = [
"application/vnd.openxmlformats-officedocument.presentationml.presentation"
]
WORD_TYPES = [
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
]
SPREADSHEET_TYPES = ["text/csv", "application/csv"]
PNG_MIME_TYPES = ["image/png"]
JPEG_MIME_TYPES = ["image/jpeg"]
WEBP_MIME_TYPES = ["image/webp"]
UPLOAD_ACCEPTED_FILE_TYPES = (
PDF_MIME_TYPES + TEXT_MIME_TYPES + POWERPOINT_TYPES + WORD_TYPES
)

View file

@ -0,0 +1,6 @@
OPENAI_URL = "https://api.openai.com/v1"
# Default models
DEFAULT_OPENAI_MODEL = "gpt-4.1"
DEFAULT_GOOGLE_MODEL = "models/gemini-2.5-flash"
DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-4-20250514"

View file

@ -0,0 +1 @@
DEFAULT_TEMPLATES = ["general", "modern", "standard", "swift"]

View file

@ -0,0 +1,180 @@
from models.ollama_model_metadata import OllamaModelMetadata
SUPPORTED_OLLAMA_MODELS = {
"llama3:8b": OllamaModelMetadata(
label="Llama 3:8b",
value="llama3:8b",
size="4.7GB",
),
"llama3:70b": OllamaModelMetadata(
label="Llama 3:70b",
value="llama3:70b",
size="40GB",
),
"llama3.1:8b": OllamaModelMetadata(
label="Llama 3.1:8b",
value="llama3.1:8b",
size="4.9GB",
),
"llama3.1:70b": OllamaModelMetadata(
label="Llama 3.1:70b",
value="llama3.1:70b",
size="43GB",
),
"llama3.1:405b": OllamaModelMetadata(
label="Llama 3.1:405b",
value="llama3.1:405b",
size="243GB",
),
"llama3.2:1b": OllamaModelMetadata(
label="Llama 3.2:1b",
value="llama3.2:1b",
size="1.3GB",
),
"llama3.2:3b": OllamaModelMetadata(
label="Llama 3.2:3b",
value="llama3.2:3b",
size="2GB",
),
"llama3.3:70b": OllamaModelMetadata(
label="Llama 3.3:70b",
value="llama3.3:70b",
size="43GB",
),
"llama4:16x17b": OllamaModelMetadata(
label="Llama 4:16x17b",
value="llama4:16x17b",
size="67GB",
),
"llama4:128x17b": OllamaModelMetadata(
label="Llama 4:128x17b",
value="llama4:128x17b",
size="245GB",
),
}
SUPPORTED_GEMMA_MODELS = {
"gemma3:1b": OllamaModelMetadata(
label="Gemma 3:1b",
value="gemma3:1b",
size="815MB",
),
"gemma3:4b": OllamaModelMetadata(
label="Gemma 3:4b",
value="gemma3:4b",
size="3.3GB",
),
"gemma3:12b": OllamaModelMetadata(
label="Gemma 3:12b",
value="gemma3:12b",
size="8.1GB",
),
"gemma3:27b": OllamaModelMetadata(
label="Gemma 3:27b",
value="gemma3:27b",
size="17GB",
),
}
SUPPORTED_DEEPSEEK_MODELS = {
"deepseek-r1:1.5b": OllamaModelMetadata(
label="DeepSeek R1:1.5b",
value="deepseek-r1:1.5b",
size="1.1GB",
),
"deepseek-r1:7b": OllamaModelMetadata(
label="DeepSeek R1:7b",
value="deepseek-r1:7b",
size="4.7GB",
),
"deepseek-r1:8b": OllamaModelMetadata(
label="DeepSeek R1:8b",
value="deepseek-r1:8b",
size="5.2GB",
),
"deepseek-r1:14b": OllamaModelMetadata(
label="DeepSeek R1:14b",
value="deepseek-r1:14b",
size="9GB",
),
"deepseek-r1:32b": OllamaModelMetadata(
label="DeepSeek R1:32b",
value="deepseek-r1:32b",
size="20GB",
),
"deepseek-r1:70b": OllamaModelMetadata(
label="DeepSeek R1:70b",
value="deepseek-r1:70b",
size="43GB",
),
"deepseek-r1:671b": OllamaModelMetadata(
label="DeepSeek R1:671b",
value="deepseek-r1:671b",
size="404GB",
),
}
SUPPORTED_QWEN_MODELS = {
"qwen3:0.6b": OllamaModelMetadata(
label="Qwen 3:0.6b",
value="qwen3:0.6b",
size="523MB",
),
"qwen3:1.7b": OllamaModelMetadata(
label="Qwen 3:1.7b",
value="qwen3:1.7b",
size="1.4GB",
),
"qwen3:4b": OllamaModelMetadata(
label="Qwen 3:4b",
value="qwen3:4b",
size="2.6GB",
),
"qwen3:8b": OllamaModelMetadata(
label="Qwen 3:8b",
value="qwen3:8b",
size="5.2GB",
),
"qwen3:14b": OllamaModelMetadata(
label="Qwen 3:14b",
value="qwen3:14b",
size="9.3GB",
),
"qwen3:30b": OllamaModelMetadata(
label="Qwen 3:30b",
value="qwen3:30b",
size="19GB",
),
"qwen3:32b": OllamaModelMetadata(
label="Qwen 3:32b",
value="qwen3:32b",
size="20GB",
),
"qwen3:235b": OllamaModelMetadata(
label="Qwen 3:235b",
value="qwen3:235b",
size="142GB",
),
}
SUPPORTED_GPT_OSS_MODELS = {
"gpt-oss:20b": OllamaModelMetadata(
label="GPT-OSS 20b",
value="gpt-oss:20b",
size="14GB",
),
"gpt-oss:120b": OllamaModelMetadata(
label="GPT-OSS 120b",
value="gpt-oss:120b",
size="65GB",
),
}
SUPPORTED_OLLAMA_MODELS = {
**SUPPORTED_OLLAMA_MODELS,
**SUPPORTED_GEMMA_MODELS,
**SUPPORTED_DEEPSEEK_MODELS,
**SUPPORTED_QWEN_MODELS,
**SUPPORTED_GPT_OSS_MODELS,
}

View file

@ -0,0 +1,11 @@
from enum import Enum
class ImageProvider(Enum):
PEXELS = "pexels"
PIXABAY = "pixabay"
GEMINI_FLASH = "gemini_flash"
NANOBANANA_PRO = "nanobanana_pro"
DALLE3 = "dall-e-3"
GPT_IMAGE_1_5 = "gpt-image-1.5"
COMFYUI = "comfyui"

View file

@ -0,0 +1,8 @@
from enum import Enum
class LLMCallType(Enum):
UNSTRUCTURED = "unstructured"
UNSTRUCTURED_STREAM = "unstructured_stream"
STRUCTURED = "structured"
STRUCTURED_STREAM = "structured_stream"

View file

@ -0,0 +1,9 @@
from enum import Enum
class LLMProvider(Enum):
OLLAMA = "ollama"
OPENAI = "openai"
GOOGLE = "google"
ANTHROPIC = "anthropic"
CUSTOM = "custom"

View file

@ -0,0 +1,11 @@
from enum import Enum
class Tone(str, Enum):
DEFAULT = "default"
CASUAL = "casual"
PROFESSIONAL = "professional"
FUNNY = "funny"
EDUCATIONAL = "educational"
SALES_PITCH = "sales_pitch"

View file

@ -0,0 +1,8 @@
from enum import Enum
class Verbosity(str, Enum):
CONCISE = "concise"
STANDARD = "standard"
TEXT_HEAVY = "text-heavy"

View file

@ -0,0 +1,6 @@
from enum import Enum
class WebhookEvent(str, Enum):
PRESENTATION_GENERATION_COMPLETED = "presentation.generation.completed"
PRESENTATION_GENERATION_FAILED = "presentation.generation.failed"

Some files were not shown because too many files have changed in this diff Show more