v2.2.1 - Intégration de LibreOffice Headless pour les exports PDF natifs (Étapes 3 & 4)
Build and Push Docker Image / build-and-push (push) Failing after 27s
Build and Push Docker Image / build-and-push (push) Failing after 27s
This commit is contained in:
1 parent
b0f8a4e62d
commit
626ec138e4
3 files changed
+229
-44
No files matched your search
+115
-3
@@ -1,16 +1,43 @@
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI, HTTPException
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse, Response
|
||||||
|
from pydantic import BaseModel
|
||||||
|
import requests
|
||||||
|
import asyncio
|
||||||
import os
|
import os
|
||||||
|
import uuid
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import urllib3
|
||||||
|
import socket
|
||||||
import config
|
import config
|
||||||
|
|
||||||
|
# Forçage IPv4
|
||||||
|
old_getaddrinfo = socket.getaddrinfo
|
||||||
|
def force_ipv4_getaddrinfo(host, port, family=0, type=0, proto=0, flags=0):
|
||||||
|
return old_getaddrinfo(host, port, socket.AF_INET, type, proto, flags)
|
||||||
|
socket.getaddrinfo = force_ipv4_getaddrinfo
|
||||||
|
|
||||||
|
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||||
|
|
||||||
app = FastAPI(title="AETHAS38 API")
|
app = FastAPI(title="AETHAS38 API")
|
||||||
|
|
||||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
FRONTEND_DIR = os.path.join(BASE_DIR, "frontend")
|
FRONTEND_DIR = os.path.join(BASE_DIR, "frontend")
|
||||||
|
SESSIONS_DIR = os.path.join(BASE_DIR, "sessions")
|
||||||
|
MEDIA_DIR = os.path.join(SESSIONS_DIR, "media")
|
||||||
|
TEMP_DIR = os.path.join(SESSIONS_DIR, "temp")
|
||||||
|
|
||||||
|
os.makedirs(MEDIA_DIR, exist_ok=True)
|
||||||
|
os.makedirs(TEMP_DIR, exist_ok=True)
|
||||||
|
|
||||||
# Montage dynamique
|
|
||||||
app.mount("/static", StaticFiles(directory=FRONTEND_DIR), name="static")
|
app.mount("/static", StaticFiles(directory=FRONTEND_DIR), name="static")
|
||||||
|
app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media")
|
||||||
|
|
||||||
|
class ExportRequest(BaseModel):
|
||||||
|
content: str
|
||||||
|
format: str # pdf, txt, md, json
|
||||||
|
type: str # prompt ou response
|
||||||
|
|
||||||
@app.get("/", response_class=HTMLResponse)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
async def serve_admin():
|
async def serve_admin():
|
||||||
@@ -26,3 +53,88 @@ async def get_config():
|
|||||||
async def update_config(data: dict):
|
async def update_config(data: dict):
|
||||||
config.save_config(data)
|
config.save_config(data)
|
||||||
return {"status": "success", "message": "Configuration sauvegardée avec succès."}
|
return {"status": "success", "message": "Configuration sauvegardée avec succès."}
|
||||||
|
|
||||||
|
@app.post("/api/export")
|
||||||
|
async def export_content(req: ExportRequest):
|
||||||
|
filename_base = f"aethas38_{req.type}_{uuid.uuid4().hex[:8]}"
|
||||||
|
|
||||||
|
if req.format == "txt":
|
||||||
|
return Response(
|
||||||
|
content=req.content,
|
||||||
|
media_type="text/plain",
|
||||||
|
headers={"Content-Disposition": f"attachment; filename={filename_base}.txt"}
|
||||||
|
)
|
||||||
|
|
||||||
|
elif req.format == "md":
|
||||||
|
return Response(
|
||||||
|
content=f"# AETHAS38 Export ({req.type.upper()})\n\n{req.content}",
|
||||||
|
media_type="text/markdown",
|
||||||
|
headers={"Content-Disposition": f"attachment; filename={filename_base}.md"}
|
||||||
|
)
|
||||||
|
|
||||||
|
elif req.format == "json":
|
||||||
|
json_data = json.dumps({"type": req.type, "content": req.content}, indent=4, ensure_ascii=False)
|
||||||
|
return Response(
|
||||||
|
content=json_data,
|
||||||
|
media_type="application/json",
|
||||||
|
headers={"Content-Disposition": f"attachment; filename={filename_base}.json"}
|
||||||
|
)
|
||||||
|
|
||||||
|
elif req.format == "pdf":
|
||||||
|
# Utilisation de LibreOffice en mode headless
|
||||||
|
html_filename = os.path.join(TEMP_DIR, f"{filename_base}.html")
|
||||||
|
pdf_filename = os.path.join(TEMP_DIR, f"{filename_base}.pdf")
|
||||||
|
|
||||||
|
# Création d'un HTML propre pour la conversion
|
||||||
|
html_content = f"""
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<style>
|
||||||
|
body {{ font-family: Arial, sans-serif; margin: 40px; color: #333; }}
|
||||||
|
h1 {{ color: #00e5ff; border-bottom: 2px solid #ddd; padding-bottom: 10px; }}
|
||||||
|
pre, p {{ white-space: pre-wrap; line-height: 1.6; background: #f4f4f4; padding: 15px; border-radius: 5px; }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>AETHAS38 - Export {req.type.upper()}</h1>
|
||||||
|
<p>{req.content}</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
with open(html_filename, "w", encoding="utf-8") as f:
|
||||||
|
f.write(html_content)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Commande LibreOffice headless pour convertir le HTML en PDF
|
||||||
|
# Note: selon l'OS (Windows vs Linux Docker), la commande peut s'appeler 'soffice' ou 'libreoffice'
|
||||||
|
cmd = ["soffice", "--headless", "--convert-to", "pdf", "--outdir", TEMP_DIR, html_filename]
|
||||||
|
|
||||||
|
# Exécution synchrone dans un thread pour ne pas bloquer l'API
|
||||||
|
def run_libreoffice():
|
||||||
|
subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||||
|
|
||||||
|
await asyncio.to_thread(run_libreoffice)
|
||||||
|
|
||||||
|
if not os.path.exists(pdf_filename):
|
||||||
|
raise Exception("La génération du PDF par LibreOffice a échoué.")
|
||||||
|
|
||||||
|
with open(pdf_filename, "rb") as pdf_file:
|
||||||
|
pdf_bytes = pdf_file.read()
|
||||||
|
|
||||||
|
# Nettoyage des fichiers temporaires
|
||||||
|
os.remove(html_filename)
|
||||||
|
os.remove(pdf_filename)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=pdf_bytes,
|
||||||
|
media_type="application/pdf",
|
||||||
|
headers={"Content-Disposition": f"attachment; filename={filename_base}.pdf"}
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Erreur de génération PDF via LibreOffice : {str(e)}")
|
||||||
|
|
||||||
|
raise HTTPException(status_code=400, detail="Format d'export non supporté.")
|
||||||
@@ -9,3 +9,4 @@ fastapi
|
|||||||
uvicorn
|
uvicorn
|
||||||
pydantic
|
pydantic
|
||||||
python-dotenv
|
python-dotenv
|
||||||
|
httpx
|
||||||
+113
-41
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>AETHAS38 - Administration</title>
|
<title>AETHAS38 - Orchestrateur</title>
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
|
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
|
||||||
<style>
|
<style>
|
||||||
@@ -13,20 +13,85 @@
|
|||||||
.input-box:focus { border-color: #00e5ff; }
|
.input-box:focus { border-color: #00e5ff; }
|
||||||
.btn-primary { background-color: #00e5ff; color: #0f172a; font-weight: bold; padding: 10px 20px; border-radius: 5px; cursor: pointer; transition: 0.2s; }
|
.btn-primary { background-color: #00e5ff; color: #0f172a; font-weight: bold; padding: 10px 20px; border-radius: 5px; cursor: pointer; transition: 0.2s; }
|
||||||
.btn-primary:hover { background-color: #38bdf8; }
|
.btn-primary:hover { background-color: #38bdf8; }
|
||||||
|
.btn-primary:disabled { background-color: #334155; color: #94a3b8; cursor: not-allowed; }
|
||||||
.btn-danger { background-color: #f43f5e; color: white; padding: 5px 10px; border-radius: 5px; cursor: pointer; font-size: 12px; }
|
.btn-danger { background-color: #f43f5e; color: white; padding: 5px 10px; border-radius: 5px; cursor: pointer; font-size: 12px; }
|
||||||
|
.tab-btn { padding: 10px 20px; font-weight: bold; cursor: pointer; transition: 0.2s; border-bottom: 2px solid transparent; }
|
||||||
|
.tab-active { color: #00e5ff; border-bottom-color: #00e5ff; }
|
||||||
|
.tab-inactive { color: #64748b; }
|
||||||
|
.tab-inactive:hover { color: #94a3b8; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="p-8">
|
<body class="p-8">
|
||||||
<div id="app" class="max-w-4xl mx-auto">
|
<div id="app" class="max-w-5xl mx-auto">
|
||||||
<div class="flex justify-between items-center mb-8">
|
|
||||||
<h1 class="text-3xl font-bold text-[#00e5ff]">⚙️ Administration AETHAS38</h1>
|
<!-- En-tête -->
|
||||||
<span class="text-xs text-gray-400">v2.0.0</span>
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<h1 class="text-3xl font-bold text-[#00e5ff]">🤖 AETHAS38</h1>
|
||||||
|
<span class="text-xs text-gray-400">v2.1.0</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Navigation Onglets -->
|
||||||
|
<div class="flex space-x-2 mb-8 border-b border-gray-700">
|
||||||
|
<button @click="currentTab = 'admin'" :class="currentTab === 'admin' ? 'tab-active' : 'tab-inactive'" class="tab-btn">⚙️ Administration</button>
|
||||||
|
<button @click="currentTab = 'studio'" :class="currentTab === 'studio' ? 'tab-active' : 'tab-inactive'" class="tab-btn">🎬 Studio Multimédia</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="notification" class="mb-4 p-4 rounded bg-emerald-900/50 border border-emerald-500 text-emerald-400">
|
<div v-if="notification" class="mb-4 p-4 rounded bg-emerald-900/50 border border-emerald-500 text-emerald-400">
|
||||||
{{ notification }}
|
{{ notification }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ONGLET : STUDIO MULTIMÉDIA (ÉTAPE 2) -->
|
||||||
|
<div v-if="currentTab === 'studio'">
|
||||||
|
<div class="card">
|
||||||
|
<h2 class="text-xl font-bold text-purple-400 mb-4">Générateur Média (HuggingFace)</h2>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4">
|
||||||
|
<div class="md:col-span-3">
|
||||||
|
<label class="text-xs font-bold text-gray-400">Votre Prompt (Description)</label>
|
||||||
|
<input type="text" v-model="mediaPrompt" class="input-box" placeholder="A futuristic cyberpunk city raining at night..." @keyup.enter="generateMedia">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-bold text-gray-400">Type de Média</label>
|
||||||
|
<select v-model="mediaType" class="input-box">
|
||||||
|
<option value="image">🖼️ Image (Stable Diffusion)</option>
|
||||||
|
<option value="voice">🎙️ Voix (Bark TTS)</option>
|
||||||
|
<option value="music">🎵 Musique (MusicGen)</option>
|
||||||
|
<option value="video">🎥 Vidéo (ZeroScope/Damo)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end mt-4">
|
||||||
|
<button @click="generateMedia" :disabled="isGenerating || !mediaPrompt" class="btn-primary">
|
||||||
|
{{ isGenerating ? '⏳ Génération en cours (Patientez)...' : '✨ Générer le Média' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Affichage du Résultat -->
|
||||||
|
<div v-if="generatedMediaUrl" class="card text-center">
|
||||||
|
<h3 class="font-bold text-gray-300 mb-4">Résultat :</h3>
|
||||||
|
|
||||||
|
<img v-if="generatedMediaType === 'image'" :src="generatedMediaUrl" class="max-w-full h-auto mx-auto rounded border border-gray-600 shadow-lg">
|
||||||
|
|
||||||
|
<audio v-if="['music', 'voice'].includes(generatedMediaType)" controls class="w-full mx-auto">
|
||||||
|
<source :src="generatedMediaUrl" type="audio/wav">
|
||||||
|
Votre navigateur ne supporte pas l'audio.
|
||||||
|
</audio>
|
||||||
|
|
||||||
|
<video v-if="generatedMediaType === 'video'" controls class="max-w-full h-auto mx-auto rounded shadow-lg">
|
||||||
|
<source :src="generatedMediaUrl" type="video/mp4">
|
||||||
|
Votre navigateur ne supporte pas la vidéo.
|
||||||
|
</video>
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<a :href="generatedMediaUrl" download class="text-[#00e5ff] hover:underline text-sm font-bold">⬇️ Télécharger le fichier</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ONGLET : ADMINISTRATION (ÉTAPE 1) -->
|
||||||
|
<div v-if="currentTab === 'admin'">
|
||||||
<!-- Clés API -->
|
<!-- Clés API -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2 class="text-xl font-bold text-purple-400 mb-4">🔑 Clés API Principales</h2>
|
<h2 class="text-xl font-bold text-purple-400 mb-4">🔑 Clés API Principales</h2>
|
||||||
@@ -46,29 +111,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Serveur SMTP -->
|
|
||||||
<div class="card">
|
|
||||||
<h2 class="text-xl font-bold text-blue-400 mb-4">📧 Configuration Serveur Mail (SMTP)</h2>
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label class="text-xs font-bold text-gray-400">Hôte (Serveur)</label>
|
|
||||||
<input type="text" v-model="config.smtp.host" class="input-box" placeholder="smtp.mail.com">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="text-xs font-bold text-gray-400">Port</label>
|
|
||||||
<input type="number" v-model="config.smtp.port" class="input-box" placeholder="587">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="text-xs font-bold text-gray-400">Utilisateur / Email</label>
|
|
||||||
<input type="text" v-model="config.smtp.user" class="input-box" placeholder="admin@aethas38.com">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="text-xs font-bold text-gray-400">Mot de passe</label>
|
|
||||||
<input type="password" v-model="config.smtp.password" class="input-box" placeholder="••••••••">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Fournisseurs Personnalisés -->
|
<!-- Fournisseurs Personnalisés -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="flex justify-between items-center mb-4">
|
<div class="flex justify-between items-center mb-4">
|
||||||
@@ -82,35 +124,43 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="text-xs text-gray-400">Nom</label>
|
<label class="text-xs text-gray-400">Nom (Ex: HuggingFace)</label>
|
||||||
<input type="text" v-model="provider.name" class="input-box" placeholder="Ex: Ollama Local">
|
<input type="text" v-model="provider.name" class="input-box">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="text-xs text-gray-400">URL de Base</label>
|
<label class="text-xs text-gray-400">URL de Base</label>
|
||||||
<input type="text" v-model="provider.base_url" class="input-box" placeholder="http://192.168.1.X:11434/v1">
|
<input type="text" v-model="provider.base_url" class="input-box" placeholder="https://api...">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="text-xs text-gray-400">Clé API (Optionnel)</label>
|
<label class="text-xs text-gray-400">Clé API</label>
|
||||||
<input type="password" v-model="provider.api_key" class="input-box" placeholder="...">
|
<input type="password" v-model="provider.api_key" class="input-box" placeholder="hf_...">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="config.custom_providers.length === 0" class="text-sm text-gray-500 italic">Aucun fournisseur supplémentaire configuré.</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sauvegarde -->
|
|
||||||
<div class="flex justify-end mt-6">
|
<div class="flex justify-end mt-6">
|
||||||
<button @click="saveConfig" class="btn-primary text-lg">💾 Sauvegarder la Configuration</button>
|
<button @click="saveConfig" class="btn-primary text-lg">💾 Sauvegarder la Configuration</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const { createApp, ref, onMounted } = Vue;
|
const { createApp, ref, onMounted } = Vue;
|
||||||
createApp({
|
createApp({
|
||||||
setup() {
|
setup() {
|
||||||
|
const currentTab = ref('studio');
|
||||||
const config = ref({ api_keys: {}, smtp: {}, custom_providers: [] });
|
const config = ref({ api_keys: {}, smtp: {}, custom_providers: [] });
|
||||||
const notification = ref("");
|
const notification = ref("");
|
||||||
|
|
||||||
|
// Variables Studio
|
||||||
|
const mediaPrompt = ref("");
|
||||||
|
const mediaType = ref("image");
|
||||||
|
const isGenerating = ref(false);
|
||||||
|
const generatedMediaUrl = ref(null);
|
||||||
|
const generatedMediaType = ref(null);
|
||||||
|
|
||||||
const loadConfig = async () => {
|
const loadConfig = async () => {
|
||||||
const res = await fetch('/api/config');
|
const res = await fetch('/api/config');
|
||||||
config.value = await res.json();
|
config.value = await res.json();
|
||||||
@@ -127,16 +177,38 @@
|
|||||||
setTimeout(() => notification.value = "", 3000);
|
setTimeout(() => notification.value = "", 3000);
|
||||||
};
|
};
|
||||||
|
|
||||||
const addProvider = () => {
|
const addProvider = () => { config.value.custom_providers.push({ name: "", base_url: "", api_key: "" }); };
|
||||||
config.value.custom_providers.push({ name: "", base_url: "", api_key: "" });
|
const removeProvider = (index) => { config.value.custom_providers.splice(index, 1); };
|
||||||
};
|
|
||||||
|
|
||||||
const removeProvider = (index) => {
|
const generateMedia = async () => {
|
||||||
config.value.custom_providers.splice(index, 1);
|
if (!mediaPrompt.value) return;
|
||||||
|
isGenerating.value = true;
|
||||||
|
generatedMediaUrl.value = null;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/media/generate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ prompt: mediaPrompt.value, media_type: mediaType.value })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
generatedMediaUrl.value = data.url;
|
||||||
|
generatedMediaType.value = data.type;
|
||||||
|
} else {
|
||||||
|
alert(data.detail);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert("Erreur de connexion au serveur.");
|
||||||
|
} finally {
|
||||||
|
isGenerating.value = false;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(loadConfig);
|
onMounted(loadConfig);
|
||||||
return { config, notification, saveConfig, addProvider, removeProvider };
|
return {
|
||||||
|
currentTab, config, notification, saveConfig, addProvider, removeProvider,
|
||||||
|
mediaPrompt, mediaType, isGenerating, generatedMediaUrl, generatedMediaType, generateMedia
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}).mount('#app');
|
}).mount('#app');
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in new issue
Block a user