Merge pull request #419 from presenton/feat/electron-support
Feature added Electron Application and CI/CD Workflow
62
.github/workflows/README.md
vendored
Normal 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
|
|
@ -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
|
|
@ -1,6 +1,5 @@
|
|||
.env
|
||||
.venv
|
||||
build
|
||||
__pycache__
|
||||
.pytest_cache
|
||||
.next
|
||||
|
|
|
|||
24
electron/.gitignore
vendored
Normal 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/
|
||||
169
electron/app/ipc/api_handlers.ts
Normal 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 [];
|
||||
}
|
||||
});
|
||||
}
|
||||
58
electron/app/ipc/export_handlers.ts
Normal 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 };
|
||||
})
|
||||
|
||||
}
|
||||
32
electron/app/ipc/footer_handlers.ts
Normal 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
|
|
@ -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();
|
||||
}
|
||||
50
electron/app/ipc/log_handler.ts
Normal 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 };
|
||||
}
|
||||
});
|
||||
}
|
||||
1935
electron/app/ipc/presentation_to_pptx_model_handlers.ts
Normal file
15
electron/app/ipc/read_file.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
}
|
||||
351
electron/app/ipc/slide_metadata.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
94
electron/app/ipc/template_api_handlers.ts
Normal 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)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
32
electron/app/ipc/theme_handlers.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
}
|
||||
28
electron/app/ipc/upload_image.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
}
|
||||
16
electron/app/ipc/user_config_handlers.ts
Normal 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
|
|
@ -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();
|
||||
});
|
||||
32
electron/app/preloads/index.ts
Normal 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),
|
||||
});
|
||||
12
electron/app/preloads/pptx-export.ts
Normal 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);
|
||||
68
electron/app/services/settings-store.ts
Normal 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
|
|
@ -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,
|
||||
}
|
||||
26
electron/app/utils/constants.ts
Normal 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 });
|
||||
}
|
||||
31
electron/app/utils/dialog.ts
Normal 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
|
|
@ -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, '_');
|
||||
}
|
||||
121
electron/app/utils/servers.ts
Normal 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
|
|
@ -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 })
|
||||
BIN
electron/build/appx/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
electron/build/appx/Square150x150LogoTransParent.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
electron/build/appx/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
electron/build/appx/Square44x44LogoTransParent.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
electron/build/appx/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
electron/build/appx/StoreLogoTransParent.png
Normal file
|
After Width: | Height: | Size: 446 KiB |
BIN
electron/build/appx/Wide310x150Logo.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
electron/build/appx/icon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
electron/build/icon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
7174
electron/package-lock.json
generated
Normal file
68
electron/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
4270
electron/resources/ui/assets/css/tailwind.css
Normal file
1
electron/resources/ui/assets/css/tailwind.import.css
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
@import 'tailwindcss';
|
||||
BIN
electron/resources/ui/assets/images/presenton_logo.png
Normal file
|
After Width: | Height: | Size: 861 KiB |
BIN
electron/resources/ui/assets/images/presenton_short_filled.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
32
electron/resources/ui/homepage/index.html
Normal 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>
|
||||
0
electron/resources/ui/homepage/script.js
Normal file
333
electron/scripts/rebuild_notice_all.py
Normal 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()
|
||||
|
||||
|
||||
1
electron/servers/fastapi/.python-version
Normal file
|
|
@ -0,0 +1 @@
|
|||
3.11
|
||||
0
electron/servers/fastapi/api/__init__.py
Normal file
23
electron/servers/fastapi/api/lifespan.py
Normal 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
|
||||
49
electron/servers/fastapi/api/main.py
Normal 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)
|
||||
12
electron/servers/fastapi/api/middlewares.py
Normal 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)
|
||||
34
electron/servers/fastapi/api/v1/mock/router.py
Normal 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",
|
||||
)
|
||||
]
|
||||
76
electron/servers/fastapi/api/v1/ppt/background_tasks.py
Normal 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()
|
||||
16
electron/servers/fastapi/api/v1/ppt/endpoints/anthropic.py
Normal 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))
|
||||
87
electron/servers/fastapi/api/v1/ppt/endpoints/files.py
Normal 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"}
|
||||
290
electron/servers/fastapi/api/v1/ppt/endpoints/fonts.py
Normal 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)}"
|
||||
)
|
||||
14
electron/servers/fastapi/api/v1/ppt/endpoints/google.py
Normal 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))
|
||||
10
electron/servers/fastapi/api/v1/ppt/endpoints/icons.py
Normal 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)
|
||||
105
electron/servers/fastapi/api/v1/ppt/endpoints/images.py
Normal 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)}")
|
||||
27
electron/servers/fastapi/api/v1/ppt/endpoints/layouts.py
Normal 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)
|
||||
85
electron/servers/fastapi/api/v1/ppt/endpoints/ollama.py
Normal 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,
|
||||
)
|
||||
17
electron/servers/fastapi/api/v1/ppt/endpoints/openai.py
Normal 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))
|
||||
113
electron/servers/fastapi/api/v1/ppt/endpoints/outlines.py
Normal 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")
|
||||
116
electron/servers/fastapi/api/v1/ppt/endpoints/pdf_slides.py
Normal 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)}"
|
||||
)
|
||||
613
electron/servers/fastapi/api/v1/ppt/endpoints/pptx_slides.py
Normal 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)}")
|
||||
956
electron/servers/fastapi/api/v1/ppt/endpoints/presentation.py
Normal 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}",
|
||||
)
|
||||
241
electron/servers/fastapi/api/v1/ppt/endpoints/prompts.py
Normal file
90
electron/servers/fastapi/api/v1/ppt/endpoints/slide.py
Normal 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
|
||||
1043
electron/servers/fastapi/api/v1/ppt/endpoints/slide_to_html.py
Normal file
39
electron/servers/fastapi/api/v1/ppt/router.py
Normal 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)
|
||||
53
electron/servers/fastapi/api/v1/webhook/router.py
Normal 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()
|
||||
583634
electron/servers/fastapi/assets/icons-vectorstore.json
Normal file
63510
electron/servers/fastapi/assets/icons.json
Normal file
73690
electron/servers/fastapi/build/server/Analysis-00.toc
Normal file
27464
electron/servers/fastapi/build/server/COLLECT-00.toc
Normal file
69
electron/servers/fastapi/build/server/EXE-00.toc
Normal 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')
|
||||
64
electron/servers/fastapi/build/server/PKG-00.toc
Normal 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)
|
||||
BIN
electron/servers/fastapi/build/server/PYZ-00.pyz
Normal file
37987
electron/servers/fastapi/build/server/PYZ-00.toc
Normal file
BIN
electron/servers/fastapi/build/server/base_library.zip
Normal file
BIN
electron/servers/fastapi/build/server/fastapi
Executable file
BIN
electron/servers/fastapi/build/server/fastapi.pkg
Normal file
BIN
electron/servers/fastapi/build/server/localpycs/struct.pyc
Normal file
1355
electron/servers/fastapi/build/server/warn-server.txt
Normal file
433078
electron/servers/fastapi/build/server/xref-server.html
Normal file
94
electron/servers/fastapi/build_vectorstore.py
Normal 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)
|
||||
0
electron/servers/fastapi/constants/__init__.py
Normal file
20
electron/servers/fastapi/constants/documents.py
Normal 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
|
||||
)
|
||||
6
electron/servers/fastapi/constants/llm.py
Normal 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"
|
||||
1
electron/servers/fastapi/constants/presentation.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
DEFAULT_TEMPLATES = ["general", "modern", "standard", "swift"]
|
||||
180
electron/servers/fastapi/constants/supported_ollama_models.py
Normal 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,
|
||||
}
|
||||
0
electron/servers/fastapi/enums/__init__.py
Normal file
11
electron/servers/fastapi/enums/image_provider.py
Normal 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"
|
||||
8
electron/servers/fastapi/enums/llm_call_type.py
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
from enum import Enum
|
||||
|
||||
|
||||
class LLMCallType(Enum):
|
||||
UNSTRUCTURED = "unstructured"
|
||||
UNSTRUCTURED_STREAM = "unstructured_stream"
|
||||
STRUCTURED = "structured"
|
||||
STRUCTURED_STREAM = "structured_stream"
|
||||
9
electron/servers/fastapi/enums/llm_provider.py
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
from enum import Enum
|
||||
|
||||
|
||||
class LLMProvider(Enum):
|
||||
OLLAMA = "ollama"
|
||||
OPENAI = "openai"
|
||||
GOOGLE = "google"
|
||||
ANTHROPIC = "anthropic"
|
||||
CUSTOM = "custom"
|
||||
11
electron/servers/fastapi/enums/tone.py
Normal 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"
|
||||
|
||||
8
electron/servers/fastapi/enums/verbosity.py
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
from enum import Enum
|
||||
|
||||
|
||||
class Verbosity(str, Enum):
|
||||
CONCISE = "concise"
|
||||
STANDARD = "standard"
|
||||
TEXT_HEAVY = "text-heavy"
|
||||
|
||||
6
electron/servers/fastapi/enums/webhook_event.py
Normal 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"
|
||||