Public Access
maj auto 1
This commit is contained in:
1 parent
e651049b4e
commit
db2b137d6b
9 files changed
+479
-410
No files matched your search
+2
-1
@@ -3,4 +3,5 @@ venv/
|
|||||||
.env
|
.env
|
||||||
.sesskey
|
.sesskey
|
||||||
aethas_history.json
|
aethas_history.json
|
||||||
Echange GEMINY.txt
|
Echange GEMINY.txt
|
||||||
|
.vscode/
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
@@ -0,0 +1,36 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 150">
|
||||||
|
<!-- Définition du filtre de lueur néon -->
|
||||||
|
<defs>
|
||||||
|
<filter id="neon-glow" x="-50%" y="-50%" width="200%" height="200%">
|
||||||
|
<feGaussianBlur stdDeviation="4" result="coloredBlur"/>
|
||||||
|
<feMerge>
|
||||||
|
<feMergeNode in="coloredBlur"/>
|
||||||
|
<feMergeNode in="SourceGraphic"/>
|
||||||
|
</feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Bloc Isométrique (Minecraft Tech) -->
|
||||||
|
<g transform="translate(50, 75)">
|
||||||
|
<!-- Face supérieure -->
|
||||||
|
<polygon points="0,-30 40,-10 0,10 -40,-10" fill="#1e1e24" stroke="#00e5ff" stroke-width="2"/>
|
||||||
|
<!-- Face gauche -->
|
||||||
|
<polygon points="-40,-10 0,10 0,50 -40,30" fill="#121212" stroke="#b300ff" stroke-width="2"/>
|
||||||
|
<!-- Face droite -->
|
||||||
|
<polygon points="0,10 40,-10 40,30 0,50" fill="#1a1a20" stroke="#00e5ff" stroke-width="2"/>
|
||||||
|
|
||||||
|
<!-- Circuits Redstone/Tech -->
|
||||||
|
<polyline points="0,10 0,30 -20,20" fill="none" stroke="#b300ff" stroke-width="3" stroke-linecap="round"/>
|
||||||
|
<polyline points="0,10 20,-2" fill="none" stroke="#00e5ff" stroke-width="3"/>
|
||||||
|
|
||||||
|
<!-- Cœur énergétique -->
|
||||||
|
<circle cx="0" cy="-10" r="5" fill="#e0e0e0" filter="url(#neon-glow)"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Texte AETHAS 38 -->
|
||||||
|
<text x="120" y="80" font-family="'Segoe UI', Tahoma, sans-serif" font-size="46" font-weight="bold" fill="#00e5ff" letter-spacing="2">AETHAS</text>
|
||||||
|
<text x="325" y="80" font-family="'Segoe UI', Tahoma, sans-serif" font-size="46" font-weight="bold" fill="#b300ff">38</text>
|
||||||
|
|
||||||
|
<!-- Sous-titre -->
|
||||||
|
<text x="125" y="115" font-family="monospace" font-size="14" fill="#888888" letter-spacing="6">TECH CORE</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -21,12 +21,14 @@ Plateforme SaaS d'IA modulaire : Orchestrateur local, routeur intelligent, sécu
|
|||||||
- [x] Architecture modulaire propre (Dossier `modules/` isolant Database, Scraper, AI Engine, Router et UI)
|
- [x] Architecture modulaire propre (Dossier `modules/` isolant Database, Scraper, AI Engine, Router et UI)
|
||||||
|
|
||||||
### Phase 4 : Gestion des Utilisateurs & Sécurité
|
### Phase 4 : Gestion des Utilisateurs & Sécurité
|
||||||
- [ ] Mettre en place une bibliothèque lociel à installer avec le serveur
|
- [ ] Céer un lien Paypal pour les dons des utilisateurs.
|
||||||
|
- [ ] Mettre en place une bibliothèque logiciel à installer avec le serveur (déporter tout ce qui peut l'être comme la création de pdf, docx, html, etc.)
|
||||||
- [ ] Choix entre l'utilisation de la version payante ou gratuite. Routage suivant le choix.
|
- [ ] Choix entre l'utilisation de la version payante ou gratuite. Routage suivant le choix.
|
||||||
- [ ] Système de connexion sécurisé (Mots de passe cryptés)
|
- [ ] Système de connexion sécurisé (Mots de passe cryptés)
|
||||||
- [ ] Fail2Ban (Blocage 15 mins après 5 erreurs)
|
- [ ] Fail2Ban (Blocage 15 mins après 5 erreurs)
|
||||||
- [ ] Intégration SMTP (`mail.aethas38.duckdns.org`) pour mot de passe oublié et notifications de thèmes spécifiques
|
- [ ] Intégration SMTP (`mail.aethas38.duckdns.org`) pour mot de passe oublié et notifications de thèmes spécifiques
|
||||||
- [ ] Gestion des rôles : Admin (Xavier, Céline), Famille (Anaëlle, Audric, Renan), Testeur (Nogapra) et Externes. Testeur juste accès à la partie gratuite.
|
- [ ] Gestion des rôles : Admin (Xavier, Céline), Famille (Anaëlle, Audric, Renan), Testeur (Nogapra) et Externes. Testeur juste accès à la partie gratuite.
|
||||||
|
- [ ] Prévoir un script automatique envoyant un mail sympathique aux utilisateurs lors de leur anniversaire.
|
||||||
|
|
||||||
### Phase 5 : Sécurité Financière & Facturation Exacte 💰
|
### Phase 5 : Sécurité Financière & Facturation Exacte 💰
|
||||||
- [ ] Modèle Excel de calcul des coûts serveur (1x 1000W H24, Internet 60€/mois). Prendre en compte le tarif réglemanté de l'électricité en France et la consommation réel du serveur.
|
- [ ] Modèle Excel de calcul des coûts serveur (1x 1000W H24, Internet 60€/mois). Prendre en compte le tarif réglemanté de l'électricité en France et la consommation réel du serveur.
|
||||||
@@ -51,7 +53,7 @@ Plateforme SaaS d'IA modulaire : Orchestrateur local, routeur intelligent, sécu
|
|||||||
|
|
||||||
### Phase 7 : RAG & Maintenance
|
### Phase 7 : RAG & Maintenance
|
||||||
- [ ] "RAG" : Base de données vectorielle (ChromaDB) pour permettre l'apprentissage et la mémorisation de l'IA locale
|
- [ ] "RAG" : Base de données vectorielle (ChromaDB) pour permettre l'apprentissage et la mémorisation de l'IA locale
|
||||||
- [ ] Script de surveillance de l'espace disque du serveur
|
- [ ] Script de surveillance de l'espace disque du serveur. Affichage d'une barre d'état (statuts des disques, remplissage, utilisations pour Xavier)
|
||||||
- [ ] Création de logs centralisés consultables et téléchargeables par l'Admin
|
- [ ] Création de logs centralisés consultables et téléchargeables par l'Admin
|
||||||
|
|
||||||
### Phase 8 : Déploiement Public & Configuration
|
### Phase 8 : Déploiement Public & Configuration
|
||||||
|
|||||||
@@ -1,446 +1,62 @@
|
|||||||
import os
|
|
||||||
import json
|
|
||||||
import uuid
|
import uuid
|
||||||
import time
|
|
||||||
import re
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
from fasthtml.common import *
|
from fasthtml.common import *
|
||||||
|
from starlette.staticfiles import StaticFiles
|
||||||
from starlette.responses import FileResponse
|
from starlette.responses import FileResponse
|
||||||
|
|
||||||
# Import des modules spécialisés
|
# Imports ultra-propres depuis les modules dédiés
|
||||||
from modules.database import load_history, save_history, nettoyer_vieilles_discussions, HISTORY_FILE
|
from modules.theme import get_theme_hdrs
|
||||||
from modules.scraper import rechercher_sur_google, extraire_contenu_urls
|
from modules.database import nettoyer_vieilles_discussions, HISTORY_FILE, load_history, save_history
|
||||||
from modules.ai_engine import local_client, interroger_gemini_cascade, appeler_groq_api
|
from modules.ui_components import render_main_page, render_sidebar
|
||||||
from modules.router import nettoyer_decision_router
|
from modules.chat_handler import handle_chat_request
|
||||||
from modules.ui_components import render_user_msg, render_ai_msg, render_sidebar
|
|
||||||
|
|
||||||
load_dotenv()
|
# On exécute le nettoyage au démarrage
|
||||||
nettoyer_vieilles_discussions()
|
nettoyer_vieilles_discussions()
|
||||||
|
|
||||||
svg_favicon = "%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Cstyle%3E@keyframes pulse { 0% { opacity: 1; stroke-width: 5; } 50% { opacity: 0.5; stroke-width: 7; } 100% { opacity: 1; stroke-width: 5; } } .neon { animation: pulse 2s infinite; transform-origin: 50px 50px; transform: rotate(45deg); }%3C/style%3E%3Crect width='100' height='100' fill='%23121212'/%3E%3Cg class='neon'%3E%3Crect x='25' y='25' width='50' height='50' fill='none' stroke='%2300e5ff'/%3E%3Cpath d='M25 50 L75 50 M50 25 L50 75' stroke='%23b300ff'/%3E%3Ccircle cx='50' cy='50' r='8' fill='%23e0e0e0'/%3E%3C/g%3E%3C/svg%3E"
|
# Initialisation du serveur FastHTML
|
||||||
|
app, rt = fast_app(hdrs=get_theme_hdrs(), secret_key="super_secret_aethas")
|
||||||
|
|
||||||
theme_hdrs = (
|
# Montage du dossier statique pour rendre accessible le logo
|
||||||
Script(src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"),
|
app.mount("/Logo", StaticFiles(directory="Logo"), name="Logo")
|
||||||
Script(src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"),
|
|
||||||
Script(src="https://cdnjs.cloudflare.com/ajax/libs/html2pdf.js/0.10.1/html2pdf.bundle.min.js"),
|
|
||||||
Script(src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"),
|
|
||||||
Link(rel="stylesheet", href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css"),
|
|
||||||
Link(rel="icon", type="image/svg+xml", href=f"data:image/svg+xml;utf8,{svg_favicon}"),
|
|
||||||
|
|
||||||
Style("""
|
|
||||||
:root { --bg-dark: #121212; --bg-panel: #1e1e24; --accent-blue: #00e5ff; --accent-purple: #b300ff; --text-main: #e0e0e0; }
|
|
||||||
body { background-color: var(--bg-dark); color: var(--text-main); font-family: 'Segoe UI', Tahoma, sans-serif; margin: 0; display: flex; height: 100vh; overflow: hidden; box-sizing: border-box; }
|
|
||||||
|
|
||||||
.sidebar { width: 280px; background-color: #0a0a0c; border-right: 1px solid #333; padding: 20px; display: flex; flex-direction: column; height: 100vh; box-sizing: border-box; }
|
|
||||||
.sidebar-top { flex-shrink: 0; }
|
|
||||||
.sidebar-history { flex: 1; overflow-y: auto; margin-top: 15px; margin-bottom: 15px; }
|
|
||||||
.sidebar-footer { flex-shrink: 0; border-top: 1px solid #333; padding-top: 15px; text-align: center; }
|
|
||||||
|
|
||||||
.nav-buttons { display: flex; flex-direction: column; gap: 10px; margin-bottom: 20px; }
|
|
||||||
.new-chat-btn { display:block; text-align:center; text-decoration:none; background: transparent; border: 1px solid var(--accent-blue); color: var(--accent-blue); padding: 10px; border-radius: 5px; cursor: pointer; font-weight: bold; transition: 0.2s; }
|
|
||||||
.new-chat-btn:hover { background: rgba(0, 229, 255, 0.1); }
|
|
||||||
.export-btn { background: #2a2a35; border: 1px solid #444; color: white; padding: 10px; border-radius: 5px; cursor: pointer; transition: 0.2s; font-size: 1em; text-align:center; text-decoration:none; display:block;}
|
|
||||||
.export-btn:hover { background: #444; }
|
|
||||||
|
|
||||||
.history-item { display: flex; justify-content: space-between; align-items: center; padding: 10px; border-bottom: 1px solid #222; font-size: 0.9em; transition: 0.2s; }
|
|
||||||
.history-item:hover { background: #1a1a20; }
|
|
||||||
.history-link { flex: 1; text-decoration: none; color: inherit; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: block; padding-right: 10px; }
|
|
||||||
.history-btn { background: none; border: none; color: #888; cursor: pointer; padding: 2px 5px; font-size: 1.1em; }
|
|
||||||
.history-btn:hover { color: var(--accent-blue); }
|
|
||||||
|
|
||||||
.main-content { flex: 1; display: flex; flex-direction: column; height: 100vh; position: relative; box-sizing: border-box; }
|
|
||||||
.chat-container { flex: 1; padding: 20px; max-width: 1000px; margin: 0 auto; width: 100%; overflow-y: auto; display: flex; flex-direction: column; gap: 20px; padding-bottom: 140px; box-sizing: border-box; }
|
|
||||||
|
|
||||||
.user-bubble { background-color: #2a2a35; padding: 15px 20px; border-radius: 15px 15px 0 15px; align-self: flex-end; max-width: 80%; border: 1px solid #444; margin-left: auto; word-break: break-word; overflow-wrap: break-word; }
|
|
||||||
.ai-bubble { background-color: var(--bg-panel); padding: 15px 20px; border-radius: 15px 15px 15px 0; align-self: flex-start; max-width: 90%; width: 100%; border: 1px solid #333; word-break: break-word; overflow-wrap: break-word; }
|
|
||||||
|
|
||||||
.msg-actions { display: flex; gap: 10px; margin-top: 15px; border-top: 1px dashed #333; padding-top: 10px; justify-content: flex-end; flex-wrap: wrap; }
|
|
||||||
.msg-action-btn { background: transparent; border: 1px solid #444; color: #888; border-radius: 15px; padding: 5px 12px; cursor: pointer; font-size: 0.85em; transition: 0.2s; }
|
|
||||||
.msg-action-btn:hover { background: #2a2a35; color: var(--accent-blue); border-color: var(--accent-blue); }
|
|
||||||
|
|
||||||
.code-header { background: #16161a; padding: 8px 15px; display: flex; justify-content: space-between; align-items: center; border-radius: 8px 8px 0 0; font-family: monospace; font-size: 0.85em; border: 1px solid #333; border-bottom: none; }
|
|
||||||
.code-actions-group { display: flex; gap: 8px; }
|
|
||||||
.code-header button { background: #2a2a35; border: 1px solid #444; color: #e0e0e0; cursor: pointer; padding: 4px 10px; border-radius: 5px; font-size: 0.85em; transition: 0.2s; }
|
|
||||||
.code-header button:hover { background: var(--accent-blue); color: black; border-color: var(--accent-blue); }
|
|
||||||
.markdown-rendered pre { margin-top: 0; border-radius: 0 0 8px 8px; border: 1px solid #333; padding: 15px; overflow-x: auto; background-color: #0d0d0f !important; }
|
|
||||||
|
|
||||||
.input-area { position: absolute; bottom: 0; width: 100%; padding: 20px; background: linear-gradient(0deg, #121212 85%, transparent); display: flex; justify-content: center; box-sizing: border-box; }
|
|
||||||
.input-container-box { width: 100%; max-width: 900px; background: #1e1e24; border-radius: 24px; border: 1px solid #444; padding: 10px 15px; box-sizing: border-box; display: flex; flex-direction: column; gap: 8px; }
|
|
||||||
|
|
||||||
#file-preview-list { display: flex; flex-wrap: wrap; gap: 6px; max-height: 80px; overflow-y: auto; padding-bottom: 5px; }
|
|
||||||
.file-pill { background: #2a2a35; border: 1px solid #555; color: var(--accent-blue); padding: 5px 12px; border-radius: 12px; font-size: 0.8em; display: flex; align-items: center; gap: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.2); }
|
|
||||||
|
|
||||||
.input-row { display: flex; align-items: flex-end; gap: 10px; width: 100%; }
|
|
||||||
.input-field { background: transparent; border: none; color: white; resize: none; flex: 1; font-size: 1em; height: 36px; max-height: 140px; overflow-y: hidden; padding: 8px; font-family: inherit; box-sizing: border-box; line-height: 20px; }
|
|
||||||
.input-field:focus { outline: none; }
|
|
||||||
|
|
||||||
.file-btn { background: #2a2a35; border: 1px solid #444; color: var(--accent-blue); width: 36px !important; height: 36px !important; border-radius: 50%; display: flex; align-items: center; justify-content: center; cursor: pointer; font-size: 1.3em; text-decoration: none; flex-shrink: 0; }
|
|
||||||
.btn-submit { background: linear-gradient(135deg, var(--accent-blue), var(--accent-purple)); color: white; border: none; width: 36px !important; height: 36px !important; border-radius: 50%; display: flex; align-items: center; justify-content: center; cursor: pointer; font-size: 1.1em; flex-shrink: 0; }
|
|
||||||
|
|
||||||
.htmx-indicator { display: none; }
|
|
||||||
.htmx-request .htmx-indicator { display: flex; }
|
|
||||||
.tech-loader-container { display: flex; align-items: center; gap: 15px; color: var(--accent-blue); font-style: italic; background: var(--bg-panel); padding: 15px 20px; border-radius: 15px 15px 15px 0; border: 1px dashed var(--accent-blue); width: fit-content; margin-bottom: 20px; font-size: 0.9em; }
|
|
||||||
.tech-spinner { width: 20px; height: 20px; border: 3px solid rgba(0, 229, 255, 0.2); border-top-color: var(--accent-blue); border-radius: 50%; animation: spin 1s linear infinite; }
|
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
|
||||||
"""),
|
|
||||||
|
|
||||||
Script(r"""
|
|
||||||
function renderMarkdown() {
|
|
||||||
try {
|
|
||||||
document.querySelectorAll('.markdown-raw').forEach(el => {
|
|
||||||
if (el.dataset.rendered) return;
|
|
||||||
let container = el.nextElementSibling;
|
|
||||||
container.innerHTML = (typeof marked !== 'undefined') ? marked.parse(el.textContent) : el.textContent;
|
|
||||||
el.dataset.rendered = "true";
|
|
||||||
el.classList.remove('markdown-raw');
|
|
||||||
|
|
||||||
container.querySelectorAll('pre').forEach(pre => {
|
|
||||||
try {
|
|
||||||
let code = pre.querySelector('code');
|
|
||||||
if (code && !pre.previousElementSibling?.classList.contains('code-header')) {
|
|
||||||
let rawLang = code.className.replace('language-', '') || 'snippet';
|
|
||||||
let header = document.createElement('div');
|
|
||||||
header.className = 'code-header';
|
|
||||||
header.innerHTML = `
|
|
||||||
<span style="color:#00e5ff; font-weight:bold;">${rawLang.toUpperCase()}</span>
|
|
||||||
<div class="code-actions-group">
|
|
||||||
<button onclick="copyCodeBlock(this)">📋 Copier</button>
|
|
||||||
<button onclick="downloadCodeZip(this, '${rawLang}')">📦 ZIP</button>
|
|
||||||
</div>`;
|
|
||||||
pre.parentNode.insertBefore(header, pre);
|
|
||||||
if (typeof hljs !== 'undefined') hljs.highlightElement(code);
|
|
||||||
}
|
|
||||||
} catch(err) { console.warn("Erreur entête code", err); }
|
|
||||||
});
|
|
||||||
});
|
|
||||||
let chat = document.getElementById('chat-history');
|
|
||||||
if (chat) chat.scrollTop = chat.scrollHeight;
|
|
||||||
} catch(e) { console.error("Erreur markdown", e); }
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSmartFilename(btn, defaultName = 'Aethas38_Reponse', extension = 'pdf') {
|
|
||||||
try {
|
|
||||||
let bubble = btn.closest('.ai-bubble');
|
|
||||||
let prevUserMsg = bubble.previousElementSibling;
|
|
||||||
if (prevUserMsg && prevUserMsg.classList.contains('user-bubble')) {
|
|
||||||
let text = prevUserMsg.innerText.replace(/📎.*$/, '').trim();
|
|
||||||
let slug = text.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9àâäéèêëîïôöùûüç\s]/g, '')
|
|
||||||
.trim()
|
|
||||||
.replace(/\s+/g, '_')
|
|
||||||
.substring(0, 35);
|
|
||||||
if (slug.length > 3) return slug + '.' + extension;
|
|
||||||
}
|
|
||||||
} catch(e) {}
|
|
||||||
return defaultName + '.' + extension;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCleanPlainText(btn) {
|
|
||||||
let rendered = btn.closest('.ai-bubble').querySelector('.markdown-rendered');
|
|
||||||
let raw = btn.closest('.ai-bubble').querySelector('.markdown-raw');
|
|
||||||
let source = (rendered && rendered.innerHTML.trim() !== "") ? rendered : raw;
|
|
||||||
return source.innerText || source.textContent || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function autoResize(textarea) {
|
|
||||||
if (!textarea) return;
|
|
||||||
textarea.style.height = '36px';
|
|
||||||
let scrollH = textarea.scrollHeight;
|
|
||||||
if (scrollH > 100) {
|
|
||||||
textarea.style.height = '100px';
|
|
||||||
textarea.style.overflowY = 'auto';
|
|
||||||
} else {
|
|
||||||
textarea.style.height = scrollH + 'px';
|
|
||||||
textarea.style.overflowY = 'hidden';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
const ta = document.getElementById('prompt-input');
|
|
||||||
const dropZone = document.querySelector('.input-container-box');
|
|
||||||
const fileInput = document.getElementById('file-upload');
|
|
||||||
|
|
||||||
if (ta) {
|
|
||||||
ta.addEventListener('input', () => autoResize(ta));
|
|
||||||
ta.addEventListener('paste', (e) => {
|
|
||||||
let items = (e.clipboardData || e.originalEvent.clipboardData).items;
|
|
||||||
let dt = new DataTransfer();
|
|
||||||
if (fileInput && fileInput.files) {
|
|
||||||
for (let f of fileInput.files) dt.items.add(f);
|
|
||||||
}
|
|
||||||
let hasFiles = false;
|
|
||||||
for (let i = 0; i < items.length; i++) {
|
|
||||||
if (items[i].kind === 'file') {
|
|
||||||
let blob = items[i].getAsFile();
|
|
||||||
if (blob) { dt.items.add(blob); hasFiles = true; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (hasFiles) {
|
|
||||||
e.preventDefault();
|
|
||||||
fileInput.files = dt.files;
|
|
||||||
handleFileSelection(fileInput);
|
|
||||||
} else {
|
|
||||||
setTimeout(() => autoResize(ta), 20);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dropZone && fileInput) {
|
|
||||||
['dragenter', 'dragover'].forEach(eName => dropZone.addEventListener(eName, e => { e.preventDefault(); dropZone.style.borderColor = 'var(--accent-blue)'; }));
|
|
||||||
['dragleave', 'drop'].forEach(eName => dropZone.addEventListener(eName, e => { e.preventDefault(); dropZone.style.borderColor = '#444'; }));
|
|
||||||
dropZone.addEventListener('drop', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
let dt = new DataTransfer();
|
|
||||||
if (fileInput.files) { for (let f of fileInput.files) dt.items.add(f); }
|
|
||||||
for (let f of e.dataTransfer.files) {
|
|
||||||
if (dt.files.length < 10) dt.items.add(f);
|
|
||||||
}
|
|
||||||
fileInput.files = dt.files;
|
|
||||||
handleFileSelection(fileInput);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function copyCodeBlock(btn) {
|
|
||||||
let pre = btn.closest('.code-header').nextElementSibling;
|
|
||||||
navigator.clipboard.writeText(pre.querySelector('code').innerText);
|
|
||||||
btn.innerText = "✅ Copié !";
|
|
||||||
setTimeout(() => btn.innerText = "📋 Copier", 2000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function downloadCodeZip(btn, lang) {
|
|
||||||
let pre = btn.closest('.code-header').nextElementSibling;
|
|
||||||
let code = pre.querySelector('code').innerText;
|
|
||||||
let ext = lang === 'python' ? 'py' : (lang === 'javascript' ? 'js' : (lang === 'html' ? 'html' : 'txt'));
|
|
||||||
let filename = getSmartFilename(btn, 'aethas_snippet', ext);
|
|
||||||
|
|
||||||
if (typeof JSZip !== 'undefined') {
|
|
||||||
let zip = new JSZip();
|
|
||||||
zip.file(filename, code);
|
|
||||||
zip.generateAsync({type: "blob"}).then(function(content) {
|
|
||||||
let link = document.createElement('a');
|
|
||||||
link.href = URL.createObjectURL(content);
|
|
||||||
link.download = filename.replace('.'+ext, '_snippet.zip');
|
|
||||||
link.click();
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
let blob = new Blob([code], {type: 'text/plain;charset=utf-8'});
|
|
||||||
let link = document.createElement('a');
|
|
||||||
link.href = URL.createObjectURL(blob);
|
|
||||||
link.download = filename;
|
|
||||||
link.click();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleFileSelection(input) {
|
|
||||||
let previewList = document.getElementById('file-preview-list');
|
|
||||||
previewList.innerHTML = '';
|
|
||||||
if (input.files) {
|
|
||||||
if (input.files.length > 10) alert("Maximum 10 fichiers autorisés.");
|
|
||||||
let count = Math.min(input.files.length, 10);
|
|
||||||
for (let i = 0; i < count; i++) {
|
|
||||||
let file = input.files[i];
|
|
||||||
let pill = document.createElement('div');
|
|
||||||
pill.className = 'file-pill';
|
|
||||||
pill.innerHTML = `📎 ${file.name} <span style="cursor:pointer; color:#ff5555; margin-left:4px;" onclick="removeFile(${i})">✕</span>`;
|
|
||||||
previewList.appendChild(pill);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeFile(index) {
|
|
||||||
let fileInput = document.getElementById('file-upload');
|
|
||||||
let dt = new DataTransfer();
|
|
||||||
let { files } = fileInput;
|
|
||||||
for (let i = 0; i < files.length; i++) {
|
|
||||||
if (i !== index) dt.items.add(files[i]);
|
|
||||||
}
|
|
||||||
fileInput.files = dt.files;
|
|
||||||
handleFileSelection(fileInput);
|
|
||||||
}
|
|
||||||
|
|
||||||
function downloadResponsePDF(btn) {
|
|
||||||
let content = btn.closest('.ai-bubble').querySelector('.markdown-rendered');
|
|
||||||
let filename = getSmartFilename(btn, 'Aethas38_Reponse', 'pdf');
|
|
||||||
let originalBg = content.style.backgroundColor;
|
|
||||||
let originalColor = content.style.color;
|
|
||||||
content.style.backgroundColor = '#ffffff';
|
|
||||||
content.style.color = '#000000';
|
|
||||||
content.style.padding = '15px';
|
|
||||||
html2pdf().set({ margin: 10, filename: filename, html2canvas: { scale: 2, backgroundColor: '#ffffff' }, jsPDF: { unit: 'mm', format: 'a4' } }).from(content).save().then(() => {
|
|
||||||
content.style.backgroundColor = originalBg;
|
|
||||||
content.style.color = originalColor;
|
|
||||||
content.style.padding = '';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function downloadResponseTXT(btn) {
|
|
||||||
let text = getCleanPlainText(btn);
|
|
||||||
let filename = getSmartFilename(btn, 'Aethas38_Reponse', 'txt');
|
|
||||||
let blob = new Blob([text], {type: 'text/plain;charset=utf-8'});
|
|
||||||
let link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = filename; link.click();
|
|
||||||
}
|
|
||||||
function downloadResponseDOC(btn) {
|
|
||||||
let html = btn.closest('.ai-bubble').querySelector('.markdown-rendered').innerHTML;
|
|
||||||
let filename = getSmartFilename(btn, 'Aethas38_Reponse', 'doc');
|
|
||||||
let preHtml = "<html xmlns:o='urn:schemas-microsoft-com:office:office' xmlns:w='urn:schemas-microsoft-com:office:word' xmlns='http://www.w3.org/TR/REC-html40'><head><meta charset='utf-8'><title>Aethas Export</title></head><body style='font-family: Arial, sans-serif; color: #000000; background-color: #ffffff;'>";
|
|
||||||
let blob = new Blob(['\ufeff', preHtml + html + "</body></html>"], { type: 'application/msword' });
|
|
||||||
let link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = filename; link.click();
|
|
||||||
}
|
|
||||||
function downloadResponseJSON(btn) {
|
|
||||||
let text = getCleanPlainText(btn);
|
|
||||||
let filename = getSmartFilename(btn, 'Aethas38_Reponse', 'json');
|
|
||||||
let blob = new Blob([JSON.stringify({content: text}, null, 2)], {type: 'application/json;charset=utf-8'});
|
|
||||||
let link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = filename; link.click();
|
|
||||||
}
|
|
||||||
function shareWhatsApp(btn) {
|
|
||||||
let text = getCleanPlainText(btn);
|
|
||||||
window.open('https://api.whatsapp.com/send?text=' + encodeURIComponent("🤖 *Aethas38 (IA) :*\n\n" + text), '_blank');
|
|
||||||
}
|
|
||||||
function shareEmail(btn) {
|
|
||||||
let text = getCleanPlainText(btn);
|
|
||||||
window.open('mailto:?subject=Aethas38 - Réponse IA&body=' + encodeURIComponent(text), '_self');
|
|
||||||
}
|
|
||||||
""")
|
|
||||||
)
|
|
||||||
|
|
||||||
app, rt = fast_app(hdrs=theme_hdrs, secret_key="super_secret_aethas")
|
|
||||||
|
|
||||||
@rt('/')
|
@rt('/')
|
||||||
def get(session):
|
def get(session):
|
||||||
if 'current_chat_id' not in session or not session['current_chat_id']:
|
if 'current_chat_id' not in session or not session['current_chat_id']:
|
||||||
session['current_chat_id'] = str(uuid.uuid4())
|
session['current_chat_id'] = str(uuid.uuid4())
|
||||||
chat_id = session['current_chat_id']
|
return render_main_page(session['current_chat_id'])
|
||||||
history = load_history()
|
|
||||||
|
|
||||||
msgs = [render_user_msg(m['content']) if m['role']=='user' else render_ai_msg(m['content'], m.get('intention',''), m.get('fournisseur',''), m.get('modele',''), m.get('couleur','#333')) for m in history.get(chat_id, {}).get('messages', [])]
|
|
||||||
if msgs: msgs.append(Script("setTimeout(renderMarkdown, 150);"))
|
|
||||||
|
|
||||||
return Title("Aethas38 - Core"), Body(
|
|
||||||
Div(
|
|
||||||
Div(
|
|
||||||
Div(H2("AETHAS 38", style="color:#00e5ff; margin:0; text-align:center;"), P("Tech Core", style="font-size:0.7em; color:#888; text-align:center;"), margin_bottom="20px"),
|
|
||||||
Div(A("+ Nouvelle discussion", href="/new_chat", cls="new-chat-btn"), A("💾 Exporter JSON", href="/export_json", target="_blank", cls="export-btn"), cls="nav-buttons"),
|
|
||||||
cls="sidebar-top"
|
|
||||||
),
|
|
||||||
render_sidebar(),
|
|
||||||
Div(P("👤 Admin (Xavier)", style="color: #aaa; margin: 0; font-size: 0.9em;"), cls="sidebar-footer"),
|
|
||||||
cls="sidebar"
|
|
||||||
),
|
|
||||||
Div(
|
|
||||||
Div(*msgs, id="chat-history", cls="chat-container"),
|
|
||||||
Div(Div(Div(cls="tech-spinner"), Span("Aethas Core analyse la requête..."), cls="tech-loader-container"), id="global-loader", cls="htmx-indicator", style="max-width: 1000px; margin: 0 auto; width: 100%; padding: 0 20px; box-sizing: border-box; padding-bottom: 120px;"),
|
|
||||||
Div(
|
|
||||||
Form(
|
|
||||||
Input(type="hidden", name="chat_id", value=chat_id),
|
|
||||||
Div(
|
|
||||||
Div(id="file-preview-list"),
|
|
||||||
Div(
|
|
||||||
Label("+", _for="file-upload", cls="file-btn"),
|
|
||||||
Input(type="file", name="files", id="file-upload", multiple=True, style="display:none;", onchange="handleFileSelection(this)"),
|
|
||||||
Textarea(name="prompt", id="prompt-input", placeholder="Requête, lien ou glissez vos fichiers ici...", cls="input-field", rows="1", oninput="autoResize(this)"),
|
|
||||||
Button("↑", type="submit", cls="btn-submit"),
|
|
||||||
cls="input-row"
|
|
||||||
),
|
|
||||||
cls="input-container-box"
|
|
||||||
),
|
|
||||||
hx_post="/chat", hx_target="#chat-history", hx_swap="beforeend", hx_indicator="#global-loader",
|
|
||||||
hx_on__after_request="this.reset(); document.getElementById('file-preview-list').innerHTML=''; document.getElementById('prompt-input').style.height='36px';",
|
|
||||||
enctype="multipart/form-data", style="width: 100%; display: flex; justify-content: center;"
|
|
||||||
),
|
|
||||||
cls="input-area"
|
|
||||||
),
|
|
||||||
cls="main-content"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
@rt('/chat')
|
@rt('/chat')
|
||||||
async def post(request, session: dict):
|
async def post(request, session: dict):
|
||||||
try:
|
return await handle_chat_request(request, session)
|
||||||
form = await request.form()
|
|
||||||
prompt = form.get("prompt", "")
|
|
||||||
chat_id = form.get("chat_id", str(uuid.uuid4()))
|
|
||||||
files = form.getlist("files")
|
|
||||||
session['current_chat_id'] = chat_id
|
|
||||||
|
|
||||||
history = load_history()
|
|
||||||
|
|
||||||
# Router Qwen
|
|
||||||
sec_prompt = f"""Analyse et route cette demande. Si recherche web nécessaire ("va sur", "site", "historique", "version", "cherche", etc.) -> choisis "gemini". Si code/technique -> "groq". Sinon -> "local".
|
|
||||||
Renvoie STRICTEMENT un JSON : {{"intention": "...", "fournisseur": "gemini|groq|local"}}
|
|
||||||
Requête : {prompt}"""
|
|
||||||
|
|
||||||
router_res = local_client.chat.completions.create(model="qwen2.5:3b", messages=[{"role": "system", "content": "JSON uniquement."}, {"role": "user", "content": sec_prompt}], temperature=0.1)
|
|
||||||
decision = nettoyer_decision_router(router_res.choices[0].message.content)
|
|
||||||
intention, fournisseur = decision.get('intention', 'Analyse'), decision.get('fournisseur', 'gemini').lower()
|
|
||||||
|
|
||||||
urls = re.findall(r'https?://[^\s]+', prompt)
|
|
||||||
mots_cles = ["cherche", "recherche", "sur internet", "sur le web", "sur le net", "va sur", "trouve", "site", "donne", "historique", "version"]
|
|
||||||
if any(m in prompt.lower() for m in mots_cles):
|
|
||||||
urls.extend(rechercher_sur_google(prompt))
|
|
||||||
|
|
||||||
web_text = extraire_contenu_urls(list(set(urls))) if urls else ""
|
|
||||||
|
|
||||||
file_summary = []
|
|
||||||
file_names = []
|
|
||||||
for f in files[:10]:
|
|
||||||
if f and f.filename:
|
|
||||||
file_names.append(f.filename)
|
|
||||||
content = await f.read()
|
|
||||||
file_summary.append(f"\n--- Fichier: {f.filename} ---\n{content.decode('utf-8', errors='ignore')}")
|
|
||||||
|
|
||||||
full_prompt = prompt + ("\n" + "\n".join(file_summary) if file_summary else "")
|
|
||||||
if web_text:
|
|
||||||
full_prompt += f"\n\n[Contexte web extrait :\n{web_text}\nUtilise ces informations pour répondre précisément.]"
|
|
||||||
|
|
||||||
if fournisseur == "gemini":
|
|
||||||
try:
|
|
||||||
reponse, modele = interroger_gemini_cascade(full_prompt)
|
|
||||||
couleur, fournisseur_nom = "#3b82f6", "GOOGLE GEMINI"
|
|
||||||
except Exception:
|
|
||||||
reponse, couleur, fournisseur_nom, modele = appeler_groq_api(full_prompt)
|
|
||||||
fournisseur_nom, intention = "GROQ (Secours)", intention + " (Secours)"
|
|
||||||
elif fournisseur == "groq":
|
|
||||||
reponse, couleur, fournisseur_nom, modele = appeler_groq_api(full_prompt)
|
|
||||||
else:
|
|
||||||
res_local = local_client.chat.completions.create(model="qwen2.5:3b", messages=[{"role": "system", "content": "Réponds en français."}, {"role": "user", "content": full_prompt}])
|
|
||||||
reponse, couleur, fournisseur_nom, modele = res_local.choices[0].message.content, "#10b981", "SERVEUR LOCAL", "qwen2.5:3b"
|
|
||||||
|
|
||||||
if chat_id not in history:
|
|
||||||
history[chat_id] = {"title": (prompt[:30] if prompt else "Discussion") + "...", "pinned": False, "timestamp": time.time(), "messages": []}
|
|
||||||
|
|
||||||
display_msg = prompt + (f"\n📎 [Fichiers: {', '.join(file_names)}]" if file_names else "")
|
|
||||||
history[chat_id]['messages'].append({"role": "user", "content": display_msg})
|
|
||||||
history[chat_id]['messages'].append({"role": "ai", "content": reponse, "intention": intention, "fournisseur": fournisseur_nom, "modele": modele, "couleur": couleur})
|
|
||||||
save_history(history)
|
|
||||||
|
|
||||||
return render_user_msg(display_msg), render_ai_msg(reponse, intention, fournisseur_nom, modele, couleur), Script("renderMarkdown();"), render_sidebar()
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
return Div(f"Erreur : {e}", style="color:red; background:#300; padding:15px; border-radius:10px;")
|
|
||||||
|
|
||||||
@rt('/favicon.ico')
|
@rt('/favicon.ico')
|
||||||
def get(): return HTTPResponse(status_code=204)
|
def get(): return HTTPResponse(status_code=204)
|
||||||
|
|
||||||
@rt('/new_chat')
|
@rt('/new_chat')
|
||||||
def get(session): session['current_chat_id'] = str(uuid.uuid4()); return RedirectResponse('/', status_code=303)
|
def get(session): session['current_chat_id'] = str(uuid.uuid4()); return RedirectResponse('/', status_code=303)
|
||||||
|
|
||||||
@rt('/load_chat/{cid}')
|
@rt('/load_chat/{cid}')
|
||||||
def get(cid: str, session):
|
def get(cid: str, session):
|
||||||
if cid and cid != "null": session['current_chat_id'] = cid
|
if cid and cid != "null": session['current_chat_id'] = cid
|
||||||
return RedirectResponse('/', status_code=303)
|
return RedirectResponse('/', status_code=303)
|
||||||
|
|
||||||
@rt('/rename/{cid}')
|
@rt('/rename/{cid}')
|
||||||
def post(cid: str, req):
|
def post(cid: str, req):
|
||||||
h = load_history()
|
h = load_history()
|
||||||
if cid in h and (nom := req.headers.get("HX-Prompt")): h[cid]['title'] = nom; save_history(h)
|
if cid in h and (nom := req.headers.get("HX-Prompt")): h[cid]['title'] = nom; save_history(h)
|
||||||
return render_sidebar()
|
return render_sidebar()
|
||||||
|
|
||||||
@rt('/pin/{cid}')
|
@rt('/pin/{cid}')
|
||||||
def post(cid: str):
|
def post(cid: str):
|
||||||
h = load_history()
|
h = load_history()
|
||||||
if cid in h: h[cid]['pinned'] = not h[cid].get('pinned', False); save_history(h)
|
if cid in h: h[cid]['pinned'] = not h[cid].get('pinned', False); save_history(h)
|
||||||
return render_sidebar()
|
return render_sidebar()
|
||||||
|
|
||||||
@rt('/delete/{cid}')
|
@rt('/delete/{cid}')
|
||||||
def post(cid: str):
|
def post(cid: str):
|
||||||
h, cid_str = load_history(), str(cid)
|
h, cid_str = load_history(), str(cid)
|
||||||
if cid_str in h: del h[cid_str]; save_history(h)
|
if cid_str in h: del h[cid_str]; save_history(h)
|
||||||
return render_sidebar()
|
return render_sidebar()
|
||||||
|
|
||||||
@rt('/export_json')
|
@rt('/export_json')
|
||||||
def get(): return FileResponse(HISTORY_FILE, media_type='application/json', filename='Aethas38_Discussion_Globale.json')
|
def get(): return FileResponse(HISTORY_FILE, media_type='application/json', filename='Aethas38_Discussion_Globale.json')
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import uuid
|
||||||
|
import time
|
||||||
|
import re
|
||||||
|
from fasthtml.common import *
|
||||||
|
from modules.database import load_history, save_history
|
||||||
|
from modules.scraper import rechercher_sur_google, extraire_contenu_urls
|
||||||
|
from modules.router import nettoyer_decision_router
|
||||||
|
from modules.ai_engine import local_client, interroger_gemini_cascade, appeler_groq_api
|
||||||
|
from modules.ui_components import render_user_msg, render_ai_msg, render_sidebar
|
||||||
|
|
||||||
|
async def handle_chat_request(request, session):
|
||||||
|
try:
|
||||||
|
form = await request.form()
|
||||||
|
prompt = form.get("prompt", "")
|
||||||
|
chat_id = form.get("chat_id", str(uuid.uuid4()))
|
||||||
|
files = form.getlist("files")
|
||||||
|
session['current_chat_id'] = chat_id
|
||||||
|
|
||||||
|
history = load_history()
|
||||||
|
|
||||||
|
sec_prompt = f"""Analyse et route cette demande. Si recherche web nécessaire ("va sur", "site", "historique", "version", "cherche", etc.) -> choisis "gemini". Si code/technique -> "groq". Sinon -> "local".
|
||||||
|
Renvoie STRICTEMENT un JSON : {{"intention": "...", "fournisseur": "gemini|groq|local"}}
|
||||||
|
Requête : {prompt}"""
|
||||||
|
|
||||||
|
router_res = local_client.chat.completions.create(model="qwen2.5:3b", messages=[{"role": "system", "content": "JSON uniquement."}, {"role": "user", "content": sec_prompt}], temperature=0.1)
|
||||||
|
decision = nettoyer_decision_router(router_res.choices[0].message.content)
|
||||||
|
intention, fournisseur = decision.get('intention', 'Analyse'), decision.get('fournisseur', 'gemini').lower()
|
||||||
|
|
||||||
|
urls = re.findall(r'https?://[^\s]+', prompt)
|
||||||
|
mots_cles = ["cherche", "recherche", "sur internet", "sur le web", "sur le net", "va sur", "trouve", "site", "donne", "historique", "version"]
|
||||||
|
if any(m in prompt.lower() for m in mots_cles):
|
||||||
|
urls.extend(rechercher_sur_google(prompt))
|
||||||
|
|
||||||
|
web_text = extraire_contenu_urls(list(set(urls))) if urls else ""
|
||||||
|
|
||||||
|
file_summary = []
|
||||||
|
file_names = []
|
||||||
|
for f in files[:10]:
|
||||||
|
if f and f.filename:
|
||||||
|
file_names.append(f.filename)
|
||||||
|
content = await f.read()
|
||||||
|
file_summary.append(f"\n--- Fichier: {f.filename} ---\n{content.decode('utf-8', errors='ignore')}")
|
||||||
|
|
||||||
|
full_prompt = prompt + ("\n" + "\n".join(file_summary) if file_summary else "")
|
||||||
|
if web_text:
|
||||||
|
full_prompt += f"\n\n[Contexte web extrait :\n{web_text}\nUtilise ces informations pour répondre précisément.]"
|
||||||
|
|
||||||
|
if fournisseur == "gemini":
|
||||||
|
try:
|
||||||
|
reponse, modele = interroger_gemini_cascade(full_prompt)
|
||||||
|
couleur, fournisseur_nom = "#3b82f6", "GOOGLE GEMINI"
|
||||||
|
except Exception:
|
||||||
|
reponse, couleur, fournisseur_nom, modele = appeler_groq_api(full_prompt)
|
||||||
|
fournisseur_nom, intention = "GROQ (Secours)", intention + " (Secours)"
|
||||||
|
elif fournisseur == "groq":
|
||||||
|
reponse, couleur, fournisseur_nom, modele = appeler_groq_api(full_prompt)
|
||||||
|
else:
|
||||||
|
res_local = local_client.chat.completions.create(model="qwen2.5:3b", messages=[{"role": "system", "content": "Réponds en français."}, {"role": "user", "content": full_prompt}])
|
||||||
|
reponse, couleur, fournisseur_nom, modele = res_local.choices[0].message.content, "#10b981", "SERVEUR LOCAL", "qwen2.5:3b"
|
||||||
|
|
||||||
|
if chat_id not in history:
|
||||||
|
history[chat_id] = {"title": (prompt[:30] if prompt else "Discussion") + "...", "pinned": False, "timestamp": time.time(), "messages": []}
|
||||||
|
|
||||||
|
display_msg = prompt + (f"\n📎 [Fichiers: {', '.join(file_names)}]" if file_names else "")
|
||||||
|
history[chat_id]['messages'].append({"role": "user", "content": display_msg})
|
||||||
|
history[chat_id]['messages'].append({"role": "ai", "content": reponse, "intention": intention, "fournisseur": fournisseur_nom, "modele": modele, "couleur": couleur})
|
||||||
|
save_history(history)
|
||||||
|
|
||||||
|
return render_user_msg(display_msg), render_ai_msg(reponse, intention, fournisseur_nom, modele, couleur), Script("renderMarkdown();"), render_sidebar()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return Div(f"Erreur : {e}", style="color:red; background:#300; padding:15px; border-radius:10px;")
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# modules/paypal.py
|
||||||
|
from fasthtml.common import *
|
||||||
|
|
||||||
|
def render_paypal_donation_widget():
|
||||||
|
"""Génère le bouton de don PayPal pour l'interface sidebar."""
|
||||||
|
# Remplacez l'URL ci-dessous par votre véritable lien ou bouton de don PayPal
|
||||||
|
paypal_url = "https://www.paypal.com/donate?hosted_button_id=VOTRE_ID_PAYPAL"
|
||||||
|
|
||||||
|
return Div(
|
||||||
|
A(
|
||||||
|
"☕ Faire un don PayPal",
|
||||||
|
href=paypal_url,
|
||||||
|
target="_blank",
|
||||||
|
cls="export-btn",
|
||||||
|
style="text-align: center; color: #00e5ff; border-color: #00e5ff; display: block; text-decoration: none;"
|
||||||
|
),
|
||||||
|
style="margin-top: 10px;"
|
||||||
|
)
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
from fasthtml.common import *
|
||||||
|
|
||||||
|
def get_theme_hdrs():
|
||||||
|
svg_favicon = "%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Cstyle%3E@keyframes pulse { 0% { opacity: 1; stroke-width: 5; } 50% { opacity: 0.5; stroke-width: 7; } 100% { opacity: 1; stroke-width: 5; } } .neon { animation: pulse 2s infinite; transform-origin: 50px 50px; transform: rotate(45deg); }%3C/style%3E%3Crect width='100' height='100' fill='%23121212'/%3E%3Cg class='neon'%3E%3Crect x='25' y='25' width='50' height='50' fill='none' stroke='%2300e5ff'/%3E%3Cpath d='M25 50 L75 50 M50 25 L50 75' stroke='%23b300ff'/%3E%3Ccircle cx='50' cy='50' r='8' fill='%23e0e0e0'/%3E%3C/g%3E%3C/svg%3E"
|
||||||
|
|
||||||
|
return (
|
||||||
|
Script(src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"),
|
||||||
|
Script(src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"),
|
||||||
|
Script(src="https://cdnjs.cloudflare.com/ajax/libs/html2pdf.js/0.10.1/html2pdf.bundle.min.js"),
|
||||||
|
Script(src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"),
|
||||||
|
Link(rel="stylesheet", href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css"),
|
||||||
|
Link(rel="icon", type="image/svg+xml", href=f"data:image/svg+xml;utf8,{svg_favicon}"),
|
||||||
|
Style("""
|
||||||
|
:root { --bg-dark: #121212; --bg-panel: #1e1e24; --accent-blue: #00e5ff; --accent-purple: #b300ff; --text-main: #e0e0e0; }
|
||||||
|
body { background-color: var(--bg-dark); color: var(--text-main); font-family: 'Segoe UI', Tahoma, sans-serif; margin: 0; display: flex; height: 100vh; overflow: hidden; box-sizing: border-box; }
|
||||||
|
.sidebar { width: 280px; background-color: #0a0a0c; border-right: 1px solid #333; padding: 20px; display: flex; flex-direction: column; height: 100vh; box-sizing: border-box; }
|
||||||
|
.sidebar-top { flex-shrink: 0; }
|
||||||
|
.sidebar-history { flex: 1; overflow-y: auto; margin-top: 15px; margin-bottom: 15px; }
|
||||||
|
.sidebar-footer { flex-shrink: 0; border-top: 1px solid #333; padding-top: 15px; text-align: center; }
|
||||||
|
.nav-buttons { display: flex; flex-direction: column; gap: 10px; margin-bottom: 20px; }
|
||||||
|
.new-chat-btn { display:block; text-align:center; text-decoration:none; background: transparent; border: 1px solid var(--accent-blue); color: var(--accent-blue); padding: 10px; border-radius: 5px; cursor: pointer; font-weight: bold; transition: 0.2s; }
|
||||||
|
.new-chat-btn:hover { background: rgba(0, 229, 255, 0.1); }
|
||||||
|
.export-btn { background: #2a2a35; border: 1px solid #444; color: white; padding: 10px; border-radius: 5px; cursor: pointer; transition: 0.2s; font-size: 1em; text-align:center; text-decoration:none; display:block;}
|
||||||
|
.export-btn:hover { background: #444; }
|
||||||
|
.history-item { display: flex; justify-content: space-between; align-items: center; padding: 10px; border-bottom: 1px solid #222; font-size: 0.9em; transition: 0.2s; }
|
||||||
|
.history-item:hover { background: #1a1a20; }
|
||||||
|
.history-link { flex: 1; text-decoration: none; color: inherit; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: block; padding-right: 10px; }
|
||||||
|
.history-btn { background: none; border: none; color: #888; cursor: pointer; padding: 2px 5px; font-size: 1.1em; }
|
||||||
|
.history-btn:hover { color: var(--accent-blue); }
|
||||||
|
.main-content { flex: 1; display: flex; flex-direction: column; height: 100vh; position: relative; box-sizing: border-box; }
|
||||||
|
.chat-container { flex: 1; padding: 20px; max-width: 1000px; margin: 0 auto; width: 100%; overflow-y: auto; display: flex; flex-direction: column; gap: 20px; padding-bottom: 140px; box-sizing: border-box; }
|
||||||
|
.user-bubble { background-color: #2a2a35; padding: 15px 20px; border-radius: 15px 15px 0 15px; align-self: flex-end; max-width: 80%; border: 1px solid #444; margin-left: auto; word-break: break-word; overflow-wrap: break-word; }
|
||||||
|
.ai-bubble { background-color: var(--bg-panel); padding: 15px 20px; border-radius: 15px 15px 15px 0; align-self: flex-start; max-width: 90%; width: 100%; border: 1px solid #333; word-break: break-word; overflow-wrap: break-word; }
|
||||||
|
.msg-actions { display: flex; gap: 10px; margin-top: 15px; border-top: 1px dashed #333; padding-top: 10px; justify-content: flex-end; flex-wrap: wrap; }
|
||||||
|
.msg-action-btn { background: transparent; border: 1px solid #444; color: #888; border-radius: 15px; padding: 5px 12px; cursor: pointer; font-size: 0.85em; transition: 0.2s; }
|
||||||
|
.msg-action-btn:hover { background: #2a2a35; color: var(--accent-blue); border-color: var(--accent-blue); }
|
||||||
|
.code-header { background: #16161a; padding: 8px 15px; display: flex; justify-content: space-between; align-items: center; border-radius: 8px 8px 0 0; font-family: monospace; font-size: 0.85em; border: 1px solid #333; border-bottom: none; }
|
||||||
|
.code-actions-group { display: flex; gap: 8px; }
|
||||||
|
.code-header button { background: #2a2a35; border: 1px solid #444; color: #e0e0e0; cursor: pointer; padding: 4px 10px; border-radius: 5px; font-size: 0.85em; transition: 0.2s; }
|
||||||
|
.code-header button:hover { background: var(--accent-blue); color: black; border-color: var(--accent-blue); }
|
||||||
|
.markdown-rendered pre { margin-top: 0; border-radius: 0 0 8px 8px; border: 1px solid #333; padding: 15px; overflow-x: auto; background-color: #0d0d0f !important; }
|
||||||
|
.input-area { position: absolute; bottom: 0; width: 100%; padding: 20px; background: linear-gradient(0deg, #121212 85%, transparent); display: flex; justify-content: center; box-sizing: border-box; }
|
||||||
|
.input-container-box { width: 100%; max-width: 900px; background: #1e1e24; border-radius: 24px; border: 1px solid #444; padding: 10px 15px; box-sizing: border-box; display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
#file-preview-list { display: flex; flex-wrap: wrap; gap: 6px; max-height: 80px; overflow-y: auto; padding-bottom: 5px; }
|
||||||
|
.file-pill { background: #2a2a35; border: 1px solid #555; color: var(--accent-blue); padding: 5px 12px; border-radius: 12px; font-size: 0.8em; display: flex; align-items: center; gap: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.2); }
|
||||||
|
.input-row { display: flex; align-items: flex-end; gap: 10px; width: 100%; }
|
||||||
|
.input-field { background: transparent; border: none; color: white; resize: none; flex: 1; font-size: 1em; height: 36px; max-height: 140px; overflow-y: hidden; padding: 8px; font-family: inherit; box-sizing: border-box; line-height: 20px; }
|
||||||
|
.input-field:focus { outline: none; }
|
||||||
|
.file-btn { background: #2a2a35; border: 1px solid #444; color: var(--accent-blue); width: 36px !important; height: 36px !important; border-radius: 50%; display: flex; align-items: center; justify-content: center; cursor: pointer; font-size: 1.3em; text-decoration: none; flex-shrink: 0; }
|
||||||
|
.btn-submit { background: linear-gradient(135deg, var(--accent-blue), var(--accent-purple)); color: white; border: none; width: 36px !important; height: 36px !important; border-radius: 50%; display: flex; align-items: center; justify-content: center; cursor: pointer; font-size: 1.1em; flex-shrink: 0; }
|
||||||
|
.htmx-indicator { display: none; }
|
||||||
|
.htmx-request .htmx-indicator { display: flex; }
|
||||||
|
.tech-loader-container { display: flex; align-items: center; gap: 15px; color: var(--accent-blue); font-style: italic; background: var(--bg-panel); padding: 15px 20px; border-radius: 15px 15px 15px 0; border: 1px dashed var(--accent-blue); width: fit-content; margin-bottom: 20px; font-size: 0.9em; }
|
||||||
|
.tech-spinner { width: 20px; height: 20px; border: 3px solid rgba(0, 229, 255, 0.2); border-top-color: var(--accent-blue); border-radius: 50%; animation: spin 1s linear infinite; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
"""),
|
||||||
|
Script(r"""
|
||||||
|
function renderMarkdown() {
|
||||||
|
try {
|
||||||
|
document.querySelectorAll('.markdown-raw').forEach(el => {
|
||||||
|
if (el.dataset.rendered) return;
|
||||||
|
let container = el.nextElementSibling;
|
||||||
|
container.innerHTML = (typeof marked !== 'undefined') ? marked.parse(el.textContent) : el.textContent;
|
||||||
|
el.dataset.rendered = "true";
|
||||||
|
el.classList.remove('markdown-raw');
|
||||||
|
|
||||||
|
container.querySelectorAll('pre').forEach(pre => {
|
||||||
|
try {
|
||||||
|
let code = pre.querySelector('code');
|
||||||
|
if (code && !pre.previousElementSibling?.classList.contains('code-header')) {
|
||||||
|
let rawLang = code.className.replace('language-', '') || 'snippet';
|
||||||
|
let header = document.createElement('div');
|
||||||
|
header.className = 'code-header';
|
||||||
|
header.innerHTML = `
|
||||||
|
<span style="color:#00e5ff; font-weight:bold;">${rawLang.toUpperCase()}</span>
|
||||||
|
<div class="code-actions-group">
|
||||||
|
<button onclick="copyCodeBlock(this)">📋 Copier</button>
|
||||||
|
<button onclick="downloadCodeZip(this, '${rawLang}')">📦 ZIP</button>
|
||||||
|
</div>`;
|
||||||
|
pre.parentNode.insertBefore(header, pre);
|
||||||
|
if (typeof hljs !== 'undefined') hljs.highlightElement(code);
|
||||||
|
}
|
||||||
|
} catch(err) { console.warn("Erreur entête code", err); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
let chat = document.getElementById('chat-history');
|
||||||
|
if (chat) chat.scrollTop = chat.scrollHeight;
|
||||||
|
} catch(e) { console.error("Erreur markdown", e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSmartFilename(btn, defaultName = 'Aethas38_Reponse', extension = 'pdf') {
|
||||||
|
try {
|
||||||
|
let bubble = btn.closest('.ai-bubble');
|
||||||
|
let prevUserMsg = bubble.previousElementSibling;
|
||||||
|
if (prevUserMsg && prevUserMsg.classList.contains('user-bubble')) {
|
||||||
|
let text = prevUserMsg.innerText.replace(/📎.*$/, '').trim();
|
||||||
|
let slug = text.toLowerCase().replace(/[^a-z0-9àâäéèêëîïôöùûüç\s]/g, '').trim().replace(/\s+/g, '_').substring(0, 35);
|
||||||
|
if (slug.length > 3) return slug + '.' + extension;
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
return defaultName + '.' + extension;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCleanPlainText(btn) {
|
||||||
|
let rendered = btn.closest('.ai-bubble').querySelector('.markdown-rendered');
|
||||||
|
let raw = btn.closest('.ai-bubble').querySelector('.markdown-raw');
|
||||||
|
let source = (rendered && rendered.innerHTML.trim() !== "") ? rendered : raw;
|
||||||
|
return source.innerText || source.textContent || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function autoResize(textarea) {
|
||||||
|
if (!textarea) return;
|
||||||
|
textarea.style.height = '36px';
|
||||||
|
let scrollH = textarea.scrollHeight;
|
||||||
|
if (scrollH > 100) {
|
||||||
|
textarea.style.height = '100px';
|
||||||
|
textarea.style.overflowY = 'auto';
|
||||||
|
} else {
|
||||||
|
textarea.style.height = scrollH + 'px';
|
||||||
|
textarea.style.overflowY = 'hidden';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const ta = document.getElementById('prompt-input');
|
||||||
|
const dropZone = document.querySelector('.input-container-box');
|
||||||
|
const fileInput = document.getElementById('file-upload');
|
||||||
|
|
||||||
|
if (ta) {
|
||||||
|
ta.addEventListener('input', () => autoResize(ta));
|
||||||
|
ta.addEventListener('paste', (e) => {
|
||||||
|
let items = (e.clipboardData || e.originalEvent.clipboardData).items;
|
||||||
|
let dt = new DataTransfer();
|
||||||
|
if (fileInput && fileInput.files) {
|
||||||
|
for (let f of fileInput.files) dt.items.add(f);
|
||||||
|
}
|
||||||
|
let hasFiles = false;
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
if (items[i].kind === 'file') {
|
||||||
|
let blob = items[i].getAsFile();
|
||||||
|
if (blob) { dt.items.add(blob); hasFiles = true; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hasFiles) {
|
||||||
|
e.preventDefault();
|
||||||
|
fileInput.files = dt.files;
|
||||||
|
handleFileSelection(fileInput);
|
||||||
|
} else {
|
||||||
|
setTimeout(() => autoResize(ta), 20);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dropZone && fileInput) {
|
||||||
|
['dragenter', 'dragover'].forEach(eName => dropZone.addEventListener(eName, e => { e.preventDefault(); dropZone.style.borderColor = 'var(--accent-blue)'; }));
|
||||||
|
['dragleave', 'drop'].forEach(eName => dropZone.addEventListener(eName, e => { e.preventDefault(); dropZone.style.borderColor = '#444'; }));
|
||||||
|
dropZone.addEventListener('drop', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
let dt = new DataTransfer();
|
||||||
|
if (fileInput.files) { for (let f of fileInput.files) dt.items.add(f); }
|
||||||
|
for (let f of e.dataTransfer.files) {
|
||||||
|
if (dt.files.length < 10) dt.items.add(f);
|
||||||
|
}
|
||||||
|
fileInput.files = dt.files;
|
||||||
|
handleFileSelection(fileInput);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function copyCodeBlock(btn) {
|
||||||
|
let pre = btn.closest('.code-header').nextElementSibling;
|
||||||
|
navigator.clipboard.writeText(pre.querySelector('code').innerText);
|
||||||
|
btn.innerText = "✅ Copié !";
|
||||||
|
setTimeout(() => btn.innerText = "📋 Copier", 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadCodeZip(btn, lang) {
|
||||||
|
let pre = btn.closest('.code-header').nextElementSibling;
|
||||||
|
let code = pre.querySelector('code').innerText;
|
||||||
|
let ext = lang === 'python' ? 'py' : (lang === 'javascript' ? 'js' : (lang === 'html' ? 'html' : 'txt'));
|
||||||
|
let filename = getSmartFilename(btn, 'aethas_snippet', ext);
|
||||||
|
|
||||||
|
if (typeof JSZip !== 'undefined') {
|
||||||
|
let zip = new JSZip();
|
||||||
|
zip.file(filename, code);
|
||||||
|
zip.generateAsync({type: "blob"}).then(function(content) {
|
||||||
|
let link = document.createElement('a');
|
||||||
|
link.href = URL.createObjectURL(content);
|
||||||
|
link.download = filename.replace('.'+ext, '_snippet.zip');
|
||||||
|
link.click();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
let blob = new Blob([code], {type: 'text/plain;charset=utf-8'});
|
||||||
|
let link = document.createElement('a');
|
||||||
|
link.href = URL.createObjectURL(blob);
|
||||||
|
link.download = filename;
|
||||||
|
link.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFileSelection(input) {
|
||||||
|
let previewList = document.getElementById('file-preview-list');
|
||||||
|
previewList.innerHTML = '';
|
||||||
|
if (input.files) {
|
||||||
|
if (input.files.length > 10) alert("Maximum 10 fichiers autorisés.");
|
||||||
|
let count = Math.min(input.files.length, 10);
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
let file = input.files[i];
|
||||||
|
let pill = document.createElement('div');
|
||||||
|
pill.className = 'file-pill';
|
||||||
|
pill.innerHTML = `📎 ${file.name} <span style="cursor:pointer; color:#ff5555; margin-left:4px;" onclick="removeFile(${i})">✕</span>`;
|
||||||
|
previewList.appendChild(pill);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFile(index) {
|
||||||
|
let fileInput = document.getElementById('file-upload');
|
||||||
|
let dt = new DataTransfer();
|
||||||
|
let { files } = fileInput;
|
||||||
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
if (i !== index) dt.items.add(files[i]);
|
||||||
|
}
|
||||||
|
fileInput.files = dt.files;
|
||||||
|
handleFileSelection(fileInput);
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadResponsePDF(btn) {
|
||||||
|
let content = btn.closest('.ai-bubble').querySelector('.markdown-rendered');
|
||||||
|
let filename = getSmartFilename(btn, 'Aethas38_Reponse', 'pdf');
|
||||||
|
let originalBg = content.style.backgroundColor;
|
||||||
|
let originalColor = content.style.color;
|
||||||
|
content.style.backgroundColor = '#ffffff';
|
||||||
|
content.style.color = '#000000';
|
||||||
|
content.style.padding = '15px';
|
||||||
|
html2pdf().set({ margin: 10, filename: filename, html2canvas: { scale: 2, backgroundColor: '#ffffff' }, jsPDF: { unit: 'mm', format: 'a4' } }).from(content).save().then(() => {
|
||||||
|
content.style.backgroundColor = originalBg;
|
||||||
|
content.style.color = originalColor;
|
||||||
|
content.style.padding = '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function downloadResponseTXT(btn) {
|
||||||
|
let text = getCleanPlainText(btn);
|
||||||
|
let filename = getSmartFilename(btn, 'Aethas38_Reponse', 'txt');
|
||||||
|
let blob = new Blob([text], {type: 'text/plain;charset=utf-8'});
|
||||||
|
let link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = filename; link.click();
|
||||||
|
}
|
||||||
|
function downloadResponseDOC(btn) {
|
||||||
|
let html = btn.closest('.ai-bubble').querySelector('.markdown-rendered').innerHTML;
|
||||||
|
let filename = getSmartFilename(btn, 'Aethas38_Reponse', 'doc');
|
||||||
|
let preHtml = "<html xmlns:o='urn:schemas-microsoft-com:office:office' xmlns:w='urn:schemas-microsoft-com:office:word' xmlns='http://www.w3.org/TR/REC-html40'><head><meta charset='utf-8'><title>Aethas Export</title></head><body style='font-family: Arial, sans-serif; color: #000000; background-color: #ffffff;'>";
|
||||||
|
let blob = new Blob(['\\ufeff', preHtml + html + "</body></html>"], { type: 'application/msword' });
|
||||||
|
let link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = filename; link.click();
|
||||||
|
}
|
||||||
|
function downloadResponseJSON(btn) {
|
||||||
|
let text = getCleanPlainText(btn);
|
||||||
|
let filename = getSmartFilename(btn, 'Aethas38_Reponse', 'json');
|
||||||
|
let blob = new Blob([JSON.stringify({content: text}, null, 2)], {type: 'application/json;charset=utf-8'});
|
||||||
|
let link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = filename; link.click();
|
||||||
|
}
|
||||||
|
function shareWhatsApp(btn) {
|
||||||
|
let text = getCleanPlainText(btn);
|
||||||
|
window.open('https://api.whatsapp.com/send?text=' + encodeURIComponent("🤖 *Aethas38 (IA) :*\\n\\n" + text), '_blank');
|
||||||
|
}
|
||||||
|
function shareEmail(btn) {
|
||||||
|
let text = getCleanPlainText(btn);
|
||||||
|
window.open('mailto:?subject=Aethas38 - Réponse IA&body=' + encodeURIComponent(text), '_self');
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
)
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
from fasthtml.common import *
|
from fasthtml.common import *
|
||||||
from modules.database import load_history
|
from modules.database import load_history
|
||||||
|
from modules.paypal import render_paypal_donation_widget
|
||||||
|
|
||||||
def render_user_msg(text):
|
def render_user_msg(text):
|
||||||
return Div(P(text, style="margin:0; font-weight:bold; white-space: pre-wrap;"), cls="user-bubble")
|
return Div(P(text, style="margin:0; font-weight:bold; white-space: pre-wrap;"), cls="user-bubble")
|
||||||
@@ -11,7 +12,6 @@ def render_ai_msg(text, intention, nom_fournisseur, nom_modele, couleur):
|
|||||||
Span(f"🧠 {nom_modele}", style=f"margin-right: 10px; color:{couleur}; font-weight:bold; font-size:0.8em;"),
|
Span(f"🧠 {nom_modele}", style=f"margin-right: 10px; color:{couleur}; font-weight:bold; font-size:0.8em;"),
|
||||||
style="border-bottom: 1px solid #333; padding-bottom: 10px; margin-bottom: 15px; display: flex; flex-wrap: wrap; gap: 5px;"
|
style="border-bottom: 1px solid #333; padding-bottom: 10px; margin-bottom: 15px; display: flex; flex-wrap: wrap; gap: 5px;"
|
||||||
)
|
)
|
||||||
|
|
||||||
actions = Div(
|
actions = Div(
|
||||||
Button("📄 PDF", onclick="downloadResponsePDF(this)", cls="msg-action-btn", title="Télécharger en PDF"),
|
Button("📄 PDF", onclick="downloadResponsePDF(this)", cls="msg-action-btn", title="Télécharger en PDF"),
|
||||||
Button("📘 DOC", onclick="downloadResponseDOC(this)", cls="msg-action-btn", title="Télécharger en Word"),
|
Button("📘 DOC", onclick="downloadResponseDOC(this)", cls="msg-action-btn", title="Télécharger en Word"),
|
||||||
@@ -21,7 +21,6 @@ def render_ai_msg(text, intention, nom_fournisseur, nom_modele, couleur):
|
|||||||
Button("📧 Email", onclick="shareEmail(this)", cls="msg-action-btn", title="Envoyer par Email"),
|
Button("📧 Email", onclick="shareEmail(this)", cls="msg-action-btn", title="Envoyer par Email"),
|
||||||
cls="msg-actions"
|
cls="msg-actions"
|
||||||
)
|
)
|
||||||
|
|
||||||
return Div(
|
return Div(
|
||||||
encart_info,
|
encart_info,
|
||||||
Div(text, cls="markdown-raw", style="display:none;"),
|
Div(text, cls="markdown-raw", style="display:none;"),
|
||||||
@@ -33,7 +32,6 @@ def render_ai_msg(text, intention, nom_fournisseur, nom_modele, couleur):
|
|||||||
def render_sidebar():
|
def render_sidebar():
|
||||||
history = load_history()
|
history = load_history()
|
||||||
sorted_chats = sorted(history.items(), key=lambda x: (not x[1].get('pinned', False), -x[1].get('timestamp', 0)))
|
sorted_chats = sorted(history.items(), key=lambda x: (not x[1].get('pinned', False), -x[1].get('timestamp', 0)))
|
||||||
|
|
||||||
items = []
|
items = []
|
||||||
for chat_id, data in sorted_chats:
|
for chat_id, data in sorted_chats:
|
||||||
pin_icon = "📌" if data.get('pinned', False) else "📍"
|
pin_icon = "📌" if data.get('pinned', False) else "📍"
|
||||||
@@ -47,3 +45,59 @@ def render_sidebar():
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
return Div(*items, id="sidebar-content", cls="sidebar-history", hx_swap_oob="true")
|
return Div(*items, id="sidebar-content", cls="sidebar-history", hx_swap_oob="true")
|
||||||
|
|
||||||
|
def render_main_page(chat_id):
|
||||||
|
history = load_history()
|
||||||
|
messages_ui = []
|
||||||
|
if chat_id in history:
|
||||||
|
for msg in history[chat_id].get('messages', []):
|
||||||
|
if msg['role'] == 'user':
|
||||||
|
messages_ui.append(render_user_msg(msg['content']))
|
||||||
|
else:
|
||||||
|
messages_ui.append(render_ai_msg(msg['content'], msg.get('intention', ''), msg.get('fournisseur', ''), msg.get('modele', ''), msg.get('couleur', '#333')))
|
||||||
|
if messages_ui:
|
||||||
|
messages_ui.append(Script("setTimeout(renderMarkdown, 150);"))
|
||||||
|
|
||||||
|
return Title("Aethas38 - Core"), Body(
|
||||||
|
Div(
|
||||||
|
Div(
|
||||||
|
# L'image est appelée depuis le dossier local "Logo" monté dans FastHTML
|
||||||
|
Img(src="/Logo/logo_aethas.svg", style="max-width: 80%; height: auto; display: block; margin: 0 auto 20px;"),
|
||||||
|
Div(
|
||||||
|
A("+ Nouvelle discussion", href="/new_chat", cls="new-chat-btn"),
|
||||||
|
A("💾 Exporter JSON", href="/export_json", target="_blank", cls="export-btn"),
|
||||||
|
render_paypal_donation_widget(),
|
||||||
|
cls="nav-buttons"
|
||||||
|
),
|
||||||
|
cls="sidebar-top"
|
||||||
|
),
|
||||||
|
render_sidebar(),
|
||||||
|
Div(P("👤 Admin (Xavier)", style="color: #aaa; margin: 0; font-size: 0.9em;"), cls="sidebar-footer"),
|
||||||
|
cls="sidebar"
|
||||||
|
),
|
||||||
|
Div(
|
||||||
|
Div(*messages_ui, id="chat-history", cls="chat-container"),
|
||||||
|
Div(Div(Div(cls="tech-spinner"), Span("Aethas Core analyse la requête..."), cls="tech-loader-container"), id="global-loader", cls="htmx-indicator", style="max-width: 1000px; margin: 0 auto; width: 100%; padding: 0 20px; box-sizing: border-box; padding-bottom: 120px;"),
|
||||||
|
Div(
|
||||||
|
Form(
|
||||||
|
Input(type="hidden", name="chat_id", value=chat_id),
|
||||||
|
Div(
|
||||||
|
Div(id="file-preview-list"),
|
||||||
|
Div(
|
||||||
|
Label("+", _for="file-upload", cls="file-btn"),
|
||||||
|
Input(type="file", name="files", id="file-upload", multiple=True, style="display:none;", onchange="handleFileSelection(this)"),
|
||||||
|
Textarea(name="prompt", id="prompt-input", placeholder="Requête, lien ou glissez vos fichiers ici...", cls="input-field", rows="1", oninput="autoResize(this)"),
|
||||||
|
Button("↑", type="submit", cls="btn-submit"),
|
||||||
|
cls="input-row"
|
||||||
|
),
|
||||||
|
cls="input-container-box"
|
||||||
|
),
|
||||||
|
hx_post="/chat", hx_target="#chat-history", hx_swap="beforeend", hx_indicator="#global-loader",
|
||||||
|
hx_on__after_request="this.reset(); document.getElementById('file-preview-list').innerHTML=''; document.getElementById('prompt-input').style.height='36px';",
|
||||||
|
enctype="multipart/form-data", style="width: 100%; display: flex; justify-content: center;"
|
||||||
|
),
|
||||||
|
cls="input-area"
|
||||||
|
),
|
||||||
|
cls="main-content"
|
||||||
|
)
|
||||||
|
)
|
||||||
Reference in new issue
Block a user