Merge branch 'main' into feat/revamp_design
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
|
||||
|
|
|
|||
43
README.md
|
|
@ -48,6 +48,7 @@ Presenton gives you complete control over your AI presentation workflow. Choose
|
|||
- ✅ **API Deployment** — Host as your own API service for your team
|
||||
- ✅ **Fully Open-Source** — Apache 2.0 licensed, inspect, modify, and contribute
|
||||
- ✅ **Docker Ready** — One-command deployment with GPU support for local models
|
||||
- ✅ **Electron Desktop App** — Run Presenton as a native desktop application on Windows, macOS, and Linux (no browser required)
|
||||
|
||||
## Presenton Cloud
|
||||
|
||||
|
|
@ -56,7 +57,11 @@ Presenton gives you complete control over your AI presentation workflow. Choose
|
|||
<img src="readme_assets/cloud-banner.png" height="350" alt="Presenton Logo" />
|
||||
</a>
|
||||
|
||||
## Running Presenton Docker
|
||||
## Running Presenton
|
||||
|
||||
You can run Presenton in two ways: **Docker** for a one-command setup without installing a local dev stack, or the **Electron desktop app** for a native app experience (ideal for development or offline use).
|
||||
|
||||
### Option 1: Docker
|
||||
|
||||
#### 1. Start Presenton
|
||||
|
||||
|
|
@ -78,9 +83,43 @@ Open http://localhost:5000 on browser of your choice to use Presenton.
|
|||
|
||||
> **Note: You can replace 5000 with any other port number of your choice to run Presenton on a different port number.**
|
||||
|
||||
### Option 2: Electron (Desktop App)
|
||||
|
||||
Run Presenton as a native desktop application. LLM and image provider (API keys, etc.) can be configured in the app; the same environment variables used for Docker apply when running the bundled backend.
|
||||
|
||||
**Prerequisites:** Node.js (LTS), npm, Python 3.11, and [uv](https://docs.astral.sh/uv/) (for the Electron FastAPI backend in `electron/servers/fastapi`).
|
||||
|
||||
**Setup (first time):**
|
||||
|
||||
```bash
|
||||
cd electron
|
||||
npm run setup:env
|
||||
```
|
||||
|
||||
This installs Node dependencies, runs `uv sync` in the FastAPI server, and installs Next.js dependencies.
|
||||
|
||||
**Run in development:**
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
This compiles TypeScript and starts Electron; the backend and UI run locally in the desktop window.
|
||||
|
||||
**Build distributable (optional):**
|
||||
|
||||
To create installers for Windows, macOS, or Linux:
|
||||
|
||||
```bash
|
||||
npm run build:all
|
||||
npm run dist
|
||||
```
|
||||
|
||||
Outputs are written to `electron/dist` (or as per your electron-builder config).
|
||||
|
||||
## Deployment Configurations
|
||||
|
||||
You may want to directly provide your API KEYS as environment variables and keep them hidden. You can set these environment variables to achieve it.
|
||||
These settings apply to both Docker and the Electron app's backend. You may want to directly provide your API KEYS as environment variables and keep them hidden. You can set these environment variables to achieve it.
|
||||
|
||||
- **CAN_CHANGE_KEYS=[true/false]**: Set this to **false** if you want to keep API Keys hidden and make them unmodifiable.
|
||||
- **LLM=[openai/google/anthropic/ollama/custom]**: Select **LLM** of your choice.
|
||||
|
|
|
|||
17
VISION.md
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# Vision
|
||||
|
||||
Presenton exists to make local AI visual asset generation simple, powerful, and private.
|
||||
|
||||
AI should not only produce unstructured text blobs. It should generate structured, beautiful visual documents with layout, hierarchy, themes, diagrams, and design systems built in from the start.
|
||||
|
||||
Visual asset generation must not require surrendering data to external platforms. It must run locally. It must work inside private networks. It must remain usable in controlled and air gapped environments.
|
||||
|
||||
Users should be free to choose their models. Local models. Open models. Proprietary providers. Hybrid setups. Text models and image models working together. Systems such as Ollama or any compatible runtime. No lock in.
|
||||
|
||||
Templates and Designs are first class citizens. Users should be able to create them, modify them, package them, share them internally, and distribute them publicly. Themes and Asset packs can be configured manually.
|
||||
|
||||
Presenton is an open source document engine, not a closed design platform. It is infrastructure for AI native visual workflows. Portable. Extensible. Model agnostic. Private by default.
|
||||
|
||||
Our ambition is to become the open foundation for local, private, AI powered visual document generation.
|
||||
|
||||
Beautiful visual assets. Generated locally. Owned by the user.
|
||||
|
|
@ -7,6 +7,8 @@ services:
|
|||
ports:
|
||||
# You can replace 5000 with any other port number of your choice to run Presenton on a different port number.
|
||||
- "5000:80"
|
||||
# Required for Codex OAuth callback (OpenAI redirects browser directly to localhost:1455)
|
||||
- "1455:1455"
|
||||
volumes:
|
||||
- ./app_data:/app_data
|
||||
environment:
|
||||
|
|
@ -23,6 +25,7 @@ services:
|
|||
- CUSTOM_LLM_URL=${CUSTOM_LLM_URL}
|
||||
- CUSTOM_LLM_API_KEY=${CUSTOM_LLM_API_KEY}
|
||||
- CUSTOM_MODEL=${CUSTOM_MODEL}
|
||||
- CODEX_MODEL=${CODEX_MODEL}
|
||||
- PEXELS_API_KEY=${PEXELS_API_KEY}
|
||||
- EXTENDED_REASONING=${EXTENDED_REASONING}
|
||||
- TOOL_CALLS=${TOOL_CALLS}
|
||||
|
|
@ -48,6 +51,8 @@ services:
|
|||
ports:
|
||||
# You can replace 5000 with any other port number of your choice to run Presenton on a different port number.
|
||||
- "5000:80"
|
||||
# Required for Codex OAuth callback (OpenAI redirects browser directly to localhost:1455)
|
||||
- "1455:1455"
|
||||
volumes:
|
||||
- ./app_data:/app_data
|
||||
environment:
|
||||
|
|
@ -64,6 +69,7 @@ services:
|
|||
- CUSTOM_LLM_URL=${CUSTOM_LLM_URL}
|
||||
- CUSTOM_LLM_API_KEY=${CUSTOM_LLM_API_KEY}
|
||||
- CUSTOM_MODEL=${CUSTOM_MODEL}
|
||||
- CODEX_MODEL=${CODEX_MODEL}
|
||||
- PEXELS_API_KEY=${PEXELS_API_KEY}
|
||||
- EXTENDED_REASONING=${EXTENDED_REASONING}
|
||||
- TOOL_CALLS=${TOOL_CALLS}
|
||||
|
|
@ -80,6 +86,8 @@ services:
|
|||
dockerfile: Dockerfile.dev
|
||||
ports:
|
||||
- "5000:80"
|
||||
# Required for Codex OAuth callback (OpenAI redirects browser directly to localhost:1455)
|
||||
- "1455:1455"
|
||||
volumes:
|
||||
- .:/app
|
||||
- ./app_data:/app_data
|
||||
|
|
@ -97,6 +105,7 @@ services:
|
|||
- CUSTOM_LLM_URL=${CUSTOM_LLM_URL}
|
||||
- CUSTOM_LLM_API_KEY=${CUSTOM_LLM_API_KEY}
|
||||
- CUSTOM_MODEL=${CUSTOM_MODEL}
|
||||
- CODEX_MODEL=${CODEX_MODEL}
|
||||
- PEXELS_API_KEY=${PEXELS_API_KEY}
|
||||
- EXTENDED_REASONING=${EXTENDED_REASONING}
|
||||
- TOOL_CALLS=${TOOL_CALLS}
|
||||
|
|
@ -120,6 +129,8 @@ services:
|
|||
capabilities: [gpu]
|
||||
ports:
|
||||
- "5000:80"
|
||||
# Required for Codex OAuth callback (OpenAI redirects browser directly to localhost:1455)
|
||||
- "1455:1455"
|
||||
volumes:
|
||||
- .:/app
|
||||
- ./app_data:/app_data
|
||||
|
|
@ -137,6 +148,7 @@ services:
|
|||
- CUSTOM_LLM_URL=${CUSTOM_LLM_URL}
|
||||
- CUSTOM_LLM_API_KEY=${CUSTOM_LLM_API_KEY}
|
||||
- CUSTOM_MODEL=${CUSTOM_MODEL}
|
||||
- CODEX_MODEL=${CODEX_MODEL}
|
||||
- PEXELS_API_KEY=${PEXELS_API_KEY}
|
||||
- EXTENDED_REASONING=${EXTENDED_REASONING}
|
||||
- TOOL_CALLS=${TOOL_CALLS}
|
||||
|
|
@ -145,4 +157,4 @@ services:
|
|||
- DATABASE_URL=${DATABASE_URL}
|
||||
- DISABLE_ANONYMOUS_TRACKING=${DISABLE_ANONYMOUS_TRACKING}
|
||||
- COMFYUI_URL=${COMFYUI_URL}
|
||||
- COMFYUI_WORKFLOW=${COMFYUI_WORKFLOW}
|
||||
- COMFYUI_WORKFLOW=${COMFYUI_WORKFLOW}
|
||||
|
|
|
|||
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();
|
||||
82
electron/app/types/index.d.ts
vendored
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
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,
|
||||
CODEX_MODEL?: string,
|
||||
CODEX_ACCESS_TOKEN?: string,
|
||||
CODEX_REFRESH_TOKEN?: string,
|
||||
CODEX_TOKEN_EXPIRES?: string,
|
||||
CODEX_ACCOUNT_ID?: 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;
|
||||
}
|
||||
}
|
||||
119
electron/app/utils/index.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
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,
|
||||
CODEX_MODEL: userConfig.CODEX_MODEL || existingConfig.CODEX_MODEL,
|
||||
CODEX_ACCESS_TOKEN: existingConfig.CODEX_ACCESS_TOKEN,
|
||||
CODEX_REFRESH_TOKEN: existingConfig.CODEX_REFRESH_TOKEN,
|
||||
CODEX_TOKEN_EXPIRES: existingConfig.CODEX_TOKEN_EXPIRES,
|
||||
CODEX_ACCOUNT_ID: existingConfig.CODEX_ACCOUNT_ID,
|
||||
}
|
||||
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))
|
||||
278
electron/servers/fastapi/api/v1/ppt/endpoints/codex_auth.py
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
"""
|
||||
OpenAI Codex OAuth endpoints.
|
||||
|
||||
Flow:
|
||||
1. POST /codex/auth/initiate — start the flow, get back an auth URL + session_id
|
||||
2. Browser opens the URL, user authenticates with OpenAI
|
||||
3. OpenAI redirects to http://localhost:1455/auth/callback (captured by local server)
|
||||
4. GET /codex/auth/status/{session_id} — poll until code captured; exchanges and stores tokens
|
||||
5. POST /codex/auth/exchange — manual fallback if browser callback didn't fire
|
||||
6. POST /codex/auth/refresh — refresh a stored token
|
||||
"""
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from utils.oauth.openai_codex import (
|
||||
OAuthCallbackServer,
|
||||
TokenSuccess,
|
||||
create_authorization_flow,
|
||||
exchange_authorization_code,
|
||||
get_account_id,
|
||||
parse_authorization_input,
|
||||
refresh_access_token,
|
||||
)
|
||||
from utils.get_env import (
|
||||
get_codex_access_token_env,
|
||||
get_codex_refresh_token_env,
|
||||
get_codex_token_expires_env,
|
||||
)
|
||||
from utils.set_env import (
|
||||
set_codex_access_token_env,
|
||||
set_codex_account_id_env,
|
||||
set_codex_refresh_token_env,
|
||||
set_codex_token_expires_env,
|
||||
set_codex_model_env,
|
||||
)
|
||||
from utils.user_config import save_codex_tokens_to_user_config
|
||||
|
||||
CODEX_AUTH_ROUTER = APIRouter(prefix="/codex/auth", tags=["Codex OAuth"])
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# In-memory session store {session_id: {"verifier": str, "state": str, "server": OAuthCallbackServer}}
|
||||
# Sessions are short-lived; garbage-collected when consumed.
|
||||
# ---------------------------------------------------------------------------
|
||||
_sessions: dict[str, dict] = {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request / Response models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class InitiateResponse(BaseModel):
|
||||
session_id: str
|
||||
url: str
|
||||
instructions: str
|
||||
|
||||
|
||||
class StatusResponse(BaseModel):
|
||||
status: str # "pending" | "success" | "failed"
|
||||
account_id: Optional[str] = None
|
||||
detail: Optional[str] = None
|
||||
|
||||
|
||||
class ExchangeRequest(BaseModel):
|
||||
session_id: str
|
||||
code: str # raw code OR full redirect URL OR code#state shorthand
|
||||
|
||||
|
||||
class ExchangeResponse(BaseModel):
|
||||
account_id: str
|
||||
|
||||
|
||||
class RefreshResponse(BaseModel):
|
||||
account_id: Optional[str]
|
||||
detail: str
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _store_token(result: TokenSuccess) -> Optional[str]:
|
||||
"""Persist token fields in env vars and userConfig.json. Returns account_id or None."""
|
||||
set_codex_access_token_env(result.access)
|
||||
set_codex_refresh_token_env(result.refresh)
|
||||
set_codex_token_expires_env(str(result.expires))
|
||||
account_id = get_account_id(result.access)
|
||||
if account_id:
|
||||
set_codex_account_id_env(account_id)
|
||||
save_codex_tokens_to_user_config()
|
||||
return account_id
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@CODEX_AUTH_ROUTER.post("/initiate", response_model=InitiateResponse)
|
||||
async def initiate_codex_auth():
|
||||
"""
|
||||
Start the OpenAI Codex OAuth flow.
|
||||
|
||||
Returns an authorization URL to open in the browser and a session_id to use
|
||||
when polling /status or calling /exchange. A local HTTP server is started
|
||||
on port 1455 to receive the redirect automatically.
|
||||
"""
|
||||
flow = create_authorization_flow()
|
||||
server = OAuthCallbackServer(state=flow.state)
|
||||
server_started = server.start()
|
||||
|
||||
session_id = str(uuid.uuid4())
|
||||
_sessions[session_id] = {
|
||||
"verifier": flow.verifier,
|
||||
"state": flow.state,
|
||||
"server": server,
|
||||
"server_started": server_started,
|
||||
}
|
||||
|
||||
instructions = (
|
||||
"Open the URL in your browser and complete the OpenAI login. "
|
||||
+ (
|
||||
"The callback will be captured automatically."
|
||||
if server_started
|
||||
else "Port 1455 could not be bound — paste the redirect URL or code into /exchange."
|
||||
)
|
||||
)
|
||||
|
||||
return InitiateResponse(
|
||||
session_id=session_id,
|
||||
url=flow.url,
|
||||
instructions=instructions,
|
||||
)
|
||||
|
||||
|
||||
@CODEX_AUTH_ROUTER.get("/status/{session_id}", response_model=StatusResponse)
|
||||
async def poll_codex_auth_status(session_id: str):
|
||||
"""
|
||||
Poll for the result of an ongoing OAuth flow.
|
||||
|
||||
Returns {"status": "pending"} until the callback server captures the code.
|
||||
On success the tokens are stored in environment variables and the session
|
||||
is cleaned up.
|
||||
"""
|
||||
session = _sessions.get(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="Session not found or already consumed")
|
||||
|
||||
server: OAuthCallbackServer = session["server"]
|
||||
|
||||
# Non-blocking peek — check whether the callback server already received a code
|
||||
code = server.get_code_nowait() if session.get("server_started") else None
|
||||
|
||||
if code is None:
|
||||
return StatusResponse(status="pending")
|
||||
|
||||
# We have a code — exchange it
|
||||
verifier: str = session["verifier"]
|
||||
result = exchange_authorization_code(code, verifier)
|
||||
|
||||
# Clean up session
|
||||
server.close()
|
||||
_sessions.pop(session_id, None)
|
||||
|
||||
if not isinstance(result, TokenSuccess):
|
||||
return StatusResponse(status="failed", detail=result.reason)
|
||||
|
||||
account_id = _store_token(result)
|
||||
return StatusResponse(status="success", account_id=account_id)
|
||||
|
||||
|
||||
@CODEX_AUTH_ROUTER.post("/exchange", response_model=ExchangeResponse)
|
||||
async def exchange_codex_code(body: ExchangeRequest):
|
||||
"""
|
||||
Manual code exchange fallback.
|
||||
|
||||
Accepts the session_id from /initiate and either:
|
||||
- a bare authorization code
|
||||
- the full redirect URL (http://localhost:1455/auth/callback?code=…&state=…)
|
||||
- the code#state shorthand
|
||||
|
||||
Exchanges the code for tokens and stores them in environment variables.
|
||||
"""
|
||||
session = _sessions.get(body.session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="Session not found or already consumed")
|
||||
|
||||
parsed = parse_authorization_input(body.code)
|
||||
code = parsed.get("code")
|
||||
incoming_state = parsed.get("state")
|
||||
|
||||
if not code:
|
||||
raise HTTPException(status_code=400, detail="Could not extract authorization code from input")
|
||||
|
||||
if incoming_state and incoming_state != session["state"]:
|
||||
raise HTTPException(status_code=400, detail="State mismatch — possible CSRF")
|
||||
|
||||
verifier: str = session["verifier"]
|
||||
server: OAuthCallbackServer = session["server"]
|
||||
|
||||
result = exchange_authorization_code(code, verifier)
|
||||
|
||||
server.close()
|
||||
_sessions.pop(body.session_id, None)
|
||||
|
||||
if not isinstance(result, TokenSuccess):
|
||||
raise HTTPException(status_code=502, detail=f"Token exchange failed: {result.reason}")
|
||||
|
||||
account_id = _store_token(result)
|
||||
if not account_id:
|
||||
raise HTTPException(status_code=502, detail="Token exchanged but could not extract account ID")
|
||||
|
||||
return ExchangeResponse(account_id=account_id)
|
||||
|
||||
|
||||
@CODEX_AUTH_ROUTER.post("/refresh", response_model=RefreshResponse)
|
||||
async def refresh_codex_token():
|
||||
"""
|
||||
Refresh the stored Codex OAuth access token using the refresh token.
|
||||
|
||||
Updates environment variables with the new tokens.
|
||||
"""
|
||||
refresh_token = get_codex_refresh_token_env()
|
||||
if not refresh_token:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="No Codex refresh token stored. Please authenticate first via /initiate",
|
||||
)
|
||||
|
||||
result = refresh_access_token(refresh_token)
|
||||
if not isinstance(result, TokenSuccess):
|
||||
raise HTTPException(status_code=502, detail=f"Token refresh failed: {result.reason}")
|
||||
|
||||
account_id = _store_token(result)
|
||||
return RefreshResponse(
|
||||
account_id=account_id,
|
||||
detail="Token refreshed successfully",
|
||||
)
|
||||
|
||||
|
||||
@CODEX_AUTH_ROUTER.get("/status", response_model=StatusResponse)
|
||||
async def get_codex_auth_status():
|
||||
"""
|
||||
Return whether a valid Codex OAuth token is currently stored.
|
||||
"""
|
||||
import time
|
||||
|
||||
access_token = get_codex_access_token_env()
|
||||
if not access_token:
|
||||
return StatusResponse(status="not_authenticated", detail="No access token stored")
|
||||
|
||||
expires_str = get_codex_token_expires_env()
|
||||
if expires_str:
|
||||
try:
|
||||
expires_ms = int(expires_str)
|
||||
now_ms = int(time.time() * 1000)
|
||||
if now_ms >= expires_ms:
|
||||
return StatusResponse(status="expired", detail="Access token has expired — call /refresh")
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
account_id = get_account_id(access_token)
|
||||
return StatusResponse(status="authenticated", account_id=account_id)
|
||||
|
||||
|
||||
@CODEX_AUTH_ROUTER.post("/logout")
|
||||
async def logout_codex():
|
||||
"""
|
||||
Clear all stored Codex OAuth credentials from environment variables and userConfig.json.
|
||||
"""
|
||||
set_codex_access_token_env("")
|
||||
set_codex_refresh_token_env("")
|
||||
set_codex_token_expires_env("")
|
||||
set_codex_account_id_env("")
|
||||
set_codex_model_env("")
|
||||
save_codex_tokens_to_user_config()
|
||||
return {"detail": "Logged out successfully"}
|
||||
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
41
electron/servers/fastapi/api/v1/ppt/router.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
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.codex_auth import CODEX_AUTH_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(CODEX_AUTH_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
|
||||
)
|
||||
7
electron/servers/fastapi/constants/llm.py
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
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"
|
||||
DEFAULT_CODEX_MODEL = "gpt-5.3-codex-spark"
|
||||
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"
|
||||