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

This commit is contained in:
xavier committed 2026-10-06 12:57:01 +02:00
1 parent b0f8a4e62d
commit 626ec138e4
3 files changed
+262 -77

No files matched your search

+115 -3
View File
@@ -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é.")
+1
View File
@@ -9,3 +9,4 @@ fastapi
uvicorn uvicorn
pydantic pydantic
python-dotenv python-dotenv
httpx
+146 -74
View File
@@ -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,104 +13,154 @@
.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>
<!-- Clés API --> <!-- ONGLET : STUDIO MULTIMÉDIA (ÉTAPE 2) -->
<div class="card"> <div v-if="currentTab === 'studio'">
<h2 class="text-xl font-bold text-purple-400 mb-4">🔑 Clés API Principales</h2> <div class="card">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <h2 class="text-xl font-bold text-purple-400 mb-4">Générateur Média (HuggingFace)</h2>
<div>
<label class="text-xs font-bold text-gray-400">OpenRouter API Key</label> <div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4">
<input type="password" v-model="config.api_keys.openrouter" class="input-box" placeholder="sk-or-v1-..."> <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>
<div>
<label class="text-xs font-bold text-gray-400">Groq API Key</label> <div class="flex justify-end mt-4">
<input type="password" v-model="config.api_keys.groq" class="input-box" placeholder="gsk_..."> <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>
<div> </div>
<label class="text-xs font-bold text-gray-400">Gemini API Key</label>
<input type="password" v-model="config.api_keys.gemini" class="input-box" placeholder="AIzaSy..."> <!-- 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> </div>
</div> </div>
<!-- Serveur SMTP --> <!-- ONGLET : ADMINISTRATION (ÉTAPE 1) -->
<div class="card"> <div v-if="currentTab === 'admin'">
<h2 class="text-xl font-bold text-blue-400 mb-4">📧 Configuration Serveur Mail (SMTP)</h2> <!-- Clés API -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="card">
<div> <h2 class="text-xl font-bold text-purple-400 mb-4">🔑 Clés API Principales</h2>
<label class="text-xs font-bold text-gray-400">Hôte (Serveur)</label> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<input type="text" v-model="config.smtp.host" class="input-box" placeholder="smtp.mail.com"> <div>
<label class="text-xs font-bold text-gray-400">OpenRouter API Key</label>
<input type="password" v-model="config.api_keys.openrouter" class="input-box" placeholder="sk-or-v1-...">
</div>
<div>
<label class="text-xs font-bold text-gray-400">Groq API Key</label>
<input type="password" v-model="config.api_keys.groq" class="input-box" placeholder="gsk_...">
</div>
<div>
<label class="text-xs font-bold text-gray-400">Gemini API Key</label>
<input type="password" v-model="config.api_keys.gemini" class="input-box" placeholder="AIzaSy...">
</div>
</div> </div>
<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"> <!-- Fournisseurs Personnalisés -->
<div class="card">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold text-amber-400">🔌 Fournisseurs Personnalisés</h2>
<button @click="addProvider" class="btn-primary text-sm py-1 px-3">+ Ajouter</button>
</div> </div>
<div> <div v-for="(provider, index) in config.custom_providers" :key="index" class="p-4 bg-slate-800 rounded border border-slate-700 mb-4">
<label class="text-xs font-bold text-gray-400">Utilisateur / Email</label> <div class="flex justify-between mb-2">
<input type="text" v-model="config.smtp.user" class="input-box" placeholder="admin@aethas38.com"> <h3 class="font-bold text-sm">Fournisseur #{{ index + 1 }}</h3>
</div> <button @click="removeProvider(index)" class="btn-danger">Supprimer</button>
<div> </div>
<label class="text-xs font-bold text-gray-400">Mot de passe</label> <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<input type="password" v-model="config.smtp.password" class="input-box" placeholder="••••••••"> <div>
<label class="text-xs text-gray-400">Nom (Ex: HuggingFace)</label>
<input type="text" v-model="provider.name" class="input-box">
</div>
<div>
<label class="text-xs text-gray-400">URL de Base</label>
<input type="text" v-model="provider.base_url" class="input-box" placeholder="https://api...">
</div>
<div>
<label class="text-xs text-gray-400">Clé API</label>
<input type="password" v-model="provider.api_key" class="input-box" placeholder="hf_...">
</div>
</div>
</div> </div>
</div> </div>
<div class="flex justify-end mt-6">
<button @click="saveConfig" class="btn-primary text-lg">💾 Sauvegarder la Configuration</button>
</div>
</div> </div>
<!-- Fournisseurs Personnalisés -->
<div class="card">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold text-amber-400">🔌 Fournisseurs Personnalisés</h2>
<button @click="addProvider" class="btn-primary text-sm py-1 px-3">+ Ajouter</button>
</div>
<div v-for="(provider, index) in config.custom_providers" :key="index" class="p-4 bg-slate-800 rounded border border-slate-700 mb-4">
<div class="flex justify-between mb-2">
<h3 class="font-bold text-sm">Fournisseur #{{ index + 1 }}</h3>
<button @click="removeProvider(index)" class="btn-danger">Supprimer</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label class="text-xs text-gray-400">Nom</label>
<input type="text" v-model="provider.name" class="input-box" placeholder="Ex: Ollama Local">
</div>
<div>
<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">
</div>
<div>
<label class="text-xs text-gray-400">Clé API (Optionnel)</label>
<input type="password" v-model="provider.api_key" class="input-box" placeholder="...">
</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>
<!-- Sauvegarde -->
<div class="flex justify-end mt-6">
<button @click="saveConfig" class="btn-primary text-lg">💾 Sauvegarder la Configuration</button>
</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>