merged
This commit is contained in:
commit
bd455a0819
47 changed files with 3571 additions and 1085 deletions
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -21,4 +21,9 @@ container.db
|
|||
.cursor
|
||||
.agents
|
||||
skills-lock.json
|
||||
.codex/
|
||||
.codex/
|
||||
|
||||
# presentation-export runtime (downloaded via scripts/sync-presentation-export.cjs or Docker build)
|
||||
presentation-export/index.js
|
||||
presentation-export/py/
|
||||
.cache/presentation-export/
|
||||
25
Dockerfile
25
Dockerfile
|
|
@ -3,20 +3,22 @@ FROM python:3.11-slim-bookworm
|
|||
|
||||
WORKDIR /app
|
||||
|
||||
# Docling + CPU torch: declared in pyproject.toml; lockfile uses PyTorch CPU index.
|
||||
# UV_EXTRA_INDEX_URL mirrors the old `pip install docling --extra-index-url .../cpu`.
|
||||
# LiteParse uses Node + @llamaindex/liteparse (same runner as Electron); OCR uses Tesseract.
|
||||
ENV APP_DATA_DIRECTORY=/app_data \
|
||||
TEMP_DIRECTORY=/tmp/presenton \
|
||||
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium \
|
||||
UV_SYSTEM_PYTHON=1 \
|
||||
UV_COMPILE_BYTECODE=1 \
|
||||
UV_LINK_MODE=copy \
|
||||
UV_EXTRA_INDEX_URL=https://download.pytorch.org/whl/cpu \
|
||||
PATH="/root/.local/bin:${PATH}"
|
||||
PATH="/root/.local/bin:${PATH}" \
|
||||
EXPORT_PACKAGE_ROOT=/app/presentation-export \
|
||||
BUILT_PYTHON_MODULE_PATH=/app/presentation-export/py/convert-linux-x64 \
|
||||
PRESENTON_APP_ROOT=/app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates curl \
|
||||
ca-certificates curl unzip \
|
||||
nginx libreoffice fontconfig chromium imagemagick zstd \
|
||||
tesseract-ocr tesseract-ocr-eng \
|
||||
&& curl -LsSf https://astral.sh/uv/install.sh | sh \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
|
@ -24,6 +26,19 @@ RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
|||
&& apt-get install -y nodejs \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN mkdir -p /app/document-extraction-liteparse \
|
||||
&& npm --prefix /app/document-extraction-liteparse init -y \
|
||||
&& npm --prefix /app/document-extraction-liteparse install @llamaindex/liteparse@1.4.0 --omit=dev
|
||||
COPY electron/resources/document-extraction/liteparse_runner.mjs /app/document-extraction-liteparse/liteparse_runner.mjs
|
||||
|
||||
# PDF/PPTX export runtime: version pin in presentation-export/export-version.json (or build-arg).
|
||||
COPY presentation-export/export-version.json /app/presentation-export/export-version.json
|
||||
COPY scripts/sync-presentation-export.cjs /app/scripts/sync-presentation-export.cjs
|
||||
ARG EXPORT_RUNTIME_VERSION
|
||||
RUN export EXPORT_RUNTIME_VERSION="${EXPORT_RUNTIME_VERSION:-}" \
|
||||
&& node /app/scripts/sync-presentation-export.cjs --force \
|
||||
&& chmod +x /app/presentation-export/py/convert-linux-x64
|
||||
|
||||
RUN curl -fsSL https://ollama.com/install.sh | sh
|
||||
|
||||
COPY servers/fastapi /app/servers/fastapi
|
||||
|
|
|
|||
|
|
@ -3,21 +3,22 @@ FROM python:3.11-slim-bookworm
|
|||
|
||||
WORKDIR /app
|
||||
|
||||
# Docling is in pyproject.toml; uv.lock pins torch to this index (same as former:
|
||||
# pip install docling --extra-index-url https://download.pytorch.org/whl/cpu
|
||||
# UV_EXTRA_INDEX_URL keeps CPU wheels available if the lock is refreshed in Docker.)
|
||||
# LiteParse (Node + @llamaindex/liteparse) for document extraction; OCR via Tesseract.
|
||||
ENV APP_DATA_DIRECTORY=/app_data \
|
||||
TEMP_DIRECTORY=/tmp/presenton \
|
||||
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium \
|
||||
UV_SYSTEM_PYTHON=1 \
|
||||
UV_COMPILE_BYTECODE=1 \
|
||||
UV_LINK_MODE=copy \
|
||||
UV_EXTRA_INDEX_URL=https://download.pytorch.org/whl/cpu \
|
||||
PATH="/root/.local/bin:${PATH}"
|
||||
PATH="/root/.local/bin:${PATH}" \
|
||||
EXPORT_PACKAGE_ROOT=/app/presentation-export \
|
||||
BUILT_PYTHON_MODULE_PATH=/app/presentation-export/py/convert-linux-x64 \
|
||||
PRESENTON_APP_ROOT=/app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates curl \
|
||||
ca-certificates curl unzip \
|
||||
nginx libreoffice fontconfig chromium imagemagick zstd \
|
||||
tesseract-ocr tesseract-ocr-eng \
|
||||
&& curl -LsSf https://astral.sh/uv/install.sh | sh \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
|
@ -25,6 +26,18 @@ RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
|||
&& apt-get install -y nodejs \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN mkdir -p /app/document-extraction-liteparse \
|
||||
&& npm --prefix /app/document-extraction-liteparse init -y \
|
||||
&& npm --prefix /app/document-extraction-liteparse install @llamaindex/liteparse@1.4.0 --omit=dev
|
||||
COPY electron/resources/document-extraction/liteparse_runner.mjs /app/document-extraction-liteparse/liteparse_runner.mjs
|
||||
|
||||
COPY presentation-export/export-version.json /app/presentation-export/export-version.json
|
||||
COPY scripts/sync-presentation-export.cjs /app/scripts/sync-presentation-export.cjs
|
||||
ARG EXPORT_RUNTIME_VERSION
|
||||
RUN export EXPORT_RUNTIME_VERSION="${EXPORT_RUNTIME_VERSION:-}" \
|
||||
&& node /app/scripts/sync-presentation-export.cjs --force \
|
||||
&& chmod +x /app/presentation-export/py/convert-linux-x64
|
||||
|
||||
# Bind mount `.:/app` hides any .venv under servers/fastapi at runtime — install deps into
|
||||
# system site-packages (same interpreter `start.js` uses as `python`).
|
||||
COPY servers/fastapi /app/servers/fastapi
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@ services:
|
|||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
# Optional: override presentation-export release (else presentation-export/export-version.json)
|
||||
EXPORT_RUNTIME_VERSION: ${EXPORT_RUNTIME_VERSION:-}
|
||||
ports:
|
||||
# You can replace 5000 with any other port number of your choice to run Presenton on a different port number.
|
||||
- "5000:80"
|
||||
|
|
@ -42,6 +45,8 @@ services:
|
|||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
EXPORT_RUNTIME_VERSION: ${EXPORT_RUNTIME_VERSION:-}
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
|
|
@ -86,6 +91,8 @@ services:
|
|||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.dev
|
||||
args:
|
||||
EXPORT_RUNTIME_VERSION: ${EXPORT_RUNTIME_VERSION:-}
|
||||
ports:
|
||||
- "5000:80"
|
||||
# Required for Codex OAuth callback (OpenAI redirects browser directly to localhost:1455)
|
||||
|
|
@ -125,6 +132,8 @@ services:
|
|||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.dev
|
||||
args:
|
||||
EXPORT_RUNTIME_VERSION: ${EXPORT_RUNTIME_VERSION:-}
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
|
|
|
|||
|
|
@ -4,4 +4,4 @@ OPENAI_URL = "https://api.openai.com/v1"
|
|||
DEFAULT_OPENAI_MODEL = "gpt-4.1"
|
||||
DEFAULT_GOOGLE_MODEL = "models/gemini-2.5-flash"
|
||||
DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-4-20250514"
|
||||
DEFAULT_CODEX_MODEL = "gpt-5.2-codex"
|
||||
DEFAULT_CODEX_MODEL = "gpt-5.1-codex-mini"
|
||||
|
|
|
|||
|
|
@ -46,16 +46,16 @@ interface CodexModel {
|
|||
}
|
||||
|
||||
const CHATGPT_MODELS: CodexModel[] = [
|
||||
{ id: "gpt-5.1", name: "GPT-5.1" },
|
||||
{ id: "gpt-5.1-codex-max", name: "GPT-5.1 Codex Max" },
|
||||
{ id: "gpt-5.2", name: "GPT-5.2" },
|
||||
{ id: "gpt-5.2-codex", name: "GPT-5.2 Codex" },
|
||||
{ id: "gpt-5.3-codex", name: "GPT-5.3 Codex" },
|
||||
{ id: "gpt-5.4-mini", name: "GPT-5.4 Mini" },
|
||||
{ id: "gpt-5.4", name: "GPT-5.4" },
|
||||
{ id: "gpt-5.2-codex", name: "GPT-5.2-Codex" },
|
||||
{ id: "gpt-5.1-codex-max", name: "GPT-5.1-Codex-Max" },
|
||||
{ id: "gpt-5.4-mini", name: "GPT-5.4-Mini" },
|
||||
{ id: "gpt-5.3-codex", name: "GPT-5.3-Codex" },
|
||||
{ id: "gpt-5.2", name: "GPT-5.2" },
|
||||
{ id: "gpt-5.1-codex-mini", name: "GPT-5.1-Codex-Mini" },
|
||||
];
|
||||
|
||||
const DEFAULT_CODEX_MODEL = "gpt-5.4-mini";
|
||||
const DEFAULT_CODEX_MODEL = "gpt-5.1-codex-mini";
|
||||
|
||||
export default function CodexConfig({
|
||||
codexModel,
|
||||
|
|
|
|||
|
|
@ -33,16 +33,16 @@ interface CodexModel {
|
|||
}
|
||||
|
||||
export const CHATGPT_MODELS: CodexModel[] = [
|
||||
{ id: "gpt-5.1", name: "GPT-5.1" },
|
||||
{ id: "gpt-5.1-codex-max", name: "GPT-5.1 Codex Max" },
|
||||
{ id: "gpt-5.2", name: "GPT-5.2" },
|
||||
{ id: "gpt-5.2-codex", name: "GPT-5.2 Codex" },
|
||||
{ id: "gpt-5.3-codex", name: "GPT-5.3 Codex" },
|
||||
{ id: "gpt-5.4 mini", name: "GPT-5.4 Mini" },
|
||||
{ id: "gpt-5.4", name: "GPT-5.4" },
|
||||
{ id: "gpt-5.2-codex", name: "GPT-5.2-Codex" },
|
||||
{ id: "gpt-5.1-codex-max", name: "GPT-5.1-Codex-Max" },
|
||||
{ id: "gpt-5.4-mini", name: "GPT-5.4-Mini" },
|
||||
{ id: "gpt-5.3-codex", name: "GPT-5.3-Codex" },
|
||||
{ id: "gpt-5.2", name: "GPT-5.2" },
|
||||
{ id: "gpt-5.1-codex-mini", name: "GPT-5.1-Codex-Mini" },
|
||||
];
|
||||
|
||||
export const DEFAULT_CODEX_MODEL = "gpt-5.4-mini";
|
||||
export const DEFAULT_CODEX_MODEL = "gpt-5.1-codex-mini";
|
||||
|
||||
export default function CodexConfig({
|
||||
codexModel,
|
||||
|
|
|
|||
|
|
@ -2,5 +2,10 @@
|
|||
"name": "presenton",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"description": "Open-source AI presentation generator"
|
||||
"description": "Open-source AI presentation generator",
|
||||
"scripts": {
|
||||
"sync:presentation-export": "node scripts/sync-presentation-export.cjs",
|
||||
"sync:presentation-export:force": "node scripts/sync-presentation-export.cjs --force",
|
||||
"check:presentation-export": "node scripts/sync-presentation-export.cjs --check-only"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
3
presentation-export/export-version.json
Normal file
3
presentation-export/export-version.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"exportVersion": "v0.2.0"
|
||||
}
|
||||
273
scripts/sync-presentation-export.cjs
Normal file
273
scripts/sync-presentation-export.cjs
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
/**
|
||||
* Download presenton-export release (Linux x64) into repo-root `presentation-export/`.
|
||||
* Same release host as Electron (`electron/sync_export_runtime.js`); Docker uses this at build time.
|
||||
*
|
||||
* Version resolution (first match):
|
||||
* 1. EXPORT_RUNTIME_VERSION env
|
||||
* 2. presentation-export/export-version.json → exportVersion
|
||||
*
|
||||
* CLI: --force re-download even if valid runtime already exists
|
||||
* --check-only verify index.js + converter exist and exit 0/1
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const https = require("https");
|
||||
const http = require("http");
|
||||
const { execFileSync } = require("child_process");
|
||||
|
||||
const repoRoot = path.join(__dirname, "..");
|
||||
const targetRoot = path.join(repoRoot, "presentation-export");
|
||||
const targetPyDir = path.join(targetRoot, "py");
|
||||
const targetIndex = path.join(targetRoot, "index.js");
|
||||
const versionFile = path.join(targetRoot, "export-version.json");
|
||||
const cacheDir = path.join(repoRoot, ".cache", "presentation-export");
|
||||
const exportRepoBase =
|
||||
"https://github.com/presenton/presenton-export/releases/download";
|
||||
const linuxAssetName = "export-Linux-X64.zip";
|
||||
|
||||
const cliArgs = new Set(process.argv.slice(2));
|
||||
const forceDownload = cliArgs.has("--force");
|
||||
const checkOnly = cliArgs.has("--check-only");
|
||||
|
||||
function ensureDir(dirPath) {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
}
|
||||
|
||||
function readPinnedVersion() {
|
||||
if (!fs.existsSync(versionFile)) {
|
||||
throw new Error(
|
||||
`Missing ${path.relative(repoRoot, versionFile)}. Create it with { "exportVersion": "vX.Y.Z" }.`
|
||||
);
|
||||
}
|
||||
const raw = JSON.parse(fs.readFileSync(versionFile, "utf8"));
|
||||
const v = (raw.exportVersion || "").trim();
|
||||
if (!v) {
|
||||
throw new Error(`${versionFile} must set "exportVersion" (e.g. "v0.2.0").`);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
async function getTargetVersion() {
|
||||
const fromEnv = (process.env.EXPORT_RUNTIME_VERSION || "").trim();
|
||||
if (fromEnv) {
|
||||
return fromEnv === "latest" ? await resolveLatestTag() : fromEnv;
|
||||
}
|
||||
const pinned = readPinnedVersion();
|
||||
if (pinned === "latest") {
|
||||
return await resolveLatestTag();
|
||||
}
|
||||
return pinned;
|
||||
}
|
||||
|
||||
function requestJson(url, redirects = 5) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const client = url.startsWith("https:") ? https : http;
|
||||
const req = client.get(
|
||||
url,
|
||||
{
|
||||
headers: {
|
||||
"User-Agent": "presenton-presentation-export-sync",
|
||||
Accept: "application/vnd.github+json",
|
||||
},
|
||||
},
|
||||
(res) => {
|
||||
if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location) {
|
||||
if (redirects <= 0) {
|
||||
reject(new Error(`Too many redirects for JSON request: ${url}`));
|
||||
return;
|
||||
}
|
||||
requestJson(res.headers.location, redirects - 1).then(resolve).catch(reject);
|
||||
return;
|
||||
}
|
||||
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||
reject(new Error(`Failed to fetch ${url}. HTTP ${res.statusCode}`));
|
||||
return;
|
||||
}
|
||||
let payload = "";
|
||||
res.setEncoding("utf8");
|
||||
res.on("data", (chunk) => {
|
||||
payload += chunk;
|
||||
});
|
||||
res.on("end", () => {
|
||||
try {
|
||||
resolve(JSON.parse(payload));
|
||||
} catch (e) {
|
||||
reject(new Error(`Invalid JSON from ${url}: ${e.message}`));
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveLatestTag() {
|
||||
const apiUrl =
|
||||
"https://api.github.com/repos/presenton/presenton-export/releases/latest";
|
||||
const latest = await requestJson(apiUrl);
|
||||
if (!latest.tag_name) {
|
||||
throw new Error(`Could not resolve latest tag from ${apiUrl}`);
|
||||
}
|
||||
return latest.tag_name;
|
||||
}
|
||||
|
||||
function chmodIfPossible(filePath) {
|
||||
if (process.platform !== "win32") {
|
||||
fs.chmodSync(filePath, 0o755);
|
||||
}
|
||||
}
|
||||
|
||||
function getConverterCandidates() {
|
||||
return [
|
||||
path.join(targetPyDir, "convert-linux-x64"),
|
||||
path.join(targetPyDir, "convert-linux-amd64"),
|
||||
];
|
||||
}
|
||||
|
||||
function validateExistingRuntime() {
|
||||
if (!fs.existsSync(targetIndex)) {
|
||||
return { ok: false, reason: `Missing runtime bundle: ${targetIndex}` };
|
||||
}
|
||||
const candidates = getConverterCandidates();
|
||||
const converterPath = candidates.find((c) => fs.existsSync(c));
|
||||
if (!converterPath) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: `No Linux converter binary under ${targetPyDir}.`,
|
||||
};
|
||||
}
|
||||
chmodIfPossible(converterPath);
|
||||
return { ok: true, converterPath };
|
||||
}
|
||||
|
||||
function downloadFile(url, outputPath, redirects = 5) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const client = url.startsWith("https:") ? https : http;
|
||||
const req = client.get(
|
||||
url,
|
||||
{
|
||||
headers: {
|
||||
"User-Agent": "presenton-presentation-export-sync",
|
||||
Accept: "application/octet-stream",
|
||||
},
|
||||
},
|
||||
(res) => {
|
||||
if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location) {
|
||||
if (redirects <= 0) {
|
||||
reject(new Error(`Too many redirects while downloading ${url}`));
|
||||
return;
|
||||
}
|
||||
downloadFile(res.headers.location, outputPath, redirects - 1)
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
return;
|
||||
}
|
||||
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||
reject(new Error(`Failed to download ${url}. HTTP ${res.statusCode}`));
|
||||
return;
|
||||
}
|
||||
ensureDir(path.dirname(outputPath));
|
||||
const fileStream = fs.createWriteStream(outputPath);
|
||||
res.pipe(fileStream);
|
||||
fileStream.on("finish", () => {
|
||||
fileStream.close(resolve);
|
||||
});
|
||||
fileStream.on("error", reject);
|
||||
}
|
||||
);
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function unzipArchive(zipPath, destDir) {
|
||||
ensureDir(destDir);
|
||||
execFileSync("unzip", ["-o", zipPath, "-d", destDir], { stdio: "inherit" });
|
||||
}
|
||||
|
||||
function resolveExtractedRoot(extractDir) {
|
||||
const directIndex = path.join(extractDir, "index.js");
|
||||
const directPy = path.join(extractDir, "py");
|
||||
if (fs.existsSync(directIndex) && fs.existsSync(directPy)) {
|
||||
return extractDir;
|
||||
}
|
||||
const children = fs.readdirSync(extractDir, { withFileTypes: true });
|
||||
for (const entry of children) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const candidate = path.join(extractDir, entry.name);
|
||||
const candidateIndex = path.join(candidate, "index.js");
|
||||
const candidatePy = path.join(candidate, "py");
|
||||
if (fs.existsSync(candidateIndex) && fs.existsSync(candidatePy)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
throw new Error(`Unable to locate export runtime root under ${extractDir}`);
|
||||
}
|
||||
|
||||
async function downloadAndInstallRuntime() {
|
||||
const tag = await getTargetVersion();
|
||||
const downloadUrl = `${exportRepoBase}/${tag}/${linuxAssetName}`;
|
||||
|
||||
const versionPinBackup = fs.existsSync(versionFile)
|
||||
? fs.readFileSync(versionFile, "utf8")
|
||||
: JSON.stringify({ exportVersion: tag }, null, 2) + "\n";
|
||||
|
||||
ensureDir(cacheDir);
|
||||
const zipPath = path.join(cacheDir, linuxAssetName);
|
||||
const extractDir = path.join(cacheDir, `extract-${Date.now()}`);
|
||||
|
||||
console.log(`[presentation-export] Downloading ${downloadUrl}`);
|
||||
await downloadFile(downloadUrl, zipPath);
|
||||
|
||||
console.log(`[presentation-export] Extracting ${zipPath}`);
|
||||
unzipArchive(zipPath, extractDir);
|
||||
|
||||
const sourceRoot = resolveExtractedRoot(extractDir);
|
||||
fs.rmSync(targetRoot, { recursive: true, force: true });
|
||||
ensureDir(targetRoot);
|
||||
fs.cpSync(sourceRoot, targetRoot, { recursive: true, force: true });
|
||||
|
||||
ensureDir(path.dirname(versionFile));
|
||||
fs.writeFileSync(versionFile, versionPinBackup, "utf8");
|
||||
|
||||
fs.rmSync(extractDir, { recursive: true, force: true });
|
||||
|
||||
return { tag, downloadUrl };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const existing = validateExistingRuntime();
|
||||
|
||||
if (checkOnly) {
|
||||
if (!existing.ok) {
|
||||
throw new Error(existing.reason);
|
||||
}
|
||||
console.log("[presentation-export] OK");
|
||||
console.log(` - ${targetIndex}`);
|
||||
console.log(` - ${existing.converterPath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (existing.ok && !forceDownload) {
|
||||
console.log("[presentation-export] Using existing runtime:");
|
||||
console.log(` - ${targetIndex}`);
|
||||
console.log(` - ${existing.converterPath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { tag, downloadUrl } = await downloadAndInstallRuntime();
|
||||
const installed = validateExistingRuntime();
|
||||
if (!installed.ok) {
|
||||
throw new Error(installed.reason);
|
||||
}
|
||||
|
||||
console.log("[presentation-export] Synced successfully:");
|
||||
console.log(` - release: ${tag}`);
|
||||
console.log(` - url: ${downloadUrl}`);
|
||||
console.log(` - ${targetIndex}`);
|
||||
console.log(` - ${installed.converterPath}`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(`[presentation-export] ${err.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -102,7 +102,7 @@ async def generate_image(
|
|||
sql_session.add(image)
|
||||
await sql_session.commit()
|
||||
|
||||
return image.file_url
|
||||
return image.path
|
||||
|
||||
|
||||
@IMAGES_ROUTER.get("/generated", response_model=List[ImageAsset])
|
||||
|
|
@ -113,12 +113,7 @@ async def get_generated_images(sql_session: AsyncSession = Depends(get_async_ses
|
|||
.where(ImageAsset.is_uploaded == False)
|
||||
.order_by(ImageAsset.created_at.desc())
|
||||
)
|
||||
images = list(images_result)
|
||||
for image in images:
|
||||
# Ensure path exposed to the frontend is a web-safe URL
|
||||
if hasattr(image, "file_url"):
|
||||
image.path = image.file_url # type: ignore[attr-defined]
|
||||
return images
|
||||
return list(images_result)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to retrieve generated images: {str(e)}"
|
||||
|
|
@ -145,10 +140,6 @@ async def upload_image(
|
|||
# Refresh to ensure all defaults are loaded
|
||||
await sql_session.refresh(image_asset)
|
||||
|
||||
# Expose a web-safe URL in the path field for the frontend
|
||||
if hasattr(image_asset, "file_url"):
|
||||
image_asset.path = image_asset.file_url # type: ignore[attr-defined]
|
||||
|
||||
return image_asset
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to upload image: {str(e)}")
|
||||
|
|
@ -162,12 +153,7 @@ async def get_uploaded_images(sql_session: AsyncSession = Depends(get_async_sess
|
|||
.where(ImageAsset.is_uploaded == True)
|
||||
.order_by(ImageAsset.created_at.desc())
|
||||
)
|
||||
images = list(images_result)
|
||||
for image in images:
|
||||
# Ensure path exposed to the frontend is a web-safe URL
|
||||
if hasattr(image, "file_url"):
|
||||
image.path = image.file_url # type: ignore[attr-defined]
|
||||
return images
|
||||
return list(images_result)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to retrieve uploaded images: {str(e)}"
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ from api.v1.ppt.endpoints.slide import SLIDE_ROUTER
|
|||
from api.v1.ppt.endpoints.pptx_slides import PPTX_FONTS_ROUTER
|
||||
from api.v1.ppt.endpoints.theme import THEMES_ROUTER
|
||||
from api.v1.ppt.endpoints.theme_generate import THEME_ROUTER
|
||||
from templates.router import TEMPLATE_ROUTER
|
||||
|
||||
|
||||
API_V1_PPT_ROUTER = APIRouter(prefix="/api/v1/ppt")
|
||||
|
|
@ -43,3 +44,4 @@ API_V1_PPT_ROUTER.include_router(CODEX_AUTH_ROUTER)
|
|||
API_V1_PPT_ROUTER.include_router(PPTX_FONTS_ROUTER)
|
||||
API_V1_PPT_ROUTER.include_router(THEMES_ROUTER)
|
||||
API_V1_PPT_ROUTER.include_router(THEME_ROUTER)
|
||||
API_V1_PPT_ROUTER.include_router(TEMPLATE_ROUTER)
|
||||
|
|
|
|||
|
|
@ -1,22 +1,90 @@
|
|||
PDF_EXTENSIONS = [".pdf"]
|
||||
TEXT_EXTENSIONS = [".txt"]
|
||||
|
||||
WORD_EXTENSIONS = [".doc", ".docx", ".docm", ".odt", ".rtf"]
|
||||
POWERPOINT_EXTENSIONS = [".ppt", ".pptx", ".pptm", ".odp"]
|
||||
SPREADSHEET_EXTENSIONS = [".xls", ".xlsx", ".xlsm", ".ods", ".csv", ".tsv"]
|
||||
|
||||
JPEG_EXTENSIONS = [".jpg", ".jpeg"]
|
||||
PNG_EXTENSIONS = [".png"]
|
||||
GIF_EXTENSIONS = [".gif"]
|
||||
BMP_EXTENSIONS = [".bmp"]
|
||||
TIFF_EXTENSIONS = [".tiff", ".tif"]
|
||||
WEBP_EXTENSIONS = [".webp"]
|
||||
SVG_EXTENSIONS = [".svg"]
|
||||
IMAGE_EXTENSIONS = (
|
||||
JPEG_EXTENSIONS
|
||||
+ PNG_EXTENSIONS
|
||||
+ GIF_EXTENSIONS
|
||||
+ BMP_EXTENSIONS
|
||||
+ TIFF_EXTENSIONS
|
||||
+ WEBP_EXTENSIONS
|
||||
+ SVG_EXTENSIONS
|
||||
)
|
||||
|
||||
OFFICE_EXTENSIONS = WORD_EXTENSIONS + POWERPOINT_EXTENSIONS + SPREADSHEET_EXTENSIONS
|
||||
|
||||
PDF_MIME_TYPES = ["application/pdf"]
|
||||
TEXT_MIME_TYPES = ["text/plain"]
|
||||
POWERPOINT_TYPES = [
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
||||
]
|
||||
# Alias used by font/PPTX validation helpers shared with the Electron server tree.
|
||||
PPTX_MIME_TYPES = POWERPOINT_TYPES
|
||||
WORD_TYPES = [
|
||||
TEXT_MIME_TYPES = ["text/plain", "text/markdown"]
|
||||
|
||||
WORD_MIME_TYPES = [
|
||||
"application/msword",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/vnd.ms-word.document.macroenabled.12",
|
||||
"application/vnd.oasis.opendocument.text",
|
||||
"application/rtf",
|
||||
"text/rtf",
|
||||
]
|
||||
SPREADSHEET_TYPES = ["text/csv", "application/csv"]
|
||||
|
||||
POWERPOINT_MIME_TYPES = [
|
||||
"application/vnd.ms-powerpoint",
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
"application/vnd.ms-powerpoint.presentation.macroenabled.12",
|
||||
"application/vnd.oasis.opendocument.presentation",
|
||||
]
|
||||
|
||||
PNG_MIME_TYPES = ["image/png"]
|
||||
JPEG_MIME_TYPES = ["image/jpeg"]
|
||||
WEBP_MIME_TYPES = ["image/webp"]
|
||||
SPREADSHEET_MIME_TYPES = [
|
||||
"application/vnd.ms-excel",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"application/vnd.ms-excel.sheet.macroenabled.12",
|
||||
"application/vnd.oasis.opendocument.spreadsheet",
|
||||
"text/csv",
|
||||
"application/csv",
|
||||
"text/tab-separated-values",
|
||||
"text/tsv",
|
||||
]
|
||||
|
||||
IMAGE_MIME_TYPES = [
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/bmp",
|
||||
"image/tiff",
|
||||
"image/webp",
|
||||
"image/svg+xml",
|
||||
]
|
||||
|
||||
UPLOAD_ACCEPTED_FILE_TYPES = (
|
||||
PDF_MIME_TYPES + TEXT_MIME_TYPES + POWERPOINT_TYPES + WORD_TYPES
|
||||
UPLOAD_ACCEPTED_MIME_TYPES = (
|
||||
PDF_MIME_TYPES
|
||||
+ TEXT_MIME_TYPES
|
||||
+ WORD_MIME_TYPES
|
||||
+ POWERPOINT_MIME_TYPES
|
||||
+ SPREADSHEET_MIME_TYPES
|
||||
+ IMAGE_MIME_TYPES
|
||||
)
|
||||
|
||||
UPLOAD_ACCEPTED_EXTENSIONS = (
|
||||
PDF_EXTENSIONS + TEXT_EXTENSIONS + OFFICE_EXTENSIONS + IMAGE_EXTENSIONS
|
||||
)
|
||||
|
||||
# Includes both MIME types and extensions because some clients upload legacy
|
||||
# office files with generic content-type values.
|
||||
UPLOAD_ACCEPTED_FILE_TYPES = UPLOAD_ACCEPTED_MIME_TYPES + UPLOAD_ACCEPTED_EXTENSIONS
|
||||
|
||||
# Kept for endpoints that strictly require modern .pptx files.
|
||||
PPTX_MIME_TYPES = ["application/vnd.openxmlformats-officedocument.presentationml.presentation"]
|
||||
|
||||
# Backward compatibility aliases used across existing modules.
|
||||
POWERPOINT_TYPES = PPTX_MIME_TYPES
|
||||
WORD_TYPES = WORD_MIME_TYPES
|
||||
SPREADSHEET_TYPES = SPREADSHEET_MIME_TYPES
|
||||
|
|
|
|||
|
|
@ -4,4 +4,4 @@ OPENAI_URL = "https://api.openai.com/v1"
|
|||
DEFAULT_OPENAI_MODEL = "gpt-4.1"
|
||||
DEFAULT_GOOGLE_MODEL = "models/gemini-2.5-flash"
|
||||
DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-4-20250514"
|
||||
DEFAULT_CODEX_MODEL = "gpt-5.2-codex"
|
||||
DEFAULT_CODEX_MODEL = "gpt-5.1-codex-mini"
|
||||
|
|
|
|||
|
|
@ -1,39 +1,5 @@
|
|||
from typing import List, Optional
|
||||
from fastapi import HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
"""Re-export layout models defined in `templates.presentation_layout`."""
|
||||
|
||||
from models.presentation_structure_model import PresentationStructureModel
|
||||
from templates.presentation_layout import PresentationLayoutModel, SlideLayoutModel
|
||||
|
||||
|
||||
class SlideLayoutModel(BaseModel):
|
||||
id: str
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
json_schema: dict
|
||||
|
||||
|
||||
class PresentationLayoutModel(BaseModel):
|
||||
name: str
|
||||
ordered: bool = Field(default=False)
|
||||
slides: List[SlideLayoutModel]
|
||||
|
||||
def get_slide_layout_index(self, slide_layout_id: str) -> int:
|
||||
for index, slide in enumerate(self.slides):
|
||||
if slide.id == slide_layout_id:
|
||||
return index
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Slide layout {slide_layout_id} not found"
|
||||
)
|
||||
|
||||
def to_presentation_structure(self):
|
||||
return PresentationStructureModel(
|
||||
slides=[index for index in range(len(self.slides))]
|
||||
)
|
||||
|
||||
def to_string(self):
|
||||
message = f"## Presentation Layout\n\n"
|
||||
for index, slide in enumerate(self.slides):
|
||||
message += f"### Slide Layout: {index}: \n"
|
||||
message += f"- Name: {slide.name or slide.json_schema.get('title')} \n"
|
||||
message += f"- Description: {slide.description} \n\n"
|
||||
return message
|
||||
__all__ = ["PresentationLayoutModel", "SlideLayoutModel"]
|
||||
|
|
|
|||
|
|
@ -1,27 +1,11 @@
|
|||
from datetime import datetime
|
||||
from typing import Optional
|
||||
import os
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import JSON, Column, DateTime
|
||||
from sqlmodel import Field, SQLModel
|
||||
|
||||
from utils.datetime_utils import get_current_utc_datetime
|
||||
from utils.get_env import get_app_data_directory_env, get_next_public_fast_api_env
|
||||
from utils.path_helpers import get_resource_path
|
||||
|
||||
|
||||
def _with_fastapi_origin(path: str) -> str:
|
||||
"""Prefix relative web paths with FastAPI origin when available."""
|
||||
if path.startswith("http://") or path.startswith("https://"):
|
||||
return path
|
||||
|
||||
fastapi_origin = (get_next_public_fast_api_env() or "").strip()
|
||||
if not fastapi_origin:
|
||||
return path
|
||||
|
||||
normalized_path = path if path.startswith("/") else f"/{path}"
|
||||
return f"{fastapi_origin.rstrip('/')}{normalized_path}"
|
||||
|
||||
|
||||
class ImageAsset(SQLModel, table=True):
|
||||
|
|
@ -34,45 +18,3 @@ class ImageAsset(SQLModel, table=True):
|
|||
is_uploaded: bool = Field(default=False)
|
||||
path: str
|
||||
extras: Optional[dict] = Field(sa_column=Column(JSON), default=None)
|
||||
|
||||
@property
|
||||
def file_url(self) -> str:
|
||||
"""
|
||||
Returns a web path suitable for FastAPI static serving.
|
||||
- HTTP(S) URLs are returned as-is.
|
||||
- Files under APP_DATA are exposed under /app_data.
|
||||
- Files under the packaged static directory are exposed under /static.
|
||||
"""
|
||||
path = self.path
|
||||
|
||||
# Already an absolute web URL
|
||||
if path.startswith("http://") or path.startswith("https://"):
|
||||
return path
|
||||
|
||||
# Already a web path under known mounts
|
||||
if path.startswith("/app_data/") or path.startswith("/static/"):
|
||||
return _with_fastapi_origin(path)
|
||||
|
||||
# Normalize filesystem path
|
||||
real_path = os.path.realpath(path)
|
||||
|
||||
# Map APP_DATA files to /app_data/...
|
||||
app_data_dir = get_app_data_directory_env()
|
||||
if app_data_dir:
|
||||
app_data_dir_real = os.path.realpath(app_data_dir)
|
||||
if real_path.startswith(app_data_dir_real):
|
||||
rel = os.path.relpath(real_path, app_data_dir_real)
|
||||
rel_web = rel.replace(os.sep, "/")
|
||||
return _with_fastapi_origin(f"/app_data/{rel_web}")
|
||||
|
||||
# Map packaged static assets to /static/...
|
||||
static_root = get_resource_path("static")
|
||||
static_root_real = os.path.realpath(static_root)
|
||||
if real_path.startswith(static_root_real):
|
||||
rel = os.path.relpath(real_path, static_root_real)
|
||||
rel_web = rel.replace(os.sep, "/")
|
||||
return _with_fastapi_origin(f"/static/{rel_web}")
|
||||
|
||||
# Fallback: return the original path (may be absolute or relative);
|
||||
# frontend can decide how to handle unusual cases.
|
||||
return path
|
||||
|
|
|
|||
25
servers/fastapi/presenton_backend.egg-info/PKG-INFO
Normal file
25
servers/fastapi/presenton_backend.egg-info/PKG-INFO
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
Metadata-Version: 2.4
|
||||
Name: presenton-backend
|
||||
Version: 0.1.0
|
||||
Summary: Add your description here
|
||||
Requires-Python: <3.12,>=3.11
|
||||
Requires-Dist: alembic>=1.14.0
|
||||
Requires-Dist: aiohttp>=3.12.15
|
||||
Requires-Dist: aiomysql>=0.2.0
|
||||
Requires-Dist: aiosqlite>=0.21.0
|
||||
Requires-Dist: anthropic>=0.60.0
|
||||
Requires-Dist: asyncpg>=0.30.0
|
||||
Requires-Dist: chromadb>=1.0.15
|
||||
Requires-Dist: dirtyjson>=1.0.8
|
||||
Requires-Dist: fastapi[standard]>=0.116.1
|
||||
Requires-Dist: fastembed-vectorstore>=0.5.2
|
||||
Requires-Dist: fastmcp>=2.11.0
|
||||
Requires-Dist: google-genai>=1.28.0
|
||||
Requires-Dist: nltk>=3.9.1
|
||||
Requires-Dist: openai>=1.98.0
|
||||
Requires-Dist: pathvalidate>=3.3.1
|
||||
Requires-Dist: pdfplumber>=0.11.7
|
||||
Requires-Dist: pytest>=8.4.1
|
||||
Requires-Dist: python-pptx>=1.0.2
|
||||
Requires-Dist: redis>=6.2.0
|
||||
Requires-Dist: sqlmodel>=0.0.24
|
||||
155
servers/fastapi/presenton_backend.egg-info/SOURCES.txt
Normal file
155
servers/fastapi/presenton_backend.egg-info/SOURCES.txt
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
pyproject.toml
|
||||
api/__init__.py
|
||||
api/lifespan.py
|
||||
api/main.py
|
||||
api/middlewares.py
|
||||
api/v1/mock/router.py
|
||||
api/v1/ppt/background_tasks.py
|
||||
api/v1/ppt/router.py
|
||||
api/v1/ppt/endpoints/__init__.py
|
||||
api/v1/ppt/endpoints/anthropic.py
|
||||
api/v1/ppt/endpoints/codex_auth.py
|
||||
api/v1/ppt/endpoints/files.py
|
||||
api/v1/ppt/endpoints/fonts.py
|
||||
api/v1/ppt/endpoints/google.py
|
||||
api/v1/ppt/endpoints/icons.py
|
||||
api/v1/ppt/endpoints/images.py
|
||||
api/v1/ppt/endpoints/layouts.py
|
||||
api/v1/ppt/endpoints/ollama.py
|
||||
api/v1/ppt/endpoints/openai.py
|
||||
api/v1/ppt/endpoints/outlines.py
|
||||
api/v1/ppt/endpoints/pdf_slides.py
|
||||
api/v1/ppt/endpoints/pptx_slides.py
|
||||
api/v1/ppt/endpoints/presentation.py
|
||||
api/v1/ppt/endpoints/prompts.py
|
||||
api/v1/ppt/endpoints/slide.py
|
||||
api/v1/ppt/endpoints/slide_to_html.py
|
||||
api/v1/ppt/endpoints/theme.py
|
||||
api/v1/ppt/endpoints/theme_generate.py
|
||||
api/v1/webhook/router.py
|
||||
constants/__init__.py
|
||||
constants/documents.py
|
||||
constants/llm.py
|
||||
constants/presentation.py
|
||||
constants/supported_ollama_models.py
|
||||
enums/__init__.py
|
||||
enums/image_provider.py
|
||||
enums/llm_call_type.py
|
||||
enums/llm_provider.py
|
||||
enums/tone.py
|
||||
enums/verbosity.py
|
||||
enums/webhook_event.py
|
||||
models/__init__.py
|
||||
models/api_error_model.py
|
||||
models/decomposed_file_info.py
|
||||
models/document_chunk.py
|
||||
models/generate_presentation_request.py
|
||||
models/image_prompt.py
|
||||
models/json_path_guide.py
|
||||
models/llm_message.py
|
||||
models/llm_tool_call.py
|
||||
models/llm_tools.py
|
||||
models/ollama_model_metadata.py
|
||||
models/ollama_model_status.py
|
||||
models/pptx_models.py
|
||||
models/presentation_and_path.py
|
||||
models/presentation_from_template.py
|
||||
models/presentation_layout.py
|
||||
models/presentation_outline_model.py
|
||||
models/presentation_structure_model.py
|
||||
models/presentation_with_slides.py
|
||||
models/slide_layout_index.py
|
||||
models/sse_response.py
|
||||
models/theme_data.py
|
||||
models/user_config.py
|
||||
models/sql/async_presentation_generation_status.py
|
||||
models/sql/image_asset.py
|
||||
models/sql/key_value.py
|
||||
models/sql/ollama_pull_status.py
|
||||
models/sql/presentation.py
|
||||
models/sql/presentation_layout_code.py
|
||||
models/sql/slide.py
|
||||
models/sql/template.py
|
||||
models/sql/template_create_info.py
|
||||
models/sql/webhook_subscription.py
|
||||
presenton_backend.egg-info/PKG-INFO
|
||||
presenton_backend.egg-info/SOURCES.txt
|
||||
presenton_backend.egg-info/dependency_links.txt
|
||||
presenton_backend.egg-info/requires.txt
|
||||
presenton_backend.egg-info/top_level.txt
|
||||
services/__init__.py
|
||||
services/codex_llm.py
|
||||
services/concurrent_service.py
|
||||
services/database.py
|
||||
services/document_conversion_service.py
|
||||
services/documents_loader.py
|
||||
services/export_task_service.py
|
||||
services/html_to_text_runs_service.py
|
||||
services/icon_finder_service.py
|
||||
services/image_generation_service.py
|
||||
services/liteparse_service.py
|
||||
services/llm_client.py
|
||||
services/llm_tool_calls_handler.py
|
||||
services/pptx_presentation_creator.py
|
||||
services/score_based_chunker.py
|
||||
services/temp_file_service.py
|
||||
services/webhook_service.py
|
||||
templates/__init__.py
|
||||
templates/example.py
|
||||
templates/font_utils.py
|
||||
templates/get_layout_by_name.py
|
||||
templates/handler.py
|
||||
templates/presentation_layout.py
|
||||
templates/preview.py
|
||||
templates/prompts.py
|
||||
templates/providers.py
|
||||
templates/router.py
|
||||
tests/test_gemini_schema_support.py
|
||||
tests/test_image_generation.py
|
||||
tests/test_mcp_server.py
|
||||
tests/test_openai_schema_support.py
|
||||
tests/test_pptx_creator.py
|
||||
tests/test_pptx_slides_processing.py
|
||||
tests/test_presentation_generation_api.py
|
||||
tests/test_slide_to_html.py
|
||||
utils/__init__.py
|
||||
utils/asset_directory_utils.py
|
||||
utils/async_iterator.py
|
||||
utils/available_models.py
|
||||
utils/datetime_utils.py
|
||||
utils/db_utils.py
|
||||
utils/dict_utils.py
|
||||
utils/download_helpers.py
|
||||
utils/dummy_functions.py
|
||||
utils/error_handling.py
|
||||
utils/export_utils.py
|
||||
utils/file_utils.py
|
||||
utils/get_dynamic_models.py
|
||||
utils/get_env.py
|
||||
utils/get_layout_by_name.py
|
||||
utils/image_provider.py
|
||||
utils/image_utils.py
|
||||
utils/llm_client_error_handler.py
|
||||
utils/llm_provider.py
|
||||
utils/model_availability.py
|
||||
utils/ocr_language.py
|
||||
utils/ollama.py
|
||||
utils/outline_utils.py
|
||||
utils/parsers.py
|
||||
utils/path_helpers.py
|
||||
utils/ppt_utils.py
|
||||
utils/process_slides.py
|
||||
utils/schema_utils.py
|
||||
utils/set_env.py
|
||||
utils/theme_utils.py
|
||||
utils/user_config.py
|
||||
utils/validators.py
|
||||
utils/llm_calls/edit_slide.py
|
||||
utils/llm_calls/edit_slide_html.py
|
||||
utils/llm_calls/generate_presentation_outlines.py
|
||||
utils/llm_calls/generate_presentation_structure.py
|
||||
utils/llm_calls/generate_slide_content.py
|
||||
utils/llm_calls/select_slide_type_on_edit.py
|
||||
utils/oauth/__init__.py
|
||||
utils/oauth/openai_codex.py
|
||||
utils/oauth/pkce.py
|
||||
|
|
@ -0,0 +1 @@
|
|||
|
||||
20
servers/fastapi/presenton_backend.egg-info/requires.txt
Normal file
20
servers/fastapi/presenton_backend.egg-info/requires.txt
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
alembic>=1.14.0
|
||||
aiohttp>=3.12.15
|
||||
aiomysql>=0.2.0
|
||||
aiosqlite>=0.21.0
|
||||
anthropic>=0.60.0
|
||||
asyncpg>=0.30.0
|
||||
chromadb>=1.0.15
|
||||
dirtyjson>=1.0.8
|
||||
fastapi[standard]>=0.116.1
|
||||
fastembed-vectorstore>=0.5.2
|
||||
fastmcp>=2.11.0
|
||||
google-genai>=1.28.0
|
||||
nltk>=3.9.1
|
||||
openai>=1.98.0
|
||||
pathvalidate>=3.3.1
|
||||
pdfplumber>=0.11.7
|
||||
pytest>=8.4.1
|
||||
python-pptx>=1.0.2
|
||||
redis>=6.2.0
|
||||
sqlmodel>=0.0.24
|
||||
7
servers/fastapi/presenton_backend.egg-info/top_level.txt
Normal file
7
servers/fastapi/presenton_backend.egg-info/top_level.txt
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
api
|
||||
constants
|
||||
enums
|
||||
models
|
||||
services
|
||||
templates
|
||||
utils
|
||||
|
|
@ -16,7 +16,6 @@ dependencies = [
|
|||
"asyncpg>=0.30.0",
|
||||
"chromadb>=1.0.15",
|
||||
"dirtyjson>=1.0.8",
|
||||
"docling>=2.43.0",
|
||||
"fastapi[standard]>=0.116.1",
|
||||
"fastembed-vectorstore>=0.5.2",
|
||||
"fastmcp>=2.11.0",
|
||||
|
|
@ -34,9 +33,6 @@ dependencies = [
|
|||
[tool.uv]
|
||||
index-strategy = "unsafe-best-match"
|
||||
|
||||
[[tool.uv.index]]
|
||||
url = "https://download.pytorch.org/whl/cpu"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["api*", "enums*", "models*", "services*", "constants*", "utils*", "templates*"]
|
||||
|
|
|
|||
|
|
@ -14,11 +14,13 @@ if __name__ == "__main__":
|
|||
args = parser.parse_args()
|
||||
reload = args.reload == "true"
|
||||
host = "127.0.0.1"
|
||||
os.environ["FASTAPI_PUBLIC_URL"] = f"http://{host}:{args.port}"
|
||||
|
||||
# PPTX-to-HTML export and other in-process callers resolve `/app_data` assets here.
|
||||
os.environ.setdefault("FASTAPI_PUBLIC_URL", f"http://{host}:{args.port}")
|
||||
|
||||
uvicorn.run(
|
||||
"api.main:app",
|
||||
host="127.0.0.1",
|
||||
host=host,
|
||||
port=args.port,
|
||||
log_level="info",
|
||||
reload=reload,
|
||||
|
|
|
|||
|
|
@ -1,33 +0,0 @@
|
|||
from docling.document_converter import (
|
||||
DocumentConverter,
|
||||
PdfFormatOption,
|
||||
PowerpointFormatOption,
|
||||
WordFormatOption,
|
||||
)
|
||||
from docling.datamodel.pipeline_options import PdfPipelineOptions
|
||||
from docling.datamodel.base_models import InputFormat
|
||||
|
||||
|
||||
class DoclingService:
|
||||
def __init__(self):
|
||||
self.pipeline_options = PdfPipelineOptions()
|
||||
self.pipeline_options.do_ocr = False
|
||||
|
||||
self.converter = DocumentConverter(
|
||||
allowed_formats=[InputFormat.PPTX, InputFormat.PDF, InputFormat.DOCX],
|
||||
format_options={
|
||||
InputFormat.DOCX: WordFormatOption(
|
||||
pipeline_options=self.pipeline_options,
|
||||
),
|
||||
InputFormat.PPTX: PowerpointFormatOption(
|
||||
pipeline_options=self.pipeline_options,
|
||||
),
|
||||
InputFormat.PDF: PdfFormatOption(
|
||||
pipeline_options=self.pipeline_options,
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
def parse_to_markdown(self, file_path: str) -> str:
|
||||
result = self.converter.convert(file_path)
|
||||
return result.document.export_to_markdown()
|
||||
235
servers/fastapi/services/document_conversion_service.py
Normal file
235
servers/fastapi/services/document_conversion_service.py
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
import os
|
||||
import subprocess
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Dict, List
|
||||
|
||||
|
||||
class DocumentConversionError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
_LOG_SNIPPET_LIMIT = 600
|
||||
|
||||
|
||||
def _snippet(value: str, limit: int = _LOG_SNIPPET_LIMIT) -> str:
|
||||
text = (value or "").strip()
|
||||
if not text:
|
||||
return "<empty>"
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return f"{text[:limit]}... [truncated {len(text) - limit} chars]"
|
||||
|
||||
|
||||
def _command_str(parts: list[str]) -> str:
|
||||
return " ".join(repr(part) for part in parts)
|
||||
|
||||
|
||||
def _windows_hidden_subprocess_kwargs() -> Dict[str, object]:
|
||||
if os.name != "nt":
|
||||
return {}
|
||||
|
||||
startupinfo = subprocess.STARTUPINFO()
|
||||
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
||||
return {
|
||||
"creationflags": getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
"startupinfo": startupinfo,
|
||||
}
|
||||
|
||||
|
||||
class DocumentConversionService:
|
||||
def __init__(self):
|
||||
self.soffice_binary = self._resolve_soffice_binary()
|
||||
self.imagemagick_binary = self._resolve_imagemagick_binary()
|
||||
|
||||
@staticmethod
|
||||
def _resolve_soffice_binary() -> str:
|
||||
configured = (os.getenv("SOFFICE_PATH") or "").strip()
|
||||
if configured:
|
||||
return configured
|
||||
return "soffice.exe" if os.name == "nt" else "soffice"
|
||||
|
||||
@staticmethod
|
||||
def _can_execute(command: str, args: List[str]) -> bool:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[command, *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=10,
|
||||
check=False,
|
||||
**_windows_hidden_subprocess_kwargs(),
|
||||
)
|
||||
return result.returncode == 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _resolve_imagemagick_binary(self) -> str:
|
||||
configured = (os.getenv("IMAGEMAGICK_BINARY") or "").strip()
|
||||
if configured:
|
||||
return configured
|
||||
|
||||
for candidate in ["magick", "convert"]:
|
||||
if self._can_execute(candidate, ["-version"]):
|
||||
return candidate
|
||||
|
||||
return "magick" if os.name == "nt" else "convert"
|
||||
|
||||
def convert_office_to_pdf(
|
||||
self,
|
||||
file_path: str,
|
||||
output_dir: str,
|
||||
timeout_seconds: int = 180,
|
||||
) -> str:
|
||||
Path(output_dir).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
existing_pdfs = {
|
||||
p.name for p in Path(output_dir).glob("*.pdf") if p.is_file()
|
||||
}
|
||||
|
||||
try:
|
||||
command = [
|
||||
self.soffice_binary,
|
||||
"--headless",
|
||||
"--convert-to",
|
||||
"pdf",
|
||||
"--outdir",
|
||||
output_dir,
|
||||
file_path,
|
||||
]
|
||||
LOGGER.info(
|
||||
"[DocumentConversion] LibreOffice conversion start input=%s output_dir=%s",
|
||||
file_path,
|
||||
output_dir,
|
||||
)
|
||||
subprocess.run(
|
||||
command,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=timeout_seconds,
|
||||
**_windows_hidden_subprocess_kwargs(),
|
||||
)
|
||||
LOGGER.info(
|
||||
"[DocumentConversion] LibreOffice conversion complete input=%s",
|
||||
file_path,
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
LOGGER.error(
|
||||
"[DocumentConversion] LibreOffice timed out command=%s",
|
||||
_command_str(exc.cmd if isinstance(exc.cmd, list) else [str(exc.cmd)]),
|
||||
)
|
||||
raise DocumentConversionError(
|
||||
f"LibreOffice conversion timed out for {os.path.basename(file_path)}"
|
||||
) from exc
|
||||
except subprocess.CalledProcessError as exc:
|
||||
stderr = (exc.stderr or "").strip()
|
||||
stdout = (exc.stdout or "").strip()
|
||||
details = stderr or stdout or str(exc)
|
||||
LOGGER.error(
|
||||
"[DocumentConversion] LibreOffice failed code=%s command=%s stderr=%s stdout=%s",
|
||||
exc.returncode,
|
||||
_command_str(exc.cmd if isinstance(exc.cmd, list) else [str(exc.cmd)]),
|
||||
_snippet(stderr),
|
||||
_snippet(stdout),
|
||||
)
|
||||
raise DocumentConversionError(
|
||||
f"LibreOffice conversion failed for {os.path.basename(file_path)}: {details} "
|
||||
f"(stderr={_snippet(stderr)}; stdout={_snippet(stdout)})"
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
LOGGER.exception("[DocumentConversion] LibreOffice conversion unexpected error")
|
||||
raise DocumentConversionError(
|
||||
f"LibreOffice conversion failed for {os.path.basename(file_path)}: {exc}"
|
||||
) from exc
|
||||
|
||||
expected_pdf = Path(output_dir) / f"{Path(file_path).stem}.pdf"
|
||||
if expected_pdf.is_file():
|
||||
return str(expected_pdf)
|
||||
|
||||
generated_pdfs = [
|
||||
p
|
||||
for p in Path(output_dir).glob("*.pdf")
|
||||
if p.is_file() and p.name not in existing_pdfs
|
||||
]
|
||||
if generated_pdfs:
|
||||
newest = max(generated_pdfs, key=lambda p: p.stat().st_mtime)
|
||||
return str(newest)
|
||||
|
||||
raise DocumentConversionError(
|
||||
f"LibreOffice did not create a PDF for {os.path.basename(file_path)}"
|
||||
)
|
||||
|
||||
def convert_image_to_png(
|
||||
self,
|
||||
file_path: str,
|
||||
output_dir: str,
|
||||
timeout_seconds: int = 120,
|
||||
) -> str:
|
||||
Path(output_dir).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
output_path = Path(output_dir) / f"{Path(file_path).stem}_converted.png"
|
||||
|
||||
command = [self.imagemagick_binary, file_path, str(output_path)]
|
||||
|
||||
try:
|
||||
LOGGER.info(
|
||||
"[DocumentConversion] ImageMagick conversion start input=%s output=%s command=%s",
|
||||
file_path,
|
||||
output_path,
|
||||
_command_str(command),
|
||||
)
|
||||
subprocess.run(
|
||||
command,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=timeout_seconds,
|
||||
**_windows_hidden_subprocess_kwargs(),
|
||||
)
|
||||
LOGGER.info(
|
||||
"[DocumentConversion] ImageMagick conversion complete output=%s",
|
||||
output_path,
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
LOGGER.error(
|
||||
"[DocumentConversion] ImageMagick timed out command=%s",
|
||||
_command_str(exc.cmd if isinstance(exc.cmd, list) else [str(exc.cmd)]),
|
||||
)
|
||||
raise DocumentConversionError(
|
||||
f"ImageMagick conversion timed out for {os.path.basename(file_path)}"
|
||||
) from exc
|
||||
except subprocess.CalledProcessError as exc:
|
||||
stderr = (exc.stderr or "").strip()
|
||||
stdout = (exc.stdout or "").strip()
|
||||
details = stderr or stdout or str(exc)
|
||||
LOGGER.error(
|
||||
"[DocumentConversion] ImageMagick failed code=%s command=%s stderr=%s stdout=%s",
|
||||
exc.returncode,
|
||||
_command_str(exc.cmd if isinstance(exc.cmd, list) else [str(exc.cmd)]),
|
||||
_snippet(stderr),
|
||||
_snippet(stdout),
|
||||
)
|
||||
raise DocumentConversionError(
|
||||
f"ImageMagick conversion failed for {os.path.basename(file_path)}: {details} "
|
||||
f"(stderr={_snippet(stderr)}; stdout={_snippet(stdout)})"
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
LOGGER.exception("[DocumentConversion] ImageMagick conversion unexpected error")
|
||||
raise DocumentConversionError(
|
||||
f"ImageMagick conversion failed for {os.path.basename(file_path)}: {exc}"
|
||||
) from exc
|
||||
|
||||
if not output_path.is_file():
|
||||
raise DocumentConversionError(
|
||||
f"ImageMagick did not create a PNG for {os.path.basename(file_path)}"
|
||||
)
|
||||
|
||||
return str(output_path)
|
||||
|
|
@ -1,24 +1,52 @@
|
|||
import mimetypes
|
||||
from fastapi import HTTPException
|
||||
import os, asyncio
|
||||
from typing import List, Optional, Tuple
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any, List, Optional, Tuple
|
||||
|
||||
import pdfplumber
|
||||
from fastapi import HTTPException
|
||||
|
||||
from constants.documents import (
|
||||
PDF_MIME_TYPES,
|
||||
POWERPOINT_TYPES,
|
||||
TEXT_MIME_TYPES,
|
||||
WORD_TYPES,
|
||||
IMAGE_EXTENSIONS,
|
||||
OFFICE_EXTENSIONS,
|
||||
PDF_EXTENSIONS,
|
||||
TEXT_EXTENSIONS,
|
||||
)
|
||||
from services.docling_service import DoclingService
|
||||
from services.document_conversion_service import (
|
||||
DocumentConversionError,
|
||||
DocumentConversionService,
|
||||
)
|
||||
from services.liteparse_service import LiteParseError, LiteParseService
|
||||
from utils.ocr_language import presentation_language_to_ocr_code
|
||||
|
||||
# Optional fallback converter (primarily useful on Windows)
|
||||
try:
|
||||
from services.lightweight_document_service import DocumentService as DocumentServiceCls
|
||||
except Exception:
|
||||
DocumentServiceCls = None
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DocumentsLoader:
|
||||
DECOMPOSE_TIMEOUT_SECONDS = 600
|
||||
|
||||
def __init__(self, file_paths: List[str]):
|
||||
def __init__(
|
||||
self,
|
||||
file_paths: List[str],
|
||||
presentation_language: Optional[str] = None,
|
||||
):
|
||||
self._file_paths = file_paths
|
||||
|
||||
self.docling_service = DoclingService()
|
||||
self._ocr_language = presentation_language_to_ocr_code(presentation_language)
|
||||
self.liteparse_service = LiteParseService(
|
||||
timeout_seconds=self.DECOMPOSE_TIMEOUT_SECONDS
|
||||
)
|
||||
self.document_conversion_service = DocumentConversionService()
|
||||
self.document_service: Any = (
|
||||
DocumentServiceCls() if DocumentServiceCls is not None else None
|
||||
)
|
||||
|
||||
self._documents: List[str] = []
|
||||
self._images: List[List[str]] = []
|
||||
|
|
@ -40,7 +68,7 @@ class DocumentsLoader:
|
|||
"""If load_images is True, temp_dir must be provided"""
|
||||
|
||||
documents: List[str] = []
|
||||
images: List[str] = []
|
||||
images: List[List[str]] = []
|
||||
|
||||
for file_path in self._file_paths:
|
||||
if not os.path.exists(file_path):
|
||||
|
|
@ -49,19 +77,35 @@ class DocumentsLoader:
|
|||
)
|
||||
|
||||
document = ""
|
||||
imgs = []
|
||||
imgs: List[str] = []
|
||||
|
||||
mime_type = mimetypes.guess_type(file_path)[0]
|
||||
if mime_type in PDF_MIME_TYPES:
|
||||
extension = Path(file_path).suffix.lower()
|
||||
LOGGER.info(
|
||||
"[DocumentsLoader] Processing file=%s extension=%s",
|
||||
file_path,
|
||||
extension,
|
||||
)
|
||||
|
||||
if extension in PDF_EXTENSIONS:
|
||||
document, imgs = await self.load_pdf(
|
||||
file_path, load_text, load_images, temp_dir
|
||||
)
|
||||
elif mime_type in TEXT_MIME_TYPES:
|
||||
elif extension in TEXT_EXTENSIONS:
|
||||
document = await self.load_text(file_path)
|
||||
elif mime_type in POWERPOINT_TYPES:
|
||||
document = self.load_powerpoint(file_path)
|
||||
elif mime_type in WORD_TYPES:
|
||||
document = self.load_msword(file_path)
|
||||
elif extension in OFFICE_EXTENSIONS:
|
||||
document = await asyncio.to_thread(
|
||||
self.load_office_document,
|
||||
file_path,
|
||||
temp_dir,
|
||||
)
|
||||
elif extension in IMAGE_EXTENSIONS:
|
||||
document = await asyncio.to_thread(
|
||||
self.load_image,
|
||||
file_path,
|
||||
temp_dir,
|
||||
)
|
||||
else:
|
||||
document = await asyncio.to_thread(self._parse_with_liteparse, file_path)
|
||||
|
||||
documents.append(document)
|
||||
images.append(imgs)
|
||||
|
|
@ -76,26 +120,88 @@ class DocumentsLoader:
|
|||
load_images: bool,
|
||||
temp_dir: Optional[str] = None,
|
||||
) -> Tuple[str, List[str]]:
|
||||
image_paths = []
|
||||
image_paths: List[str] = []
|
||||
document: str = ""
|
||||
|
||||
if load_text:
|
||||
document = self.docling_service.parse_to_markdown(file_path)
|
||||
document = await asyncio.to_thread(self._parse_with_liteparse, file_path)
|
||||
|
||||
if load_images:
|
||||
if temp_dir is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="temp_dir is required when load_images is true",
|
||||
)
|
||||
image_paths = await self.get_page_images_from_pdf_async(file_path, temp_dir)
|
||||
|
||||
return document, image_paths
|
||||
|
||||
async def load_text(self, file_path: str) -> str:
|
||||
with open(file_path, "r") as file:
|
||||
with open(file_path, "r", encoding="utf-8") as file:
|
||||
return await asyncio.to_thread(file.read)
|
||||
|
||||
def load_msword(self, file_path: str) -> str:
|
||||
return self.docling_service.parse_to_markdown(file_path)
|
||||
def load_office_document(self, file_path: str, temp_dir: Optional[str] = None) -> str:
|
||||
if temp_dir:
|
||||
converted_path = self.document_conversion_service.convert_office_to_pdf(
|
||||
file_path,
|
||||
temp_dir,
|
||||
timeout_seconds=self.DECOMPOSE_TIMEOUT_SECONDS,
|
||||
)
|
||||
return self._parse_with_liteparse(converted_path)
|
||||
|
||||
def load_powerpoint(self, file_path: str) -> str:
|
||||
return self.docling_service.parse_to_markdown(file_path)
|
||||
with tempfile.TemporaryDirectory(prefix="office-convert-") as conversion_dir:
|
||||
converted_path = self.document_conversion_service.convert_office_to_pdf(
|
||||
file_path,
|
||||
conversion_dir,
|
||||
timeout_seconds=self.DECOMPOSE_TIMEOUT_SECONDS,
|
||||
)
|
||||
return self._parse_with_liteparse(converted_path)
|
||||
|
||||
def load_image(self, file_path: str, temp_dir: Optional[str] = None) -> str:
|
||||
if temp_dir:
|
||||
converted_path = self.document_conversion_service.convert_image_to_png(
|
||||
file_path,
|
||||
temp_dir,
|
||||
timeout_seconds=self.DECOMPOSE_TIMEOUT_SECONDS,
|
||||
)
|
||||
return self._parse_with_liteparse(converted_path)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="image-convert-") as conversion_dir:
|
||||
converted_path = self.document_conversion_service.convert_image_to_png(
|
||||
file_path,
|
||||
conversion_dir,
|
||||
timeout_seconds=self.DECOMPOSE_TIMEOUT_SECONDS,
|
||||
)
|
||||
return self._parse_with_liteparse(converted_path)
|
||||
|
||||
def _parse_with_liteparse(self, file_path: str) -> str:
|
||||
try:
|
||||
LOGGER.info("[DocumentsLoader] LiteParse start file=%s", file_path)
|
||||
return self.liteparse_service.parse_to_markdown(
|
||||
file_path,
|
||||
ocr_enabled=True,
|
||||
ocr_language=self._ocr_language,
|
||||
)
|
||||
except (LiteParseError, DocumentConversionError) as exc:
|
||||
LOGGER.warning(
|
||||
"[DocumentsLoader] Primary parse failed file=%s error=%s",
|
||||
file_path,
|
||||
exc,
|
||||
)
|
||||
if self.document_service is not None:
|
||||
try:
|
||||
LOGGER.info("[DocumentsLoader] Trying fallback parser file=%s", file_path)
|
||||
return self.document_service.parse_to_markdown(file_path)
|
||||
except Exception:
|
||||
LOGGER.exception(
|
||||
"[DocumentsLoader] Fallback parser failed file=%s",
|
||||
file_path,
|
||||
)
|
||||
pass
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to parse document {os.path.basename(file_path)}: {exc}",
|
||||
) from exc
|
||||
|
||||
@classmethod
|
||||
def get_page_images_from_pdf(cls, file_path: str, temp_dir: str) -> List[str]:
|
||||
|
|
|
|||
242
servers/fastapi/services/export_task_service.py
Normal file
242
servers/fastapi/services/export_task_service.py
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import uuid
|
||||
from typing import Mapping
|
||||
|
||||
from fastapi import HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from services.liteparse_service import _snippet, _subprocess_text_kwargs
|
||||
from utils.asset_directory_utils import resolve_app_path_to_filesystem
|
||||
from utils.get_env import get_app_data_directory_env, get_temp_directory_env
|
||||
|
||||
|
||||
class PptxToHtmlDocument(BaseModel):
|
||||
slides: list[str]
|
||||
font_css: str = ""
|
||||
width: float
|
||||
height: float
|
||||
images_dir: str
|
||||
fonts_dir: str
|
||||
|
||||
|
||||
class ExportTaskService:
|
||||
def __init__(self, timeout_seconds: int = 300):
|
||||
self.timeout_seconds = timeout_seconds
|
||||
self.node_binary = os.getenv("LITEPARSE_NODE_BINARY", "node")
|
||||
self.export_dir = self._resolve_export_dir()
|
||||
self.entrypoint_path = os.path.join(self.export_dir, "index.js")
|
||||
self.converter_path = self._resolve_converter_path(self.export_dir)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_export_dir() -> str:
|
||||
configured = (os.getenv("EXPORT_RUNTIME_DIR") or "").strip()
|
||||
if configured:
|
||||
return configured
|
||||
|
||||
cwd = os.path.abspath(".")
|
||||
service_dir = os.path.dirname(__file__)
|
||||
candidates = [
|
||||
os.path.abspath(os.path.join(cwd, "..", "..", "resources", "export")),
|
||||
os.path.abspath(os.path.join(cwd, "..", "export")),
|
||||
os.path.abspath(
|
||||
os.path.join(service_dir, "..", "..", "..", "resources", "export")
|
||||
),
|
||||
os.path.abspath(os.path.join(service_dir, "..", "..", "export")),
|
||||
os.path.abspath(
|
||||
os.path.join(cwd, "..", "..", "electron", "resources", "export")
|
||||
),
|
||||
os.path.abspath(
|
||||
os.path.join(
|
||||
service_dir, "..", "..", "..", "..", "electron", "resources", "export"
|
||||
)
|
||||
),
|
||||
]
|
||||
|
||||
for candidate in candidates:
|
||||
if os.path.isfile(os.path.join(candidate, "index.js")):
|
||||
return candidate
|
||||
|
||||
return candidates[0]
|
||||
|
||||
@staticmethod
|
||||
def _resolve_converter_path(export_dir: str) -> str:
|
||||
py_dir = os.path.join(export_dir, "py")
|
||||
extension = ".exe" if os.name == "nt" else ""
|
||||
platform_name = sys_platform()
|
||||
arch_name = sys_arch()
|
||||
candidates = [
|
||||
os.path.join(py_dir, f"convert-{platform_name}-{arch_name}{extension}"),
|
||||
os.path.join(py_dir, f"convert-{platform_name}{extension}"),
|
||||
os.path.join(py_dir, f"convert{extension}"),
|
||||
os.path.join(py_dir, "convert"),
|
||||
]
|
||||
for candidate in candidates:
|
||||
if candidate and os.path.isfile(candidate):
|
||||
return candidate
|
||||
return candidates[1]
|
||||
|
||||
def _build_node_env(self) -> Mapping[str, str]:
|
||||
env = os.environ.copy()
|
||||
binary_name = os.path.basename(self.node_binary).lower()
|
||||
if binary_name not in {"node", "node.exe"}:
|
||||
env.setdefault("ELECTRON_RUN_AS_NODE", "1")
|
||||
|
||||
app_data_directory = get_app_data_directory_env()
|
||||
if not app_data_directory:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="APP_DATA_DIRECTORY must be set for PPTX-to-HTML export",
|
||||
)
|
||||
env["APP_DATA_DIRECTORY"] = app_data_directory
|
||||
|
||||
temp_directory = get_temp_directory_env() or os.path.join(
|
||||
tempfile.gettempdir(), "presenton"
|
||||
)
|
||||
os.makedirs(temp_directory, exist_ok=True)
|
||||
env["TEMP_DIRECTORY"] = temp_directory
|
||||
|
||||
fastapi_public_url = (os.getenv("FASTAPI_PUBLIC_URL") or "").strip()
|
||||
if not fastapi_public_url:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="FASTAPI_PUBLIC_URL must be set for PPTX-to-HTML export",
|
||||
)
|
||||
env["ASSETS_BASE_URL"] = f"{fastapi_public_url.rstrip('/')}/app_data"
|
||||
env["BUILT_PYTHON_MODULE_PATH"] = self.converter_path
|
||||
|
||||
return env
|
||||
|
||||
def _ensure_runtime_ready(self) -> None:
|
||||
if not os.path.isfile(self.entrypoint_path):
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Export runtime not found at {self.entrypoint_path}",
|
||||
)
|
||||
if not os.path.isfile(self.converter_path):
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Export converter binary not found at {self.converter_path}",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_output_path(response_data: dict) -> str:
|
||||
path_value = response_data.get("path")
|
||||
if isinstance(path_value, str):
|
||||
resolved = resolve_app_path_to_filesystem(path_value) or path_value
|
||||
if os.path.isfile(resolved):
|
||||
return resolved
|
||||
|
||||
url_value = response_data.get("url")
|
||||
if isinstance(url_value, str):
|
||||
resolved = resolve_app_path_to_filesystem(url_value)
|
||||
if resolved and os.path.isfile(resolved):
|
||||
return resolved
|
||||
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="PPTX-to-HTML task completed without a valid output path",
|
||||
)
|
||||
|
||||
async def convert_pptx_to_html(
|
||||
self, pptx_path: str, get_fonts: bool = False
|
||||
) -> PptxToHtmlDocument:
|
||||
self._ensure_runtime_ready()
|
||||
if not os.path.isfile(pptx_path):
|
||||
raise HTTPException(status_code=400, detail=f"PPTX not found: {pptx_path}")
|
||||
|
||||
temp_root = get_temp_directory_env() or os.path.join(tempfile.gettempdir(), "presenton")
|
||||
os.makedirs(temp_root, exist_ok=True)
|
||||
temp_dir = tempfile.mkdtemp(prefix="export-task-", dir=temp_root)
|
||||
task_path = os.path.join(temp_dir, "export_task.json")
|
||||
response_path = os.path.join(temp_dir, "export_task.response.json")
|
||||
|
||||
try:
|
||||
with open(task_path, "w", encoding="utf-8") as task_file:
|
||||
json.dump(
|
||||
{
|
||||
"type": "pptx-to-html",
|
||||
"pptx_path": pptx_path,
|
||||
"get_fonts": get_fonts,
|
||||
},
|
||||
task_file,
|
||||
)
|
||||
|
||||
result = await asyncio.to_thread(
|
||||
subprocess.run,
|
||||
[self.node_binary, self.entrypoint_path, task_path],
|
||||
cwd=self.export_dir,
|
||||
capture_output=True,
|
||||
timeout=self.timeout_seconds,
|
||||
env=dict(self._build_node_env()),
|
||||
**_subprocess_text_kwargs(),
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=(
|
||||
"PPTX-to-HTML export task failed. "
|
||||
f"stderr={_snippet(result.stderr)} stdout={_snippet(result.stdout)}"
|
||||
),
|
||||
)
|
||||
|
||||
if not os.path.isfile(response_path):
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="PPTX-to-HTML export task did not produce a response file",
|
||||
)
|
||||
|
||||
with open(response_path, "r", encoding="utf-8") as response_file:
|
||||
response_data = json.load(response_file)
|
||||
|
||||
output_path = self._resolve_output_path(response_data)
|
||||
with open(output_path, "r", encoding="utf-8") as output_file:
|
||||
output_data = json.load(output_file)
|
||||
|
||||
return PptxToHtmlDocument(**output_data)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"PPTX-to-HTML export timed out after {self.timeout_seconds} seconds",
|
||||
) from exc
|
||||
except json.JSONDecodeError as exc:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="PPTX-to-HTML export produced invalid JSON output",
|
||||
) from exc
|
||||
except OSError as exc:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to run PPTX-to-HTML export task: {exc}",
|
||||
) from exc
|
||||
finally:
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
def sys_platform() -> str:
|
||||
if os.name == "nt":
|
||||
return "win32"
|
||||
return os.sys.platform
|
||||
|
||||
|
||||
def sys_arch() -> str:
|
||||
machine = (os.environ.get("PROCESSOR_ARCHITECTURE") or "").lower()
|
||||
if not machine and hasattr(os, "uname"):
|
||||
machine = os.uname().machine.lower()
|
||||
|
||||
arch_map = {
|
||||
"x86_64": "x64",
|
||||
"amd64": "x64",
|
||||
"x64": "x64",
|
||||
"aarch64": "arm64",
|
||||
"arm64": "arm64",
|
||||
}
|
||||
return arch_map.get(machine, machine or "x64")
|
||||
|
||||
|
||||
EXPORT_TASK_SERVICE = ExportTaskService()
|
||||
|
|
@ -12,7 +12,6 @@ from models.sql.image_asset import ImageAsset
|
|||
from utils.get_env import (
|
||||
get_dall_e_3_quality_env,
|
||||
get_gpt_image_1_5_quality_env,
|
||||
get_next_public_fast_api_env,
|
||||
get_pexels_api_key_env,
|
||||
)
|
||||
from utils.get_env import get_pixabay_api_key_env
|
||||
|
|
@ -60,17 +59,6 @@ class ImageGenerationService:
|
|||
def is_stock_provider_selected(self):
|
||||
return is_pixels_selected() or is_pixabay_selected()
|
||||
|
||||
def _to_frontend_url(self, path: str) -> str:
|
||||
if path.startswith("http://") or path.startswith("https://"):
|
||||
return path
|
||||
|
||||
fastapi_origin = (get_next_public_fast_api_env() or "").strip()
|
||||
if not fastapi_origin:
|
||||
return path
|
||||
|
||||
normalized_path = path if path.startswith("/") else f"/{path}"
|
||||
return f"{fastapi_origin.rstrip('/')}{normalized_path}"
|
||||
|
||||
async def generate_image(self, prompt: ImagePrompt) -> str | ImageAsset:
|
||||
"""
|
||||
Generates an image based on the provided prompt.
|
||||
|
|
@ -81,11 +69,11 @@ class ImageGenerationService:
|
|||
"""
|
||||
if self.is_image_generation_disabled:
|
||||
print("Image generation is disabled. Using placeholder image.")
|
||||
return self._to_frontend_url("/static/images/placeholder.jpg")
|
||||
return "/static/images/placeholder.jpg"
|
||||
|
||||
if not self.image_gen_func:
|
||||
print("No image generation function found. Using placeholder image.")
|
||||
return self._to_frontend_url("/static/images/placeholder.jpg")
|
||||
return "/static/images/placeholder.jpg"
|
||||
|
||||
image_prompt = prompt.get_image_prompt(
|
||||
with_theme=not self.is_stock_provider_selected()
|
||||
|
|
@ -114,12 +102,12 @@ class ImageGenerationService:
|
|||
elif image_path.startswith("/app_data/") or image_path.startswith(
|
||||
"/static/"
|
||||
):
|
||||
return self._to_frontend_url(image_path)
|
||||
return image_path
|
||||
raise Exception(f"Image not found at {image_path}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error generating image: {e}")
|
||||
return self._to_frontend_url("/static/images/placeholder.jpg")
|
||||
return "/static/images/placeholder.jpg"
|
||||
|
||||
async def generate_image_openai(
|
||||
self, prompt: str, output_directory: str, model: str, quality: str
|
||||
|
|
|
|||
309
servers/fastapi/services/liteparse_service.py
Normal file
309
servers/fastapi/services/liteparse_service.py
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
from typing import Any, Dict, Mapping, Tuple
|
||||
|
||||
|
||||
class LiteParseError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
_LOG_SNIPPET_LIMIT = 600
|
||||
|
||||
|
||||
def _snippet(value: str, limit: int = _LOG_SNIPPET_LIMIT) -> str:
|
||||
text = (value or "").strip()
|
||||
if not text:
|
||||
return "<empty>"
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return f"{text[:limit]}... [truncated {len(text) - limit} chars]"
|
||||
|
||||
|
||||
def _command_str(parts: list[str]) -> str:
|
||||
return " ".join(json.dumps(part) for part in parts)
|
||||
|
||||
|
||||
def _subprocess_text_kwargs() -> Mapping[str, object]:
|
||||
"""Decode subprocess output consistently across platforms.
|
||||
|
||||
Windows defaults to a locale-dependent code page (often cp1252), which can
|
||||
crash while decoding UTF-8 output from Node tools. Use UTF-8 and replace
|
||||
undecodable bytes to keep parsing resilient.
|
||||
"""
|
||||
return {"text": True, "encoding": "utf-8", "errors": "replace"}
|
||||
|
||||
|
||||
class LiteParseService:
|
||||
def __init__(self, timeout_seconds: int = 180):
|
||||
self.timeout_seconds = timeout_seconds
|
||||
self.node_binary = os.getenv("LITEPARSE_NODE_BINARY", "node")
|
||||
self.runner_path = os.getenv("LITEPARSE_RUNNER_PATH", self._resolve_runner_path())
|
||||
self.runner_dir = os.path.dirname(self.runner_path)
|
||||
self._npm_project_root = self._resolve_npm_project_root()
|
||||
|
||||
def _build_node_env(self) -> Dict[str, str]:
|
||||
"""Build environment for Node subprocesses.
|
||||
|
||||
When the configured runtime binary is not the canonical `node` executable
|
||||
(for example Electron's app binary), force Node-compatible mode.
|
||||
"""
|
||||
env = os.environ.copy()
|
||||
binary_name = os.path.basename(self.node_binary).lower()
|
||||
if binary_name not in {"node", "node.exe"}:
|
||||
env.setdefault("ELECTRON_RUN_AS_NODE", "1")
|
||||
|
||||
# LiteParse checks ImageMagick availability with `which magick`.
|
||||
# On macOS app launches, PATH often excludes Homebrew bins, even when
|
||||
# IMAGEMAGICK_BINARY is configured to an absolute executable path.
|
||||
path_entries = [p for p in (env.get("PATH") or "").split(os.pathsep) if p]
|
||||
additional_entries = []
|
||||
|
||||
imagemagick_binary = (env.get("IMAGEMAGICK_BINARY") or "").strip()
|
||||
if imagemagick_binary:
|
||||
magick_dir = os.path.dirname(imagemagick_binary)
|
||||
if magick_dir:
|
||||
additional_entries.append(magick_dir)
|
||||
|
||||
soffice_binary = (env.get("SOFFICE_PATH") or "").strip()
|
||||
if soffice_binary:
|
||||
soffice_dir = os.path.dirname(soffice_binary)
|
||||
if soffice_dir:
|
||||
additional_entries.append(soffice_dir)
|
||||
|
||||
if os.name != "nt":
|
||||
additional_entries.extend([
|
||||
"/opt/homebrew/bin",
|
||||
"/usr/local/bin",
|
||||
"/opt/local/bin",
|
||||
"/usr/bin",
|
||||
"/bin",
|
||||
])
|
||||
|
||||
deduped_additional_entries = []
|
||||
for entry in additional_entries:
|
||||
normalized = entry.strip()
|
||||
if not normalized or not os.path.isdir(normalized):
|
||||
continue
|
||||
if normalized in path_entries or normalized in deduped_additional_entries:
|
||||
continue
|
||||
deduped_additional_entries.append(normalized)
|
||||
|
||||
if deduped_additional_entries:
|
||||
env["PATH"] = os.pathsep.join(deduped_additional_entries + path_entries)
|
||||
|
||||
return env
|
||||
|
||||
def _resolve_npm_project_root(self) -> str:
|
||||
"""Directory whose node_modules contains @llamaindex/liteparse (runner dir or Electron app root)."""
|
||||
local_nm = os.path.join(
|
||||
self.runner_dir, "node_modules", "@llamaindex", "liteparse"
|
||||
)
|
||||
if os.path.isdir(local_nm):
|
||||
return self.runner_dir
|
||||
electron_nm = os.path.abspath(
|
||||
os.path.join(self.runner_dir, "..", "..", "node_modules", "@llamaindex", "liteparse")
|
||||
)
|
||||
if os.path.isdir(electron_nm):
|
||||
return os.path.abspath(os.path.join(self.runner_dir, "..", ".."))
|
||||
return os.path.abspath(os.path.join(self.runner_dir, "..", ".."))
|
||||
|
||||
@staticmethod
|
||||
def _resolve_runner_path() -> str:
|
||||
cwd = os.path.abspath(".")
|
||||
candidates = [
|
||||
# electron/servers/fastapi → electron/resources/...
|
||||
os.path.abspath(
|
||||
os.path.join(
|
||||
cwd, "..", "..", "resources", "document-extraction", "liteparse_runner.mjs"
|
||||
)
|
||||
),
|
||||
# servers/fastapi (repo root layout) → electron/resources/...
|
||||
os.path.abspath(
|
||||
os.path.join(
|
||||
cwd,
|
||||
"..",
|
||||
"..",
|
||||
"electron",
|
||||
"resources",
|
||||
"document-extraction",
|
||||
"liteparse_runner.mjs",
|
||||
)
|
||||
),
|
||||
# PyInstaller bundle layout
|
||||
os.path.abspath(
|
||||
os.path.join(
|
||||
cwd, "..", "..", "app", "resources", "document-extraction", "liteparse_runner.mjs"
|
||||
)
|
||||
),
|
||||
# Docker / explicit layout
|
||||
"/app/document-extraction-liteparse/liteparse_runner.mjs",
|
||||
]
|
||||
for path in candidates:
|
||||
if os.path.isfile(path):
|
||||
return path
|
||||
return candidates[0]
|
||||
|
||||
def check_runtime_ready(self) -> Tuple[bool, str]:
|
||||
if not os.path.isfile(self.runner_path):
|
||||
return False, f"LiteParse runner not found at: {self.runner_path}"
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
[self.node_binary, "--version"],
|
||||
cwd=self.runner_dir,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
timeout=10,
|
||||
env=self._build_node_env(),
|
||||
**_subprocess_text_kwargs(),
|
||||
)
|
||||
except Exception as exc:
|
||||
return False, f"Node.js runtime is unavailable: {exc}"
|
||||
|
||||
liteparse_dir = os.path.join(
|
||||
self._npm_project_root, "node_modules", "@llamaindex", "liteparse"
|
||||
)
|
||||
if not os.path.isdir(liteparse_dir):
|
||||
return (
|
||||
False,
|
||||
f"LiteParse npm package missing at {liteparse_dir}. Run npm install in the Electron app directory.",
|
||||
)
|
||||
|
||||
# @llamaindex/liteparse is ESM-only; require.resolve() fails. Use dynamic import.
|
||||
try:
|
||||
subprocess.run(
|
||||
[
|
||||
self.node_binary,
|
||||
"--input-type=module",
|
||||
"-e",
|
||||
"import '@llamaindex/liteparse'",
|
||||
],
|
||||
cwd=self._npm_project_root,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
timeout=20,
|
||||
env=self._build_node_env(),
|
||||
**_subprocess_text_kwargs(),
|
||||
)
|
||||
except Exception as exc:
|
||||
return False, f"LiteParse dependency is unavailable: {exc}"
|
||||
|
||||
return True, "ok"
|
||||
|
||||
def parse_to_markdown(
|
||||
self,
|
||||
file_path: str,
|
||||
ocr_enabled: bool = True,
|
||||
ocr_language: str = "eng",
|
||||
) -> str:
|
||||
result = self.parse(
|
||||
file_path=file_path,
|
||||
ocr_enabled=ocr_enabled,
|
||||
ocr_language=ocr_language,
|
||||
)
|
||||
return str(result.get("text") or "")
|
||||
|
||||
def parse(
|
||||
self,
|
||||
file_path: str,
|
||||
ocr_enabled: bool = True,
|
||||
ocr_language: str = "eng",
|
||||
) -> Dict[str, Any]:
|
||||
is_ready, reason = self.check_runtime_ready()
|
||||
if not is_ready:
|
||||
raise LiteParseError(reason)
|
||||
|
||||
command = [
|
||||
self.node_binary,
|
||||
self.runner_path,
|
||||
"--file",
|
||||
file_path,
|
||||
"--ocr-enabled",
|
||||
"true" if ocr_enabled else "false",
|
||||
"--ocr-language",
|
||||
ocr_language,
|
||||
]
|
||||
ocr_server = (os.getenv("LITEPARSE_OCR_SERVER_URL") or "").strip()
|
||||
if ocr_server:
|
||||
command.extend(["--ocr-server-url", ocr_server])
|
||||
tessdata = (os.getenv("LITEPARSE_TESSDATA_PATH") or "").strip()
|
||||
if tessdata:
|
||||
command.extend(["--tessdata-path", tessdata])
|
||||
|
||||
LOGGER.info(
|
||||
"[LiteParse] Parsing file=%s ocr_enabled=%s ocr_language=%s",
|
||||
file_path,
|
||||
ocr_enabled,
|
||||
ocr_language,
|
||||
)
|
||||
|
||||
process = subprocess.run(
|
||||
command,
|
||||
cwd=self._npm_project_root,
|
||||
capture_output=True,
|
||||
timeout=self.timeout_seconds,
|
||||
env=self._build_node_env(),
|
||||
**_subprocess_text_kwargs(),
|
||||
)
|
||||
LOGGER.info(
|
||||
"[LiteParse] Command finished returncode=%s command=%s",
|
||||
process.returncode,
|
||||
_command_str(command),
|
||||
)
|
||||
|
||||
payload: Dict[str, Any]
|
||||
try:
|
||||
payload = self._decode_runner_output(process.stdout)
|
||||
except LiteParseError as exc:
|
||||
raise LiteParseError(
|
||||
f"{exc}; returncode={process.returncode}; "
|
||||
f"stderr={_snippet(process.stderr)}; stdout={_snippet(process.stdout)}"
|
||||
) from exc
|
||||
|
||||
if process.returncode != 0:
|
||||
message = payload.get("error") or process.stderr.strip() or "Unknown error"
|
||||
LOGGER.error(
|
||||
"[LiteParse] Parse failed returncode=%s stderr=%s stdout=%s",
|
||||
process.returncode,
|
||||
_snippet(process.stderr),
|
||||
_snippet(process.stdout),
|
||||
)
|
||||
raise LiteParseError(message)
|
||||
|
||||
if not payload.get("ok"):
|
||||
LOGGER.error(
|
||||
"[LiteParse] Runner returned not-ok payload=%s",
|
||||
_snippet(json.dumps(payload)),
|
||||
)
|
||||
raise LiteParseError(payload.get("error") or "LiteParse parse failed")
|
||||
|
||||
return payload
|
||||
|
||||
@staticmethod
|
||||
def _decode_runner_output(stdout: str) -> Dict[str, Any]:
|
||||
raw = (stdout or "").lstrip("\ufeff").strip()
|
||||
if not raw:
|
||||
raise LiteParseError("LiteParse runner returned empty output")
|
||||
|
||||
# Prefer the last line that parses as JSON (handles stray log lines before our payload).
|
||||
lines = [line.strip() for line in raw.splitlines() if line.strip()]
|
||||
for line in reversed(lines):
|
||||
try:
|
||||
parsed = json.loads(line)
|
||||
if isinstance(parsed, dict):
|
||||
return parsed
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
# Single blob without newlines (entire stdout is one JSON object).
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
if isinstance(parsed, dict):
|
||||
return parsed
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
raise LiteParseError("LiteParse runner returned invalid JSON output")
|
||||
98
servers/fastapi/templates/example.py
Normal file
98
servers/fastapi/templates/example.py
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
from typing import Any
|
||||
|
||||
from templates.presentation_layout import PresentationLayoutModel
|
||||
|
||||
PLACEHOLDER_IMAGE_URL = "/static/images/replaceable_template_image.png"
|
||||
PLACEHOLDER_ICON_URL = "/static/icons/placeholder.svg"
|
||||
|
||||
|
||||
def build_schema_example(schema: dict) -> Any:
|
||||
if not isinstance(schema, dict):
|
||||
return None
|
||||
|
||||
if "default" in schema:
|
||||
return schema["default"]
|
||||
|
||||
for key in ("anyOf", "oneOf", "allOf"):
|
||||
options = schema.get(key)
|
||||
if isinstance(options, list):
|
||||
for option in options:
|
||||
example = build_schema_example(option)
|
||||
if example is not None:
|
||||
return example
|
||||
|
||||
enum_values = schema.get("enum")
|
||||
if enum_values:
|
||||
return enum_values[0]
|
||||
|
||||
schema_type = schema.get("type")
|
||||
if schema_type == "object":
|
||||
properties = schema.get("properties", {})
|
||||
result = {}
|
||||
for field_name, field_schema in properties.items():
|
||||
result[field_name] = build_schema_example(field_schema)
|
||||
return result
|
||||
|
||||
if schema_type == "array":
|
||||
items_schema = schema.get("items", {})
|
||||
if "default" in schema:
|
||||
return schema["default"]
|
||||
item_example = build_schema_example(items_schema)
|
||||
return [] if item_example is None else [item_example]
|
||||
|
||||
if schema_type == "string":
|
||||
schema_description = (schema.get("description") or "").lower()
|
||||
if "icon" in schema_description:
|
||||
return PLACEHOLDER_ICON_URL
|
||||
if "image" in schema_description or "url" in schema_description:
|
||||
return PLACEHOLDER_IMAGE_URL
|
||||
return "Sample text"
|
||||
|
||||
if schema_type == "integer":
|
||||
return schema.get("minimum", 1)
|
||||
|
||||
if schema_type == "number":
|
||||
return schema.get("minimum", 1)
|
||||
|
||||
if schema_type == "boolean":
|
||||
return False
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def replace_special_placeholders(value: Any) -> Any:
|
||||
if isinstance(value, dict):
|
||||
result = {}
|
||||
for key, child in value.items():
|
||||
if key == "__image_url__":
|
||||
result[key] = PLACEHOLDER_IMAGE_URL
|
||||
elif key == "__icon_url__":
|
||||
result[key] = PLACEHOLDER_ICON_URL
|
||||
else:
|
||||
result[key] = replace_special_placeholders(child)
|
||||
return result
|
||||
|
||||
if isinstance(value, list):
|
||||
return [replace_special_placeholders(item) for item in value]
|
||||
|
||||
if value == "__image_url__":
|
||||
return PLACEHOLDER_IMAGE_URL
|
||||
if value == "__icon_url__":
|
||||
return PLACEHOLDER_ICON_URL
|
||||
return value
|
||||
|
||||
|
||||
def build_template_example(
|
||||
template_id: str, layout: PresentationLayoutModel
|
||||
) -> dict[str, Any]:
|
||||
slides = []
|
||||
for slide in layout.slides:
|
||||
example_content = replace_special_placeholders(
|
||||
build_schema_example(slide.json_schema)
|
||||
)
|
||||
slides.append({"layout": slide.id, "content": example_content})
|
||||
|
||||
return {
|
||||
"template": template_id,
|
||||
"slides": slides,
|
||||
}
|
||||
18
servers/fastapi/templates/get_layout_by_name.py
Normal file
18
servers/fastapi/templates/get_layout_by_name.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import aiohttp
|
||||
from fastapi import HTTPException
|
||||
|
||||
from templates.presentation_layout import PresentationLayoutModel
|
||||
|
||||
|
||||
async def get_layout_by_name(layout_name: str) -> PresentationLayoutModel:
|
||||
url = f"http://localhost/api/template?group={layout_name}"
|
||||
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=404,
|
||||
detail=f"Template '{layout_name}' not found: {error_text}",
|
||||
)
|
||||
layout_json = await response.json()
|
||||
return PresentationLayoutModel(**layout_json)
|
||||
707
servers/fastapi/templates/handler.py
Normal file
707
servers/fastapi/templates/handler.py
Normal file
|
|
@ -0,0 +1,707 @@
|
|||
import os
|
||||
import random
|
||||
import re
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any, List, Optional
|
||||
|
||||
import aiohttp
|
||||
from fastapi import Body, Depends, File, Form, HTTPException, Path, Query, UploadFile
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlmodel import delete, select
|
||||
|
||||
from constants.presentation import DEFAULT_TEMPLATES
|
||||
from models.sql.presentation_layout_code import PresentationLayoutCodeModel
|
||||
from models.sql.template import TemplateModel
|
||||
from models.sql.template_create_info import TemplateCreateInfoModel
|
||||
from services.database import get_async_session
|
||||
from services.export_task_service import EXPORT_TASK_SERVICE
|
||||
from templates.example import build_template_example
|
||||
from templates.get_layout_by_name import get_layout_by_name
|
||||
from templates.presentation_layout import PresentationLayoutModel
|
||||
from templates.preview import (
|
||||
FontsUploadAndSlidesPreviewResponse,
|
||||
upload_fonts_and_slides_preview_handler,
|
||||
)
|
||||
from templates.prompts import (
|
||||
SLIDE_LAYOUT_CREATION_SYSTEM_PROMPT,
|
||||
SLIDE_LAYOUT_EDIT_SECTION_SYSTEM_PROMPT,
|
||||
SLIDE_LAYOUT_EDIT_SYSTEM_PROMPT,
|
||||
)
|
||||
from templates.providers import edit_slide_layout_code, generate_slide_layout_code
|
||||
from utils.asset_directory_utils import (
|
||||
resolve_app_path_to_filesystem,
|
||||
resolve_image_path_to_filesystem,
|
||||
)
|
||||
|
||||
|
||||
class TemplateDetail(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
total_layouts: Optional[int] = None
|
||||
|
||||
|
||||
class TemplateLayoutData(BaseModel):
|
||||
template: uuid.UUID
|
||||
layout_id: str
|
||||
layout_name: str
|
||||
layout_code: str
|
||||
fonts: Optional[Any] = None
|
||||
|
||||
|
||||
class TemplateData(BaseModel):
|
||||
id: uuid.UUID
|
||||
init_id: Optional[uuid.UUID] = None
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class GetTemplateLayoutsResponse(BaseModel):
|
||||
layouts: list[TemplateLayoutData]
|
||||
template: Optional[TemplateData] = None
|
||||
fonts: Optional[Any] = None
|
||||
|
||||
|
||||
class TemplateExample(BaseModel):
|
||||
template: str
|
||||
slides: List[dict]
|
||||
|
||||
|
||||
class CreateTemplateInitRequest(BaseModel):
|
||||
pptx_url: str
|
||||
slide_image_urls: List[str]
|
||||
fonts: dict = {}
|
||||
|
||||
|
||||
class CreateSlideLayoutRequest(BaseModel):
|
||||
id: uuid.UUID
|
||||
index: int
|
||||
|
||||
|
||||
class CreateSlideLayoutResponse(BaseModel):
|
||||
react_component: str
|
||||
|
||||
|
||||
class EditSlideLayoutRequest(BaseModel):
|
||||
react_component: str
|
||||
prompt: str
|
||||
|
||||
|
||||
class EditSlideLayoutResponse(CreateSlideLayoutResponse):
|
||||
pass
|
||||
|
||||
|
||||
class EditSlideLayoutSectionRequest(BaseModel):
|
||||
react_component: str
|
||||
section: str
|
||||
prompt: str
|
||||
|
||||
|
||||
class EditSlideLayoutSectionResponse(CreateSlideLayoutResponse):
|
||||
pass
|
||||
|
||||
|
||||
class SaveTemplateLayoutData(BaseModel):
|
||||
layout_id: str
|
||||
layout_name: str
|
||||
layout_code: str
|
||||
|
||||
|
||||
class SaveTemplateRequest(BaseModel):
|
||||
template_info_id: uuid.UUID
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
layouts: List[SaveTemplateLayoutData]
|
||||
|
||||
|
||||
class SaveTemplateResponse(BaseModel):
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class CloneTemplateRequest(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class UpdateTemplateRequest(BaseModel):
|
||||
id: uuid.UUID
|
||||
layouts: List[SaveTemplateLayoutData]
|
||||
|
||||
|
||||
class SaveSlideLayoutRequest(BaseModel):
|
||||
template_id: uuid.UUID
|
||||
layout_id: str
|
||||
layout_code: str
|
||||
|
||||
|
||||
class CloneSlideLayoutRequest(BaseModel):
|
||||
template_id: str
|
||||
layout_id: str
|
||||
layout_name: Optional[str] = None
|
||||
|
||||
|
||||
def _strip_code_fences(value: str) -> str:
|
||||
return (
|
||||
value.replace("```tsx", "")
|
||||
.replace("```typescript", "")
|
||||
.replace("```ts", "")
|
||||
.replace("```", "")
|
||||
.strip()
|
||||
)
|
||||
|
||||
|
||||
def _normalize_layout_code_for_create(code: str) -> str:
|
||||
normalized = _strip_code_fences(code)
|
||||
normalized = (
|
||||
normalized.replace("image_url", "__image_url__")
|
||||
.replace("icon_url", "__icon_url__")
|
||||
.replace("image_prompt", "__image_prompt__")
|
||||
.replace("icon_query", "__icon_query__")
|
||||
)
|
||||
|
||||
first_import_match = re.search(r"(?m)^\s*import\b", normalized)
|
||||
if first_import_match:
|
||||
normalized = normalized[first_import_match.start() :]
|
||||
|
||||
first_export_match = re.search(r"(?m)^\s*export\b", normalized)
|
||||
if first_export_match:
|
||||
normalized = normalized[: first_export_match.start()]
|
||||
|
||||
normalized = re.sub(
|
||||
r"(?ms)^\s*(?:import|export)\b.*?;(?:\r?\n|$)",
|
||||
"",
|
||||
normalized,
|
||||
)
|
||||
normalized = re.sub(
|
||||
r"(?m)^\s*(?:import|export)\b.*(?:\r?\n|$)",
|
||||
"",
|
||||
normalized,
|
||||
)
|
||||
normalized = normalized.strip()
|
||||
normalized = re.sub(
|
||||
r'(layoutId\s*=\s*["\'])([^"\']+)(["\'])',
|
||||
lambda match: (
|
||||
match.group(0)
|
||||
if re.search(r"-\d{4}$", match.group(2))
|
||||
else f"{match.group(1)}{match.group(2)}-{random.randint(1000, 9999)}{match.group(3)}"
|
||||
),
|
||||
normalized,
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
def _update_layout_id_in_code(code: str) -> tuple[str, str]:
|
||||
match = re.search(r'(layoutId\s*=\s*["\'])([^"\']+)(["\'])', code)
|
||||
if not match:
|
||||
raise HTTPException(status_code=400, detail="layoutId not found in layout code")
|
||||
|
||||
current_id = match.group(2)
|
||||
suffix = f"{random.randint(1000, 9999)}"
|
||||
new_id = re.sub(r"-\d{4}$", f"-{suffix}", current_id)
|
||||
if new_id == current_id:
|
||||
new_id = f"{current_id}-{suffix}"
|
||||
|
||||
new_code = re.sub(
|
||||
r'(layoutId\s*=\s*["\'])([^"\']+)(["\'])',
|
||||
f"\\1{new_id}\\3",
|
||||
code,
|
||||
count=1,
|
||||
)
|
||||
return new_code, new_id
|
||||
|
||||
|
||||
async def _download_image_bytes(image_url: str) -> bytes:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(image_url) as response:
|
||||
if response.status != 200:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Failed to download slide image: {image_url}",
|
||||
)
|
||||
return await response.read()
|
||||
|
||||
|
||||
async def _read_image_bytes_and_media_type(image_url: str) -> tuple[bytes, str]:
|
||||
actual_image_path = resolve_image_path_to_filesystem(image_url)
|
||||
if actual_image_path and os.path.isfile(actual_image_path):
|
||||
with open(actual_image_path, "rb") as image_file:
|
||||
image_bytes = image_file.read()
|
||||
file_extension = os.path.splitext(actual_image_path)[1].lower()
|
||||
else:
|
||||
image_bytes = await _download_image_bytes(image_url)
|
||||
file_extension = os.path.splitext(image_url)[1].lower()
|
||||
|
||||
media_type_map = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
}
|
||||
return image_bytes, media_type_map.get(file_extension, "image/png")
|
||||
|
||||
|
||||
async def get_all_templates(
|
||||
include_defaults: bool = Query(
|
||||
default=True, description="Whether to include default templates"
|
||||
),
|
||||
sql_session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
result = await sql_session.execute(
|
||||
select(
|
||||
TemplateModel.id,
|
||||
TemplateModel.name,
|
||||
func.count(PresentationLayoutCodeModel.id).label("total_layouts"),
|
||||
)
|
||||
.join(
|
||||
PresentationLayoutCodeModel,
|
||||
PresentationLayoutCodeModel.presentation == TemplateModel.id,
|
||||
)
|
||||
.group_by(TemplateModel.id, TemplateModel.name)
|
||||
)
|
||||
rows = result.all()
|
||||
|
||||
templates: list[TemplateDetail] = []
|
||||
if include_defaults:
|
||||
templates.extend(
|
||||
TemplateDetail(id=template, name=template) for template in DEFAULT_TEMPLATES
|
||||
)
|
||||
|
||||
templates.extend(
|
||||
TemplateDetail(
|
||||
id=f"custom-{template_id}",
|
||||
name=template_name,
|
||||
total_layouts=total_layouts,
|
||||
)
|
||||
for template_id, template_name, total_layouts in rows
|
||||
)
|
||||
return templates
|
||||
|
||||
|
||||
async def get_layouts(
|
||||
template_id: str = Path(..., description="The id of the template"),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
if not template_id or not template_id.strip():
|
||||
raise HTTPException(status_code=400, detail="Template ID cannot be empty")
|
||||
|
||||
try:
|
||||
cleaned_template_id = template_id.replace("custom-", "")
|
||||
template_id_uuid = uuid.UUID(cleaned_template_id)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail="Invalid custom template ID") from exc
|
||||
|
||||
result = await session.execute(
|
||||
select(PresentationLayoutCodeModel).where(
|
||||
PresentationLayoutCodeModel.presentation == template_id_uuid
|
||||
)
|
||||
)
|
||||
layouts_db = result.scalars().all()
|
||||
if not layouts_db:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"No layouts found for template ID: {template_id}",
|
||||
)
|
||||
|
||||
template_meta = await session.get(TemplateModel, template_id_uuid)
|
||||
template = None
|
||||
if template_meta:
|
||||
template = TemplateData(
|
||||
id=template_id_uuid,
|
||||
init_id=None,
|
||||
name=template_meta.name,
|
||||
description=template_meta.description,
|
||||
created_at=template_meta.created_at,
|
||||
)
|
||||
|
||||
layouts = [
|
||||
TemplateLayoutData(
|
||||
template=template_id_uuid,
|
||||
layout_id=layout.layout_id,
|
||||
layout_name=layout.layout_name,
|
||||
layout_code=layout.layout_code,
|
||||
fonts=layout.fonts,
|
||||
)
|
||||
for layout in layouts_db
|
||||
]
|
||||
return GetTemplateLayoutsResponse(
|
||||
layouts=layouts,
|
||||
template=template,
|
||||
fonts=layouts[0].fonts if layouts else None,
|
||||
)
|
||||
|
||||
|
||||
async def get_template_by_id(
|
||||
id: str = Path(
|
||||
...,
|
||||
description=f"The id of the template, must be one of {', '.join(DEFAULT_TEMPLATES)} or your custom template",
|
||||
),
|
||||
sql_session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
if id.startswith("custom-"):
|
||||
try:
|
||||
template_id = uuid.UUID(id.replace("custom-", ""))
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Template not found. Please use a valid template.",
|
||||
) from exc
|
||||
|
||||
template = await sql_session.get(TemplateModel, template_id)
|
||||
if not template:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Template not found. Please use a valid template.",
|
||||
)
|
||||
|
||||
return await get_layout_by_name(id)
|
||||
|
||||
|
||||
async def get_template_example(
|
||||
id: str = Path(
|
||||
...,
|
||||
description=f"The id of the template, must be one of {', '.join(DEFAULT_TEMPLATES)} or your custom template",
|
||||
),
|
||||
sql_session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
template = await get_template_by_id(id=id, sql_session=sql_session)
|
||||
return TemplateExample(**build_template_example(id, template))
|
||||
|
||||
|
||||
async def upload_fonts_and_slides_preview(
|
||||
pptx_file: UploadFile = File(..., description="PPTX file to preview"),
|
||||
font_files: Optional[List[UploadFile]] = File(
|
||||
default=None, description="Font files to upload"
|
||||
),
|
||||
original_font_names: Optional[List[str]] = Form(default=None),
|
||||
):
|
||||
return await upload_fonts_and_slides_preview_handler(
|
||||
pptx_file=pptx_file,
|
||||
font_files=font_files,
|
||||
original_font_names=original_font_names,
|
||||
max_slides=25,
|
||||
)
|
||||
|
||||
|
||||
async def init_create_template(
|
||||
request: CreateTemplateInitRequest,
|
||||
sql_session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
if not request.slide_image_urls:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="At least one slide image is required"
|
||||
)
|
||||
|
||||
pptx_path = resolve_app_path_to_filesystem(request.pptx_url)
|
||||
if not pptx_path or not os.path.isfile(pptx_path):
|
||||
raise HTTPException(status_code=400, detail="PPTX file not found")
|
||||
|
||||
pptx_document = await EXPORT_TASK_SERVICE.convert_pptx_to_html(
|
||||
pptx_path, get_fonts=False
|
||||
)
|
||||
if not pptx_document.slides:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="PPTX-to-HTML export returned no slides",
|
||||
)
|
||||
|
||||
if len(pptx_document.slides) < len(request.slide_image_urls):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
"PPTX-to-HTML export returned fewer slides than the preview images. "
|
||||
f"Expected at least {len(request.slide_image_urls)}, got {len(pptx_document.slides)}."
|
||||
),
|
||||
)
|
||||
|
||||
slide_htmls = pptx_document.slides[: len(request.slide_image_urls)]
|
||||
template_create_info = TemplateCreateInfoModel(
|
||||
fonts=request.fonts or {},
|
||||
pptx_url=request.pptx_url,
|
||||
slide_image_urls=request.slide_image_urls,
|
||||
slide_htmls=slide_htmls,
|
||||
)
|
||||
sql_session.add(template_create_info)
|
||||
await sql_session.commit()
|
||||
await sql_session.refresh(template_create_info)
|
||||
return template_create_info.id
|
||||
|
||||
|
||||
async def create_slide_layout(
|
||||
request: CreateSlideLayoutRequest = Body(...),
|
||||
sql_session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
template_info = await sql_session.get(TemplateCreateInfoModel, request.id)
|
||||
if not template_info:
|
||||
raise HTTPException(status_code=400, detail="Template not found")
|
||||
|
||||
total_slides = len(template_info.slide_htmls)
|
||||
if request.index < 0 or request.index >= total_slides:
|
||||
raise HTTPException(status_code=400, detail="Invalid slide index")
|
||||
|
||||
slide_html = template_info.slide_htmls[request.index]
|
||||
slide_image_url = template_info.slide_image_urls[request.index]
|
||||
image_bytes, media_type = await _read_image_bytes_and_media_type(slide_image_url)
|
||||
|
||||
fonts_text = ""
|
||||
if template_info.fonts:
|
||||
font_names = [font.replace(" ", "_") for font in template_info.fonts.keys()]
|
||||
fonts_text = "#PROVIDED FONTS\n- " + "\n- ".join(font_names)
|
||||
|
||||
user_text = f"{fonts_text}\n\n#SLIDE HTML REFERENCE\n{slide_html}"
|
||||
react_component = await generate_slide_layout_code(
|
||||
system_prompt=SLIDE_LAYOUT_CREATION_SYSTEM_PROMPT,
|
||||
user_text=user_text,
|
||||
image_bytes=image_bytes,
|
||||
media_type=media_type,
|
||||
)
|
||||
normalized_react_component = _normalize_layout_code_for_create(react_component)
|
||||
|
||||
return CreateSlideLayoutResponse(react_component=normalized_react_component)
|
||||
|
||||
|
||||
async def edit_slide_layout(
|
||||
request: EditSlideLayoutRequest,
|
||||
):
|
||||
user_text = f"#Prompt\n{request.prompt}\n\n#TSX code\n{request.react_component}"
|
||||
react_component = await edit_slide_layout_code(
|
||||
system_prompt=SLIDE_LAYOUT_EDIT_SYSTEM_PROMPT,
|
||||
user_text=user_text,
|
||||
)
|
||||
return EditSlideLayoutResponse(react_component=_strip_code_fences(react_component))
|
||||
|
||||
|
||||
async def edit_slide_layout_section(
|
||||
request: EditSlideLayoutSectionRequest,
|
||||
):
|
||||
user_text = (
|
||||
f"#Prompt\n{request.prompt}\n\n"
|
||||
f"#Section to make changes around\n{request.section}\n\n"
|
||||
f"#TSX code\n{request.react_component}"
|
||||
)
|
||||
react_component = await edit_slide_layout_code(
|
||||
system_prompt=SLIDE_LAYOUT_EDIT_SECTION_SYSTEM_PROMPT,
|
||||
user_text=user_text,
|
||||
)
|
||||
return EditSlideLayoutSectionResponse(
|
||||
react_component=_strip_code_fences(react_component)
|
||||
)
|
||||
|
||||
|
||||
async def save_template(
|
||||
request: SaveTemplateRequest,
|
||||
sql_session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
if not request.layouts:
|
||||
raise HTTPException(status_code=400, detail="Layouts are required")
|
||||
|
||||
template_info = await sql_session.get(TemplateCreateInfoModel, request.template_info_id)
|
||||
if not template_info:
|
||||
raise HTTPException(status_code=400, detail="Template info not found")
|
||||
|
||||
template = TemplateModel(
|
||||
id=uuid.uuid4(),
|
||||
name=request.name,
|
||||
description=request.description,
|
||||
)
|
||||
sql_session.add(template)
|
||||
|
||||
sql_session.add_all(
|
||||
[
|
||||
PresentationLayoutCodeModel(
|
||||
presentation=template.id,
|
||||
layout_id=layout.layout_id,
|
||||
layout_name=layout.layout_name,
|
||||
layout_code=layout.layout_code,
|
||||
fonts=template_info.fonts,
|
||||
)
|
||||
for layout in request.layouts
|
||||
]
|
||||
)
|
||||
await sql_session.commit()
|
||||
await sql_session.refresh(template)
|
||||
|
||||
return SaveTemplateResponse(
|
||||
id=template.id,
|
||||
name=template.name,
|
||||
description=template.description,
|
||||
created_at=template.created_at,
|
||||
)
|
||||
|
||||
|
||||
async def clone_template(
|
||||
request: CloneTemplateRequest = Body(...),
|
||||
sql_session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
if not request.id or not request.id.strip():
|
||||
raise HTTPException(status_code=400, detail="Template ID cannot be empty")
|
||||
|
||||
try:
|
||||
template_id_uuid = uuid.UUID(request.id.replace("custom-", ""))
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail="Invalid custom template ID") from exc
|
||||
|
||||
template = await sql_session.get(TemplateModel, template_id_uuid)
|
||||
if not template:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Template not found. Please use a valid template.",
|
||||
)
|
||||
|
||||
result = await sql_session.execute(
|
||||
select(PresentationLayoutCodeModel).where(
|
||||
PresentationLayoutCodeModel.presentation == template_id_uuid
|
||||
)
|
||||
)
|
||||
layouts_db = result.scalars().all()
|
||||
if not layouts_db:
|
||||
raise HTTPException(status_code=400, detail="No layouts found for template")
|
||||
|
||||
new_template = TemplateModel(
|
||||
id=uuid.uuid4(),
|
||||
name=request.name,
|
||||
description=template.description
|
||||
if request.description is None
|
||||
else request.description,
|
||||
)
|
||||
sql_session.add(new_template)
|
||||
|
||||
sql_session.add_all(
|
||||
[
|
||||
PresentationLayoutCodeModel(
|
||||
presentation=new_template.id,
|
||||
layout_id=layout.layout_id,
|
||||
layout_name=layout.layout_name,
|
||||
layout_code=layout.layout_code,
|
||||
fonts=layout.fonts,
|
||||
)
|
||||
for layout in layouts_db
|
||||
]
|
||||
)
|
||||
await sql_session.commit()
|
||||
await sql_session.refresh(new_template)
|
||||
|
||||
return SaveTemplateResponse(
|
||||
id=new_template.id,
|
||||
name=new_template.name,
|
||||
description=new_template.description,
|
||||
created_at=new_template.created_at,
|
||||
)
|
||||
|
||||
|
||||
async def update_template(
|
||||
request: UpdateTemplateRequest,
|
||||
sql_session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
if not request.layouts:
|
||||
raise HTTPException(status_code=400, detail="Layouts are required")
|
||||
|
||||
template = await sql_session.get(TemplateModel, request.id)
|
||||
if not template:
|
||||
raise HTTPException(status_code=400, detail="Template not found")
|
||||
|
||||
existing_layout = await sql_session.scalar(
|
||||
select(PresentationLayoutCodeModel).where(
|
||||
PresentationLayoutCodeModel.presentation == request.id
|
||||
)
|
||||
)
|
||||
fonts = existing_layout.fonts if existing_layout else None
|
||||
|
||||
await sql_session.execute(
|
||||
delete(PresentationLayoutCodeModel).where(
|
||||
PresentationLayoutCodeModel.presentation == request.id
|
||||
)
|
||||
)
|
||||
sql_session.add_all(
|
||||
[
|
||||
PresentationLayoutCodeModel(
|
||||
presentation=template.id,
|
||||
layout_id=layout.layout_id,
|
||||
layout_name=layout.layout_name,
|
||||
layout_code=layout.layout_code,
|
||||
fonts=fonts,
|
||||
)
|
||||
for layout in request.layouts
|
||||
]
|
||||
)
|
||||
await sql_session.commit()
|
||||
|
||||
return SaveTemplateResponse(
|
||||
id=template.id,
|
||||
name=template.name,
|
||||
description=template.description,
|
||||
created_at=template.created_at,
|
||||
)
|
||||
|
||||
|
||||
async def save_slide_layout(
|
||||
request: SaveSlideLayoutRequest,
|
||||
sql_session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
template = await sql_session.get(TemplateModel, request.template_id)
|
||||
if not template:
|
||||
raise HTTPException(status_code=400, detail="Template not found")
|
||||
|
||||
layout = await sql_session.scalar(
|
||||
select(PresentationLayoutCodeModel).where(
|
||||
PresentationLayoutCodeModel.presentation == request.template_id,
|
||||
PresentationLayoutCodeModel.layout_id == request.layout_id,
|
||||
)
|
||||
)
|
||||
if not layout:
|
||||
raise HTTPException(status_code=400, detail="Layout not found")
|
||||
|
||||
layout.layout_code = request.layout_code
|
||||
sql_session.add(layout)
|
||||
await sql_session.commit()
|
||||
|
||||
|
||||
async def clone_slide_layout(
|
||||
request: CloneSlideLayoutRequest = Body(...),
|
||||
sql_session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
if not request.template_id or not request.template_id.strip():
|
||||
raise HTTPException(status_code=400, detail="Template ID cannot be empty")
|
||||
|
||||
try:
|
||||
template_id_uuid = uuid.UUID(request.template_id.replace("custom-", ""))
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail="Invalid custom template ID") from exc
|
||||
|
||||
template = await sql_session.get(TemplateModel, template_id_uuid)
|
||||
if not template:
|
||||
raise HTTPException(status_code=400, detail="Template not found")
|
||||
|
||||
layout = await sql_session.scalar(
|
||||
select(PresentationLayoutCodeModel).where(
|
||||
PresentationLayoutCodeModel.presentation == template_id_uuid,
|
||||
PresentationLayoutCodeModel.layout_id == request.layout_id,
|
||||
)
|
||||
)
|
||||
if not layout:
|
||||
raise HTTPException(status_code=400, detail="Layout not found")
|
||||
|
||||
new_layout_code, new_layout_id = _update_layout_id_in_code(layout.layout_code)
|
||||
new_layout = PresentationLayoutCodeModel(
|
||||
presentation=template_id_uuid,
|
||||
layout_id=new_layout_id,
|
||||
layout_name=request.layout_name or layout.layout_name,
|
||||
layout_code=new_layout_code,
|
||||
fonts=layout.fonts,
|
||||
)
|
||||
sql_session.add(new_layout)
|
||||
await sql_session.commit()
|
||||
await sql_session.refresh(new_layout)
|
||||
|
||||
return SaveTemplateLayoutData(
|
||||
layout_id=new_layout.layout_id,
|
||||
layout_name=new_layout.layout_name,
|
||||
layout_code=new_layout.layout_code,
|
||||
)
|
||||
40
servers/fastapi/templates/presentation_layout.py
Normal file
40
servers/fastapi/templates/presentation_layout.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
from typing import List, Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from models.presentation_structure_model import PresentationStructureModel
|
||||
|
||||
|
||||
class SlideLayoutModel(BaseModel):
|
||||
id: str
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
json_schema: dict
|
||||
|
||||
|
||||
class PresentationLayoutModel(BaseModel):
|
||||
name: str
|
||||
ordered: bool = Field(default=False)
|
||||
slides: List[SlideLayoutModel]
|
||||
|
||||
def get_slide_layout_index(self, slide_layout_id: str) -> int:
|
||||
for index, slide in enumerate(self.slides):
|
||||
if slide.id == slide_layout_id:
|
||||
return index
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Slide layout {slide_layout_id} not found"
|
||||
)
|
||||
|
||||
def to_presentation_structure(self) -> PresentationStructureModel:
|
||||
return PresentationStructureModel(
|
||||
slides=[index for index in range(len(self.slides))]
|
||||
)
|
||||
|
||||
def to_string(self) -> str:
|
||||
message = "## Presentation Layout\n\n"
|
||||
for index, slide in enumerate(self.slides):
|
||||
message += f"### Slide Layout: {index}\n"
|
||||
message += f"- Name: {slide.name or slide.json_schema.get('title')}\n"
|
||||
message += f"- Description: {slide.description}\n\n"
|
||||
return message
|
||||
220
servers/fastapi/templates/prompts.py
Normal file
220
servers/fastapi/templates/prompts.py
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
SLIDE_LAYOUT_CREATION_SYSTEM_PROMPT = """
|
||||
You need to generate a Zod schema and a TSX React component and provide it as output.
|
||||
Provide reusable TSX code which can be used as template to generate new slides with different content.
|
||||
|
||||
# Steps:
|
||||
1. Analyze the slide image to understand the visual hierarchy.
|
||||
3. Classify elements into decorative and content elements.
|
||||
4. Group content elements into logical sections like Header, Body, BulletPoints, etc.
|
||||
5. Generate a Zod schema for the content elements.
|
||||
6. Generate id, name and description for the layout.
|
||||
6. Generate a TSX React component using the Zod schema and the HTML reference.
|
||||
|
||||
# Decorative Elements:
|
||||
- Arrows, Lines, Shapes, etc.
|
||||
- Images with Grid patterns, background patterns, gradients, solid colors, etc.
|
||||
- Background of infographics like funnel, timeline, etc.
|
||||
- Company name, logos, etc.
|
||||
- Images covering the entire slide.
|
||||
- Images containing company name, logos, etc.
|
||||
|
||||
# Decorative Elements Rules:
|
||||
- Use them exactly as they are in the HTML reference.
|
||||
- Do not change decorative images and icons urls.
|
||||
- Images containing company name, logos, etc should be identified as decorative elements.
|
||||
|
||||
# Content Elements:
|
||||
- Title, Description, BulletPoints, etc.
|
||||
- Graphs, Charts, etc.
|
||||
- Images and Icons representing textual content like title, description, bullet points, etc.
|
||||
- Meaningful Images and Icons.
|
||||
- Icons in infographics that represent the data.
|
||||
|
||||
# Content Elements Rules:
|
||||
- Properly identify between images and icons elements.
|
||||
- Image content:
|
||||
- Image field should be 'z.object({"image_url": z.string(), "image_prompt": z.string().max(100)})'
|
||||
- Replace actual image url with '/static/images/replaceable_template_image.png'
|
||||
- Icon content:
|
||||
- Icon field should be 'z.object({"icon_url": z.string(), "icon_query": z.string().max(30)})'
|
||||
- Replace actual icon url with '/static/icons/placeholder.svg'
|
||||
- Add color styling to the icon to match the color in the image.
|
||||
- Make sure the urls are correct.
|
||||
|
||||
# Layout Rules:
|
||||
- The layout should be fixed 1280px width and 720px height.
|
||||
- Adjust the positions and sizes of elements to fit the layout.
|
||||
- Try to keep the positions and sizes of elements as close to HTML reference as possible.
|
||||
|
||||
# Flexible Positioning and Sizes Rules:
|
||||
- Must not use 'absolute' positioning for elements.
|
||||
- Must use 'flex', 'grid', 'margin', 'padding', 'gap', 'basis', 'justify', 'align', etc for positioning of elements.
|
||||
- For variable length lists, wrap list into a container and center it.
|
||||
- Don't use specific sizes (height, width) for elements if not necessary.
|
||||
|
||||
# Schema Field Name and Description Rules:
|
||||
- Must not use content specific words.
|
||||
- Only use words based on what content types are present in the slide image.
|
||||
- Use words like 'title', description', 'heading', 'image', 'graph', 'table', 'bullet points', etc.
|
||||
- Must not use words like 'budget', 'market', 'revenue', 'sales', 'growth', 'workflow', 'channel', 'plannedValue', 'actualValue', etc.
|
||||
|
||||
# Layout ID, Name and Description Rules:
|
||||
- Must only use slide structure to derive layout id, name and description.
|
||||
- Informations like: Type of content, position of content, etc. should be used.
|
||||
- layoutId example: title-description-right-image.
|
||||
- layoutName example: Title Description Image.
|
||||
- layoutDescription example: A slide with a title, description, and an image on right.
|
||||
|
||||
# Zod Schema Rules:
|
||||
- "describe" must be added for every fields.
|
||||
- Add `.default(...)` to every top-level field directly inside the initial `z.object({ ... })` shape.
|
||||
- Must not put a single `default` on the whole object like `const Schema = z.object({ ... }).default({ ... })`.
|
||||
- Top level fields are those not nested inside other fields.
|
||||
- Don't mention string type in schema like "url()", "email()", etc.
|
||||
- Table must be object with "columns" and "rows" fields.
|
||||
- "columns" must be an array of strings.
|
||||
- "rows" must be an array of arrays of strings.
|
||||
- Graph must be object with "categories" and "series" fields.
|
||||
- "categories" must be an array of strings.
|
||||
- "series" must be an array of objects with {"name": string, "data": array of numbers}.
|
||||
- Must not use z.record() anywhere in the schema.
|
||||
|
||||
# String and Array Field Rules:
|
||||
- Every string field must include `.max(...)`; every array field must include `.max(...)`.
|
||||
- For strings, set `max` to the exact character count of the text content it represents.
|
||||
- For arrays, set `max` to the exact item count of the array content it represents.
|
||||
- Choose a `max` that keeps the longest allowed content from overflowing its container.
|
||||
|
||||
# Table Rules:
|
||||
- Construct "tr -> th" by iterating over the "columns" field.
|
||||
- Construct "tr -> td" by iterating over the "rows" field.
|
||||
- Make sure table height and width adjusts to fit the content.
|
||||
|
||||
# Grahps, Charts, etc Rules:
|
||||
- Identify if graphs, charts, etc are present in the slide image.
|
||||
- Identify the type of graph, chart, etc.
|
||||
- If present, generate a zod schema for the graph, chart, etc.
|
||||
- Generate TSX code for the graph, chart, etc. even if it is not present in the HTML reference.
|
||||
- Use graph schema and image to generate the TSX code.
|
||||
- Use Recharts library for graphs.
|
||||
|
||||
# Fonts Rules:
|
||||
- Check for "PROVIDED FONTS".
|
||||
- Must use fonts only from "PROVIDED FONTS".
|
||||
- Add "font-[\"font-name\"]" to every text element in the slide.
|
||||
|
||||
# Page Number Rules:
|
||||
- Identify if the slide contains page number from provided HTML reference and image.
|
||||
- If page number is present, add a "page: z.number().min(1).meta({ description: "Page number" })" field in the schema.
|
||||
|
||||
# React Component Rules:
|
||||
- React component must be named dynamicSlideLayout.
|
||||
- dynamicSlideLayout must take "{ data }: { data: Partial<z.infer<typeof Schema>> }" as props.
|
||||
- Wrap the code inside these classes: "relative w-full rounded-sm max-w-[1280px] shadow-lg max-h-[720px] aspect-video bg-white z-20 mx-auto overflow-hidden".
|
||||
- Make sure camelCase is used for all styles. For e.g. "letter-spacing" should be "letterSpacing".
|
||||
- Schema.parse must not be used in the code.
|
||||
- Use 'const {field1, field2, ...} = data;' to access the data.
|
||||
- field1 or field2 or ... can be undefined, so use optional chaining to access them.
|
||||
- Don't use "min-height" on cards and instead make its height grow/shrink to fit the content.
|
||||
- Make sure cards/items are centered vertically and horizontally in the available space.
|
||||
- Make sure no element is scrollable.
|
||||
- Don't add any animations, transitions, or effects.
|
||||
- Make sure no content elements are overflowing the slide boundaries.
|
||||
|
||||
# Import and Export Rules:
|
||||
- All import statements must be defined at the top.
|
||||
- Export using 'export {Schema, layoutId, layoutName, layoutDescription, dynamicSlideLayout}' statement at the bottom.
|
||||
- There must be only one 'export' statement in the whole TSX code.
|
||||
|
||||
# Output Code Rules:
|
||||
- Code should be in following order:
|
||||
- Zod Schema (Schema)
|
||||
- Layout ID, Name and Description (layoutId, layoutName, layoutDescription)
|
||||
- React Component (dynamicSlideLayout)
|
||||
- Give just one valid TSX code as output.
|
||||
- Don't add comments in the code.
|
||||
- Make sure the generated code is valid TSX code.
|
||||
- Give only code as output and nothing else. (no json, no markdown, no text, no explanation)
|
||||
|
||||
- Go through generated code and make sure all rules are followed.
|
||||
- Think as long as you can and iterate as many times as necessary to make sure all rules are followed.
|
||||
"""
|
||||
|
||||
SLIDE_LAYOUT_EDIT_SYSTEM_PROMPT = """
|
||||
You need to edit the given TSX code of the slide layout code according to the prompt and provide it as output.
|
||||
|
||||
# Steps
|
||||
1. Analyze the TSX code to understand the slide layout.
|
||||
2. Analyze the prompt to understand the changes to be made.
|
||||
3. Edit the TSX code according to the prompt.
|
||||
4. Provide the updated TSX code as output.
|
||||
|
||||
# Rules
|
||||
- Make sure the changes does not break the existing code.
|
||||
- Make sure to follow the pattern of the existing code.
|
||||
- Make sure there are no unused schema fields after the changes are made.
|
||||
|
||||
# Icons and Images Rules
|
||||
Follow these rules if new icons/images are asked:
|
||||
- Image field should be 'z.object({"image_url": z.string(), "image_prompt": z.string().max(100)})'
|
||||
- Use this as default image url: '/static/images/replaceable_template_image.png'
|
||||
- Icon field should be 'z.object({"icon_url": z.string(), "icon_query": z.string().max(30)})'
|
||||
- Use this as default icon url: '/static/icons/placeholder.svg'
|
||||
|
||||
# Schema Rules
|
||||
- "describe" must be added for every fields.
|
||||
- "default" must be added in top level fields of schema.
|
||||
- Top level fields are those not nested inside other fields.
|
||||
- Must set max for every string and array fields.
|
||||
- Must set max to a number that will not cause overflow on max content.
|
||||
|
||||
# Graphs And Table Rules
|
||||
Follow these rules if new graphs/tables are asked:
|
||||
1. Schema Rules
|
||||
- Table must be object with "columns" and "rows" fields.
|
||||
- "columns" must be an array of strings.
|
||||
- "rows" must be an array of arrays of strings.
|
||||
- Graph must be object with "categories" and "series" fields.
|
||||
- "categories" must be an array of strings.
|
||||
- "series" must be an array of objects with {"name": string, "data": array of numbers}.
|
||||
2. React Component Rules
|
||||
- Use recharts library for graphs.
|
||||
|
||||
# Common Prompts
|
||||
1. Fix the slide
|
||||
- Check if text/cards/items is overflowing the slide boundaries or text/cards/items are overlapping.
|
||||
- If yes, fix by moving the element to a better position or resizing the element.
|
||||
|
||||
# Output Rules
|
||||
- Make sure the schema and react component are valid.
|
||||
- No matter what prompt is given, don't break the code.
|
||||
- Provide only the updated TSX code as output and nothing else. (no json, no markdown, no text, no explanation)
|
||||
"""
|
||||
|
||||
SLIDE_LAYOUT_EDIT_SECTION_SYSTEM_PROMPT = """
|
||||
You need to edit the given TSX code of the slide layout code according to the prompt and provide it as output.
|
||||
|
||||
# Steps
|
||||
1. Analyze the TSX code to understand the slide layout.
|
||||
2. Analyze the prompt to understand the changes to be made.
|
||||
3. Edit the TSX code according to the prompt.
|
||||
4. Provide the updated TSX code as output.
|
||||
|
||||
# Rules
|
||||
- Changes should be made only around the mentioned "section to make changes around".
|
||||
- Make sure the changes does not break the existing code.
|
||||
- Make sure to follow the pattern of the existing code.
|
||||
- Make sure there are no unused schema fields after the changes are made.
|
||||
|
||||
# Icons and Images Rules
|
||||
Follow these rules if new icons/images are asked:
|
||||
- Image field should be 'z.object({"image_url": z.string(), "image_prompt": z.string().max(100)})'
|
||||
- Use this as default image url: '/static/images/replaceable_template_image.png'
|
||||
- Icon field should be 'z.object({"icon_url": z.string(), "icon_query": z.string().max(30)})'
|
||||
- Use this as default icon url: '/static/icons/placeholder.svg'
|
||||
|
||||
# Output Rules
|
||||
- Make sure the schema and react component are valid.
|
||||
- No matter what prompt is given, don't break the code.
|
||||
- Provide only the updated TSX code as output and nothing else. (no json, no markdown, no text, no explanation)
|
||||
"""
|
||||
425
servers/fastapi/templates/providers.py
Normal file
425
servers/fastapi/templates/providers.py
Normal file
|
|
@ -0,0 +1,425 @@
|
|||
import asyncio
|
||||
import base64
|
||||
from dataclasses import dataclass
|
||||
import time
|
||||
from typing import Any, Awaitable, Callable, Optional
|
||||
|
||||
from anthropic import AsyncAnthropic
|
||||
from fastapi import HTTPException
|
||||
from google import genai
|
||||
from google.genai import types as google_types
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from enums.llm_provider import LLMProvider
|
||||
from utils.get_env import (
|
||||
get_anthropic_api_key_env,
|
||||
get_codex_access_token_env,
|
||||
get_codex_account_id_env,
|
||||
get_codex_refresh_token_env,
|
||||
get_codex_token_expires_env,
|
||||
get_google_api_key_env,
|
||||
get_openai_api_key_env,
|
||||
)
|
||||
from utils.llm_provider import get_llm_provider, get_model
|
||||
from utils.set_env import (
|
||||
set_codex_access_token_env,
|
||||
set_codex_account_id_env,
|
||||
set_codex_refresh_token_env,
|
||||
set_codex_token_expires_env,
|
||||
)
|
||||
|
||||
MAX_ATTEMPTS_PER_PROVIDER = 4
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TemplateProviderSpec:
|
||||
provider: LLMProvider
|
||||
model: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PlainLLMProvider:
|
||||
name: str
|
||||
call: Callable[[], Awaitable[str]]
|
||||
|
||||
def get_template_provider_spec() -> TemplateProviderSpec:
|
||||
provider = get_llm_provider()
|
||||
if provider == LLMProvider.OPENAI:
|
||||
return TemplateProviderSpec(provider=provider, model=get_model())
|
||||
if provider == LLMProvider.CODEX:
|
||||
return TemplateProviderSpec(provider=provider, model=get_model())
|
||||
if provider == LLMProvider.GOOGLE:
|
||||
return TemplateProviderSpec(provider=provider, model=get_model())
|
||||
if provider == LLMProvider.ANTHROPIC:
|
||||
return TemplateProviderSpec(provider=provider, model=get_model())
|
||||
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Template generation only supports OpenAI, Codex, Google, or Anthropic.",
|
||||
)
|
||||
|
||||
|
||||
async def run_plain_provider_buckets(*, providers: list[PlainLLMProvider]) -> str:
|
||||
last_exception: Optional[Exception] = None
|
||||
|
||||
for provider in providers:
|
||||
for attempt in range(1, MAX_ATTEMPTS_PER_PROVIDER + 1):
|
||||
try:
|
||||
response_text = await provider.call()
|
||||
if response_text:
|
||||
return response_text
|
||||
raise ValueError("No output from template generation provider")
|
||||
except Exception as exc:
|
||||
last_exception = exc
|
||||
|
||||
if isinstance(last_exception, HTTPException):
|
||||
raise last_exception
|
||||
raise HTTPException(status_code=500, detail="Failed to generate template output")
|
||||
|
||||
|
||||
def _read_openai_response_text(response) -> str:
|
||||
output_text = getattr(response, "output_text", None)
|
||||
if output_text:
|
||||
return output_text
|
||||
text = getattr(response, "text", None)
|
||||
if text:
|
||||
return text
|
||||
return ""
|
||||
|
||||
|
||||
def _get_openai_client() -> AsyncOpenAI:
|
||||
api_key = get_openai_api_key_env()
|
||||
if not api_key:
|
||||
raise HTTPException(status_code=400, detail="OPENAI_API_KEY is not set")
|
||||
return AsyncOpenAI(api_key=api_key, timeout=120.0)
|
||||
|
||||
|
||||
def _get_codex_headers() -> dict:
|
||||
access_token = get_codex_access_token_env()
|
||||
if not access_token:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Codex OAuth access token is not set. Please authenticate via /api/v1/ppt/codex/auth/initiate",
|
||||
)
|
||||
|
||||
expires_str = get_codex_token_expires_env()
|
||||
if expires_str:
|
||||
try:
|
||||
expires_ms = int(expires_str)
|
||||
now_ms = int(time.time() * 1000)
|
||||
if now_ms >= expires_ms - 60_000:
|
||||
refresh_token = get_codex_refresh_token_env()
|
||||
if refresh_token:
|
||||
from utils.oauth.openai_codex import (
|
||||
TokenSuccess,
|
||||
get_account_id,
|
||||
refresh_access_token,
|
||||
)
|
||||
|
||||
result = refresh_access_token(refresh_token)
|
||||
if isinstance(result, TokenSuccess):
|
||||
set_codex_access_token_env(result.access)
|
||||
set_codex_refresh_token_env(result.refresh)
|
||||
set_codex_token_expires_env(str(result.expires))
|
||||
account_id = get_account_id(result.access)
|
||||
if account_id:
|
||||
set_codex_account_id_env(account_id)
|
||||
access_token = result.access
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
account_id = get_codex_account_id_env() or ""
|
||||
return {
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"chatgpt-account-id": account_id,
|
||||
"OpenAI-Beta": "responses=experimental",
|
||||
"originator": "pi",
|
||||
}
|
||||
|
||||
|
||||
def _get_codex_client() -> AsyncOpenAI:
|
||||
headers = _get_codex_headers()
|
||||
access_token = (headers.get("Authorization") or "").replace("Bearer ", "").strip()
|
||||
default_headers = {
|
||||
key: value
|
||||
for key, value in headers.items()
|
||||
if key.lower() not in {"authorization", "content-type", "accept"}
|
||||
}
|
||||
return AsyncOpenAI(
|
||||
base_url="https://chatgpt.com/backend-api/codex",
|
||||
api_key=access_token or "codex",
|
||||
default_headers=default_headers,
|
||||
timeout=120.0,
|
||||
)
|
||||
|
||||
|
||||
def _get_google_client() -> genai.Client:
|
||||
api_key = get_google_api_key_env()
|
||||
if not api_key:
|
||||
raise HTTPException(status_code=400, detail="GOOGLE_API_KEY is not set")
|
||||
return genai.Client(api_key=api_key)
|
||||
|
||||
|
||||
def _get_anthropic_client() -> AsyncAnthropic:
|
||||
api_key = get_anthropic_api_key_env()
|
||||
if not api_key:
|
||||
raise HTTPException(status_code=400, detail="ANTHROPIC_API_KEY is not set")
|
||||
return AsyncAnthropic(api_key=api_key)
|
||||
|
||||
|
||||
async def _call_openai_like(
|
||||
*,
|
||||
client: AsyncOpenAI,
|
||||
model: str,
|
||||
system_prompt: str,
|
||||
user_text: str,
|
||||
image_bytes: Optional[bytes] = None,
|
||||
media_type: str = "image/png",
|
||||
) -> str:
|
||||
content = [{"type": "input_text", "text": user_text}]
|
||||
if image_bytes:
|
||||
content.insert(
|
||||
0,
|
||||
{
|
||||
"type": "input_image",
|
||||
"image_url": f"data:{media_type};base64,{base64.b64encode(image_bytes).decode('utf-8')}",
|
||||
},
|
||||
)
|
||||
|
||||
response = await client.responses.create(
|
||||
model=model,
|
||||
instructions=system_prompt,
|
||||
input=[{"role": "user", "content": content}],
|
||||
text={"verbosity": "medium"},
|
||||
store=False,
|
||||
)
|
||||
output_text = _read_openai_response_text(response)
|
||||
if not output_text:
|
||||
raise HTTPException(status_code=500, detail="No output from template provider")
|
||||
return output_text
|
||||
|
||||
|
||||
def _response_event_to_dict(event: Any) -> dict:
|
||||
if isinstance(event, dict):
|
||||
return event
|
||||
if hasattr(event, "model_dump"):
|
||||
return event.model_dump()
|
||||
return {
|
||||
"type": getattr(event, "type", None),
|
||||
"delta": getattr(event, "delta", None),
|
||||
"text": getattr(event, "text", None),
|
||||
"item": getattr(event, "item", None),
|
||||
"response": getattr(event, "response", None),
|
||||
"error": getattr(event, "error", None),
|
||||
"message": getattr(event, "message", None),
|
||||
}
|
||||
|
||||
|
||||
async def _call_codex(
|
||||
*,
|
||||
model: str,
|
||||
system_prompt: str,
|
||||
user_text: str,
|
||||
image_bytes: Optional[bytes] = None,
|
||||
media_type: str = "image/png",
|
||||
) -> str:
|
||||
client = _get_codex_client()
|
||||
content = [{"type": "input_text", "text": user_text}]
|
||||
if image_bytes:
|
||||
content.insert(
|
||||
0,
|
||||
{
|
||||
"type": "input_image",
|
||||
"image_url": f"data:{media_type};base64,{base64.b64encode(image_bytes).decode('utf-8')}",
|
||||
},
|
||||
)
|
||||
|
||||
stream = await client.responses.create(
|
||||
model=model,
|
||||
instructions=system_prompt,
|
||||
input=[{"role": "user", "content": content}],
|
||||
text={"verbosity": "medium"},
|
||||
store=False,
|
||||
stream=True,
|
||||
)
|
||||
|
||||
text_parts: list[str] = []
|
||||
|
||||
async for event in stream:
|
||||
payload = _response_event_to_dict(event)
|
||||
event_type = payload.get("type") or ""
|
||||
|
||||
if event_type == "response.output_text.delta":
|
||||
delta = payload.get("delta") or ""
|
||||
if delta:
|
||||
text_parts.append(delta)
|
||||
continue
|
||||
|
||||
if event_type == "response.output_text.done":
|
||||
text = payload.get("text") or ""
|
||||
if text and not text_parts:
|
||||
text_parts.append(text)
|
||||
continue
|
||||
|
||||
if event_type in ("response.error", "response.failed", "error"):
|
||||
error_detail = payload.get("message") or payload.get("error") or str(payload)
|
||||
raise HTTPException(status_code=502, detail=f"Codex error: {error_detail}"[:400])
|
||||
|
||||
output_text = "".join(text_parts).strip()
|
||||
if not output_text:
|
||||
raise HTTPException(status_code=500, detail="No output from template provider")
|
||||
return output_text
|
||||
|
||||
|
||||
async def _call_google(
|
||||
*,
|
||||
model: str,
|
||||
system_prompt: str,
|
||||
user_text: str,
|
||||
image_bytes: Optional[bytes] = None,
|
||||
media_type: str = "image/png",
|
||||
) -> str:
|
||||
client = _get_google_client()
|
||||
parts = [google_types.Part.from_text(text=user_text)]
|
||||
if image_bytes:
|
||||
parts.append(google_types.Part.from_bytes(data=image_bytes, mime_type=media_type))
|
||||
|
||||
response = await asyncio.to_thread(
|
||||
client.models.generate_content,
|
||||
model=model,
|
||||
contents=[google_types.Content(role="user", parts=parts)],
|
||||
config=google_types.GenerateContentConfig(
|
||||
system_instruction=system_prompt,
|
||||
response_mime_type="text/plain",
|
||||
),
|
||||
)
|
||||
output_text = getattr(response, "text", None) or ""
|
||||
if not output_text:
|
||||
raise HTTPException(status_code=500, detail="No output from template provider")
|
||||
return output_text
|
||||
|
||||
|
||||
async def _call_anthropic(
|
||||
*,
|
||||
model: str,
|
||||
system_prompt: str,
|
||||
user_text: str,
|
||||
image_bytes: Optional[bytes] = None,
|
||||
media_type: str = "image/png",
|
||||
) -> str:
|
||||
client = _get_anthropic_client()
|
||||
content = [{"type": "text", "text": user_text}]
|
||||
if image_bytes:
|
||||
content.append(
|
||||
{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": media_type,
|
||||
"data": base64.b64encode(image_bytes).decode("utf-8"),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
response = await client.messages.create(
|
||||
model=model,
|
||||
max_tokens=8192,
|
||||
system=system_prompt,
|
||||
messages=[{"role": "user", "content": content}],
|
||||
)
|
||||
output_text = "".join(
|
||||
block.text for block in response.content if getattr(block, "type", None) == "text"
|
||||
)
|
||||
if not output_text:
|
||||
raise HTTPException(status_code=500, detail="No output from template provider")
|
||||
return output_text
|
||||
|
||||
|
||||
def _build_provider_call(
|
||||
*,
|
||||
spec: Optional[TemplateProviderSpec] = None,
|
||||
system_prompt: str,
|
||||
user_text: str,
|
||||
image_bytes: Optional[bytes] = None,
|
||||
media_type: str = "image/png",
|
||||
) -> PlainLLMProvider:
|
||||
spec = spec or get_template_provider_spec()
|
||||
|
||||
if spec.provider == LLMProvider.OPENAI:
|
||||
return PlainLLMProvider(
|
||||
name="OpenAI",
|
||||
call=lambda: _call_openai_like(
|
||||
client=_get_openai_client(),
|
||||
model=spec.model,
|
||||
system_prompt=system_prompt,
|
||||
user_text=user_text,
|
||||
image_bytes=image_bytes,
|
||||
media_type=media_type,
|
||||
),
|
||||
)
|
||||
if spec.provider == LLMProvider.CODEX:
|
||||
return PlainLLMProvider(
|
||||
name="Codex",
|
||||
call=lambda: _call_codex(
|
||||
model=spec.model,
|
||||
system_prompt=system_prompt,
|
||||
user_text=user_text,
|
||||
image_bytes=image_bytes,
|
||||
media_type=media_type,
|
||||
),
|
||||
)
|
||||
if spec.provider == LLMProvider.GOOGLE:
|
||||
return PlainLLMProvider(
|
||||
name="Google",
|
||||
call=lambda: _call_google(
|
||||
model=spec.model,
|
||||
system_prompt=system_prompt,
|
||||
user_text=user_text,
|
||||
image_bytes=image_bytes,
|
||||
media_type=media_type,
|
||||
),
|
||||
)
|
||||
if spec.provider == LLMProvider.ANTHROPIC:
|
||||
return PlainLLMProvider(
|
||||
name="Anthropic",
|
||||
call=lambda: _call_anthropic(
|
||||
model=spec.model,
|
||||
system_prompt=system_prompt,
|
||||
user_text=user_text,
|
||||
image_bytes=image_bytes,
|
||||
media_type=media_type,
|
||||
),
|
||||
)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Template generation only supports OpenAI, Codex, Google, or Anthropic.",
|
||||
)
|
||||
|
||||
|
||||
async def generate_slide_layout_code(
|
||||
*,
|
||||
system_prompt: str,
|
||||
user_text: str,
|
||||
image_bytes: bytes,
|
||||
media_type: str = "image/png",
|
||||
) -> str:
|
||||
provider = _build_provider_call(
|
||||
system_prompt=system_prompt,
|
||||
user_text=user_text,
|
||||
image_bytes=image_bytes,
|
||||
media_type=media_type,
|
||||
)
|
||||
return await run_plain_provider_buckets(providers=[provider])
|
||||
|
||||
|
||||
async def edit_slide_layout_code(
|
||||
*,
|
||||
system_prompt: str,
|
||||
user_text: str,
|
||||
) -> str:
|
||||
provider = _build_provider_call(
|
||||
system_prompt=system_prompt,
|
||||
user_text=user_text,
|
||||
)
|
||||
return await run_plain_provider_buckets(providers=[provider])
|
||||
65
servers/fastapi/templates/router.py
Normal file
65
servers/fastapi/templates/router.py
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import uuid
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from templates.handler import (
|
||||
CreateSlideLayoutResponse,
|
||||
EditSlideLayoutResponse,
|
||||
EditSlideLayoutSectionResponse,
|
||||
FontsUploadAndSlidesPreviewResponse,
|
||||
GetTemplateLayoutsResponse,
|
||||
PresentationLayoutModel,
|
||||
SaveTemplateLayoutData,
|
||||
SaveTemplateResponse,
|
||||
TemplateDetail,
|
||||
TemplateExample,
|
||||
clone_slide_layout,
|
||||
clone_template,
|
||||
create_slide_layout,
|
||||
edit_slide_layout,
|
||||
edit_slide_layout_section,
|
||||
get_all_templates,
|
||||
get_layouts,
|
||||
get_template_by_id,
|
||||
get_template_example,
|
||||
init_create_template,
|
||||
save_slide_layout,
|
||||
save_template,
|
||||
update_template,
|
||||
upload_fonts_and_slides_preview,
|
||||
)
|
||||
|
||||
TEMPLATE_ROUTER = APIRouter(prefix="/template", tags=["Template"])
|
||||
|
||||
TEMPLATE_ROUTER.get("/all", response_model=list[TemplateDetail])(get_all_templates)
|
||||
TEMPLATE_ROUTER.get(
|
||||
"/{template_id}/layouts", response_model=GetTemplateLayoutsResponse
|
||||
)(get_layouts)
|
||||
TEMPLATE_ROUTER.get("/{id}", response_model=PresentationLayoutModel)(get_template_by_id)
|
||||
TEMPLATE_ROUTER.get("/{id}/example", response_model=TemplateExample)(
|
||||
get_template_example
|
||||
)
|
||||
TEMPLATE_ROUTER.post(
|
||||
"/fonts-upload-and-slides-preview",
|
||||
response_model=FontsUploadAndSlidesPreviewResponse,
|
||||
)(upload_fonts_and_slides_preview)
|
||||
TEMPLATE_ROUTER.post("/create/init", response_model=uuid.UUID)(init_create_template)
|
||||
TEMPLATE_ROUTER.post("/slide-layout/create", response_model=CreateSlideLayoutResponse)(
|
||||
create_slide_layout
|
||||
)
|
||||
TEMPLATE_ROUTER.post("/create/slide-layout", response_model=CreateSlideLayoutResponse)(
|
||||
create_slide_layout
|
||||
)
|
||||
TEMPLATE_ROUTER.post("/slide-layout/edit", response_model=EditSlideLayoutResponse)(
|
||||
edit_slide_layout
|
||||
)
|
||||
TEMPLATE_ROUTER.post(
|
||||
"/slide-layout/edit-section", response_model=EditSlideLayoutSectionResponse
|
||||
)(edit_slide_layout_section)
|
||||
TEMPLATE_ROUTER.post("/save", response_model=SaveTemplateResponse)(save_template)
|
||||
TEMPLATE_ROUTER.post("/clone", response_model=SaveTemplateResponse)(clone_template)
|
||||
TEMPLATE_ROUTER.put("/update", response_model=SaveTemplateResponse)(update_template)
|
||||
TEMPLATE_ROUTER.post("/slide-layout/save", status_code=200)(save_slide_layout)
|
||||
TEMPLATE_ROUTER.post("/slide-layout/clone", response_model=SaveTemplateLayoutData)(
|
||||
clone_slide_layout
|
||||
)
|
||||
|
|
@ -156,10 +156,6 @@ def get_migrate_database_on_startup_env():
|
|||
return os.getenv("MIGRATE_DATABASE_ON_STARTUP")
|
||||
|
||||
|
||||
def get_next_public_fast_api_env():
|
||||
return os.getenv("FASTAPI_PUBLIC_URL")
|
||||
|
||||
|
||||
def get_sentry_dsn_env():
|
||||
return os.getenv("SENTRY_DSN")
|
||||
|
||||
|
|
|
|||
|
|
@ -1,18 +1,5 @@
|
|||
import aiohttp
|
||||
from fastapi import HTTPException
|
||||
from models.presentation_layout import PresentationLayoutModel
|
||||
from typing import List
|
||||
"""Re-export for callers that import from `utils.get_layout_by_name`."""
|
||||
|
||||
async def get_layout_by_name(layout_name: str) -> PresentationLayoutModel:
|
||||
url = f"http://localhost/api/template?group={layout_name}"
|
||||
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=404,
|
||||
detail=f"Template '{layout_name}' not found: {error_text}"
|
||||
)
|
||||
layout_json = await response.json()
|
||||
# Parse the JSON into your Pydantic model
|
||||
return PresentationLayoutModel(**layout_json)
|
||||
from templates.get_layout_by_name import get_layout_by_name
|
||||
|
||||
__all__ = ["get_layout_by_name"]
|
||||
|
|
|
|||
126
servers/fastapi/utils/ocr_language.py
Normal file
126
servers/fastapi/utils/ocr_language.py
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
"""
|
||||
Map presentation UI language strings (LanguageType enum values from Next.js) to
|
||||
Tesseract / LiteParse OCR language codes (ISO 639-3 where applicable).
|
||||
|
||||
Keep keys in sync with:
|
||||
electron/servers/nextjs/app/(presentation-generator)/upload/type.ts → LanguageType
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
# Values must match `LanguageType` string literals in the upload UI.
|
||||
PRESENTATION_LANGUAGE_TO_TESSERACT: dict[str, str] = {
|
||||
"English": "eng",
|
||||
"Spanish (Español)": "spa",
|
||||
"French (Français)": "fra",
|
||||
"German (Deutsch)": "deu",
|
||||
"Portuguese (Português)": "por",
|
||||
"Italian (Italiano)": "ita",
|
||||
"Dutch (Nederlands)": "nld",
|
||||
"Russian (Русский)": "rus",
|
||||
"Chinese (Simplified - 中文, 汉语)": "chi_sim",
|
||||
"Chinese (Traditional - 中文, 漢語)": "chi_tra",
|
||||
"Japanese (日本語)": "jpn",
|
||||
"Korean (한국어)": "kor",
|
||||
"Arabic (العربية)": "ara",
|
||||
"Hindi (हिन्दी)": "hin",
|
||||
"Bengali (বাংলা)": "ben",
|
||||
"Polish (Polski)": "pol",
|
||||
"Czech (Čeština)": "ces",
|
||||
"Slovak (Slovenčina)": "slk",
|
||||
"Hungarian (Magyar)": "hun",
|
||||
"Romanian (Română)": "ron",
|
||||
"Bulgarian (Български)": "bul",
|
||||
"Greek (Ελληνικά)": "ell",
|
||||
"Serbian (Српски / Srpski)": "srp",
|
||||
"Croatian (Hrvatski)": "hrv",
|
||||
"Bosnian (Bosanski)": "bos",
|
||||
"Slovenian (Slovenščina)": "slv",
|
||||
"Finnish (Suomi)": "fin",
|
||||
"Swedish (Svenska)": "swe",
|
||||
"Danish (Dansk)": "dan",
|
||||
"Norwegian (Norsk)": "nor",
|
||||
"Icelandic (Íslenska)": "isl",
|
||||
"Lithuanian (Lietuvių)": "lit",
|
||||
"Latvian (Latviešu)": "lav",
|
||||
"Estonian (Eesti)": "est",
|
||||
"Maltese (Malti)": "mlt",
|
||||
"Welsh (Cymraeg)": "cym",
|
||||
"Irish (Gaeilge)": "gle",
|
||||
"Scottish Gaelic (Gàidhlig)": "gla",
|
||||
"Ukrainian (Українська)": "ukr",
|
||||
"Hebrew (עברית)": "heb",
|
||||
"Persian/Farsi (فارسی)": "fas",
|
||||
"Turkish (Türkçe)": "tur",
|
||||
"Kurdish (Kurdî / کوردی)": "kmr",
|
||||
"Pashto (پښتو)": "pus",
|
||||
"Dari (دری)": "prs",
|
||||
"Uzbek (Oʻzbek)": "uzb",
|
||||
"Kazakh (Қазақша)": "kaz",
|
||||
"Tajik (Тоҷикӣ)": "tgk",
|
||||
"Turkmen (Türkmençe)": "tuk",
|
||||
"Azerbaijani (Azərbaycan dili)": "aze",
|
||||
"Urdu (اردو)": "urd",
|
||||
"Tamil (தமிழ்)": "tam",
|
||||
"Telugu (తెలుగు)": "tel",
|
||||
"Marathi (मराठी)": "mar",
|
||||
"Punjabi (ਪੰਜਾਬੀ / پنجابی)": "pan",
|
||||
"Gujarati (ગુજરાતી)": "guj",
|
||||
"Malayalam (മലയാളം)": "mal",
|
||||
"Kannada (ಕನ್ನಡ)": "kan",
|
||||
"Odia (ଓଡ଼ିଆ)": "ori",
|
||||
"Sinhala (සිංහල)": "sin",
|
||||
"Nepali (नेपाली)": "nep",
|
||||
"Thai (ไทย)": "tha",
|
||||
"Vietnamese (Tiếng Việt)": "vie",
|
||||
"Lao (ລາວ)": "lao",
|
||||
"Khmer (ភាសាខ្មែរ)": "khm",
|
||||
"Burmese (မြန်မာစာ)": "mya",
|
||||
"Tagalog/Filipino (Tagalog/Filipino)": "tgl",
|
||||
"Javanese (Basa Jawa)": "jav",
|
||||
"Sundanese (Basa Sunda)": "sun",
|
||||
"Malay (Bahasa Melayu)": "msa",
|
||||
"Mongolian (Монгол)": "mon",
|
||||
"Swahili (Kiswahili)": "swa",
|
||||
"Hausa (Hausa)": "hau",
|
||||
"Yoruba (Yorùbá)": "yor",
|
||||
"Igbo (Igbo)": "ibo",
|
||||
"Amharic (አማርኛ)": "amh",
|
||||
"Zulu (isiZulu)": "zul",
|
||||
"Xhosa (isiXhosa)": "xho",
|
||||
"Shona (ChiShona)": "sna",
|
||||
"Somali (Soomaaliga)": "som",
|
||||
"Basque (Euskara)": "eus",
|
||||
"Catalan (Català)": "cat",
|
||||
"Galician (Galego)": "glg",
|
||||
"Quechua (Runasimi)": "que",
|
||||
"Nahuatl (Nāhuatl)": "nah",
|
||||
"Hawaiian (ʻŌlelo Hawaiʻi)": "haw",
|
||||
"Maori (Te Reo Māori)": "mri",
|
||||
# No dedicated Tahitian traineddata in default Tesseract bundles.
|
||||
"Tahitian (Reo Tahiti)": "eng",
|
||||
"Samoan (Gagana Samoa)": "smo",
|
||||
}
|
||||
|
||||
_LOWER_MAP = {k.lower(): v for k, v in PRESENTATION_LANGUAGE_TO_TESSERACT.items()}
|
||||
|
||||
_OCR_CODE_RE = re.compile(r"^[a-zA-Z0-9_,+]+$")
|
||||
|
||||
|
||||
def presentation_language_to_ocr_code(language: Optional[str]) -> str:
|
||||
"""Resolve UI language label to a Tesseract language code; default English."""
|
||||
if language is None:
|
||||
return "eng"
|
||||
s = str(language).strip()
|
||||
if not s:
|
||||
return "eng"
|
||||
if s in PRESENTATION_LANGUAGE_TO_TESSERACT:
|
||||
code = PRESENTATION_LANGUAGE_TO_TESSERACT[s]
|
||||
else:
|
||||
code = _LOWER_MAP.get(s.lower(), "eng")
|
||||
if not _OCR_CODE_RE.fullmatch(code):
|
||||
return "eng"
|
||||
return code
|
||||
|
|
@ -1,14 +1,11 @@
|
|||
import asyncio
|
||||
import os
|
||||
from typing import List, Optional, Tuple
|
||||
from typing import List, Optional
|
||||
from models.image_prompt import ImagePrompt
|
||||
from models.sql.image_asset import ImageAsset
|
||||
from models.sql.slide import SlideModel
|
||||
from services.icon_finder_service import ICON_FINDER_SERVICE
|
||||
from services.image_generation_service import ImageGenerationService
|
||||
from utils.asset_directory_utils import get_images_directory
|
||||
from utils.dict_utils import get_dict_at_path, get_dict_paths_with_key, set_dict_at_path
|
||||
from utils.path_helpers import get_resource_path
|
||||
|
||||
|
||||
async def process_slide_and_fetch_assets(
|
||||
|
|
@ -59,7 +56,7 @@ async def process_slide_and_fetch_assets(
|
|||
image_dict = get_dict_at_path(slide.content, asset_path)
|
||||
if isinstance(result, ImageAsset):
|
||||
return_assets.append(result)
|
||||
image_dict["__image_url__"] = result.file_url
|
||||
image_dict["__image_url__"] = result.path
|
||||
else:
|
||||
image_dict["__image_url__"] = result
|
||||
set_dict_at_path(slide.content, asset_path, image_dict)
|
||||
|
|
@ -172,7 +169,7 @@ async def process_old_and_new_slides_and_fetch_assets(
|
|||
fetched_image = new_images[i]
|
||||
if isinstance(fetched_image, ImageAsset):
|
||||
new_assets.append(fetched_image)
|
||||
image_url = fetched_image.file_url
|
||||
image_url = fetched_image.path
|
||||
else:
|
||||
image_url = fetched_image
|
||||
new_image_dicts[i]["__image_url__"] = image_url
|
||||
|
|
|
|||
831
servers/fastapi/uv.lock
generated
831
servers/fastapi/uv.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -46,16 +46,16 @@ interface CodexModel {
|
|||
}
|
||||
|
||||
const CHATGPT_MODELS: CodexModel[] = [
|
||||
{ id: "gpt-5.1", name: "GPT-5.1" },
|
||||
{ id: "gpt-5.1-codex-max", name: "GPT-5.1 Codex Max" },
|
||||
{ id: "gpt-5.2", name: "GPT-5.2" },
|
||||
{ id: "gpt-5.2-codex", name: "GPT-5.2 Codex" },
|
||||
{ id: "gpt-5.3-codex", name: "GPT-5.3 Codex" },
|
||||
{ id: "gpt-5.4-mini", name: "GPT-5.4 Mini" },
|
||||
{ id: "gpt-5.4", name: "GPT-5.4" },
|
||||
{ id: "gpt-5.2-codex", name: "GPT-5.2-Codex" },
|
||||
{ id: "gpt-5.1-codex-max", name: "GPT-5.1-Codex-Max" },
|
||||
{ id: "gpt-5.4-mini", name: "GPT-5.4-Mini" },
|
||||
{ id: "gpt-5.3-codex", name: "GPT-5.3-Codex" },
|
||||
{ id: "gpt-5.2", name: "GPT-5.2" },
|
||||
{ id: "gpt-5.1-codex-mini", name: "GPT-5.1-Codex-Mini" },
|
||||
];
|
||||
|
||||
const DEFAULT_CODEX_MODEL = "gpt-5.4-mini";
|
||||
const DEFAULT_CODEX_MODEL = "gpt-5.1-codex-mini";
|
||||
|
||||
export default function CodexConfig({
|
||||
codexModel,
|
||||
|
|
|
|||
|
|
@ -106,7 +106,9 @@ const SettingsPage = () => {
|
|||
};
|
||||
|
||||
const handleSaveConfig = async () => {
|
||||
trackEvent(MixpanelEvent.Settings_SaveConfiguration_Button_Clicked, { pathname });
|
||||
trackEvent(MixpanelEvent.Settings_SaveConfiguration_Button_Clicked, {
|
||||
pathname,
|
||||
});
|
||||
const validationError = getLLMConfigValidationError(llmConfig);
|
||||
if (validationError) {
|
||||
notify.error("Cannot save settings", validationError);
|
||||
|
|
@ -119,7 +121,7 @@ const SettingsPage = () => {
|
|||
}
|
||||
|
||||
try {
|
||||
setButtonState(prev => ({
|
||||
setButtonState((prev) => ({
|
||||
...prev,
|
||||
isLoading: true,
|
||||
isDisabled: true,
|
||||
|
|
@ -152,20 +154,19 @@ const SettingsPage = () => {
|
|||
"Settings saved",
|
||||
"Your configuration was saved successfully."
|
||||
);
|
||||
setButtonState(prev => ({
|
||||
setButtonState((prev) => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
isDisabled: false,
|
||||
text: "Save Configuration",
|
||||
}));
|
||||
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Something went wrong while saving.";
|
||||
notify.error("Could not save settings", message);
|
||||
setButtonState(prev => ({
|
||||
setButtonState((prev) => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
isDisabled: false,
|
||||
|
|
@ -274,7 +275,8 @@ const SettingsPage = () => {
|
|||
const imageSummary = llmConfig.DISABLE_IMAGE_GENERATION
|
||||
? "Image generation disabled"
|
||||
: llmConfig.IMAGE_PROVIDER
|
||||
? IMAGE_PROVIDERS[llmConfig.IMAGE_PROVIDER]?.label || llmConfig.IMAGE_PROVIDER
|
||||
? IMAGE_PROVIDERS[llmConfig.IMAGE_PROVIDER]?.label ||
|
||||
llmConfig.IMAGE_PROVIDER
|
||||
: "No image provider";
|
||||
|
||||
|
||||
|
|
@ -341,16 +343,22 @@ const SettingsPage = () => {
|
|||
return (
|
||||
<div className="h-screen font-syne flex flex-col overflow-hidden relative">
|
||||
<div
|
||||
className='fixed z-0 bottom-[-14.5rem] left-0 w-full h-full'
|
||||
className="fixed z-0 bottom-[-14.5rem] left-0 w-full h-full"
|
||||
style={{
|
||||
height: "341px",
|
||||
borderRadius: '1440px',
|
||||
background: 'radial-gradient(5.92% 104.69% at 50% 100%, rgba(122, 90, 248, 0.00) 0%, rgba(255, 255, 255, 0.00) 100%), radial-gradient(50% 50% at 50% 50%, rgba(122, 90, 248, 0.80) 0%, rgba(122, 90, 248, 0.00) 100%)',
|
||||
borderRadius: "1440px",
|
||||
background:
|
||||
"radial-gradient(5.92% 104.69% at 50% 100%, rgba(122, 90, 248, 0.00) 0%, rgba(255, 255, 255, 0.00) 100%), radial-gradient(50% 50% at 50% 50%, rgba(122, 90, 248, 0.80) 0%, rgba(122, 90, 248, 0.00) 100%)",
|
||||
}}
|
||||
/>
|
||||
|
||||
<main className="w-full mx-auto gap-6 overflow-hidden flex ">
|
||||
<SettingSideBar mode={mode} setMode={setMode} selectedProvider={selectedProvider} setSelectedProvider={setSelectedProvider} />
|
||||
<SettingSideBar
|
||||
mode={mode}
|
||||
setMode={setMode}
|
||||
selectedProvider={selectedProvider}
|
||||
setSelectedProvider={setSelectedProvider}
|
||||
/>
|
||||
<div className="w-full">
|
||||
<div className="sticky top-0 right-0 z-50 py-[28px] backdrop-blur mb-4 ">
|
||||
<div className="flex gap-3 items-center ">
|
||||
|
|
@ -360,7 +368,6 @@ const SettingsPage = () => {
|
|||
<p className="text-[10px] px-2.5 py-0.5 rounded-[50px] text-[#7A5AF8] border border-[#EDEEEF] font-medium ">
|
||||
{textSummary} · {imageSummary}
|
||||
</p>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -390,12 +397,13 @@ const SettingsPage = () => {
|
|||
onClick={handleSaveConfig}
|
||||
disabled={buttonState.isDisabled}
|
||||
style={{
|
||||
background: "linear-gradient(270deg, #D5CAFC 2.4%, #E3D2EB 27.88%, #F4DCD3 69.23%, #FDE4C2 100%)",
|
||||
background:
|
||||
"linear-gradient(270deg, #D5CAFC 2.4%, #E3D2EB 27.88%, #F4DCD3 69.23%, #FDE4C2 100%)",
|
||||
color: "#101323",
|
||||
}}
|
||||
className={`w-full font-syne font-semibold flex items-center justify-center gap-2 py-3 px-5 rounded-[58px] transition-all duration-500 ${buttonState.isDisabled
|
||||
? "bg-gray-400 cursor-not-allowed"
|
||||
: "bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 focus:ring-4 focus:ring-blue-200"
|
||||
? "bg-gray-400 cursor-not-allowed"
|
||||
: "bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 focus:ring-4 focus:ring-blue-200"
|
||||
} text-white`}
|
||||
>
|
||||
{buttonState.isLoading ? (
|
||||
|
|
|
|||
|
|
@ -1,18 +1,23 @@
|
|||
import path from "path";
|
||||
import fs from "fs";
|
||||
import puppeteer from "puppeteer";
|
||||
|
||||
import { sanitizeFilename } from "@/app/(presentation-generator)/utils/others";
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { id, title } = await req.json();
|
||||
if (!id) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing Presentation ID" },
|
||||
{ status: 400 }
|
||||
);
|
||||
import { sanitizeFilename } from "@/app/(presentation-generator)/utils/others";
|
||||
import {
|
||||
bundledExportPackageAvailable,
|
||||
runBundledPdfExport,
|
||||
} from "@/lib/run-bundled-pdf-export";
|
||||
|
||||
async function exportPdfWithInlinePuppeteer(
|
||||
id: string,
|
||||
title: string | undefined
|
||||
): Promise<{ path: string }> {
|
||||
let nextjsUrl = process.env.NEXT_PUBLIC_URL;
|
||||
if (!nextjsUrl) {
|
||||
nextjsUrl = "http://127.0.0.1";
|
||||
}
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||
headless: true,
|
||||
|
|
@ -34,7 +39,7 @@ export async function POST(req: NextRequest) {
|
|||
page.setDefaultNavigationTimeout(300000);
|
||||
page.setDefaultTimeout(300000);
|
||||
|
||||
await page.goto(`http://localhost/pdf-maker?id=${id}`, {
|
||||
await page.goto(`${nextjsUrl}/pdf-maker?id=${id}`, {
|
||||
waitUntil: "networkidle0",
|
||||
timeout: 300000,
|
||||
});
|
||||
|
|
@ -78,15 +83,12 @@ export async function POST(req: NextRequest) {
|
|||
margin: { top: 0, right: 0, bottom: 0, left: 0 },
|
||||
});
|
||||
|
||||
browser.close();
|
||||
await browser.close();
|
||||
|
||||
const sanitizedTitle = sanitizeFilename(title ?? "presentation");
|
||||
const appDataDirectory = process.env.APP_DATA_DIRECTORY!;
|
||||
if (!appDataDirectory) {
|
||||
return NextResponse.json({
|
||||
error: "App data directory not found",
|
||||
status: 500,
|
||||
});
|
||||
throw new Error("App data directory not found");
|
||||
}
|
||||
const destinationPath = path.join(
|
||||
appDataDirectory,
|
||||
|
|
@ -96,8 +98,41 @@ export async function POST(req: NextRequest) {
|
|||
await fs.promises.mkdir(path.dirname(destinationPath), { recursive: true });
|
||||
await fs.promises.writeFile(destinationPath, pdfBuffer);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
path: destinationPath,
|
||||
});
|
||||
return { path: destinationPath };
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { id, title } = await req.json();
|
||||
if (!id) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing Presentation ID" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
if (await bundledExportPackageAvailable()) {
|
||||
const { path: outPath } = await runBundledPdfExport({
|
||||
presentationId: id,
|
||||
title,
|
||||
});
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
path: outPath,
|
||||
});
|
||||
}
|
||||
|
||||
const { path: outPath } = await exportPdfWithInlinePuppeteer(id, title);
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
path: outPath,
|
||||
});
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
console.error("[export-as-pdf]", message);
|
||||
return NextResponse.json(
|
||||
{ error: message, success: false },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,16 +33,16 @@ interface CodexModel {
|
|||
}
|
||||
|
||||
export const CHATGPT_MODELS: CodexModel[] = [
|
||||
{ id: "gpt-5.1", name: "GPT-5.1" },
|
||||
{ id: "gpt-5.1-codex-max", name: "GPT-5.1 Codex Max" },
|
||||
{ id: "gpt-5.2", name: "GPT-5.2" },
|
||||
{ id: "gpt-5.2-codex", name: "GPT-5.2 Codex" },
|
||||
{ id: "gpt-5.3-codex", name: "GPT-5.3 Codex" },
|
||||
{ id: "gpt-5.4 mini", name: "GPT-5.4 Mini" },
|
||||
{ id: "gpt-5.4", name: "GPT-5.4" },
|
||||
{ id: "gpt-5.2-codex", name: "GPT-5.2-Codex" },
|
||||
{ id: "gpt-5.1-codex-max", name: "GPT-5.1-Codex-Max" },
|
||||
{ id: "gpt-5.4-mini", name: "GPT-5.4-Mini" },
|
||||
{ id: "gpt-5.3-codex", name: "GPT-5.3-Codex" },
|
||||
{ id: "gpt-5.2", name: "GPT-5.2" },
|
||||
{ id: "gpt-5.1-codex-mini", name: "GPT-5.1-Codex-Mini" },
|
||||
];
|
||||
|
||||
export const DEFAULT_CODEX_MODEL = "gpt-5.4-mini";
|
||||
export const DEFAULT_CODEX_MODEL = "gpt-5.1-codex-mini";
|
||||
|
||||
export default function CodexConfig({
|
||||
codexModel,
|
||||
|
|
|
|||
139
servers/nextjs/lib/run-bundled-pdf-export.ts
Normal file
139
servers/nextjs/lib/run-bundled-pdf-export.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import path from "path";
|
||||
import os from "os";
|
||||
import fs from "fs/promises";
|
||||
import { spawn } from "child_process";
|
||||
import { sanitizeFilename } from "@/app/(presentation-generator)/utils/others";
|
||||
|
||||
/** Repo `presentation-export/` at app root (`/app/presentation-export` in Docker). */
|
||||
export function getExportPackageRoot(): string {
|
||||
return (
|
||||
process.env.EXPORT_PACKAGE_ROOT?.trim() ||
|
||||
path.join(process.cwd(), "..", "..", "presentation-export")
|
||||
);
|
||||
}
|
||||
|
||||
export function getPresentonAppRoot(): string {
|
||||
return (
|
||||
process.env.PRESENTON_APP_ROOT?.trim() ||
|
||||
path.join(process.cwd(), "..", "..")
|
||||
);
|
||||
}
|
||||
|
||||
function bundledConverterPath(exportRoot: string): string {
|
||||
const fromEnv = process.env.BUILT_PYTHON_MODULE_PATH?.trim();
|
||||
if (fromEnv) {
|
||||
return fromEnv;
|
||||
}
|
||||
if (process.platform === "linux" && process.arch === "x64") {
|
||||
return path.join(exportRoot, "py", "convert-linux-x64");
|
||||
}
|
||||
throw new Error(
|
||||
`No bundled export converter for ${process.platform}/${process.arch}. Set BUILT_PYTHON_MODULE_PATH.`
|
||||
);
|
||||
}
|
||||
|
||||
export async function bundledExportPackageAvailable(): Promise<boolean> {
|
||||
try {
|
||||
const root = getExportPackageRoot();
|
||||
await fs.access(path.join(root, "index.js"));
|
||||
await fs.access(bundledConverterPath(root));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export type BundledPdfExportResult = { path: string };
|
||||
|
||||
/**
|
||||
* Runs the bundled export entrypoint (`presentation-export/index.js`) with
|
||||
* `BUILT_PYTHON_MODULE_PATH` pointing at the PyInstaller converter binary.
|
||||
*/
|
||||
export async function runBundledPdfExport(params: {
|
||||
presentationId: string;
|
||||
title: string | undefined;
|
||||
}): Promise<BundledPdfExportResult> {
|
||||
const { presentationId, title } = params;
|
||||
const exportRoot = getExportPackageRoot();
|
||||
const indexJs = path.join(exportRoot, "index.js");
|
||||
const converter = bundledConverterPath(exportRoot);
|
||||
const appRoot = getPresentonAppRoot();
|
||||
|
||||
await fs.access(indexJs);
|
||||
await fs.access(converter);
|
||||
|
||||
const nextjsUrl =
|
||||
process.env.NEXT_PUBLIC_URL?.trim() || "http://127.0.0.1";
|
||||
const q = new URLSearchParams({ id: presentationId });
|
||||
const fastapiUrl = process.env.NEXT_PUBLIC_FAST_API?.trim();
|
||||
if (fastapiUrl) {
|
||||
q.set("fastapiUrl", fastapiUrl);
|
||||
}
|
||||
const pptUrl = `${nextjsUrl}/pdf-maker?${q.toString()}`;
|
||||
|
||||
const tempBase =
|
||||
process.env.TEMP_DIRECTORY?.trim() || path.join(os.tmpdir(), "presenton");
|
||||
await fs.mkdir(tempBase, { recursive: true });
|
||||
const workDir = await fs.mkdtemp(path.join(tempBase, "export-"));
|
||||
const exportTaskPath = path.join(workDir, "export_task.json");
|
||||
|
||||
const exportTask = {
|
||||
type: "export",
|
||||
url: pptUrl,
|
||||
format: "pdf",
|
||||
title: sanitizeFilename(title ?? "presentation"),
|
||||
fastapiUrl: fastapiUrl || undefined,
|
||||
};
|
||||
|
||||
await fs.writeFile(exportTaskPath, JSON.stringify(exportTask), "utf8");
|
||||
|
||||
const responsePath = exportTaskPath.replace(/\.json$/i, ".response.json");
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(process.execPath, [indexJs, exportTaskPath], {
|
||||
cwd: appRoot,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: {
|
||||
...process.env,
|
||||
BUILT_PYTHON_MODULE_PATH: converter,
|
||||
ELECTRON_RUN_AS_NODE: "0",
|
||||
},
|
||||
});
|
||||
const stderr: Buffer[] = [];
|
||||
const stdout: Buffer[] = [];
|
||||
child.stderr?.on("data", (d) => stderr.push(d));
|
||||
child.stdout?.on("data", (d) => stdout.push(d));
|
||||
child.on("error", reject);
|
||||
child.on("exit", (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
const errText = Buffer.concat(stderr).toString("utf8").trim();
|
||||
const outText = Buffer.concat(stdout).toString("utf8").trim();
|
||||
reject(
|
||||
new Error(
|
||||
`Export process exited with code ${code}${errText ? `. ${errText}` : ""}${outText ? ` stdout: ${outText}` : ""}`
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const responseRaw = await fs.readFile(responsePath, "utf8");
|
||||
const responseData = JSON.parse(responseRaw) as { path?: string };
|
||||
|
||||
if (!responseData?.path || typeof responseData.path !== "string") {
|
||||
throw new Error("Export finished but response did not include a path.");
|
||||
}
|
||||
|
||||
let outPath = responseData.path;
|
||||
if (!path.isAbsolute(outPath)) {
|
||||
const appData = process.env.APP_DATA_DIRECTORY?.trim();
|
||||
if (!appData) {
|
||||
throw new Error("APP_DATA_DIRECTORY is required for relative export paths.");
|
||||
}
|
||||
outPath = path.join(appData, outPath);
|
||||
}
|
||||
|
||||
return { path: outPath };
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Loading…
Add table
Reference in a new issue