Initial: presenton
188
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
|
||||
|
||||
# Logs
|
||||
|
||||
logs
|
||||
_.log
|
||||
npm-debug.log_
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Caches
|
||||
|
||||
.cache
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# Runtime data
|
||||
|
||||
pids
|
||||
_.pid
|
||||
_.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
servers/fastapi/.env
|
||||
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
|
||||
.temp
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
# IntelliJ based IDEs
|
||||
.idea
|
||||
|
||||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
||||
|
||||
env
|
||||
.venv
|
||||
__pycache__
|
||||
|
||||
*.db
|
||||
|
||||
resources/nextjs
|
||||
resources/fastapi
|
||||
|
||||
servers/nextjs/data
|
||||
202
LICENSE
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2025 presenton
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
0
README.md
Normal file
1
app/constants.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export const localhost = "http://0.0.0.0"
|
||||
83
app/main.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
require("dotenv").config();
|
||||
import { app, BrowserWindow } from "electron";
|
||||
import path from "path";
|
||||
import { findTwoUnusedPorts, killProcess } from "./utils";
|
||||
import { startFastApiServer, startNextJsServer } from "./servers";
|
||||
import { ChildProcessByStdio } from "child_process";
|
||||
import { localhost } from "./constants";
|
||||
|
||||
var isDev = process.env.DEBUG === "True";
|
||||
var baseDir = isDev ? process.cwd() : process.resourcesPath;
|
||||
var resourcesDir = path.join(baseDir, "resources");
|
||||
var fastapiDir = isDev ? path.join(baseDir, "servers/fastapi") : path.join(resourcesDir, "fastapi");
|
||||
var nextjsDir = isDev ? path.join(baseDir, "servers/nextjs") : path.join(resourcesDir, "nextjs");
|
||||
|
||||
var win: BrowserWindow | undefined;
|
||||
var fastApiProcess: ChildProcessByStdio<any, any, any> | undefined;
|
||||
var nextjsProcess: ChildProcessByStdio<any, any, any> | undefined;
|
||||
|
||||
const createWindow = () => {
|
||||
win = new BrowserWindow({
|
||||
webPreferences: {
|
||||
webSecurity: false,
|
||||
},
|
||||
width: 1280,
|
||||
height: 720,
|
||||
});
|
||||
};
|
||||
|
||||
async function startServers(fastApiPort: number, nextjsPort: number) {
|
||||
try {
|
||||
fastApiProcess = await startFastApiServer(
|
||||
fastapiDir,
|
||||
fastApiPort,
|
||||
{
|
||||
DEBUG: isDev ? "True" : "False",
|
||||
LLM: process.env.LLM || "",
|
||||
LIBREOFFICE: process.env.LIBREOFFICE || "",
|
||||
OPENAI_API_KEY: process.env.OPENAI_API_KEY || "",
|
||||
GOOGLE_API_KEY: process.env.GOOGLE_API_KEY || "",
|
||||
APP_DATA_DIRECTORY: process.env.APP_DATA_DIRECTORY || "",
|
||||
TEMP_DIRECTORY: process.env.TEMP_DIRECTORY || "",
|
||||
},
|
||||
isDev
|
||||
);
|
||||
nextjsProcess = await startNextJsServer(
|
||||
nextjsDir,
|
||||
nextjsPort,
|
||||
{
|
||||
NEXT_PUBLIC_API: `${localhost}:${fastApiPort}`,
|
||||
TEMP_DIRECTORY: process.env.TEMP_DIRECTORY || "",
|
||||
},
|
||||
isDev
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Server startup error:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function stopServers() {
|
||||
if (fastApiProcess?.pid) {
|
||||
await killProcess(fastApiProcess.pid);
|
||||
}
|
||||
if (nextjsProcess?.pid) {
|
||||
await killProcess(nextjsProcess.pid);
|
||||
}
|
||||
}
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
createWindow();
|
||||
win?.loadFile(path.join(resourcesDir, "ui/homepage/index.html"));
|
||||
win?.webContents.openDevTools();
|
||||
|
||||
const [fastApiPort, nextjsPort] = await findTwoUnusedPorts();
|
||||
console.log(`FastAPI port: ${fastApiPort}, NextJS port: ${nextjsPort}`);
|
||||
|
||||
await startServers(fastApiPort, nextjsPort);
|
||||
win?.loadURL(`${localhost}:${nextjsPort}/upload`);
|
||||
});
|
||||
|
||||
app.on("window-all-closed", async () => {
|
||||
await stopServers();
|
||||
app.quit();
|
||||
});
|
||||
76
app/servers.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { spawn, exec } from "child_process";
|
||||
import util from "util";
|
||||
import { localhost } from "./constants";
|
||||
|
||||
const execAsync = util.promisify(exec);
|
||||
|
||||
export async function startFastApiServer(
|
||||
directory: string,
|
||||
port: number,
|
||||
env: FastApiEnv,
|
||||
isDev: boolean,
|
||||
) {
|
||||
// Start FastAPI server
|
||||
const startCommand = isDev ? [
|
||||
".venv/bin/python",
|
||||
["server.py", "--port", port.toString()],
|
||||
] : [
|
||||
"./fastapi", ["--port", port.toString()],
|
||||
];
|
||||
|
||||
|
||||
const fastApiProcess = spawn(
|
||||
startCommand[0] as string,
|
||||
startCommand[1] as string[],
|
||||
{
|
||||
cwd: directory,
|
||||
stdio: ["inherit", "pipe", "pipe"],
|
||||
env: { ...process.env, ...env },
|
||||
}
|
||||
);
|
||||
fastApiProcess.stdout.on("data", (data: any) => {
|
||||
console.log(`FastAPI: ${data}`);
|
||||
});
|
||||
fastApiProcess.stderr.on("data", (data: any) => {
|
||||
console.error(`FastAPI Error: ${data}`);
|
||||
});
|
||||
// Wait for FastAPI server to start
|
||||
await execAsync(`npx wait-on ${localhost}:${port}/docs`);
|
||||
return fastApiProcess;
|
||||
}
|
||||
|
||||
export async function startNextJsServer(
|
||||
directory: string,
|
||||
port: number,
|
||||
env: NextJsEnv,
|
||||
isDev: boolean,
|
||||
) {
|
||||
// Start NextJS server
|
||||
const startCommand = isDev ? [
|
||||
"npm",
|
||||
["run", "dev", "--", "-p", port.toString()],
|
||||
] : [
|
||||
"npx",
|
||||
["next", "start", "--", "-p", port.toString()],
|
||||
];
|
||||
|
||||
|
||||
const nextjsProcess = spawn(
|
||||
startCommand[0] as string,
|
||||
startCommand[1] as string[],
|
||||
{
|
||||
cwd: directory,
|
||||
stdio: ["inherit", "pipe", "pipe"],
|
||||
env: { ...process.env, ...env },
|
||||
}
|
||||
);
|
||||
nextjsProcess.stdout.on("data", (data: any) => {
|
||||
console.log(`NextJS: ${data}`);
|
||||
});
|
||||
nextjsProcess.stderr.on("data", (data: any) => {
|
||||
console.error(`NextJS Error: ${data}`);
|
||||
});
|
||||
// Wait for NextJS server to start
|
||||
await execAsync(`npx wait-on ${localhost}:${port}`);
|
||||
return nextjsProcess;
|
||||
}
|
||||
14
app/types/index.d.ts
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
interface FastApiEnv {
|
||||
DEBUG: string,
|
||||
LLM: string,
|
||||
LIBREOFFICE: string,
|
||||
OPENAI_API_KEY: string,
|
||||
GOOGLE_API_KEY: string,
|
||||
APP_DATA_DIRECTORY: string,
|
||||
TEMP_DIRECTORY: string,
|
||||
}
|
||||
|
||||
interface NextJsEnv {
|
||||
NEXT_PUBLIC_API: string,
|
||||
TEMP_DIRECTORY: string,
|
||||
}
|
||||
44
app/utils.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import net from 'net'
|
||||
import treeKill from 'tree-kill'
|
||||
|
||||
export function killProcess(pid: number) {
|
||||
return new Promise((resolve, reject) => {
|
||||
treeKill(pid, "SIGTERM", (err: any) => {
|
||||
if (err) {
|
||||
console.error(`Error killing process ${pid}:`, err)
|
||||
reject(err)
|
||||
} else {
|
||||
console.log(`Process ${pid} killed`)
|
||||
resolve(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export async function findTwoUnusedPorts(startPort: number = 40000): Promise<[number, number]> {
|
||||
const ports: number[] = [];
|
||||
|
||||
const isPortAvailable = (port: number): Promise<boolean> => {
|
||||
return new Promise((resolve) => {
|
||||
const server = net.createServer();
|
||||
server.once('error', () => {
|
||||
resolve(false);
|
||||
});
|
||||
server.once('listening', () => {
|
||||
server.close();
|
||||
resolve(true);
|
||||
});
|
||||
server.listen(port);
|
||||
});
|
||||
};
|
||||
|
||||
let currentPort = startPort;
|
||||
while (ports.length < 2) {
|
||||
if (await isPortAvailable(currentPort)) {
|
||||
ports.push(currentPort);
|
||||
}
|
||||
currentPort++;
|
||||
}
|
||||
|
||||
return [ports[0], ports[1]];
|
||||
}
|
||||
47
forge.config.js
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
const { FusesPlugin } = require('@electron-forge/plugin-fuses');
|
||||
const { FuseV1Options, FuseVersion } = require('@electron/fuses');
|
||||
|
||||
module.exports = {
|
||||
packagerConfig: {
|
||||
asar: true,
|
||||
extraResource: [
|
||||
'resources',
|
||||
]
|
||||
},
|
||||
rebuildConfig: {},
|
||||
makers: [
|
||||
// {
|
||||
// name: '@electron-forge/maker-squirrel',
|
||||
// config: {},
|
||||
// },
|
||||
// {
|
||||
// name: '@electron-forge/maker-zip',
|
||||
// platforms: ['darwin'],
|
||||
// },
|
||||
{
|
||||
name: '@electron-forge/maker-deb',
|
||||
config: {},
|
||||
},
|
||||
// {
|
||||
// name: '@electron-forge/maker-rpm',
|
||||
// config: {},
|
||||
// },
|
||||
],
|
||||
plugins: [
|
||||
{
|
||||
name: '@electron-forge/plugin-auto-unpack-natives',
|
||||
config: {},
|
||||
},
|
||||
// Fuses are used to enable/disable various Electron functionality
|
||||
// at package time, before code signing the application
|
||||
new FusesPlugin({
|
||||
version: FuseVersion.V1,
|
||||
[FuseV1Options.RunAsNode]: false,
|
||||
[FuseV1Options.EnableCookieEncryption]: true,
|
||||
[FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false,
|
||||
[FuseV1Options.EnableNodeCliInspectArguments]: false,
|
||||
[FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true,
|
||||
[FuseV1Options.OnlyLoadAppFromAsar]: true,
|
||||
}),
|
||||
],
|
||||
};
|
||||
1684
package-lock.json
generated
Normal file
32
package.json
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
{
|
||||
"name": "presenton_open_source",
|
||||
"main": "dist/main.js",
|
||||
"version": "0.0.0",
|
||||
"description": "Presenton Open Source",
|
||||
"scripts": {
|
||||
"dev": "tsc && electron --gtk-version=3 .",
|
||||
"start": "tsc && electron-forge start",
|
||||
"package": "tsc && electron-forge package",
|
||||
"make": "tsc && electron-forge make",
|
||||
"setup:env": "cd servers/fastapi && poetry env remove --all && poetry install",
|
||||
"build:ts": "tsc",
|
||||
"build:css": "tailwindcss -i ./resources/ui/assets/tailwind.import.css -o ./resources/ui/assets/tailwind.css",
|
||||
"build:nextjs": "rm -rf resources/nextjs && mkdir -p resources/nextjs && cd servers/nextjs && npm run build && cp -r .next ../../resources/nextjs",
|
||||
"build:fastapi": "rm -rf resources/fastapi && cd servers/fastapi && .venv/bin/pyinstaller --name fastapi --distpath ../../resources server.py",
|
||||
"clean:build": "rm -rf resources/nextjs && rm -rf resources/fastapi"
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "^36.1.0",
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/cli": "^4.1.5",
|
||||
"dotenv": "^16.5.0",
|
||||
"electron-squirrel-startup": "^1.0.1",
|
||||
"tailwindcss": "^4.1.5",
|
||||
"tree-kill": "^1.2.2"
|
||||
}
|
||||
}
|
||||
6418
resources/ui/assets/tailwind.css
Normal file
1
resources/ui/assets/tailwind.import.css
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
@import 'tailwindcss';
|
||||
15
resources/ui/homepage/index.html
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<title>Presenton Open Source</title>
|
||||
<link rel="stylesheet" href="../assets/tailwind.css">
|
||||
<script src="./script.js"></script>
|
||||
</head>
|
||||
|
||||
<body class="bg-gray-900 text-white flex flex-col items-center justify-center h-screen">
|
||||
<h1 class="text-4xl font-bold">Presenton Open Source</h1>
|
||||
<p class="mt-4">Waiting for server to start...</p>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
0
resources/ui/homepage/script.js
Normal file
7
servers/fastapi/.vscode/settings.json
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"python.testing.pytestArgs": [
|
||||
"tests"
|
||||
],
|
||||
"python.testing.unittestEnabled": false,
|
||||
"python.testing.pytestEnabled": true
|
||||
}
|
||||
50
servers/fastapi/Dockerfile
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
# Build stage
|
||||
FROM python:3.11-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
|
||||
|
||||
# Install build dependencies and pip packages in one layer
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libreoffice \
|
||||
poppler-utils \
|
||||
ffmpeg \
|
||||
fonts-noto \
|
||||
fonts-dejavu \
|
||||
fonts-liberation \
|
||||
fonts-freefont-ttf \
|
||||
fonts-roboto \
|
||||
fonts-noto-core \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Final stage
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install only runtime dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libreoffice \
|
||||
poppler-utils \
|
||||
ffmpeg \
|
||||
fonts-noto \
|
||||
fonts-dejavu \
|
||||
fonts-liberation \
|
||||
fonts-freefont-ttf \
|
||||
fonts-roboto \
|
||||
fonts-noto-core \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
# Copy installed Python packages from builder
|
||||
COPY --from=builder /usr/local/lib/python3.11/site-packages/ /usr/local/lib/python3.11/site-packages/
|
||||
COPY --from=builder /usr/local/bin/ /usr/local/bin/
|
||||
|
||||
# Copy application code and fonts
|
||||
COPY . /app
|
||||
COPY ./fonts /usr/share/fonts/
|
||||
RUN fc-cache -fv
|
||||
|
||||
CMD ["python", "server.py"]
|
||||
0
servers/fastapi/api/__init__.py
Normal file
27
servers/fastapi/api/main.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import os
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from sqlmodel import SQLModel
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from api.routers.presentation.router import presentation_router
|
||||
from api.services.database import sql_engine
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI):
|
||||
os.makedirs(os.getenv("APP_DATA_DIRECTORY"), exist_ok=True)
|
||||
SQLModel.metadata.create_all(sql_engine)
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
origins = ["*"]
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
app.include_router(presentation_router)
|
||||
37
servers/fastapi/api/models.py
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
from typing import Optional
|
||||
from pydantic import BaseModel
|
||||
|
||||
from api.sql_models import PresentationSqlModel
|
||||
|
||||
|
||||
class LogMetadata(BaseModel):
|
||||
presentation: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
endpoint: Optional[str] = None
|
||||
status_code: Optional[int] = None
|
||||
|
||||
@classmethod
|
||||
def from_presentation(
|
||||
cls, presentation: PresentationSqlModel, endpoint: Optional[str] = None
|
||||
):
|
||||
return cls(
|
||||
presentation=presentation.id,
|
||||
title=presentation.title,
|
||||
endpoint=endpoint,
|
||||
)
|
||||
|
||||
@property
|
||||
def stream_name(self):
|
||||
return f"Endpoint - {self.endpoint}, Presentation - {self.presentation}"
|
||||
|
||||
|
||||
class SessionModel(BaseModel):
|
||||
session: str
|
||||
|
||||
|
||||
class SSEResponse(BaseModel):
|
||||
event: str
|
||||
data: str
|
||||
|
||||
def to_string(self):
|
||||
return f"event: {self.event}\ndata: {self.data}\n\n"
|
||||
18
servers/fastapi/api/request_utils.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
from typing import Optional
|
||||
|
||||
from api.models import LogMetadata
|
||||
from api.services.logging import LoggingService
|
||||
|
||||
|
||||
class RequestUtils:
|
||||
def __init__(self, endpoint: str):
|
||||
self.endpoint = endpoint
|
||||
|
||||
async def initialize_logger(
|
||||
self,
|
||||
presentation_id: Optional[str] = None,
|
||||
):
|
||||
metadata = LogMetadata(presentation=presentation_id, endpoint=self.endpoint)
|
||||
logging_service = LoggingService(metadata.stream_name)
|
||||
|
||||
return logging_service, metadata
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
import asyncio
|
||||
from typing import List
|
||||
import uuid
|
||||
from api.models import LogMetadata
|
||||
from api.routers.presentation.models import (
|
||||
DecomposeDocumentsRequest,
|
||||
DecomposeDocumentsResponse,
|
||||
)
|
||||
from api.services.instances import temp_file_service
|
||||
from api.services.logging import LoggingService
|
||||
from document_processor.loader import DocumentsLoader
|
||||
|
||||
|
||||
class DecomposeDocumentsHandler:
|
||||
|
||||
def __init__(self, data: DecomposeDocumentsRequest):
|
||||
self.data = data
|
||||
self.documents = list(
|
||||
filter(lambda doc: not doc.endswith(".csv"), self.data.documents or [])
|
||||
)
|
||||
|
||||
self.session = str(uuid.uuid4())
|
||||
self.temp_dir = temp_file_service.create_temp_dir(self.session)
|
||||
|
||||
async def post(self, logging_service: LoggingService, log_metadata: LogMetadata):
|
||||
logging_service.logger.info(
|
||||
logging_service.message(self.data.model_dump(mode="json")),
|
||||
extra=log_metadata.model_dump(),
|
||||
)
|
||||
|
||||
documents_loader = DocumentsLoader(self.documents)
|
||||
await documents_loader.load_documents(self.temp_dir)
|
||||
parsed_documents = documents_loader.documents
|
||||
|
||||
document_paths = []
|
||||
for parsed_doc in parsed_documents:
|
||||
file_path = temp_file_service.create_temp_file_path(
|
||||
f"{str(uuid.uuid4())}.txt", self.temp_dir
|
||||
)
|
||||
parsed_doc = parsed_doc.page_content.replace("<br>", "\n")
|
||||
with open(file_path, "w") as text_file:
|
||||
text_file.write(parsed_doc)
|
||||
document_paths.append(file_path)
|
||||
|
||||
documents = {}
|
||||
for index, each in enumerate(self.documents):
|
||||
documents[each] = document_paths[index]
|
||||
|
||||
response = DecomposeDocumentsResponse(
|
||||
documents=documents,
|
||||
)
|
||||
|
||||
logging_service.logger.info(
|
||||
logging_service.message(response.model_dump(mode="json")),
|
||||
extra=log_metadata.model_dump(),
|
||||
)
|
||||
|
||||
return response
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
from api.models import LogMetadata
|
||||
from api.services.logging import LoggingService
|
||||
from api.sql_models import PresentationSqlModel
|
||||
from api.services.database import get_sql_session
|
||||
|
||||
|
||||
class DeletePresentationHandler:
|
||||
|
||||
def __init__(self, id):
|
||||
self.id = id
|
||||
|
||||
async def delete(self, logging_service: LoggingService, log_metadata: LogMetadata):
|
||||
logging_service.logger.info(
|
||||
logging_service.message({"presentation": self.id}),
|
||||
extra=log_metadata.model_dump(),
|
||||
)
|
||||
|
||||
with get_sql_session() as sql_session:
|
||||
presentation = sql_session.get(PresentationSqlModel, self.id)
|
||||
sql_session.delete(presentation)
|
||||
sql_session.commit()
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
from api.models import LogMetadata
|
||||
from api.services.logging import LoggingService
|
||||
from api.services.database import get_sql_session
|
||||
from api.sql_models import SlideSqlModel
|
||||
|
||||
|
||||
class DeleteSlideHandler:
|
||||
|
||||
def __init__(self, id):
|
||||
self.id = id
|
||||
|
||||
async def delete(self, logging_service: LoggingService, log_metadata: LogMetadata):
|
||||
logging_service.logger.info(
|
||||
logging_service.message({"slide": self.id}),
|
||||
extra=log_metadata.model_dump(),
|
||||
)
|
||||
|
||||
with get_sql_session() as sql_session:
|
||||
slide = sql_session.get(SlideSqlModel, self.id)
|
||||
sql_session.delete(slide)
|
||||
sql_session.commit()
|
||||
231
servers/fastapi/api/routers/presentation/handlers/edit.py
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
import asyncio
|
||||
import os
|
||||
from typing import List, Tuple
|
||||
import uuid
|
||||
|
||||
from sqlmodel import select
|
||||
from api.models import LogMetadata
|
||||
from api.routers.presentation.models import (
|
||||
EditPresentationSlideRequest,
|
||||
)
|
||||
from api.services.instances import temp_file_service
|
||||
from api.services.logging import LoggingService
|
||||
from api.utils import get_presentation_dir
|
||||
from image_processor.images_finder import generate_image
|
||||
from image_processor.icons_finder import get_icon
|
||||
from ppt_generator.models.slide_model import SlideModel
|
||||
from ppt_generator.slide_generator import (
|
||||
get_edited_slide_content_model,
|
||||
get_slide_type_from_prompt,
|
||||
)
|
||||
from ppt_generator.slide_model_utils import SlideModelUtils
|
||||
from api.sql_models import PresentationSqlModel, SlideSqlModel
|
||||
from api.services.database import get_sql_session
|
||||
|
||||
|
||||
class PresentationEditHandler:
|
||||
def __init__(self, data: EditPresentationSlideRequest):
|
||||
self.data = data
|
||||
self.presentation_id = data.presentation_id
|
||||
|
||||
self.slide_index = data.index
|
||||
self.prompt = data.prompt
|
||||
|
||||
self.session = str(uuid.uuid4())
|
||||
self.temp_dir = temp_file_service.create_temp_dir(self.session)
|
||||
|
||||
self.presentation_dir = get_presentation_dir(self.presentation_id)
|
||||
|
||||
def __del__(self):
|
||||
temp_file_service.cleanup_temp_dir(self.temp_dir)
|
||||
|
||||
async def post(self, logging_service: LoggingService, log_metadata: LogMetadata):
|
||||
logging_service.logger.info(
|
||||
logging_service.message(self.data.model_dump(mode="json")),
|
||||
extra=log_metadata.model_dump(),
|
||||
)
|
||||
|
||||
with get_sql_session() as sql_session:
|
||||
presentation = sql_session.get(PresentationSqlModel, self.presentation_id)
|
||||
slide_to_edit = sql_session.exec(
|
||||
select(SlideSqlModel).where(SlideSqlModel.index == self.slide_index)
|
||||
).first()
|
||||
|
||||
slide_to_edit = SlideModel.from_dict(slide_to_edit.model_dump(mode="json"))
|
||||
|
||||
new_slide_type = await get_slide_type_from_prompt(
|
||||
self.prompt, slide_to_edit
|
||||
)
|
||||
|
||||
edited_content = await get_edited_slide_content_model(
|
||||
self.prompt,
|
||||
new_slide_type.slide_type,
|
||||
slide_to_edit,
|
||||
presentation.theme,
|
||||
presentation.language,
|
||||
)
|
||||
|
||||
new_slide_model = SlideModel(
|
||||
id=slide_to_edit.id,
|
||||
index=slide_to_edit.index,
|
||||
type=new_slide_type.slide_type,
|
||||
design_index=slide_to_edit.design_index,
|
||||
images=None,
|
||||
icons=None,
|
||||
presentation=slide_to_edit.presentation,
|
||||
content=edited_content,
|
||||
)
|
||||
|
||||
# Images to delete - is list of cloud paths
|
||||
# Images to generate - is list of index of images to generate
|
||||
images_to_delete, images_to_generate, icons_to_delete, icons_to_generate = (
|
||||
self.get_all_assets_to_generate_and_delete(
|
||||
slide_to_edit,
|
||||
new_slide_model,
|
||||
)
|
||||
)
|
||||
new_image_paths = slide_to_edit.images or []
|
||||
new_icon_paths = slide_to_edit.icons or []
|
||||
images_count = len(new_image_paths)
|
||||
icons_count = len(new_icon_paths)
|
||||
for index in images_to_generate:
|
||||
file_key = f"{self.presentation_dir}/images/{str(uuid.uuid4())}.jpg"
|
||||
if index < images_count:
|
||||
new_image_paths.pop(index)
|
||||
new_image_paths.insert(index, file_key)
|
||||
else:
|
||||
new_image_paths.append(file_key)
|
||||
for index in icons_to_generate:
|
||||
file_key = f"{self.presentation_dir}/icons/{str(uuid.uuid4())}.png"
|
||||
if index < icons_count:
|
||||
new_icon_paths.pop(index)
|
||||
new_icon_paths.insert(index, file_key)
|
||||
else:
|
||||
new_icon_paths.append(file_key)
|
||||
|
||||
if new_image_paths:
|
||||
new_slide_model.images = new_image_paths
|
||||
if new_icon_paths:
|
||||
new_slide_model.icons = new_icon_paths
|
||||
|
||||
# Generate and Delete Images and Icons
|
||||
objects_to_delete = [*images_to_delete, *icons_to_delete]
|
||||
if objects_to_delete:
|
||||
for each in objects_to_delete:
|
||||
os.remove(each)
|
||||
|
||||
new_image_prompts = {}
|
||||
new_icon_queries = {}
|
||||
if images_to_generate:
|
||||
slide_model_utils = SlideModelUtils(presentation.theme, new_slide_model)
|
||||
image_prompts = slide_model_utils.get_image_prompts()
|
||||
for image_index in images_to_generate:
|
||||
new_image_prompts[new_slide_model.images[image_index]] = (
|
||||
image_prompts[image_index]
|
||||
)
|
||||
|
||||
if icons_to_generate:
|
||||
slide_model_utils = SlideModelUtils(presentation.theme, new_slide_model)
|
||||
icon_queries = slide_model_utils.get_icon_queries()
|
||||
for icon_index in icons_to_generate:
|
||||
new_icon_queries[new_slide_model.icons[icon_index]] = icon_queries[
|
||||
icon_index
|
||||
]
|
||||
|
||||
coroutines = [
|
||||
generate_image(value, key) for key, value in new_image_prompts.items()
|
||||
] + [get_icon(value, key) for key, value in new_icon_queries.items()]
|
||||
|
||||
await asyncio.gather(*coroutines)
|
||||
|
||||
slide_to_edit.images = new_slide_model.images
|
||||
slide_to_edit.icons = new_slide_model.icons
|
||||
slide_to_edit.content = new_slide_model.content
|
||||
slide_to_edit.type = new_slide_type.slide_type
|
||||
sql_session.commit()
|
||||
sql_session.refresh(slide_to_edit)
|
||||
|
||||
logging_service.logger.info(
|
||||
logging_service.message(slide_to_edit.model_dump(mode="json")),
|
||||
extra=log_metadata.model_dump(),
|
||||
)
|
||||
return slide_to_edit
|
||||
|
||||
def get_all_assets_to_generate_and_delete(
|
||||
self,
|
||||
old_slide_model: SlideModel,
|
||||
new_slide_model: SlideModel,
|
||||
) -> Tuple[List[str], List[str], List[str], List[str]]:
|
||||
|
||||
images_to_delete, images_to_generate = self.get_assets_to_generate_and_delete(
|
||||
old_slide_model,
|
||||
new_slide_model,
|
||||
"image_prompts",
|
||||
"images",
|
||||
)
|
||||
|
||||
icons_to_delete, icons_to_generate = self.get_assets_to_generate_and_delete(
|
||||
old_slide_model,
|
||||
new_slide_model,
|
||||
"icon_queries",
|
||||
"icons",
|
||||
)
|
||||
|
||||
return images_to_delete, images_to_generate, icons_to_delete, icons_to_generate
|
||||
|
||||
def get_assets_to_generate_and_delete(
|
||||
self,
|
||||
old_slide_model: SlideModel,
|
||||
new_slide_model: SlideModel,
|
||||
content_attr: str,
|
||||
slide_model_attr: str,
|
||||
) -> Tuple[List[str], List[str]]:
|
||||
|
||||
items_to_delete = []
|
||||
items_to_generate = []
|
||||
|
||||
existing_paths = getattr(old_slide_model, slide_model_attr, [])
|
||||
new_content_items = getattr(new_slide_model.content, content_attr, [])
|
||||
old_content_items = getattr(old_slide_model.content, content_attr, [])
|
||||
|
||||
# Case 1: No new items but slide has existing items - delete all
|
||||
if not new_content_items and existing_paths:
|
||||
items_to_delete.extend(existing_paths)
|
||||
return items_to_delete, items_to_generate
|
||||
|
||||
# Case 2: New items but slide has no existing items - generate all
|
||||
if new_content_items and not existing_paths:
|
||||
items_to_generate = [idx for idx in range(len(new_content_items))]
|
||||
return items_to_delete, items_to_generate
|
||||
|
||||
# Case 3: Both new and existing items - compare and update
|
||||
if new_content_items and existing_paths:
|
||||
new_count = len(new_content_items)
|
||||
old_count = len(existing_paths)
|
||||
|
||||
generate_idx = []
|
||||
for idx in range(max(new_count, old_count)):
|
||||
# Generate additional new items
|
||||
if idx >= old_count:
|
||||
generate_idx.append(idx)
|
||||
# Delete excess old items
|
||||
elif idx >= new_count:
|
||||
items_to_delete.append(existing_paths[idx])
|
||||
# Compare and update changed items
|
||||
else:
|
||||
old_value = old_content_items[idx]
|
||||
new_value = new_content_items[idx]
|
||||
if old_value != new_value:
|
||||
items_to_delete.append(existing_paths[idx])
|
||||
generate_idx.append(idx)
|
||||
|
||||
if generate_idx:
|
||||
items_to_generate = generate_idx
|
||||
|
||||
filtered_items_to_delete = []
|
||||
for each in items_to_delete:
|
||||
if not each:
|
||||
continue
|
||||
filtered_items_to_delete.append(each)
|
||||
|
||||
return filtered_items_to_delete, items_to_generate
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
import os
|
||||
import uuid
|
||||
|
||||
from api.models import LogMetadata
|
||||
from api.routers.presentation.mixins.fetch_presentation_assets import (
|
||||
FetchPresentationAssetsMixin,
|
||||
)
|
||||
from api.routers.presentation.models import (
|
||||
ExportAsRequest,
|
||||
PresentationAndPath,
|
||||
)
|
||||
from api.services.instances import temp_file_service
|
||||
from api.services.logging import LoggingService
|
||||
from api.utils import get_presentation_dir
|
||||
from image_processor.image_from_pptx import get_pdf_from_pptx
|
||||
from ppt_generator.pptx_presentation_creator import PptxPresentationCreator
|
||||
|
||||
|
||||
class ExportAsPDFHandler(FetchPresentationAssetsMixin):
|
||||
|
||||
def __init__(self, data: ExportAsRequest):
|
||||
self.data = data
|
||||
|
||||
self.session = str(uuid.uuid4())
|
||||
self.temp_dir = temp_file_service.create_temp_dir(self.session)
|
||||
|
||||
self.presentation_dir = get_presentation_dir(self.data.presentation_id)
|
||||
|
||||
def __del__(self):
|
||||
temp_file_service.cleanup_temp_dir(self.temp_dir)
|
||||
|
||||
async def post(self, logging_service: LoggingService, log_metadata: LogMetadata):
|
||||
logging_service.logger.info(
|
||||
logging_service.message(self.data.model_dump(mode="json")),
|
||||
extra=log_metadata.model_dump(),
|
||||
)
|
||||
|
||||
await self.fetch_presentation_assets()
|
||||
|
||||
ppt_path = os.path.join(self.presentation_dir, "presentation.pptx")
|
||||
|
||||
ppt_creator = PptxPresentationCreator(self.data.pptx_model, self.temp_dir)
|
||||
ppt_creator.create_ppt()
|
||||
ppt_creator.save(ppt_path)
|
||||
|
||||
pdf_path = get_pdf_from_pptx(ppt_path, self.presentation_dir)
|
||||
|
||||
response = PresentationAndPath(
|
||||
presentation_id=self.data.presentation_id,
|
||||
path=pdf_path,
|
||||
)
|
||||
|
||||
logging_service.logger.info(
|
||||
logging_service.message(response.model_dump()),
|
||||
extra=log_metadata.model_dump(),
|
||||
)
|
||||
|
||||
return response
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
import os
|
||||
import uuid
|
||||
from api.models import LogMetadata
|
||||
from api.routers.presentation.mixins.fetch_presentation_assets import (
|
||||
FetchPresentationAssetsMixin,
|
||||
)
|
||||
from api.routers.presentation.models import (
|
||||
ExportAsRequest,
|
||||
PresentationAndPath,
|
||||
)
|
||||
from api.services.logging import LoggingService
|
||||
from api.services.instances import temp_file_service
|
||||
from api.sql_models import PresentationSqlModel
|
||||
from api.utils import get_presentation_dir
|
||||
from ppt_generator.pptx_presentation_creator import PptxPresentationCreator
|
||||
from api.services.database import get_sql_session
|
||||
|
||||
|
||||
class ExportAsPptxHandler(FetchPresentationAssetsMixin):
|
||||
|
||||
def __init__(self, data: ExportAsRequest):
|
||||
self.data = data
|
||||
|
||||
self.session = str(uuid.uuid4())
|
||||
self.temp_dir = temp_file_service.create_temp_dir(self.session)
|
||||
|
||||
self.presentation_dir = get_presentation_dir(self.data.presentation_id)
|
||||
|
||||
def __del__(self):
|
||||
temp_file_service.cleanup_temp_dir(self.temp_dir)
|
||||
|
||||
async def post(self, logging_service: LoggingService, log_metadata: LogMetadata):
|
||||
logging_service.logger.info(
|
||||
logging_service.message(self.data.model_dump(mode="json")),
|
||||
extra=log_metadata.model_dump(),
|
||||
)
|
||||
|
||||
await self.fetch_presentation_assets()
|
||||
|
||||
ppt_path = os.path.join(self.presentation_dir, "presentation.pptx")
|
||||
ppt_creator = PptxPresentationCreator(self.data.pptx_model, self.temp_dir)
|
||||
ppt_creator.create_ppt()
|
||||
ppt_creator.save(ppt_path)
|
||||
|
||||
response = PresentationAndPath(
|
||||
presentation_id=self.data.presentation_id, path=ppt_path
|
||||
)
|
||||
|
||||
with get_sql_session() as sql_session:
|
||||
presentation = sql_session.get(
|
||||
PresentationSqlModel, self.data.presentation_id
|
||||
)
|
||||
presentation.file = ppt_path
|
||||
sql_session.commit()
|
||||
|
||||
logging_service.logger.info(
|
||||
logging_service.message(response.model_dump(mode="json")),
|
||||
extra=log_metadata.model_dump(),
|
||||
)
|
||||
|
||||
return response
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import uuid
|
||||
|
||||
from fastapi import HTTPException
|
||||
from api.models import LogMetadata, SessionModel
|
||||
from api.routers.presentation.models import PresentationGenerateRequest
|
||||
from api.services.logging import LoggingService
|
||||
from api.sql_models import KeyValueSqlModel
|
||||
from api.services.database import get_sql_session
|
||||
|
||||
|
||||
class PresentationGenerateDataHandler:
|
||||
|
||||
def __init__(self, data: PresentationGenerateRequest):
|
||||
self.data = data
|
||||
self.session = str(uuid.uuid4())
|
||||
|
||||
async def post(self, logging_service: LoggingService, log_metadata: LogMetadata):
|
||||
logging_service.logger.info(
|
||||
logging_service.message(self.data.model_dump()),
|
||||
extra=log_metadata.model_dump(),
|
||||
)
|
||||
|
||||
if not self.data.titles:
|
||||
raise HTTPException(400, "Titles can not be empty")
|
||||
|
||||
key_value_model = KeyValueSqlModel(
|
||||
id=self.session,
|
||||
key=self.session,
|
||||
value=self.data.model_dump(mode="json"),
|
||||
)
|
||||
|
||||
with get_sql_session() as sql_session:
|
||||
sql_session.add(key_value_model)
|
||||
sql_session.commit()
|
||||
sql_session.refresh(key_value_model)
|
||||
|
||||
response = SessionModel(session=self.session)
|
||||
logging_service.logger.info(
|
||||
logging_service.message(response),
|
||||
extra=log_metadata.model_dump(),
|
||||
)
|
||||
|
||||
return response
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import uuid
|
||||
from api.models import LogMetadata
|
||||
from api.routers.presentation.models import (
|
||||
GenerateImageRequest,
|
||||
PresentationAndPaths,
|
||||
)
|
||||
from api.services.logging import LoggingService
|
||||
from api.services.instances import temp_file_service
|
||||
from api.utils import get_presentation_dir
|
||||
from image_processor.images_finder import generate_image
|
||||
|
||||
|
||||
class GenerateImageHandler:
|
||||
|
||||
def __init__(self, data: GenerateImageRequest):
|
||||
self.data = data
|
||||
|
||||
self.session = str(uuid.uuid4())
|
||||
self.temp_dir = temp_file_service.create_temp_dir(self.session)
|
||||
|
||||
self.presentation_dir = get_presentation_dir(self.data.presentation_id)
|
||||
|
||||
async def post(self, logging_service: LoggingService, log_metadata: LogMetadata):
|
||||
|
||||
logging_service.logger.info(
|
||||
logging_service.message(self.data.model_dump(mode="json")),
|
||||
extra=log_metadata.model_dump(),
|
||||
)
|
||||
|
||||
image_path = temp_file_service.create_temp_file_path(
|
||||
self.presentation_dir, "generated_images", str(uuid.uuid4()) + ".jpg"
|
||||
)
|
||||
await generate_image(self.data.prompt, image_path)
|
||||
|
||||
response = PresentationAndPaths(
|
||||
presentation_id=self.data.presentation_id, paths=[image_path]
|
||||
)
|
||||
|
||||
logging_service.logger.info(
|
||||
logging_service.message(response.model_dump(mode="json")),
|
||||
extra=log_metadata.model_dump(),
|
||||
)
|
||||
|
||||
return response
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
import uuid
|
||||
from api.models import LogMetadata
|
||||
from api.routers.presentation.models import GeneratePresentationRequirementsRequest
|
||||
from api.services.logging import LoggingService
|
||||
from api.services.database import get_sql_session
|
||||
from api.services.instances import temp_file_service
|
||||
from api.sql_models import PresentationSqlModel
|
||||
from document_processor.loader import DocumentsLoader
|
||||
from ppt_config_generator.document_summary_generator import generate_document_summary
|
||||
|
||||
|
||||
class GeneratePresentationRequirementsHandler:
|
||||
def __init__(
|
||||
self,
|
||||
presentation_id: str,
|
||||
data: GeneratePresentationRequirementsRequest,
|
||||
):
|
||||
self.data = data
|
||||
self.presentation_id = presentation_id
|
||||
self.prompt = data.prompt
|
||||
self.n_slides = data.n_slides
|
||||
self.documents = data.documents or []
|
||||
self.language = data.language
|
||||
self.research_reports = data.research_reports or []
|
||||
self.images = data.images or []
|
||||
|
||||
self.session = str(uuid.uuid4())
|
||||
self.temp_dir = temp_file_service.create_temp_dir(self.session)
|
||||
|
||||
async def post(self, logging_service: LoggingService, log_metadata: LogMetadata):
|
||||
logging_service.logger.info(
|
||||
logging_service.message(self.data.model_dump(mode="json")),
|
||||
extra=log_metadata.model_dump(),
|
||||
)
|
||||
|
||||
all_document_paths = [*self.documents, *self.research_reports]
|
||||
|
||||
documents_loader = DocumentsLoader(all_document_paths)
|
||||
await documents_loader.load_documents(self.temp_dir)
|
||||
|
||||
summary = await generate_document_summary(documents_loader.documents)
|
||||
|
||||
presentation = PresentationSqlModel(
|
||||
id=self.presentation_id,
|
||||
prompt=self.prompt,
|
||||
n_slides=self.n_slides,
|
||||
language=self.language,
|
||||
summary=summary,
|
||||
)
|
||||
|
||||
with get_sql_session() as sql_session:
|
||||
sql_session.add(presentation)
|
||||
sql_session.commit()
|
||||
sql_session.refresh(presentation)
|
||||
|
||||
logging_service.logger.info(
|
||||
logging_service.message(presentation.model_dump(mode="json")),
|
||||
extra=log_metadata.model_dump(),
|
||||
)
|
||||
|
||||
return presentation
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import uuid
|
||||
from api.models import LogMetadata
|
||||
from api.routers.presentation.models import GenerateResearchReportRequest
|
||||
from api.services.logging import LoggingService
|
||||
from api.services.instances import temp_file_service
|
||||
from research_report.generator import get_report
|
||||
|
||||
|
||||
class GenerateResearchReportHandler:
|
||||
def __init__(self, data: GenerateResearchReportRequest):
|
||||
self.data = data
|
||||
|
||||
self.session = str(uuid.uuid4())
|
||||
self.temp_dir = temp_file_service.create_temp_dir(self.session)
|
||||
|
||||
async def post(self, logging_service: LoggingService, log_metadata: LogMetadata):
|
||||
logging_service.logger.info(
|
||||
logging_service.message(self.data.model_dump(mode="json")),
|
||||
extra=log_metadata.model_dump(),
|
||||
)
|
||||
|
||||
report = await get_report(self.data.query, self.data.language)
|
||||
|
||||
file_name = f"{report[:30]}.txt"
|
||||
file_path = temp_file_service.create_temp_file_path(file_name, self.temp_dir)
|
||||
with open(file_path, "w") as text_file:
|
||||
text_file.write(report)
|
||||
|
||||
logging_service.logger.info(
|
||||
logging_service.message(file_path), extra=log_metadata.model_dump()
|
||||
)
|
||||
return file_path
|
||||
|
|
@ -0,0 +1,231 @@
|
|||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from typing import List
|
||||
import uuid
|
||||
|
||||
from fastapi import HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlmodel import delete, select
|
||||
|
||||
from api.models import LogMetadata, SSEResponse
|
||||
|
||||
from api.routers.presentation.models import (
|
||||
PresentationAndSlides,
|
||||
PresentationGenerateRequest,
|
||||
)
|
||||
from api.services.database import get_sql_session
|
||||
from api.services.logging import LoggingService
|
||||
from api.sql_models import KeyValueSqlModel, PresentationSqlModel, SlideSqlModel
|
||||
from api.utils import get_presentation_dir
|
||||
from image_processor.icons_vectorstore_utils import get_icons_vectorstore
|
||||
from image_processor.images_finder import generate_image
|
||||
from image_processor.icons_finder import get_icon
|
||||
from ppt_generator.generator import generate_presentation_stream
|
||||
from ppt_generator.models.llm_models import LLMPresentationModel
|
||||
from ppt_generator.models.slide_model import SlideModel
|
||||
from ppt_generator.slide_model_utils import SlideModelUtils
|
||||
from api.services.instances import temp_file_service
|
||||
from langchain_core.output_parsers import JsonOutputParser
|
||||
|
||||
output_parser = JsonOutputParser(pydantic_object=LLMPresentationModel)
|
||||
|
||||
|
||||
class PresentationGenerateStreamHandler:
|
||||
|
||||
def __init__(self, presentation_id: str, session: str):
|
||||
self.session = session
|
||||
self.presentation_id = presentation_id
|
||||
|
||||
self.temp_dir = temp_file_service.create_temp_dir(self.session)
|
||||
self.presentation_dir = get_presentation_dir(self.presentation_id)
|
||||
|
||||
def __del__(self):
|
||||
temp_file_service.cleanup_temp_dir(self.temp_dir)
|
||||
|
||||
async def get(self, *args, **kwargs):
|
||||
with get_sql_session() as sql_session:
|
||||
key_value_model = sql_session.get(KeyValueSqlModel, self.session)
|
||||
|
||||
if not key_value_model.value:
|
||||
raise HTTPException(400, "Data not found for provided session")
|
||||
|
||||
self.data = PresentationGenerateRequest(**key_value_model.value)
|
||||
|
||||
self.presentation_id = self.data.presentation_id
|
||||
self.theme = self.data.theme
|
||||
self.images = self.data.images
|
||||
self.titles = self.data.titles
|
||||
self.watermark = self.data.watermark
|
||||
|
||||
return StreamingResponse(
|
||||
self.get_stream(*args, **kwargs), media_type="text/event-stream"
|
||||
)
|
||||
|
||||
async def get_stream(
|
||||
self, logging_service: LoggingService, log_metadata: LogMetadata
|
||||
):
|
||||
logging_service.logger.info(
|
||||
logging_service.message(self.data.model_dump(mode="json")),
|
||||
extra=log_metadata.model_dump(),
|
||||
)
|
||||
|
||||
if not self.titles:
|
||||
raise HTTPException(400, "Titles can not be empty")
|
||||
|
||||
with get_sql_session() as sql_session:
|
||||
presentation = sql_session.get(PresentationSqlModel, self.presentation_id)
|
||||
presentation.n_slides = len(self.titles)
|
||||
presentation.titles = self.titles
|
||||
presentation.theme = self.theme
|
||||
sql_session.exec(
|
||||
delete(SlideSqlModel).where(
|
||||
SlideSqlModel.presentation == self.presentation_id
|
||||
)
|
||||
)
|
||||
sql_session.commit()
|
||||
sql_session.refresh(presentation)
|
||||
|
||||
yield SSEResponse(
|
||||
event="response", data=json.dumps({"status": "Analyzing information 📊"})
|
||||
).to_string()
|
||||
|
||||
presentation_text = ""
|
||||
async for chunk in generate_presentation_stream(
|
||||
self.titles,
|
||||
presentation.prompt or "create presentation",
|
||||
presentation.n_slides,
|
||||
presentation.language,
|
||||
presentation.summary,
|
||||
):
|
||||
presentation_text += chunk.content
|
||||
yield SSEResponse(
|
||||
event="response",
|
||||
data=json.dumps({"type": "chunk", "chunk": chunk.content}),
|
||||
).to_string()
|
||||
|
||||
presentation_json = output_parser.parse(presentation_text)
|
||||
|
||||
print("-" * 40)
|
||||
print(presentation_json)
|
||||
print("-" * 40)
|
||||
|
||||
slide_models: List[SlideModel] = []
|
||||
for i, content in enumerate(presentation_json["slides"]):
|
||||
content["index"] = i
|
||||
content["presentation"] = presentation.id
|
||||
slide_model = SlideModel(**content)
|
||||
slide_content = slide_model.content
|
||||
has_images = hasattr(slide_content, "image_prompts")
|
||||
has_icons = hasattr(slide_content, "icon_queries")
|
||||
|
||||
if has_images:
|
||||
slide_model.images = [
|
||||
os.path.join(
|
||||
self.presentation_dir,
|
||||
"images",
|
||||
f"{str(uuid.uuid4())}.jpg",
|
||||
)
|
||||
for _ in range(len(slide_content.image_prompts))
|
||||
]
|
||||
|
||||
if has_icons:
|
||||
slide_model.icons = [
|
||||
os.path.join(
|
||||
self.presentation_dir,
|
||||
"icons",
|
||||
f"{str(uuid.uuid4())}.png",
|
||||
)
|
||||
for _ in range(len(slide_content.icon_queries))
|
||||
]
|
||||
|
||||
slide_models.append(slide_model)
|
||||
|
||||
yield SSEResponse(
|
||||
event="response",
|
||||
data=json.dumps({"type": "status", "status": "Fetching slide assets"}),
|
||||
).to_string()
|
||||
|
||||
async for result in self.fetch_slide_assets(slide_models):
|
||||
yield result
|
||||
|
||||
slide_sql_models = [
|
||||
SlideSqlModel(**each.model_dump(mode="json")) for each in slide_models
|
||||
]
|
||||
|
||||
with get_sql_session() as sql_session:
|
||||
sql_session.add_all(slide_sql_models)
|
||||
sql_session.commit()
|
||||
for each in slide_sql_models:
|
||||
sql_session.refresh(each)
|
||||
|
||||
yield SSEResponse(
|
||||
event="response",
|
||||
data=json.dumps({"type": "status", "status": "Packing slide data"}),
|
||||
).to_string()
|
||||
|
||||
response = PresentationAndSlides(
|
||||
presentation=presentation, slides=slide_sql_models
|
||||
).to_response_dict()
|
||||
|
||||
yield SSEResponse(
|
||||
event="response",
|
||||
data=json.dumps({"type": "complete", "presentation": response}),
|
||||
).to_string()
|
||||
yield SSEResponse(
|
||||
event="response",
|
||||
data=json.dumps({"type": "closing", "content": "First Warning"}),
|
||||
).to_string()
|
||||
await asyncio.sleep(3)
|
||||
yield SSEResponse(
|
||||
event="response",
|
||||
data=json.dumps({"type": "closing", "content": "Final Warning"}),
|
||||
).to_string()
|
||||
|
||||
async def fetch_slide_assets(self, slide_models: List[SlideModel]):
|
||||
image_prompts = []
|
||||
icon_queries = []
|
||||
|
||||
image_paths = []
|
||||
icon_paths = []
|
||||
|
||||
for each_slide_model in slide_models:
|
||||
slide_model_utils = SlideModelUtils(self.theme, each_slide_model)
|
||||
|
||||
if each_slide_model.images:
|
||||
prompts = slide_model_utils.get_image_prompts()
|
||||
image_prompts.extend(prompts)
|
||||
image_paths.extend(each_slide_model.images)
|
||||
if each_slide_model.icons:
|
||||
icon_queries.extend(slide_model_utils.get_icon_queries())
|
||||
icon_paths.extend(each_slide_model.icons)
|
||||
|
||||
if icon_paths:
|
||||
icon_vector_store = get_icons_vectorstore()
|
||||
|
||||
coroutines = [
|
||||
generate_image(
|
||||
each,
|
||||
image_path,
|
||||
)
|
||||
for each, image_path in zip(image_prompts, image_paths)
|
||||
] + [
|
||||
get_icon(icon_vector_store, each, icon_path)
|
||||
for each, icon_path in zip(icon_queries, icon_paths)
|
||||
]
|
||||
|
||||
assets_future = asyncio.gather(*coroutines)
|
||||
|
||||
while not assets_future.done():
|
||||
status = SSEResponse(
|
||||
event="response",
|
||||
data=json.dumps({"status": "Fetching slide assets..."}),
|
||||
).to_string()
|
||||
yield status
|
||||
await asyncio.sleep(5)
|
||||
|
||||
await assets_future
|
||||
|
||||
yield SSEResponse(
|
||||
event="response", data=json.dumps({"status": "Slide assets fetched"})
|
||||
).to_string()
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import uuid
|
||||
|
||||
from api.models import LogMetadata
|
||||
from api.routers.presentation.models import GenerateTitleRequest
|
||||
from api.services.instances import temp_file_service
|
||||
from api.services.logging import LoggingService
|
||||
from api.sql_models import PresentationSqlModel
|
||||
from ppt_config_generator.models import PresentationTitlesModel
|
||||
from ppt_config_generator.ppt_title_summary_generator import generate_ppt_titles
|
||||
from api.services.database import get_sql_session
|
||||
|
||||
|
||||
class PresentationTitlesGenerateHandler:
|
||||
def __init__(self, data: GenerateTitleRequest):
|
||||
self.data = data
|
||||
|
||||
self.session = str(uuid.uuid4())
|
||||
self.temp_dir = temp_file_service.create_temp_dir(self.session)
|
||||
|
||||
def __del__(self):
|
||||
temp_file_service.cleanup_temp_dir(self.temp_dir)
|
||||
|
||||
async def post(self, logging_service: LoggingService, log_metadata: LogMetadata):
|
||||
|
||||
logging_service.logger.info(
|
||||
logging_service.message(self.data.model_dump(mode="json")),
|
||||
extra=log_metadata.model_dump(),
|
||||
)
|
||||
|
||||
with get_sql_session() as sql_session:
|
||||
presentation = sql_session.get(
|
||||
PresentationSqlModel, self.data.presentation_id
|
||||
)
|
||||
|
||||
presentation_titles: PresentationTitlesModel = await generate_ppt_titles(
|
||||
presentation.prompt,
|
||||
presentation.n_slides,
|
||||
presentation.summary,
|
||||
presentation.language,
|
||||
)
|
||||
|
||||
presentation.title = presentation_titles.presentation_title
|
||||
presentation.titles = presentation_titles.titles
|
||||
|
||||
sql_session.commit()
|
||||
sql_session.refresh(presentation)
|
||||
|
||||
logging_service.logger.info(
|
||||
logging_service.message(presentation.model_dump(mode="json")),
|
||||
extra=log_metadata.model_dump(),
|
||||
)
|
||||
|
||||
return presentation
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import asyncio
|
||||
|
||||
from sqlmodel import select
|
||||
from api.models import LogMetadata
|
||||
from api.routers.presentation.models import PresentationAndSlides
|
||||
from api.services.logging import LoggingService
|
||||
from api.sql_models import PresentationSqlModel, SlideSqlModel
|
||||
from api.services.database import get_sql_session
|
||||
|
||||
|
||||
class GetPresentationHandler:
|
||||
|
||||
def __init__(self, id: str):
|
||||
self.id = id
|
||||
|
||||
async def get(self, logging_service: LoggingService, log_metadata: LogMetadata):
|
||||
|
||||
logging_service.logger.info(
|
||||
logging_service.message({"presentation": self.id}),
|
||||
extra=log_metadata.model_dump(),
|
||||
)
|
||||
|
||||
with get_sql_session() as sql_session:
|
||||
presentation = sql_session.get(PresentationSqlModel, self.id)
|
||||
slide_models = sql_session.exec(
|
||||
select(SlideSqlModel).where(SlideSqlModel.presentation == self.id)
|
||||
).all()
|
||||
|
||||
response = PresentationAndSlides(
|
||||
presentation=presentation, slides=slide_models
|
||||
).to_response_dict()
|
||||
|
||||
logging_service.logger.info(
|
||||
logging_service.message(response),
|
||||
extra=log_metadata.model_dump(),
|
||||
)
|
||||
return response
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
from sqlmodel import select
|
||||
from api.models import LogMetadata
|
||||
from api.services.logging import LoggingService
|
||||
from api.sql_models import PresentationSqlModel
|
||||
from api.services.database import get_sql_session
|
||||
|
||||
|
||||
class GetPresentationsHandler:
|
||||
|
||||
async def get(self, logging_service: LoggingService, log_metadata: LogMetadata):
|
||||
|
||||
with get_sql_session() as sql_session:
|
||||
presentations = sql_session.exec(select(PresentationSqlModel)).all()
|
||||
|
||||
for each in presentations:
|
||||
each.data = None
|
||||
each.summary = None
|
||||
|
||||
logging_service.logger.info(
|
||||
logging_service.message(
|
||||
[each.model_dump(mode="json") for each in presentations]
|
||||
),
|
||||
extra=log_metadata.model_dump(),
|
||||
)
|
||||
return presentations
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
import uuid
|
||||
from api.models import LogMetadata
|
||||
from api.routers.presentation.models import (
|
||||
PresentationAndPaths,
|
||||
SearchIconRequest,
|
||||
)
|
||||
from api.services.logging import LoggingService
|
||||
from image_processor.icons_finder import get_icons
|
||||
from api.services.instances import temp_file_service
|
||||
from image_processor.icons_vectorstore_utils import get_icons_vectorstore
|
||||
|
||||
|
||||
class SearchIconHandler:
|
||||
|
||||
def __init__(self, data: SearchIconRequest):
|
||||
self.data = data
|
||||
|
||||
self.session = str(uuid.uuid4())
|
||||
self.temp_dir = temp_file_service.create_temp_dir(self.session)
|
||||
|
||||
async def post(self, logging_service: LoggingService, log_metadata: LogMetadata):
|
||||
|
||||
logging_service.logger.info(
|
||||
logging_service.message(self.data.model_dump(mode="json")),
|
||||
extra=log_metadata.model_dump(),
|
||||
)
|
||||
|
||||
vector_store = get_icons_vectorstore()
|
||||
|
||||
icon_paths = await get_icons(
|
||||
vector_store,
|
||||
self.data.query or "",
|
||||
self.data.page,
|
||||
self.data.limit,
|
||||
self.data.category,
|
||||
self.temp_dir,
|
||||
)
|
||||
|
||||
response = PresentationAndPaths(
|
||||
presentation_id=self.data.presentation_id, paths=icon_paths
|
||||
)
|
||||
|
||||
logging_service.logger.info(
|
||||
logging_service.message(response.model_dump(mode="json")),
|
||||
extra=log_metadata.model_dump(),
|
||||
)
|
||||
|
||||
return response
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import uuid
|
||||
from api.models import LogMetadata
|
||||
from api.routers.presentation.models import PresentationAndUrls, SearchImageRequest
|
||||
from api.services.logging import LoggingService
|
||||
|
||||
|
||||
class SearchImageHandler:
|
||||
|
||||
def __init__(self, data: SearchImageRequest):
|
||||
self.data = data
|
||||
|
||||
self.session = str(uuid.uuid4())
|
||||
|
||||
async def post(self, logging_service: LoggingService, log_metadata: LogMetadata):
|
||||
|
||||
logging_service.logger.info(
|
||||
logging_service.message(self.data.model_dump(mode="json")),
|
||||
extra=log_metadata.model_dump(),
|
||||
)
|
||||
|
||||
response = PresentationAndUrls(
|
||||
presentation_id=self.data.presentation_id, urls=[]
|
||||
)
|
||||
|
||||
logging_service.logger.info(
|
||||
logging_service.message(response.model_dump(mode="json")),
|
||||
extra=log_metadata.model_dump(),
|
||||
)
|
||||
|
||||
return response
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
from fastapi import UploadFile
|
||||
|
||||
from api.models import LogMetadata
|
||||
from api.services.logging import LoggingService
|
||||
|
||||
|
||||
class UpdateParsedDocumentHandler:
|
||||
|
||||
def __init__(self, file_path: str, file: UploadFile):
|
||||
self.file_path = file_path
|
||||
self.file = file
|
||||
|
||||
async def post(self, logging_service: LoggingService, log_metadata: LogMetadata):
|
||||
logging_service.logger.info(
|
||||
logging_service.message({"path": self.file_path, "file": self.file}),
|
||||
extra=log_metadata.model_dump(),
|
||||
)
|
||||
|
||||
with open(self.file_path, "wb") as f:
|
||||
f.write(await self.file.read())
|
||||
|
||||
return {"message": "File saved successfully"}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
from api.models import LogMetadata
|
||||
from api.routers.presentation.models import UpdatePresentationThemeRequest
|
||||
from api.services.logging import LoggingService
|
||||
from api.sql_models import PreferencesSqlModel, PresentationSqlModel
|
||||
from api.services.database import get_sql_session
|
||||
|
||||
|
||||
class UpdatePresentationThemeHandler:
|
||||
|
||||
def __init__(self, data: UpdatePresentationThemeRequest):
|
||||
self.data = data
|
||||
|
||||
async def post(self, logging_service: LoggingService, log_metadata: LogMetadata):
|
||||
logging_service.logger.info(
|
||||
logging_service.message(self.data.model_dump(mode="json")),
|
||||
extra=log_metadata.model_dump(),
|
||||
)
|
||||
|
||||
with get_sql_session() as sql_session:
|
||||
presentation = sql_session.get(
|
||||
PresentationSqlModel, self.data.presentation_id
|
||||
)
|
||||
preferences = sql_session.get(PreferencesSqlModel, 0)
|
||||
|
||||
if not preferences:
|
||||
preferences = PreferencesSqlModel(id=0, theme=None)
|
||||
sql_session.add(preferences)
|
||||
sql_session.commit()
|
||||
sql_session.refresh(preferences)
|
||||
|
||||
if self.data.theme:
|
||||
theme_name = self.data.theme.get("name", None)
|
||||
if theme_name and theme_name.lower() == "custom":
|
||||
preferences.theme = self.data.theme
|
||||
|
||||
presentation.theme = self.data.theme
|
||||
sql_session.commit()
|
||||
|
||||
return {"message": "Theme updated successfully"}
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
import os
|
||||
from typing import List
|
||||
from urllib.parse import unquote, urlparse
|
||||
import uuid
|
||||
|
||||
from sqlmodel import delete
|
||||
from api.models import LogMetadata
|
||||
from api.routers.presentation.models import (
|
||||
PresentationUpdateRequest,
|
||||
PresentationAndSlides,
|
||||
)
|
||||
from api.services.logging import LoggingService
|
||||
from api.sql_models import PresentationSqlModel, SlideSqlModel
|
||||
from api.utils import download_files, get_presentation_dir, replace_file_name
|
||||
from api.services.database import get_sql_session
|
||||
from api.services.instances import temp_file_service
|
||||
|
||||
|
||||
class UpdateSlideModelsHandler:
|
||||
|
||||
def __init__(self, data: PresentationUpdateRequest):
|
||||
self.data = data
|
||||
self.presentation_id = data.presentation_id
|
||||
self.session = str(uuid.uuid4())
|
||||
self.temp_dir = temp_file_service.create_temp_dir()
|
||||
|
||||
self.presentation_dir = get_presentation_dir(self.presentation_id)
|
||||
|
||||
def __del__(self):
|
||||
temp_file_service.cleanup_temp_dir(self.temp_dir)
|
||||
|
||||
async def post(self, logging_service: LoggingService, log_metadata: LogMetadata):
|
||||
logging_service.logger.info(
|
||||
logging_service.message(self.data.model_dump(mode="json")),
|
||||
extra=log_metadata.model_dump(),
|
||||
)
|
||||
|
||||
presentation_id = self.data.presentation_id
|
||||
new_slides = self.data.slides
|
||||
|
||||
# Handle assets (images and icons)
|
||||
assets_local_paths = []
|
||||
assets_download_links = []
|
||||
for new_slide in new_slides:
|
||||
new_images = new_slide.images or []
|
||||
new_icons = new_slide.icons or []
|
||||
|
||||
for new_assets, asset_type in [
|
||||
(new_images, "images"),
|
||||
(new_icons, "icons"),
|
||||
]:
|
||||
for i, asset in enumerate(new_assets):
|
||||
if asset.startswith("http"):
|
||||
parsed_url = unquote(urlparse(asset).path)
|
||||
image_name = replace_file_name(
|
||||
os.path.basename(parsed_url), str(uuid.uuid4())
|
||||
)
|
||||
asset_path = (
|
||||
f"{self.presentation_dir}/{asset_type}/{image_name}"
|
||||
)
|
||||
assets_local_paths.append(asset_path)
|
||||
assets_download_links.append(asset)
|
||||
getattr(new_slide, asset_type)[i] = asset_path
|
||||
|
||||
if assets_download_links:
|
||||
await download_files(assets_download_links, assets_local_paths)
|
||||
|
||||
with get_sql_session() as sql_session:
|
||||
slide_sql_models = [
|
||||
SlideSqlModel(**each.model_dump(mode="json")) for each in new_slides
|
||||
]
|
||||
to_update_slides_ids = [each.id for each in slide_sql_models]
|
||||
sql_session.exec(
|
||||
delete(SlideSqlModel).where(SlideSqlModel.id.in_(to_update_slides_ids))
|
||||
)
|
||||
sql_session.add_all(slide_sql_models)
|
||||
sql_session.commit()
|
||||
for each in slide_sql_models:
|
||||
sql_session.refresh(each)
|
||||
presentation = sql_session.get(PresentationSqlModel, presentation_id)
|
||||
|
||||
response = PresentationAndSlides(
|
||||
presentation=presentation, slides=slide_sql_models
|
||||
)
|
||||
response = response.to_response_dict()
|
||||
|
||||
logging_service.logger.info(
|
||||
logging_service.message(response),
|
||||
extra=log_metadata.model_dump(),
|
||||
)
|
||||
return response
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
from typing import List, Optional
|
||||
import uuid
|
||||
from fastapi import UploadFile
|
||||
from api.models import LogMetadata
|
||||
from api.routers.presentation.models import DocumentsAndImagesPath
|
||||
from api.services.logging import LoggingService
|
||||
from api.validators import validate_files
|
||||
from document_processor.loader import UPLOAD_ACCEPTED_DOCUMENTS
|
||||
from api.services.instances import temp_file_service
|
||||
|
||||
|
||||
class UploadFilesHandler:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
documents: Optional[List[UploadFile]],
|
||||
images: Optional[List[UploadFile]],
|
||||
):
|
||||
self.documents = documents
|
||||
self.images = images
|
||||
|
||||
self.session = str(uuid.uuid4())
|
||||
self.temp_dir = temp_file_service.create_temp_dir(self.session)
|
||||
print("Upload Temp Dir: " + self.temp_dir)
|
||||
|
||||
async def post(self, logging_service: LoggingService, log_metadata: LogMetadata):
|
||||
logging_service.logger.info(
|
||||
logging_service.message(
|
||||
{
|
||||
"documents": self.documents,
|
||||
"images": self.images,
|
||||
}
|
||||
),
|
||||
extra=log_metadata.model_dump(),
|
||||
)
|
||||
|
||||
validate_files(self.documents, True, True, 50, UPLOAD_ACCEPTED_DOCUMENTS)
|
||||
validate_files(
|
||||
self.images, True, True, 10, ["image/jpeg", "image/png", "image/webp"]
|
||||
)
|
||||
|
||||
self.documents = self.documents or []
|
||||
self.images = self.images or []
|
||||
|
||||
temp_documents: List[str] = []
|
||||
if self.documents or self.images:
|
||||
all_documents = self.documents + self.images
|
||||
for doc in all_documents:
|
||||
temp_path = temp_file_service.create_temp_file_path(
|
||||
doc.filename, self.temp_dir
|
||||
)
|
||||
with open(temp_path, "wb") as f:
|
||||
content = await doc.read()
|
||||
f.write(content)
|
||||
|
||||
temp_documents.append(temp_path)
|
||||
|
||||
documents_count = len(temp_documents)
|
||||
response = DocumentsAndImagesPath(
|
||||
documents=temp_documents[:documents_count],
|
||||
images=temp_documents[documents_count:],
|
||||
)
|
||||
|
||||
logging_service.logger.info(
|
||||
logging_service.message(response.model_dump(mode="json")),
|
||||
extra=log_metadata.model_dump(),
|
||||
)
|
||||
|
||||
return response
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import os
|
||||
import uuid
|
||||
from fastapi import UploadFile
|
||||
|
||||
from api.models import LogMetadata
|
||||
from api.routers.presentation.models import PresentationAndPath
|
||||
from api.services.logging import LoggingService
|
||||
from api.services.instances import temp_file_service
|
||||
from api.sql_models import PresentationSqlModel
|
||||
from api.services.database import get_sql_session
|
||||
from api.utils import get_presentation_dir
|
||||
|
||||
|
||||
class UploadPresentationThumbnailHandler:
|
||||
|
||||
def __init__(self, presentation_id: str, thumbnail: UploadFile):
|
||||
self.presentation_id = presentation_id
|
||||
self.thumbnail = thumbnail
|
||||
|
||||
self.session = str(uuid.uuid4())
|
||||
self.temp_dir = temp_file_service.create_temp_dir(self.session)
|
||||
|
||||
self.presentation_dir = get_presentation_dir(self.presentation_id)
|
||||
|
||||
def __del__(self):
|
||||
temp_file_service.cleanup_temp_dir(self.temp_dir)
|
||||
|
||||
async def post(self, logging_service: LoggingService, log_metadata: LogMetadata):
|
||||
logging_service.logger.info(
|
||||
logging_service.message(
|
||||
{
|
||||
"presentation_id": self.presentation_id,
|
||||
"thumbnail": self.thumbnail,
|
||||
}
|
||||
),
|
||||
extra=log_metadata.model_dump(),
|
||||
)
|
||||
|
||||
with get_sql_session() as sql_session:
|
||||
presentation = sql_session.get(PresentationSqlModel, self.presentation_id)
|
||||
|
||||
with open(os.path.join(self.presentation_dir, "thumbnail.jpg"), "wb") as f:
|
||||
f.write(await self.thumbnail.read())
|
||||
|
||||
presentation.thumbnail = os.path.join(
|
||||
self.presentation_dir, "thumbnail.jpg"
|
||||
)
|
||||
sql_session.commit()
|
||||
sql_session.refresh(presentation)
|
||||
|
||||
response = PresentationAndPath(
|
||||
presentation_id=self.presentation_id, path=presentation.thumbnail
|
||||
)
|
||||
logging_service.logger.info(
|
||||
logging_service.message(response.model_dump(mode="json")),
|
||||
extra=log_metadata.model_dump(),
|
||||
)
|
||||
|
||||
return response
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import os
|
||||
from urllib.parse import unquote, urlparse
|
||||
import uuid
|
||||
from api.utils import download_files, replace_file_name
|
||||
from ppt_generator.models.pptx_models import PptxPictureBoxModel
|
||||
|
||||
|
||||
class FetchPresentationAssetsMixin:
|
||||
|
||||
async def fetch_presentation_assets(self):
|
||||
image_urls = []
|
||||
image_local_paths = []
|
||||
|
||||
for each_slide in self.data.pptx_model.slides:
|
||||
for each_shape in each_slide.shapes:
|
||||
if isinstance(each_shape, PptxPictureBoxModel):
|
||||
image_path = each_shape.picture.path
|
||||
if image_path.startswith("http"):
|
||||
image_urls.append(image_path)
|
||||
parsed_url = unquote(urlparse(image_path).path)
|
||||
image_name = replace_file_name(
|
||||
os.path.basename(parsed_url), str(uuid.uuid4())
|
||||
)
|
||||
image_path = os.path.join(self.temp_dir, image_name)
|
||||
image_local_paths.append(image_path)
|
||||
|
||||
each_shape.picture.path = image_path
|
||||
each_shape.picture.is_network = False
|
||||
|
||||
await download_files(image_urls, image_local_paths)
|
||||
138
servers/fastapi/api/routers/presentation/models.py
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
from typing import List, Optional
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ppt_generator.models.pptx_models import PptxPresentationModel
|
||||
from ppt_generator.models.query_and_prompt_models import (
|
||||
IconCategoryEnum,
|
||||
ImagePromptWithThemeAndAspectRatio,
|
||||
)
|
||||
from ppt_generator.models.slide_model import SlideModel
|
||||
from api.sql_models import PresentationSqlModel, SlideSqlModel
|
||||
|
||||
|
||||
class DocumentsAndImagesPath(BaseModel):
|
||||
documents: Optional[List[str]] = None
|
||||
images: Optional[List[str]] = None
|
||||
|
||||
|
||||
class GenerateResearchReportRequest(BaseModel):
|
||||
language: Optional[str] = None
|
||||
query: str
|
||||
|
||||
|
||||
class DecomposeDocumentsRequest(DocumentsAndImagesPath):
|
||||
pass
|
||||
|
||||
|
||||
class GeneratePresentationRequirementsRequest(BaseModel):
|
||||
prompt: Optional[str] = None
|
||||
n_slides: int
|
||||
language: str
|
||||
documents: Optional[List[str]] = None
|
||||
research_reports: Optional[List[str]] = None
|
||||
images: Optional[List[str]] = None
|
||||
|
||||
|
||||
class GenerateTitleRequest(BaseModel):
|
||||
presentation_id: str
|
||||
|
||||
|
||||
class PresentationGenerateRequest(BaseModel):
|
||||
presentation_id: str
|
||||
theme: Optional[dict] = None
|
||||
images: Optional[List[str]] = None
|
||||
watermark: bool = True
|
||||
titles: List[str]
|
||||
|
||||
|
||||
class GenerateImageRequest(BaseModel):
|
||||
presentation_id: str
|
||||
prompt: ImagePromptWithThemeAndAspectRatio
|
||||
|
||||
|
||||
class SearchImageRequest(BaseModel):
|
||||
presentation_id: str
|
||||
query: Optional[str] = None
|
||||
page: int = 1
|
||||
limit: int = 10
|
||||
|
||||
|
||||
class SearchIconRequest(BaseModel):
|
||||
presentation_id: str
|
||||
query: Optional[str] = None
|
||||
category: Optional[IconCategoryEnum] = None
|
||||
page: int = 1
|
||||
limit: int = 10
|
||||
|
||||
|
||||
class SlideEditRequest(BaseModel):
|
||||
index: int
|
||||
prompt: str
|
||||
|
||||
|
||||
class EditPresentationRequest(BaseModel):
|
||||
presentation_id: str
|
||||
watermark: bool = True
|
||||
changes: List[SlideEditRequest]
|
||||
|
||||
|
||||
class EditPresentationSlideRequest(BaseModel):
|
||||
presentation_id: str
|
||||
index: int
|
||||
prompt: str
|
||||
|
||||
|
||||
class UpdatePresentationThemeRequest(BaseModel):
|
||||
presentation_id: str
|
||||
theme: Optional[dict] = None
|
||||
|
||||
|
||||
class ExportAsRequest(BaseModel):
|
||||
presentation_id: str
|
||||
pptx_model: PptxPresentationModel
|
||||
|
||||
|
||||
class DecomposeDocumentsResponse(BaseModel):
|
||||
documents: dict
|
||||
|
||||
|
||||
class PresentationAndSlides(BaseModel):
|
||||
presentation: PresentationSqlModel
|
||||
slides: List[SlideSqlModel]
|
||||
|
||||
def to_response_dict(self):
|
||||
presentation = self.presentation.model_dump(mode="json")
|
||||
return {
|
||||
"presentation": presentation,
|
||||
"slides": [each.model_dump(mode="json") for each in self.slides],
|
||||
}
|
||||
|
||||
|
||||
class PresentationUpdateRequest(BaseModel):
|
||||
presentation_id: str
|
||||
slides: List[SlideModel]
|
||||
|
||||
|
||||
class PresentationAndUrl(BaseModel):
|
||||
presentation_id: str
|
||||
url: str
|
||||
|
||||
|
||||
class PresentationAndUrls(BaseModel):
|
||||
presentation_id: str
|
||||
urls: List[str]
|
||||
|
||||
|
||||
class PresentationAndPath(BaseModel):
|
||||
presentation_id: str
|
||||
path: str
|
||||
|
||||
|
||||
class PresentationAndPaths(BaseModel):
|
||||
presentation_id: str
|
||||
paths: List[str]
|
||||
|
||||
|
||||
class UpdatePresentationTitlesRequest(BaseModel):
|
||||
presentation_id: str
|
||||
titles: List[str]
|
||||
335
servers/fastapi/api/routers/presentation/router.py
Normal file
|
|
@ -0,0 +1,335 @@
|
|||
from typing import Annotated, List, Optional
|
||||
import uuid
|
||||
from fastapi import APIRouter, Body, File, UploadFile, Depends
|
||||
|
||||
from api.models import SessionModel
|
||||
from api.request_utils import RequestUtils
|
||||
from api.routers.presentation.handlers.decompose_documents import (
|
||||
DecomposeDocumentsHandler,
|
||||
)
|
||||
from api.routers.presentation.handlers.delete_presentation import (
|
||||
DeletePresentationHandler,
|
||||
)
|
||||
from api.routers.presentation.handlers.delete_slide import DeleteSlideHandler
|
||||
from api.routers.presentation.handlers.edit import PresentationEditHandler
|
||||
from api.routers.presentation.handlers.export_as_pdf import ExportAsPDFHandler
|
||||
from api.routers.presentation.handlers.export_as_pptx import ExportAsPptxHandler
|
||||
from api.routers.presentation.handlers.generate_data import (
|
||||
PresentationGenerateDataHandler,
|
||||
)
|
||||
from api.routers.presentation.handlers.generate_image import GenerateImageHandler
|
||||
from api.routers.presentation.handlers.generate_presentation_requirements import (
|
||||
GeneratePresentationRequirementsHandler,
|
||||
)
|
||||
from api.routers.presentation.handlers.generate_research_report import (
|
||||
GenerateResearchReportHandler,
|
||||
)
|
||||
from api.routers.presentation.handlers.generate_stream import (
|
||||
PresentationGenerateStreamHandler,
|
||||
)
|
||||
from api.routers.presentation.handlers.generate_titles import (
|
||||
PresentationTitlesGenerateHandler,
|
||||
)
|
||||
from api.routers.presentation.handlers.get_presentation import GetPresentationHandler
|
||||
from api.routers.presentation.handlers.get_presentations import GetPresentationsHandler
|
||||
from api.routers.presentation.handlers.search_icon import SearchIconHandler
|
||||
from api.routers.presentation.handlers.search_image import SearchImageHandler
|
||||
from api.routers.presentation.handlers.update_parsed_document import (
|
||||
UpdateParsedDocumentHandler,
|
||||
)
|
||||
from api.routers.presentation.handlers.update_presentation_theme import (
|
||||
UpdatePresentationThemeHandler,
|
||||
)
|
||||
from api.routers.presentation.handlers.update_slide_models import (
|
||||
UpdateSlideModelsHandler,
|
||||
)
|
||||
from api.routers.presentation.handlers.upload_files import UploadFilesHandler
|
||||
from api.routers.presentation.handlers.upload_presentation_thumbnail import (
|
||||
UploadPresentationThumbnailHandler,
|
||||
)
|
||||
from api.routers.presentation.models import (
|
||||
DecomposeDocumentsRequest,
|
||||
DecomposeDocumentsResponse,
|
||||
DocumentsAndImagesPath,
|
||||
EditPresentationSlideRequest,
|
||||
ExportAsRequest,
|
||||
GenerateImageRequest,
|
||||
GeneratePresentationRequirementsRequest,
|
||||
GenerateResearchReportRequest,
|
||||
PresentationAndPath,
|
||||
PresentationAndSlides,
|
||||
GenerateTitleRequest,
|
||||
PresentationAndUrl,
|
||||
PresentationAndUrls,
|
||||
PresentationGenerateRequest,
|
||||
SearchIconRequest,
|
||||
SearchImageRequest,
|
||||
UpdatePresentationThemeRequest,
|
||||
PresentationUpdateRequest,
|
||||
)
|
||||
from api.sql_models import PresentationSqlModel
|
||||
from api.utils import handle_errors
|
||||
from ppt_generator.models.slide_model import SlideModel
|
||||
|
||||
presentation_router = APIRouter(prefix="/ppt")
|
||||
|
||||
|
||||
@presentation_router.get(
|
||||
"/user_presentations", response_model=List[PresentationSqlModel]
|
||||
)
|
||||
async def get_user_presentations():
|
||||
request_utils = RequestUtils("/ppt/user_presentations")
|
||||
logging_service, log_metadata = await request_utils.initialize_logger()
|
||||
return await handle_errors(
|
||||
GetPresentationsHandler().get, logging_service, log_metadata
|
||||
)
|
||||
|
||||
|
||||
@presentation_router.get("/presentation", response_model=PresentationAndSlides)
|
||||
async def get_presentation_from_id(presentation_id: str):
|
||||
request_utils = RequestUtils("/ppt/presentation")
|
||||
logging_service, log_metadata = await request_utils.initialize_logger(
|
||||
presentation_id=presentation_id,
|
||||
)
|
||||
return await handle_errors(
|
||||
GetPresentationHandler(presentation_id).get, logging_service, log_metadata
|
||||
)
|
||||
|
||||
|
||||
@presentation_router.post("/files/upload", response_model=DocumentsAndImagesPath)
|
||||
async def upload_files(
|
||||
documents: Annotated[Optional[List[UploadFile]], File()] = None,
|
||||
images: Annotated[Optional[List[UploadFile]], File()] = None,
|
||||
):
|
||||
request_utils = RequestUtils("/ppt/files/upload")
|
||||
logging_service, log_metadata = await request_utils.initialize_logger()
|
||||
return await handle_errors(
|
||||
UploadFilesHandler(documents, images).post,
|
||||
logging_service,
|
||||
log_metadata,
|
||||
)
|
||||
|
||||
|
||||
@presentation_router.post("/report/generate", response_model=str)
|
||||
async def generate_research_report(
|
||||
data: GenerateResearchReportRequest,
|
||||
):
|
||||
request_utils = RequestUtils("/ppt/report/generate")
|
||||
logging_service, log_metadata = await request_utils.initialize_logger()
|
||||
return await handle_errors(
|
||||
GenerateResearchReportHandler(data).post, logging_service, log_metadata
|
||||
)
|
||||
|
||||
|
||||
@presentation_router.post("/files/decompose", response_model=DecomposeDocumentsResponse)
|
||||
async def decompose_documents(data: DecomposeDocumentsRequest):
|
||||
request_utils = RequestUtils("/ppt/files/decompose")
|
||||
logging_service, log_metadata = await request_utils.initialize_logger()
|
||||
return await handle_errors(
|
||||
DecomposeDocumentsHandler(data).post, logging_service, log_metadata
|
||||
)
|
||||
|
||||
|
||||
@presentation_router.post("/document/update")
|
||||
async def update_document(
|
||||
path: Annotated[str, Body()],
|
||||
file: Annotated[UploadFile, File()],
|
||||
):
|
||||
request_utils = RequestUtils("/ppt/document/update")
|
||||
logging_service, log_metadata = await request_utils.initialize_logger()
|
||||
return await handle_errors(
|
||||
UpdateParsedDocumentHandler(path, file).post,
|
||||
logging_service,
|
||||
log_metadata,
|
||||
)
|
||||
|
||||
|
||||
@presentation_router.post("/create", response_model=PresentationSqlModel)
|
||||
async def create_presentation(
|
||||
data: GeneratePresentationRequirementsRequest,
|
||||
):
|
||||
request_utils = RequestUtils("/ppt/create")
|
||||
presentation_id = str(uuid.uuid4())
|
||||
logging_service, log_metadata = await request_utils.initialize_logger(
|
||||
presentation_id=presentation_id,
|
||||
)
|
||||
return await handle_errors(
|
||||
GeneratePresentationRequirementsHandler(presentation_id, data).post,
|
||||
logging_service,
|
||||
log_metadata,
|
||||
)
|
||||
|
||||
|
||||
@presentation_router.post("/titles/generate", response_model=PresentationSqlModel)
|
||||
async def generate_titles(data: GenerateTitleRequest):
|
||||
request_utils = RequestUtils("/ppt/titles/generate")
|
||||
logging_service, log_metadata = await request_utils.initialize_logger(
|
||||
presentation_id=data.presentation_id,
|
||||
)
|
||||
return await handle_errors(
|
||||
PresentationTitlesGenerateHandler(data).post,
|
||||
logging_service,
|
||||
log_metadata,
|
||||
)
|
||||
|
||||
|
||||
@presentation_router.post("/generate/data", response_model=SessionModel)
|
||||
async def submit_presentation_generation_data(
|
||||
data: PresentationGenerateRequest,
|
||||
):
|
||||
request_utils = RequestUtils("/ppt/generate/data")
|
||||
logging_service, log_metadata = await request_utils.initialize_logger(
|
||||
presentation_id=data.presentation_id,
|
||||
)
|
||||
return await handle_errors(
|
||||
PresentationGenerateDataHandler(data).post, logging_service, log_metadata
|
||||
)
|
||||
|
||||
|
||||
@presentation_router.get("/generate/stream")
|
||||
async def presentation_generation_stream(presentation_id: str, session: str):
|
||||
request_utils = RequestUtils("/ppt/generate/stream")
|
||||
logging_service, log_metadata = await request_utils.initialize_logger(
|
||||
presentation_id=presentation_id,
|
||||
)
|
||||
return await handle_errors(
|
||||
PresentationGenerateStreamHandler(presentation_id, session).get,
|
||||
logging_service,
|
||||
log_metadata,
|
||||
)
|
||||
|
||||
|
||||
@presentation_router.post("/presentation/thumbnail", response_model=PresentationAndPath)
|
||||
async def update_presentation(
|
||||
presentation_id: Annotated[str, Body()],
|
||||
thumbnail: Annotated[UploadFile, File()],
|
||||
):
|
||||
request_utils = RequestUtils("/ppt/presentation/thumbnail")
|
||||
logging_service, log_metadata = await request_utils.initialize_logger(
|
||||
presentation_id=presentation_id,
|
||||
)
|
||||
return await handle_errors(
|
||||
UploadPresentationThumbnailHandler(presentation_id, thumbnail).post,
|
||||
logging_service,
|
||||
log_metadata,
|
||||
)
|
||||
|
||||
|
||||
@presentation_router.post("/presentation/theme")
|
||||
async def update_presentation(
|
||||
data: UpdatePresentationThemeRequest,
|
||||
):
|
||||
request_utils = RequestUtils("/ppt/presentation/theme")
|
||||
logging_service, log_metadata = await request_utils.initialize_logger(
|
||||
presentation_id=data.presentation_id,
|
||||
)
|
||||
return await handle_errors(
|
||||
UpdatePresentationThemeHandler(data).post,
|
||||
logging_service,
|
||||
log_metadata,
|
||||
)
|
||||
|
||||
|
||||
@presentation_router.post("/edit", response_model=SlideModel)
|
||||
async def update_presentation(
|
||||
data: EditPresentationSlideRequest,
|
||||
):
|
||||
request_utils = RequestUtils("/ppt/edit")
|
||||
logging_service, log_metadata = await request_utils.initialize_logger(
|
||||
presentation_id=data.presentation_id
|
||||
)
|
||||
return await handle_errors(
|
||||
PresentationEditHandler(data).post, logging_service, log_metadata
|
||||
)
|
||||
|
||||
|
||||
@presentation_router.post("/slides/update", response_model=PresentationAndSlides)
|
||||
async def update_slide_models(data: PresentationUpdateRequest):
|
||||
request_utils = RequestUtils("/ppt/slides/update")
|
||||
logging_service, log_metadata = await request_utils.initialize_logger(
|
||||
presentation_id=data.presentation_id,
|
||||
)
|
||||
return await handle_errors(
|
||||
UpdateSlideModelsHandler(data).post, logging_service, log_metadata
|
||||
)
|
||||
|
||||
|
||||
@presentation_router.post("/image/generate", response_model=PresentationAndUrls)
|
||||
async def generate_image(data: GenerateImageRequest):
|
||||
request_utils = RequestUtils("/ppt/image/generate")
|
||||
logging_service, log_metadata = await request_utils.initialize_logger(
|
||||
presentation_id=data.presentation_id,
|
||||
)
|
||||
return await handle_errors(
|
||||
GenerateImageHandler(data).post, logging_service, log_metadata
|
||||
)
|
||||
|
||||
|
||||
@presentation_router.post("/image/search", response_model=PresentationAndUrls)
|
||||
async def search_image(data: SearchImageRequest):
|
||||
request_utils = RequestUtils("/ppt/image/search")
|
||||
logging_service, log_metadata = await request_utils.initialize_logger(
|
||||
presentation_id=data.presentation_id,
|
||||
)
|
||||
return await handle_errors(
|
||||
SearchImageHandler(data).post, logging_service, log_metadata
|
||||
)
|
||||
|
||||
|
||||
@presentation_router.post("/icon/search", response_model=PresentationAndUrls)
|
||||
async def search_icon(data: SearchIconRequest):
|
||||
request_utils = RequestUtils("/ppt/icon/search")
|
||||
logging_service, log_metadata = await request_utils.initialize_logger(
|
||||
presentation_id=data.presentation_id,
|
||||
)
|
||||
return await handle_errors(
|
||||
SearchIconHandler(data).post, logging_service, log_metadata
|
||||
)
|
||||
|
||||
|
||||
@presentation_router.post(
|
||||
"/presentation/export_as_pptx", response_model=PresentationAndUrl
|
||||
)
|
||||
async def export_as_pptx(data: ExportAsRequest):
|
||||
request_utils = RequestUtils("/ppt/presentation/export_as_pptx")
|
||||
logging_service, log_metadata = await request_utils.initialize_logger(
|
||||
presentation_id=data.presentation_id,
|
||||
)
|
||||
return await handle_errors(
|
||||
ExportAsPptxHandler(data).post, logging_service, log_metadata
|
||||
)
|
||||
|
||||
|
||||
@presentation_router.post(
|
||||
"/presentation/export_as_pdf", response_model=PresentationAndUrl
|
||||
)
|
||||
async def export_as_pdf(data: ExportAsRequest):
|
||||
request_utils = RequestUtils("/ppt/presentation/export_as_pdf")
|
||||
logging_service, log_metadata = await request_utils.initialize_logger(
|
||||
presentation_id=data.presentation_id,
|
||||
)
|
||||
return await handle_errors(
|
||||
ExportAsPDFHandler(data).post, logging_service, log_metadata
|
||||
)
|
||||
|
||||
|
||||
@presentation_router.delete("/delete", status_code=204)
|
||||
async def delete_presentation(presentation_id: str):
|
||||
request_utils = RequestUtils("/ppt/delete")
|
||||
logging_service, log_metadata = await request_utils.initialize_logger(
|
||||
presentation_id=presentation_id,
|
||||
)
|
||||
return await handle_errors(
|
||||
DeletePresentationHandler(presentation_id).delete, logging_service, log_metadata
|
||||
)
|
||||
|
||||
|
||||
@presentation_router.delete("/slide/delete", status_code=204)
|
||||
async def delete_slide(slide_id: str, presentation_id: str):
|
||||
request_utils = RequestUtils("/ppt/slide/delete")
|
||||
logging_service, log_metadata = await request_utils.initialize_logger(
|
||||
presentation_id=presentation_id,
|
||||
)
|
||||
return await handle_errors(
|
||||
DeleteSlideHandler(slide_id).delete, logging_service, log_metadata
|
||||
)
|
||||
16
servers/fastapi/api/services/database.py
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
from contextlib import contextmanager
|
||||
from sqlalchemy import create_engine
|
||||
from sqlmodel import Session
|
||||
|
||||
|
||||
sql_url = "sqlite:///sqlite.db"
|
||||
sql_engine = create_engine(sql_url, connect_args={"check_same_thread": False})
|
||||
|
||||
|
||||
@contextmanager
|
||||
def get_sql_session():
|
||||
session = Session(sql_engine)
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
4
servers/fastapi/api/services/instances.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
from api.services.temp_file import TempFileService
|
||||
|
||||
|
||||
temp_file_service = TempFileService()
|
||||
15
servers/fastapi/api/services/logging.py
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
from typing import Any
|
||||
from logging import Logger
|
||||
|
||||
|
||||
class LoggingService:
|
||||
|
||||
def __init__(self, stream_name: str):
|
||||
self._logger = Logger(stream_name)
|
||||
|
||||
@property
|
||||
def logger(self) -> Logger:
|
||||
return self._logger
|
||||
|
||||
def message(self, msg: Any):
|
||||
return {"msg": msg}
|
||||
64
servers/fastapi/api/services/temp_file.py
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import os
|
||||
import uuid
|
||||
from typing import Optional, Union
|
||||
|
||||
|
||||
class TempFileService:
|
||||
base_dir = os.getenv("TEMP_DIRECTORY")
|
||||
|
||||
def __init__(self):
|
||||
os.makedirs(self.base_dir, exist_ok=True)
|
||||
|
||||
def create_dir_in_dir(self, base_dir: str, dir_name: Optional[str] = None) -> str:
|
||||
temp_dir = os.path.join(base_dir, dir_name if dir_name else str(uuid.uuid4()))
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
return temp_dir
|
||||
|
||||
def create_temp_dir(self, dir_name: Optional[str] = None) -> str:
|
||||
return self.create_dir_in_dir(self.base_dir, dir_name)
|
||||
|
||||
def create_temp_file_path(
|
||||
self, file_path: str, dir_path: Optional[str] = None
|
||||
) -> str:
|
||||
if dir_path is None:
|
||||
dir_path = self.base_dir
|
||||
|
||||
full_path = os.path.join(dir_path, file_path)
|
||||
|
||||
os.makedirs(os.path.dirname(full_path), exist_ok=True)
|
||||
return full_path
|
||||
|
||||
def create_temp_file(
|
||||
self, file_path: str, content: Union[bytes, str], dir_path: Optional[str] = None
|
||||
) -> str:
|
||||
file_path = self.create_temp_file_path(file_path, dir_path)
|
||||
mode = "wb" if isinstance(content, bytes) else "w"
|
||||
with open(file_path, mode) as f:
|
||||
f.write(content)
|
||||
|
||||
return file_path
|
||||
|
||||
def read_temp_file(self, file_path: str, binary: bool = True) -> Union[bytes, str]:
|
||||
mode = "rb" if binary else "r"
|
||||
with open(file_path, mode) as f:
|
||||
return f.read()
|
||||
|
||||
def cleanup_temp_file(self, file_path: str):
|
||||
if os.path.exists(file_path):
|
||||
os.remove(file_path)
|
||||
|
||||
def delete_dir_files(self, dir_path: str):
|
||||
if os.path.exists(dir_path):
|
||||
for root, dirs, files in os.walk(dir_path, topdown=False):
|
||||
for name in files:
|
||||
os.remove(os.path.join(root, name))
|
||||
for name in dirs:
|
||||
os.rmdir(os.path.join(root, name))
|
||||
|
||||
def cleanup_temp_dir(self, dir_path: str):
|
||||
if os.path.exists(dir_path):
|
||||
self.delete_dir_files(dir_path)
|
||||
os.rmdir(dir_path)
|
||||
|
||||
def cleanup_base_dir(self):
|
||||
self.cleanup_temp_dir(self.base_dir)
|
||||
56
servers/fastapi/api/sql_models.py
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
import uuid
|
||||
from sqlmodel import SQLModel, Field, Column, JSON
|
||||
|
||||
from ppt_generator.models.other_models import SlideType
|
||||
|
||||
|
||||
def get_random_uuid() -> str:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
class PresentationSqlModel(SQLModel, table=True):
|
||||
id: str = Field(default_factory=get_random_uuid, primary_key=True)
|
||||
created_at: datetime = Field(default=datetime.now())
|
||||
prompt: Optional[str] = None
|
||||
n_slides: int
|
||||
theme: Optional[dict] = Field(sa_column=Column(JSON, nullable=True), default=None)
|
||||
file: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
titles: Optional[List[str]] = Field(
|
||||
sa_column=Column(JSON, nullable=True), default=None
|
||||
)
|
||||
language: Optional[str] = None
|
||||
summary: Optional[str] = None
|
||||
thumbnail: Optional[str] = None
|
||||
data: Optional[dict] = Field(sa_column=Column(JSON, nullable=True), default=None)
|
||||
|
||||
|
||||
class SlideSqlModel(SQLModel, table=True):
|
||||
id: str = Field(default_factory=get_random_uuid, primary_key=True)
|
||||
index: int = Field(index=True)
|
||||
type: int
|
||||
design_index: Optional[int] = None
|
||||
images: Optional[List[str]] = Field(
|
||||
sa_column=Column(JSON, nullable=True), default=None
|
||||
)
|
||||
icons: Optional[List[str]] = Field(
|
||||
sa_column=Column(JSON, nullable=True), default=None
|
||||
)
|
||||
presentation: str
|
||||
content: dict = Field(sa_column=Column(JSON, nullable=False), default=None)
|
||||
properties: Optional[dict] = Field(
|
||||
sa_column=Column(JSON, nullable=True), default=None
|
||||
)
|
||||
|
||||
|
||||
class KeyValueSqlModel(SQLModel, table=True):
|
||||
id: str = Field(default_factory=get_random_uuid, primary_key=True)
|
||||
key: str = Field(index=True)
|
||||
value: dict = Field(sa_column=Column(JSON, nullable=True), default=None)
|
||||
|
||||
|
||||
class PreferencesSqlModel(SQLModel, table=True):
|
||||
id: int = Field(default=0, primary_key=True)
|
||||
theme: Optional[dict] = Field(sa_column=Column(JSON, nullable=True), default=None)
|
||||
103
servers/fastapi/api/utils.py
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import asyncio
|
||||
import os
|
||||
import traceback
|
||||
from typing import List, Optional
|
||||
|
||||
import aiohttp
|
||||
from fastapi import HTTPException, UploadFile
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from api.models import LogMetadata
|
||||
from api.services.logging import LoggingService
|
||||
|
||||
|
||||
def get_presentation_dir(presentation_id: str) -> str:
|
||||
presentation_dir = os.path.join(os.getenv("APP_DATA_DIRECTORY"), presentation_id)
|
||||
os.makedirs(presentation_dir, exist_ok=True)
|
||||
return presentation_dir
|
||||
|
||||
|
||||
def replace_file_name(old_name: str, new_name: str) -> str:
|
||||
splitted = old_name.split(".")
|
||||
if len(splitted) < 1:
|
||||
return new_name
|
||||
else:
|
||||
return ".".join([new_name, splitted[-1]])
|
||||
|
||||
|
||||
def save_uploaded_files(
|
||||
temp_file_service, files: List[UploadFile], file_paths: List[str], temp_dir: str
|
||||
) -> List:
|
||||
full_file_paths = []
|
||||
for index, each_file in enumerate(files):
|
||||
temp_file_path = temp_file_service.create_temp_file(
|
||||
file_paths[index], each_file.file.read(), dir_path=temp_dir
|
||||
)
|
||||
full_file_paths.append(temp_file_path)
|
||||
return full_file_paths
|
||||
|
||||
|
||||
async def download_file(url: str, save_path: str, headers: Optional[dict] = None):
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url, headers=headers) as response:
|
||||
if response.status == 200:
|
||||
with open(save_path, "wb") as file:
|
||||
while True:
|
||||
chunk = await response.content.read(1024)
|
||||
if not chunk:
|
||||
break
|
||||
file.write(chunk)
|
||||
print(f"File downloaded successfully to {save_path}")
|
||||
return True
|
||||
else:
|
||||
print(f"Failed to download file. HTTP status: {response.status}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"Error while downloading file from {url} to {save_path}")
|
||||
return False
|
||||
|
||||
|
||||
async def download_files(urls: List[str], save_paths: List[str]):
|
||||
for url, save_path in zip(urls, save_paths):
|
||||
print(url)
|
||||
print(save_path)
|
||||
print("-" * 10)
|
||||
coroutines = [
|
||||
download_file(url, save_paths[index]) for index, url in enumerate(urls)
|
||||
]
|
||||
await asyncio.gather(*coroutines)
|
||||
|
||||
|
||||
async def handle_errors(
|
||||
func, logging_service: LoggingService, log_metadata: LogMetadata
|
||||
):
|
||||
try:
|
||||
logging_service.logger.info(f"START", extra=log_metadata.model_dump())
|
||||
response = await func(
|
||||
logging_service=logging_service, log_metadata=log_metadata
|
||||
)
|
||||
is_stream = isinstance(response, StreamingResponse)
|
||||
logging_service.logger.info(
|
||||
"STREAMING" if is_stream else "END", extra=log_metadata.model_dump()
|
||||
)
|
||||
return response
|
||||
|
||||
except HTTPException as e:
|
||||
log_metadata.status_code = e.status_code
|
||||
logging_service.logger.error(
|
||||
f"Raised HTTPException - {e.detail}", extra=log_metadata.model_dump()
|
||||
)
|
||||
raise e
|
||||
except Exception as e:
|
||||
print(traceback.print_stack())
|
||||
print(traceback.print_exc())
|
||||
|
||||
log_metadata.status_code = 400
|
||||
logging_service.logger.critical(
|
||||
"Unhandled Exception",
|
||||
exc_info=True,
|
||||
stack_info=True,
|
||||
extra=log_metadata.model_dump(),
|
||||
)
|
||||
raise HTTPException(400, "Something went wrong while processing your request.")
|
||||
26
servers/fastapi/api/validators.py
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
from typing import List
|
||||
|
||||
from fastapi import HTTPException, UploadFile
|
||||
|
||||
|
||||
def validate_files(
|
||||
field,
|
||||
nullable: bool,
|
||||
multiple: bool,
|
||||
max_size: int,
|
||||
accepted_types: List[str],
|
||||
):
|
||||
|
||||
if field:
|
||||
files: List[UploadFile] = field if multiple else [field]
|
||||
for each_file in files:
|
||||
if (max_size * 1024 * 1024) < each_file.size:
|
||||
raise HTTPException(
|
||||
400,
|
||||
f"File '{each_file.filename}' exceeded max upload size of {max_size} MB",
|
||||
)
|
||||
elif each_file.content_type not in accepted_types:
|
||||
raise HTTPException(400, f"File '{each_file.filename}' not accepted.")
|
||||
|
||||
elif not (field or nullable):
|
||||
raise HTTPException(400, "File must be provided.")
|
||||
63510
servers/fastapi/assets/icons.json
Normal file
BIN
servers/fastapi/assets/icons/bold/acorn-bold.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
servers/fastapi/assets/icons/bold/address-book-bold.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
servers/fastapi/assets/icons/bold/address-book-tabs-bold.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
servers/fastapi/assets/icons/bold/air-traffic-control-bold.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
servers/fastapi/assets/icons/bold/airplane-bold.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
servers/fastapi/assets/icons/bold/airplane-in-flight-bold.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
servers/fastapi/assets/icons/bold/airplane-landing-bold.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
servers/fastapi/assets/icons/bold/airplane-takeoff-bold.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
servers/fastapi/assets/icons/bold/airplane-taxiing-bold.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
servers/fastapi/assets/icons/bold/airplane-tilt-bold.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
servers/fastapi/assets/icons/bold/airplay-bold.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
servers/fastapi/assets/icons/bold/alarm-bold.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
BIN
servers/fastapi/assets/icons/bold/alien-bold.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
BIN
servers/fastapi/assets/icons/bold/align-bottom-bold.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
servers/fastapi/assets/icons/bold/align-bottom-simple-bold.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
BIN
servers/fastapi/assets/icons/bold/align-center-vertical-bold.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
BIN
servers/fastapi/assets/icons/bold/align-left-bold.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
servers/fastapi/assets/icons/bold/align-left-simple-bold.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
servers/fastapi/assets/icons/bold/align-right-bold.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
servers/fastapi/assets/icons/bold/align-right-simple-bold.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
servers/fastapi/assets/icons/bold/align-top-bold.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
servers/fastapi/assets/icons/bold/align-top-simple-bold.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
servers/fastapi/assets/icons/bold/amazon-logo-bold.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
servers/fastapi/assets/icons/bold/ambulance-bold.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
servers/fastapi/assets/icons/bold/anchor-bold.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
servers/fastapi/assets/icons/bold/anchor-simple-bold.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
servers/fastapi/assets/icons/bold/android-logo-bold.png
Normal file
|
After Width: | Height: | Size: 4 KiB |
BIN
servers/fastapi/assets/icons/bold/angle-bold.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
servers/fastapi/assets/icons/bold/angular-logo-bold.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
servers/fastapi/assets/icons/bold/aperture-bold.png
Normal file
|
After Width: | Height: | Size: 6.4 KiB |
BIN
servers/fastapi/assets/icons/bold/app-store-logo-bold.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
servers/fastapi/assets/icons/bold/app-window-bold.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
servers/fastapi/assets/icons/bold/apple-logo-bold.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
servers/fastapi/assets/icons/bold/apple-podcasts-logo-bold.png
Normal file
|
After Width: | Height: | Size: 6 KiB |
BIN
servers/fastapi/assets/icons/bold/approximate-equals-bold.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
servers/fastapi/assets/icons/bold/archive-bold.png
Normal file
|
After Width: | Height: | Size: 2 KiB |
BIN
servers/fastapi/assets/icons/bold/armchair-bold.png
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
servers/fastapi/assets/icons/bold/arrow-arc-left-bold.png
Normal file
|
After Width: | Height: | Size: 3 KiB |
BIN
servers/fastapi/assets/icons/bold/arrow-arc-right-bold.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
BIN
servers/fastapi/assets/icons/bold/arrow-bend-down-left-bold.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
servers/fastapi/assets/icons/bold/arrow-bend-down-right-bold.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
servers/fastapi/assets/icons/bold/arrow-bend-left-down-bold.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |