AI-Chat-Server / static /index.html
NathMen12's picture
Upload index.html
57beef4 verified
Raw
History Blame Contribute Delete
119 kB
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Chat IA Multi-Modèles V9.4 - Sync Serveur, Vision, Galerie, Prix & Contrôle</title>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/localforage/1.10.0/localforage.min.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
:root {
--bg: #1e1e2e; --surface: #313244; --text: #cdd6f4;
--primary: #89b4fa; --border: #45475a; --danger: #f38ba8;
--success: #a6e3a1; --warning: #fab387; --sidebar-width: 280px; --file-panel-width: 280px;
}
body, html {
margin: 0; padding: 0; font-family: system-ui, sans-serif; background: var(--bg); color: var(--text);
height: 100vh; height: 100dvh; display: flex; overflow: hidden;
}
/* Left Sidebar */
.sidebar { width: var(--sidebar-width); background: var(--surface); display: flex; flex-direction: column; border-right: 1px solid var(--border); transition: transform 0.3s ease; z-index: 100; flex-shrink: 0; }
.sidebar-header, .sidebar-footer { padding: 15px; }
.btn { background: var(--primary); color: #111; border: none; padding: 12px; border-radius: 8px; cursor: pointer; font-weight: 600; display: flex; align-items: center; justify-content: center; gap: 10px; width: 100%; transition: opacity 0.2s; }
.btn:hover { opacity: 0.9; }
.chat-list { flex: 1; overflow-y: auto; padding: 0 10px; }
.chat-item { padding: 10px; border-radius: 8px; cursor: pointer; display: flex; justify-content: space-between; align-items: center; margin-bottom: 5px; transition: background 0.2s; }
.chat-item:hover, .chat-item.active { background: var(--border); }
.chat-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; font-size: 0.95rem; }
.chat-actions { display: none; gap: 5px; }
.chat-item:hover .chat-actions { display: flex; }
.icon-action { background: none; border: none; color: var(--text); cursor: pointer; padding: 5px; opacity: 0.7; }
.icon-action:hover { opacity: 1; }
.icon-action.delete { color: var(--danger); }
/* Main Chat Area */
.main { flex: 1; display: flex; flex-direction: column; min-width: 0; position: relative; }
.header { padding: 10px 15px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 10px; background: var(--bg); justify-content: space-between; flex-wrap: wrap; }
.header-left { display: flex; align-items: center; gap: 10px; flex: 1; min-width: 0; flex-wrap: wrap; }
.mobile-toggle { display: none; background: none; border: none; color: var(--text); font-size: 1.5rem; cursor: pointer; padding: 5px 10px 5px 0; flex-shrink: 0; }
.search-container { display: flex; align-items: center; gap: 5px; max-width: 100%; }
.model-search-input { padding: 10px; background: var(--surface); color: var(--text); border: 1px solid var(--border); border-radius: 8px; outline: none; font-size: 0.85rem; width: 130px; transition: width 0.3s; }
.model-search-input:focus { width: 180px; }
.model-select { padding: 10px; background: var(--surface); color: var(--text); border: 1px solid var(--border); border-radius: 8px; outline: none; flex: 1; max-width: 250px; text-overflow: ellipsis; }
/* Cloud Sync Status & Costs */
.sync-status { display: flex; align-items: center; gap: 5px; font-size: 0.8rem; color: #a6adc8; padding: 5px 10px; border-radius: 15px; background: var(--surface); white-space: nowrap; cursor: pointer; user-select: none; transition: 0.2s;}
.sync-status:hover { filter: brightness(1.2); }
.sync-status.online { color: var(--success); border: 1px solid var(--success); background: rgba(166, 227, 161, 0.1); }
.sync-status.syncing { color: var(--primary); border: 1px solid var(--primary); background: rgba(137, 180, 250, 0.1); }
.sync-status.cost-badge { color: #f9e2af; border: 1px solid #f9e2af; background: rgba(249, 226, 175, 0.1); cursor: default; }
.messages { flex: 1; overflow-y: auto; padding: 15px; display: flex; flex-direction: column; gap: 15px; }
.message-wrapper { display: flex; flex-direction: column; max-width: 90%; }
.message-wrapper.user { align-self: flex-end; }
.message-wrapper.assistant { align-self: flex-start; width: 100%; }
.message { padding: 12px 18px; border-radius: 12px; line-height: 1.5; word-wrap: break-word; }
.message.user { background: var(--primary); color: #111; border-bottom-right-radius: 2px; }
.message.assistant { background: var(--surface); border-bottom-left-radius: 2px; }
.message img.attached-img { max-width: 250px; border-radius: 8px; margin-top: 10px; border: 1px solid rgba(0,0,0,0.2); }
.message p { margin: 0 0 10px 0; }
.message p:last-child { margin: 0; }
/* Image Generator Styles */
.img-gen-wrapper { background: #252636; min-height: 250px; width: 100%; max-width: 350px; border-radius: 10px; margin-top: 10px; display: flex; align-items: center; justify-content: center; position: relative; overflow: hidden; border: 1px solid var(--border); box-shadow: 0 4px 6px rgba(0,0,0,0.3); }
.img-gen-wrapper img { max-width: 100%; height: auto; opacity: 0; transition: opacity 0.4s ease; z-index: 2; border-radius: 10px; }
.img-gen-wrapper img.loaded { opacity: 1; }
.img-spinner { position: absolute; color: #a6adc8; font-size: 1.5rem; z-index: 1; text-align: center; }
.img-spinner i { margin-bottom: 5px; }
/* Gallery Grid Styles */
.gallery-item { border-radius: 8px; overflow: hidden; border: 1px solid var(--border); background: var(--surface); transition: transform 0.2s ease, box-shadow 0.2s ease; }
.gallery-item:hover { transform: translateY(-5px); box-shadow: 0 5px 15px rgba(0,0,0,0.5); }
.gallery-item img { width: 100%; height: 150px; object-fit: cover; display: block; }
/* Code & Files styling */
.code-block { background: #111; border-radius: 8px; margin: 10px 0; overflow: hidden; border: 1px solid var(--border); }
.code-header { background: #222; padding: 8px 12px; display: flex; justify-content: space-between; color: #a6adc8; font-size: 0.85rem; }
.code-header button { background: none; border: none; color: #a6adc8; cursor: pointer; display: flex; gap: 5px; align-items: center; }
.code-header button:hover { color: #fff; }
.code-block pre { margin: 0; padding: 15px; overflow-x: auto; color: #fff; font-size: 0.9rem; }
.file-action { background: rgba(166, 227, 161, 0.1); border-left: 3px solid var(--success); padding: 10px 15px; border-radius: 6px; margin: 10px 0; font-family: monospace; font-size: 0.9rem; color: var(--text); }
.file-action.file-created { background: rgba(137, 180, 250, 0.1); border-left-color: var(--primary); }
.msg-footer { display: flex; justify-content: space-between; align-items: center; margin-top: 5px; font-size: 0.8rem; color: #a6adc8; padding: 0 5px; flex-wrap: wrap; gap: 5px; }
.retry-btn { background: none; border: none; color: var(--text); cursor: pointer; opacity: 0.6; display: flex; gap: 5px; align-items: center; }
.retry-btn:hover { opacity: 1; color: var(--primary); }
/* Input area */
.input-container { padding: 15px; background: var(--bg); border-top: 1px solid var(--border); display: flex; flex-direction: column; gap: 10px; }
.image-preview-wrapper, .text-preview-wrapper { display: none; position: relative; width: fit-content; background: var(--surface); padding: 5px 10px; border-radius: 8px; border: 1px solid var(--border); align-items: center; gap: 10px;}
.image-preview { max-height: 80px; border-radius: 4px; }
.remove-attachment { background: var(--danger); color: white; border: none; border-radius: 50%; width: 20px; height: 20px; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 10px; position: absolute; top: -5px; right: -5px; }
.attachments-preview { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 10px; }
.attachment-chip { position: relative; display: flex; align-items: center; gap: 8px; background: var(--surface); padding: 5px 10px; border-radius: 8px; border: 1px solid var(--border); width: fit-content; }
.attachment-chip .attachment-thumb { max-height: 60px; max-width: 90px; border-radius: 4px; display: block; }
.attachment-chip .attachment-name { font-size: 0.8rem; max-width: 140px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.message .file-attachments { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
.message .file-chip { display: flex; align-items: center; gap: 6px; background: rgba(255,255,255,0.08); padding: 6px 12px; border-radius: 8px; cursor: pointer; font-size: 0.82rem; border: 1px solid var(--border); transition: background 0.15s; }
.message .file-chip:hover { background: rgba(255,255,255,0.18); }
.message.user .file-chip { background: rgba(0,0,0,0.15); }
.message.user .file-chip:hover { background: rgba(0,0,0,0.25); }
.input-row { display: flex; align-items: flex-end; gap: 8px; }
textarea { flex: 1; background: var(--surface); border: 1px solid var(--border); color: var(--text); padding: 12px; border-radius: 10px; resize: none; max-height: 150px; outline: none; font-family: inherit; font-size: 1rem; }
textarea:disabled { opacity: 0.6; cursor: not-allowed; }
.icon-btn { background: var(--surface); border: 1px solid var(--border); color: var(--text); width: 45px; height: 45px; border-radius: 10px; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 1.1rem; flex-shrink: 0; transition: background 0.2s; }
.icon-btn:hover:not(:disabled) { background: var(--border); }
.icon-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.icon-btn.send { background: var(--primary); color: #111; border: none; }
.icon-btn.stop { background: var(--danger); color: #fff; border: none; display: none; }
/* Right File Panel */
.file-panel { width: var(--file-panel-width); background: var(--surface); border-left: 1px solid var(--border); display: flex; flex-direction: column; flex-shrink: 0; transition: width 0.3s ease, min-width 0.3s ease, opacity 0.3s; }
.file-panel.hidden { width: 0; min-width: 0; opacity: 0; overflow: hidden; border-left: none; }
.file-panel-header { padding: 15px; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; font-weight: bold; white-space: nowrap; overflow: hidden;}
.file-tree { flex: 1; overflow-y: auto; padding: 10px 5px; font-size: 0.9rem; user-select: none; }
.file-item { display: flex; align-items: center; gap: 8px; padding: 6px 10px; color: var(--text); word-break: break-all; transition: background 0.2s, color 0.2s; border-radius: 4px; }
.file-item.clickable { cursor: pointer; }
.file-item.clickable:hover, .file-item.folder-toggle:hover { background: rgba(255,255,255,0.05); color: var(--primary); }
.file-item i.fa-folder { color: #f9e2af; width: 16px; text-align: center; }
.file-item i.fa-file-alt { color: var(--primary); width: 16px; text-align: center; }
.folder-group { margin-bottom: 2px; }
.folder-toggle { cursor: pointer; }
.folder-arrow { font-size: 0.7em; transition: transform 0.2s ease; width: 12px; text-align: center; color: #a6adc8; }
.folder-content { display: none; border-left: 1px solid rgba(255,255,255,0.1); margin-left: 15px; }
.folder-group.open > .folder-content { display: block; }
.folder-group.open > .folder-toggle .folder-arrow { transform: rotate(90deg); }
.folder-group.open > .folder-toggle i.fa-folder::before { content: "\f07c"; }
.typing-indicator span { display: inline-block; animation: blink 1.4s infinite both; font-size: 1.5rem; line-height: 1; margin: 0 2px;}
.typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
.typing-indicator span:nth-child(3) { animation-delay: 0.4s; }
.search-status { display: flex; align-items: center; gap: 8px; font-size: 0.9rem; opacity: 0.85; }
@keyframes blink { 0% { opacity: 0.2; transform: scale(0.8); } 20% { opacity: 1; transform: scale(1.2); } 100% { opacity: 0.2; transform: scale(0.8); } }
/* Modals & Toggles */
.modal { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.85); align-items: center; justify-content: center; z-index: 1000; padding: 15px; backdrop-filter: blur(5px); }
.modal-content { background: var(--bg); padding: 25px; border-radius: 15px; width: 100%; max-width: 500px; border: 1px solid var(--border); max-height: 90vh; overflow-y: auto; }
.modal-content.large { max-width: 800px; height: 85vh; display: flex; flex-direction: column; }
.input-group { margin-bottom: 15px; }
.input-group label { display: block; margin-bottom: 5px; font-weight: 500; font-size: 0.9rem; }
.input-group input[type="text"], .input-group input[type="password"] { width: 100%; padding: 10px; box-sizing: border-box; background: var(--surface); border: 1px solid var(--border); color: var(--text); border-radius: 8px; outline: none; margin-bottom: 5px; }
.toggle-group { display: flex; align-items: center; justify-content: space-between; background: var(--surface); padding: 12px; border-radius: 8px; margin-bottom: 10px; border: 1px solid var(--border); }
.switch { position: relative; display: inline-block; width: 40px; height: 22px; }
.switch input { opacity: 0; width: 0; height: 0; }
.slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: var(--bg); border: 1px solid var(--border); transition: .4s; border-radius: 34px; }
.slider:before { position: absolute; content: ""; height: 14px; width: 14px; left: 3px; bottom: 3px; background-color: var(--text); transition: .4s; border-radius: 50%; }
input:checked + .slider { background-color: var(--primary); }
input:checked + .slider:before { transform: translateX(18px); background-color: #111; }
.custom-api-box { background: rgba(0,0,0,0.2); padding: 10px; border-radius: 8px; border: 1px solid var(--border); margin-bottom: 10px; position: relative; }
.remove-api { position: absolute; top: 10px; right: 10px; color: var(--danger); cursor: pointer; background: none; border: none; }
.sidebar-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 90; }
.auth-error { color: var(--danger); font-size: 0.9rem; margin-bottom: 15px; display: none; }
@media (max-width: 768px) {
.sidebar { position: absolute; height: 100%; transform: translateX(-100%); }
.sidebar.open { transform: translateX(0); }
.sidebar-overlay.open { display: block; }
.mobile-toggle { display: block; }
.chat-actions { display: flex; }
.message-wrapper { max-width: 100%; }
.file-panel { position: absolute; right: 0; height: 100%; transform: translateX(100%); width: 85%; z-index: 95; border-left: 1px solid var(--border); }
.file-panel.open-mobile { transform: translateX(0); }
}
</style>
</head>
<body>
<div class="modal" id="authModal">
<div class="modal-content" style="max-width: 400px; text-align: center;">
<div style="display: flex; justify-content: flex-end;">
<button class="icon-btn" style="background: transparent; border: none; width: auto; height: auto;" onclick="document.getElementById('authModal').style.display = 'none';"><i class="fas fa-times"></i></button>
</div>
<i class="fas fa-robot" style="font-size: 3rem; color: var(--primary); margin-bottom: 15px;"></i>
<h2 style="margin-top: 0;">Bienvenue !</h2>
<p style="color: #a6adc8; font-size: 0.9rem; margin-bottom: 20px;">Connectez-vous pour synchroniser vos chats sur le serveur, ou continuez en local.</p>
<div id="authError" class="auth-error">Erreur</div>
<div class="input-group" style="text-align: left;"><input type="text" id="authUsername" placeholder="Nom d'utilisateur"></div>
<div class="input-group" style="text-align: left;"><input type="password" id="authPassword" placeholder="Mot de passe"></div>
<button class="btn" style="margin-top: 15px;" onclick="auth.login()"><i class="fas fa-sign-in-alt"></i> Se Connecter</button>
<button class="btn" style="background: var(--surface); color: var(--text); margin-top: 10px;" onclick="auth.register()"><i class="fas fa-user-plus"></i> Créer un Compte</button>
<div style="margin: 20px 0; border-top: 1px solid var(--border); position: relative;">
<span style="position: absolute; top: -10px; left: 50%; transform: translateX(-50%); background: var(--bg); padding: 0 10px; color: #a6adc8; font-size: 0.8rem;">OU</span>
</div>
<button class="btn" style="background: transparent; border: 1px solid var(--border); color: var(--text);" onclick="auth.continueLocal()"><i class="fas fa-hdd"></i> Continuer en mode local (Hors-ligne)</button>
</div>
</div>
<div class="modal" id="galleryModal">
<div class="modal-content large">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
<h2 style="margin: 0;"><i class="fas fa-images"></i> Ma Galerie</h2>
<button class="icon-btn" onclick="document.getElementById('galleryModal').style.display='none'"><i class="fas fa-times"></i></button>
</div>
<div id="galleryGrid" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 15px; overflow-y: auto; padding: 10px; flex: 1;"></div>
</div>
</div>
<div class="modal" id="editFileModal">
<div class="modal-content large">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
<h2 id="editFileTitle" style="margin: 0; font-size: 1.1rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">Éditer le fichier</h2>
<button class="icon-btn" onclick="app.closeFileEditor()"><i class="fas fa-times"></i></button>
</div>
<input type="hidden" id="editFilePath">
<textarea id="editFileContent" style="flex: 1; font-family: monospace; white-space: pre; padding: 15px; border-radius: 8px; border: 1px solid var(--border); background: #111; color: #fff; resize: none; max-height:none;"></textarea>
<div style="display: flex; justify-content: flex-end; gap: 10px; margin-top: 15px;">
<button class="btn" style="background: transparent; border: 1px solid var(--border); color: var(--text); width: auto;" onclick="app.closeFileEditor()">Annuler</button>
<button class="btn" style="width: auto;" onclick="app.saveFileContent()">Enregistrer</button>
</div>
</div>
</div>
<div class="sidebar-overlay" id="sidebarOverlay" onclick="app.closeAllPanels()"></div>
<aside class="sidebar" id="sidebar">
<div class="sidebar-header">
<button class="btn" onclick="app.createNewChat()"><i class="fas fa-plus"></i> Nouveau Chat</button>
<button class="btn" style="margin-top: 10px; background: var(--surface); color: var(--text); border: 1px solid var(--border);" onclick="app.openGallery()">
<i class="fas fa-images" style="color: var(--primary);"></i> Galerie d'Images
</button>
<div style="display: flex; gap: 5px; margin-top: 10px;">
<button class="btn" style="flex: 1; padding: 8px; font-size: 0.8rem; background: var(--surface); color: var(--text); border: 1px solid var(--border);" onclick="app.exportChats()"><i class="fas fa-file-export"></i> Exporter</button>
<button class="btn" style="flex: 1; padding: 8px; font-size: 0.8rem; background: var(--surface); color: var(--text); border: 1px solid var(--border);" onclick="document.getElementById('importJsonInput').click()"><i class="fas fa-file-import"></i> Importer</button>
<input type="file" id="importJsonInput" accept=".json" style="display: none" onchange="app.importChats(event)">
</div>
</div>
<div class="chat-list" id="chatList"></div>
<div class="sidebar-footer">
<button class="btn" style="background: var(--surface); color: var(--text); border: 1px solid var(--border);" onclick="app.toggleSettings(true)"><i class="fas fa-cog"></i> Paramètres</button>
</div>
</aside>
<main class="main">
<header class="header">
<div class="header-left">
<button class="mobile-toggle" onclick="app.toggleSidebar(true)"><i class="fas fa-bars"></i></button>
<div id="syncStatus" class="sync-status" onclick="auth.logout()">
<i class="fas fa-hdd"></i> Local
</div>
<div id="chatCostDisplay" class="sync-status cost-badge" style="display: none;">
<i class="fas fa-dollar-sign"></i> 0.00000 $
</div>
<div class="search-container">
<input type="text" id="modelSearch" class="model-search-input" placeholder="🔍 Filtrer..." oninput="app.filterModels()">
<select id="modelSelect" class="model-select" onchange="app.saveCurrentModel()">
<option value="">Veuillez configurer vos clés API...</option>
</select>
</div>
</div>
<button class="icon-btn" id="toggleFileBtn" style="display: none; height: 38px; width: 38px;" onclick="app.toggleFilePanel()" title="Fichiers">
<i class="fas fa-folder-open"></i>
</button>
</header>
<div class="messages" id="messagesArea"></div>
<div class="input-container">
<div class="attachments-preview" id="attachmentsPreview" style="display: none;"></div>
<div class="input-row">
<input type="file" id="fileInput" accept="image/*,.txt,.md,.js,.json,.html,.css,.py,.csv" multiple style="display: none" onchange="app.handleFile(this)">
<button class="icon-btn" id="attachBtn" onclick="document.getElementById('fileInput').click()" title="Ajouter une ou plusieurs images / fichiers texte"><i class="fas fa-paperclip"></i></button>
<textarea id="userInput" placeholder="Écrivez votre message..." rows="1" onkeydown="app.handleKeyDown(event)"></textarea>
<button class="icon-btn send" id="sendBtn" onclick="app.sendMessage()" title="Envoyer"><i class="fas fa-paper-plane"></i></button>
<button class="icon-btn stop" id="stopBtn" onclick="app.stopGeneration()" title="Interrompre le modèle"><i class="fas fa-stop"></i></button>
</div>
</div>
</main>
<div class="modal" id="attachmentViewerModal">
<div class="modal-content large">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
<h2 id="attachmentViewerTitle" style="margin: 0; font-size: 1.1rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"><i class="fas fa-file-alt"></i> Fichier joint</h2>
<button class="icon-btn" onclick="document.getElementById('attachmentViewerModal').style.display='none'"><i class="fas fa-times"></i></button>
</div>
<pre id="attachmentViewerContent" style="flex: 1; margin: 0; overflow: auto; font-family: monospace; white-space: pre-wrap; word-break: break-word; padding: 15px; border-radius: 8px; border: 1px solid var(--border); background: #111; color: #fff;"></pre>
</div>
</div>
<aside class="file-panel hidden" id="filePanel">
<div class="file-panel-header">
<span><i class="fas fa-folder-tree"></i> Espace de Travail</span>
<button class="icon-btn" style="width:30px; height:30px; font-size:0.9rem;" onclick="app.downloadZip()" title="Télécharger en ZIP"><i class="fas fa-download"></i></button>
</div>
<div class="file-tree" id="fileTree"></div>
</aside>
<div class="modal" id="settingsModal">
<div class="modal-content">
<h2>Paramètres & Modèles</h2>
<div class="toggle-group">
<div>
<strong>Recherche Web (agent)</strong>
<div style="font-size: 0.8rem; color: #a6adc8;">L'IA décide si une recherche est utile, interroge plusieurs sites via le serveur MCP de victor (HuggingFace), puis répond avec les résultats. Nécessite le mode connecté (compte + serveur).</div>
</div>
<label class="switch"><input type="checkbox" id="toggleWebSearch"><span class="slider"></span></label>
</div>
<div class="toggle-group">
<div>
<strong>Système de Fichiers (Mode Chat Libre)</strong>
<div style="font-size: 0.8rem; color: #a6adc8;">Active les outils fichiers pour les discussions.</div>
</div>
<label class="switch"><input type="checkbox" id="toggleFileSystem"><span class="slider"></span></label>
</div>
<div class="toggle-group">
<div>
<strong>Streaming lisse (effet machine à écrire)</strong>
<div style="font-size: 0.8rem; color: #a6adc8;">Si désactivé, le texte s'affiche instantanément dès qu'il arrive (aucune animation).</div>
</div>
<label class="switch"><input type="checkbox" id="toggleSmoothStream" checked onchange="document.getElementById('typeSpeedGroup').style.opacity = this.checked ? '1' : '0.4'; document.getElementById('typeSpeedRange').disabled = !this.checked;"><span class="slider"></span></label>
</div>
<div class="input-group" id="typeSpeedGroup">
<label><i class="fas fa-keyboard"></i> Vitesse d'affichage du streaming (ms / caractère)</label>
<input type="range" id="typeSpeedRange" min="2" max="40" value="18" style="width:100%;" oninput="document.getElementById('typeSpeedVal').innerText = this.value + ' ms'">
<div style="font-size: 0.75rem; color: #a6adc8; display:flex; justify-content: space-between;">
<span>Rapide</span><span id="typeSpeedVal">18 ms</span><span>Lent (fluide type machine à écrire)</span>
</div>
</div>
<hr style="border: 0; border-top: 1px solid var(--border); margin: 15px 0;">
<div class="input-group">
<label><i class="fas fa-eye"></i> Modèle Vision (Analyse d'images jointe)</label>
<select id="visionModelSelect" class="model-select" style="width: 100%; margin-bottom: 10px;">
<option value="">-- Aucun modèle Vision détecté --</option>
</select>
<div style="font-size: 0.75rem; color: #a6adc8;">
Seuls les modèles supportant la vision (multimodaux) sont listés ici.
<br>Recommandés : <b>GPT-4o / GPT-4o-mini</b> (OpenAI), <b>Gemini 1.5 Flash/Pro</b> (Google), <b>Llava / Llama 3.2 Vision</b> (Groq/OpenRouter).
</div>
</div>
<hr style="border: 0; border-top: 1px solid var(--border); margin: 15px 0;">
<div id="customApisContainer"></div>
<button class="btn" style="background: transparent; border: 1px dashed var(--border); color: var(--text); margin-bottom: 20px;" onclick="app.addCustomApiUI()"><i class="fas fa-plus"></i> Ajouter une API (Compatible OpenAI)</button>
<div class="input-group"><label>Clé OpenAI</label><input type="password" id="keyOpenai" placeholder="sk-..."></div>
<div class="input-group"><label>Clé Groq</label><input type="password" id="keyGroq" placeholder="gsk_..."></div>
<div class="input-group"><label>Clé Gemini (Google)</label><input type="password" id="keyGemini" placeholder="AIza..."></div>
<div class="input-group"><label>Clé DeepAI</label><input type="password" id="keyDeepai" placeholder="Clé DeepAI..."></div>
<div style="display: flex; gap: 10px; margin-top: 20px;">
<button class="btn" onclick="app.saveKeys()">Sauvegarder</button>
<button class="btn" style="background: transparent; color: var(--text); border: 1px solid var(--border);" onclick="app.toggleSettings(false)">Fermer</button>
</div>
</div>
</div>
<script>
// Par défaut, le serveur de sync/génération est celui qui sert cette page.
// Si ce fichier HTML est hébergé ailleurs (ex: GitHub Pages) et que le Space HF
// est séparé, remplacez la ligne suivante par :
// const API_SERVER = "https://VOTRE-USER-VOTRE-SPACE.hf.space";
const API_SERVER = window.location.origin;
const auth = {
token: null,
username: null,
isLocal: true,
syncTimeout: null,
init() {
this.token = localStorage.getItem('chatToken');
this.username = localStorage.getItem('chatUsername');
const mode = localStorage.getItem('chatMode');
if (mode === 'cloud' && this.token) {
this.isLocal = false;
this.updateStatus('online');
app.loadStateFromServer();
} else {
this.continueLocal();
}
},
continueLocal() {
this.isLocal = true;
localStorage.setItem('chatMode', 'local');
this.updateStatus('local');
document.getElementById('authModal').style.display = 'none';
app.init();
},
showAuth() {
document.getElementById('authError').style.display = 'none';
document.getElementById('authModal').style.display = 'flex';
},
updateStatus(state) {
const badge = document.getElementById('syncStatus');
if (!badge) return;
if (state === 'local') {
badge.className = 'sync-status';
badge.innerHTML = '<i class="fas fa-hdd"></i> Local';
badge.title = "Mode Local - Cliquez pour vous connecter au Cloud";
} else if (state === 'online') {
badge.className = 'sync-status online';
badge.innerHTML = `<i class="fas fa-cloud"></i> Sync: ${this.username}`;
badge.title = "Cliquez pour vous déconnecter";
} else if (state === 'syncing') {
badge.className = 'sync-status syncing';
badge.innerHTML = '<i class="fas fa-sync fa-spin"></i> Sauvegarde...';
}
},
logout() {
if (this.isLocal) {
this.showAuth();
} else {
if (confirm("Voulez-vous vous déconnecter du Cloud et repasser en mode local ?")) {
localStorage.clear();
this.token = null;
this.username = null;
this.continueLocal();
window.location.reload();
}
}
},
async login() {
const u = document.getElementById('authUsername').value.trim();
const p = document.getElementById('authPassword').value;
const err = document.getElementById('authError');
if(!u || !p) { err.innerText = "Champs requis."; err.style.display='block'; return; }
try {
const res = await fetch(API_SERVER + '/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: u, password: p }) });
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Erreur de connexion");
localStorage.setItem('chatToken', data.token);
localStorage.setItem('chatUsername', u);
localStorage.setItem('chatMode', 'cloud');
window.location.reload();
} catch(e) { err.innerText = e.message; err.style.display='block'; }
},
async register() {
const u = document.getElementById('authUsername').value.trim();
const p = document.getElementById('authPassword').value;
const err = document.getElementById('authError');
if(!u || !p) { err.innerText = "Champs requis."; err.style.display='block'; return; }
try {
const res = await fetch(API_SERVER + '/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: u, password: p }) });
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Erreur d'inscription");
alert("Compte créé avec succès ! Connectez-vous maintenant.");
} catch(e) { err.innerText = e.message; err.style.display='block'; }
},
triggerSync(stateData) {
if (this.isLocal || !this.token) return;
this.updateStatus('syncing');
clearTimeout(this.syncTimeout);
this.syncTimeout = setTimeout(() => this._doSync(stateData), 1200);
},
// Sync immédiate, sans le délai (utilisée après une action explicite de
// l'utilisateur comme "Sauvegarder les clés", pour ne rien perdre si
// l'utilisateur recharge la page tout de suite après).
syncNow(stateData) {
if (this.isLocal || !this.token) return;
clearTimeout(this.syncTimeout);
this.updateStatus('syncing');
return this._doSync(stateData);
},
async _doSync(stateData) {
try {
await fetch(API_SERVER + '/sync', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + this.token }, body: JSON.stringify({ data: stateData }) });
this.updateStatus('online');
} catch (e) {
this.updateStatus('local');
console.error("Erreur de synchro Cloud :", e);
}
},
// Filet de sécurité : si l'onglet se ferme/se met en arrière-plan alors
// qu'une sync est encore en attente (debounce de 1.2s pas encore écoulé),
// on l'envoie quand même via sendBeacon (fonctionne même pendant la fermeture).
flushOnUnload(stateData) {
if (this.isLocal || !this.token) return;
if (!this.syncTimeout) return; // rien en attente
clearTimeout(this.syncTimeout);
this.syncTimeout = null;
try {
const blob = new Blob([JSON.stringify({ token: this.token, data: stateData })], { type: 'application/json' });
navigator.sendBeacon(API_SERVER + '/sync/beacon', blob);
} catch(e) {}
}
};
const renderer = new marked.Renderer();
renderer.code = function(token, lang, escaped) {
let codeText = '';
let language = '';
if (typeof token === 'object' && token !== null) { codeText = token.text || ''; language = token.lang || ''; }
else if (typeof token === 'string') { codeText = token; language = lang || ''; }
let encodedCode = '';
try { encodedCode = btoa(unescape(encodeURIComponent(codeText))); } catch(e) {}
const escapedCode = codeText.replace(/</g, '&lt;').replace(/>/g, '&gt;');
return `<div class="code-block">
<div class="code-header">
<span><i class="fas fa-code"></i> ${language || 'code'}</span>
<button onclick="app.copyCode(this, '${encodedCode}')"><i class="fas fa-copy"></i> Copier</button>
</div>
<pre><code class="language-${language}">${escapedCode}</code></pre>
</div>`;
};
marked.setOptions({ renderer: renderer, gfm: true, breaks: true });
const app = {
state: {
chats: [],
activeChatId: null,
virtualFiles: {},
currentAttachments: [], // [{id, kind:'image'|'text', name, content}]
keys: { openai: '', groq: '', gemini: '', deepai: '', custom: [] },
settings: { webSearch: false, fileSystem: false, visionModel: '', typeSpeed: 18, smoothStream: true }
},
allModelsBackup: [],
isGenerating: false,
abortController: null,
_smoothStreams: {}, // aiIndex -> { queue, finished, timer }
_activeJobId: null, // job_id du message assistant en cours (mode cloud)
uid() {
if (window.crypto && crypto.randomUUID) return crypto.randomUUID();
return 'id-' + Date.now() + '-' + Math.random().toString(16).slice(2);
},
// Construit le texte RÉELLEMENT envoyé au modèle pour un message donné :
// texte de l'utilisateur + contenu des fichiers texte joints + descriptions
// des images jointes (générées par le modèle Vision). Ce texte n'est jamais
// affiché tel quel dans la bulle du chat (voir buildMessageHTML / m.content) —
// l'utilisateur ne voit que son texte + des pastilles cliquables pour les fichiers.
buildMessageContentForAI(m) {
let text = m.rawContent !== undefined ? m.rawContent : (m.content || '');
if (m.imageDescriptions && m.imageDescriptions.length) {
m.imageDescriptions.forEach(d => {
text += `\n\n[Description de l'image jointe "${d.name}" par l'utilisateur : ${d.description}]`;
});
} else if (m.imageDescription) {
// Compatibilité avec les anciennes discussions (une seule image, avant la V9.5).
text += `\n\n[Description de l'image jointe par l'utilisateur : ${m.imageDescription}]`;
}
return text;
},
// --- LISSAGE DU STREAMING (effet machine à écrire fluide) ---
// Découple la vitesse d'affichage à l'écran de la vitesse (souvent en rafales)
// à laquelle les morceaux de texte arrivent réellement du réseau.
// Si l'option est désactivée dans les paramètres, on affiche tout instantanément.
isSmoothStreamEnabled() {
return this.state.settings.smoothStream !== undefined ? this.state.settings.smoothStream : true;
},
pushSmoothChunk(aiIndex, text) {
if (!text) return;
const chat = this.getActiveChat();
const m = chat && chat.messages[aiIndex];
if (!m) return;
m.content = (m.content || '') + text;
if (!this.isSmoothStreamEnabled()) {
// Affichage instantané : pas d'animation, pas de file d'attente.
m.displayContent = m.content;
if (m.isThinking) m.isThinking = false;
this.updateMessageDOM(aiIndex);
return;
}
if (m.displayContent === undefined) m.displayContent = (m.content.length - text.length) > 0 ? m.content.slice(0, m.content.length - text.length) : '';
if (!this._smoothStreams[aiIndex]) this._smoothStreams[aiIndex] = { queue: '', finished: false, timer: null };
this._smoothStreams[aiIndex].queue += text;
this._pumpSmooth(aiIndex);
},
// Affiche instantanément un texte déjà généré (rattrapage lors d'une reprise
// de connexion) sans l'animer lettre par lettre.
syncSmoothContent(aiIndex, fullText) {
const chat = this.getActiveChat();
const m = chat && chat.messages[aiIndex];
if (!m) return;
m.content = fullText || '';
m.displayContent = fullText || '';
if (this._smoothStreams[aiIndex]) { this._smoothStreams[aiIndex].queue = ''; }
if (m.isThinking && fullText) m.isThinking = false;
this.updateMessageDOM(aiIndex);
},
// Affiche/efface un statut court pendant une étape agentique invisible
// (ex: "Je vais rechercher sur le web...") à la place des "...", tant que le
// message est encore en isThinking. Jamais utilisé pour montrer le détail des tool calls.
setSearchStatus(aiIndex, text) {
const chat = this.getActiveChat();
const m = chat && chat.messages[aiIndex];
if (!m) return;
m.searchStatus = text || undefined;
if (m.isThinking) this.updateMessageDOM(aiIndex);
},
finishSmoothStream(aiIndex) {
const s = this._smoothStreams[aiIndex];
const chat = this.getActiveChat();
const m = chat && chat.messages[aiIndex];
if (!s || !this.isSmoothStreamEnabled()) {
if (m) delete m.displayContent;
if (s) { delete this._smoothStreams[aiIndex]; }
return;
}
s.finished = true;
this._pumpSmooth(aiIndex);
},
_pumpSmooth(aiIndex) {
const s = this._smoothStreams[aiIndex];
if (!s || s.timer) return;
const tick = () => {
const chat = this.getActiveChat();
const m = chat && chat.messages[aiIndex];
if (!m) { delete this._smoothStreams[aiIndex]; return; }
const speedInput = document.getElementById('typeSpeedRange');
const speed = this.state.settings.typeSpeed !== undefined ? this.state.settings.typeSpeed : (speedInput ? parseInt(speedInput.value) : 18);
if (s.queue.length > 0) {
// Rattrape toujours la file en ~800ms max, quelle que soit sa taille
// (un gros pavé de code arrivé d'un coup ne doit pas mettre 5s à s'afficher),
// tout en gardant un vrai effet lettre-par-lettre sur les petits paquets.
const targetDrainMs = 800;
const ticksRemaining = Math.max(1, Math.round(targetDrainMs / speed));
const take = Math.max(1, Math.ceil(s.queue.length / ticksRemaining));
m.displayContent = (m.displayContent || '') + s.queue.slice(0, take);
s.queue = s.queue.slice(take);
if (m.isThinking) m.isThinking = false;
this.updateMessageDOM(aiIndex);
s.timer = setTimeout(tick, speed);
} else if (!s.finished) {
s.timer = setTimeout(tick, speed);
} else {
delete m.displayContent;
delete this._smoothStreams[aiIndex];
this.updateMessageDOM(aiIndex);
}
};
tick();
},
VISION_KEYWORDS: [
'gpt-4o', 'gpt-4-turbo', 'gpt-4-vision', 'gpt-4.1', 'o1',
'gemini-1.5', 'gemini-1.0-pro-vision',
'llava', 'llama-3.2-11b-vision', 'llama-3.2-90b-vision', 'llama-3.2-vision',
'claude-3', 'claude-3.5', 'claude-3-opus', 'claude-3-sonnet', 'claude-3-haiku',
'pixtral', 'qwen-vl', 'qwen2-vl', 'phi-3-vision', 'nemotron-3-ultra',
'vision', 'multimodal'
],
isVisionModel(modelId, provider) {
if (!modelId) return false;
const id = modelId.toLowerCase();
if (this.VISION_KEYWORDS.some(kw => id.includes(kw))) return true;
if (provider === 'gemini' && (id.includes('1.5') || id.includes('pro-vision'))) return true;
if (provider === 'openai' && (id.startsWith('gpt-4') || id.startsWith('o1'))) return true;
if (provider === 'groq' && (id.includes('llava') || id.includes('vision') || id.includes('llama-3.2'))) return true;
if (provider === 'custom') return true;
return false;
},
async init(skipLocalLoad) {
if (!skipLocalLoad) await this.loadState();
if (!this.state.chats.length) this.createNewChat();
this.renderChats();
this.renderMessages();
this.fetchModels();
this.setupAutoresize();
this.setupGlobalPaste();
this.setupKeyboardShortcuts();
this.resumeActiveJobs();
},
setupKeyboardShortcuts() {
document.addEventListener('keydown', (e) => {
});
},
setupGlobalPaste() {
window.addEventListener('paste', (e) => {
if (e.clipboardData && e.clipboardData.files && e.clipboardData.files.length > 0) {
Array.from(e.clipboardData.files).forEach(file => {
if (file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onload = (event) => {
this.state.currentAttachments.push({ id: this.uid(), kind: 'image', name: file.name || 'image collée', content: event.target.result });
this.renderAttachmentsPreview();
};
reader.readAsDataURL(file);
}
});
}
});
},
async loadStateFromServer() {
let loadedFromServer = false;
try {
const res = await fetch(API_SERVER + '/sync', { headers: { 'Authorization': 'Bearer ' + auth.token } });
if (res.ok) {
const data = await res.json();
if (data && data.data) {
this.state = data.data;
if(!this.state.chats) this.state.chats = [];
if(!this.state.virtualFiles) this.state.virtualFiles = {};
if(!this.state.currentAttachments) this.state.currentAttachments = [];
if(!this.state.keys) this.state.keys = { openai: '', groq: '', gemini: '', deepai: '', custom: [] };
if(!this.state.settings) this.state.settings = { webSearch: false, fileSystem: false, visionModel: '', typeSpeed: 18, smoothStream: true };
if (this.state.settings.smoothStream === undefined) this.state.settings.smoothStream = true;
this.saveStateLocal();
loadedFromServer = true;
}
}
} catch(e) { console.error("Erreur serveur de chargement initial. Mode local utilisé en secours."); }
// skipLocalLoad=true quand le serveur a répondu : on ne veut surtout pas
// qu'init() réécrase l'état serveur fraîchement chargé avec le cache local
// du navigateur (c'était le bug empêchant la sync multi-appareils).
this.init(loadedFromServer);
},
async loadState() {
try {
let parsed = await localforage.getItem('multiAiChatStateV5_fix');
if (!parsed) {
const stored = localStorage.getItem('multiAiChatStateV5_fix');
if (stored) {
parsed = JSON.parse(stored);
}
}
if (parsed) {
Object.assign(this.state, parsed);
}
} catch(e) { console.error(e); }
},
saveStateLocal() {
try {
localforage.setItem('multiAiChatStateV5_fix', this.state).catch(e => {
console.error("Échec de l'écriture asynchrone localforage :", e);
});
} catch (e) {
console.error("Erreur localforage :", e);
}
},
saveState() {
this.saveStateLocal();
auth.triggerSync(this.state);
},
exportChats() {
const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(this.state.chats));
const dlAnchorElem = document.createElement('a');
dlAnchorElem.setAttribute("href", dataStr);
dlAnchorElem.setAttribute("download", `Sauvegarde_Chats_IA_${new Date().toISOString().slice(0,10)}.json`);
dlAnchorElem.click();
},
importChats(event) {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
try {
const importedChats = JSON.parse(e.target.result);
if (Array.isArray(importedChats)) {
const existingIds = new Set(this.state.chats.map(c => c.id));
importedChats.forEach(chat => {
if (!existingIds.has(chat.id)) this.state.chats.push(chat);
});
this.state.chats.sort((a, b) => b.id - a.id);
this.saveState();
this.renderChats();
alert("Importation réussie !");
} else { alert("Format de fichier JSON invalide."); }
} catch (err) { alert("Erreur lors de la lecture du fichier JSON."); }
};
reader.readAsText(file);
event.target.value = '';
},
openGallery() {
const grid = document.getElementById('galleryGrid');
let imagesHTML = '';
this.state.chats.forEach(chat => {
chat.messages.forEach(m => {
if (m.isImageGenerated && m.content) {
imagesHTML += `<div class="gallery-item"><a href="${m.content}" target="_blank"><img src="${m.content}" alt="AI Gen"></a></div>`;
} else {
const imgs = m.attachedImages && m.attachedImages.length ? m.attachedImages : (m.attachedImage ? [m.attachedImage] : []);
imgs.forEach(src => {
imagesHTML += `<div class="gallery-item"><a href="${src}" target="_blank"><img src="${src}" alt="User Attachment"></a></div>`;
});
}
});
});
grid.innerHTML = imagesHTML || '<p style="color:#a6adc8; grid-column: 1/-1; text-align:center; padding:20px;">Aucune image trouvée dans vos fils de discussions.</p>';
document.getElementById('galleryModal').style.display = 'flex';
},
updateFileSystemVisibility() {
const toggleBtn = document.getElementById('toggleFileBtn');
const filePanel = document.getElementById('filePanel');
if (this.state.settings.fileSystem) {
toggleBtn.style.display = 'flex';
if (window.innerWidth > 768) filePanel.classList.remove('hidden');
} else {
toggleBtn.style.display = 'none';
filePanel.classList.add('hidden');
filePanel.classList.remove('open-mobile');
}
},
toggleSettings(show) {
const modal = document.getElementById('settingsModal');
if (show) {
document.getElementById('keyOpenai').value = this.state.keys.openai || '';
document.getElementById('keyGroq').value = this.state.keys.groq || '';
document.getElementById('keyGemini').value = this.state.keys.gemini || '';
document.getElementById('keyDeepai').value = this.state.keys.deepai || '';
document.getElementById('toggleWebSearch').checked = this.state.settings.webSearch;
document.getElementById('toggleFileSystem').checked = this.state.settings.fileSystem;
const ts = this.state.settings.typeSpeed !== undefined ? this.state.settings.typeSpeed : 18;
document.getElementById('typeSpeedRange').value = ts;
document.getElementById('typeSpeedVal').innerText = ts + ' ms';
const smoothOn = this.state.settings.smoothStream !== undefined ? this.state.settings.smoothStream : true;
document.getElementById('toggleSmoothStream').checked = smoothOn;
document.getElementById('typeSpeedRange').disabled = !smoothOn;
document.getElementById('typeSpeedGroup').style.opacity = smoothOn ? '1' : '0.4';
if (this.state.settings.visionModel) document.getElementById('visionModelSelect').value = this.state.settings.visionModel;
this.renderCustomApisSettings();
modal.style.display = 'flex';
} else modal.style.display = 'none';
},
renderCustomApisSettings() {
document.getElementById('customApisContainer').innerHTML = this.state.keys.custom.map((api, i) => `
<div class="custom-api-box" data-index="${i}">
<button class="remove-api" onclick="app.removeCustomApi(${i})"><i class="fas fa-trash"></i></button>
<div class="input-group"><label>Nom de l'API</label><input type="text" class="c-name" value="${api.name || ''}"></div>
<div class="input-group"><label>URL de base</label><input type="text" class="c-url" value="${api.url || ''}"></div>
<div class="input-group"><label>Clé API</label><input type="password" class="c-key" value="${api.key || ''}"></div>
</div>`).join('');
},
addCustomApiUI() {
this.state.keys.custom.push({ name: '', url: '', key: '' });
this.renderCustomApisSettings();
},
removeCustomApi(index) {
this.state.keys.custom.splice(index, 1);
this.renderCustomApisSettings();
},
saveKeys() {
this.state.keys.openai = document.getElementById('keyOpenai').value.trim();
this.state.keys.groq = document.getElementById('keyGroq').value.trim();
this.state.keys.gemini = document.getElementById('keyGemini').value.trim();
this.state.keys.deepai = document.getElementById('keyDeepai').value.trim();
this.state.settings.webSearch = document.getElementById('toggleWebSearch').checked;
this.state.settings.fileSystem = document.getElementById('toggleFileSystem').checked;
this.state.settings.typeSpeed = parseInt(document.getElementById('typeSpeedRange').value);
this.state.settings.smoothStream = document.getElementById('toggleSmoothStream').checked;
this.state.settings.visionModel = document.getElementById('visionModelSelect').value;
const boxes = document.querySelectorAll('.custom-api-box');
this.state.keys.custom = Array.from(boxes).map(box => ({
name: box.querySelector('.c-name').value.trim(),
url: box.querySelector('.c-url').value.trim(),
key: box.querySelector('.c-key').value.trim()
}));
this.saveStateLocal();
auth.syncNow(this.state);
this.fetchModels();
this.toggleSettings(false);
},
saveCurrentModel() {
const select = document.getElementById('modelSelect');
const chat = this.getActiveChat();
if (chat && select.value) {
chat.model = select.value;
chat.provider = select.options[select.selectedIndex]?.dataset.provider || '';
try {
if (select.options[select.selectedIndex]?.dataset.extra) {
chat.extraPricing = JSON.parse(select.options[select.selectedIndex].dataset.extra);
} else { chat.extraPricing = null; }
} catch(e) { chat.extraPricing = null; }
this.saveState();
}
},
// --- PARSER SYSTÈME FICHIERS ---
parseFileSystemAndFormat(content) {
if (!content) return '';
let formattedContent = content;
const imageRegex = /!CreateImage\s*\(\s*([^)]+)\s*\)/gi;
formattedContent = formattedContent.replace(imageRegex, (match, prompt) => {
let cleanPrompt = encodeURIComponent(prompt.trim());
let url = `https://image.pollinations.ai/prompt/${cleanPrompt}?nologo=true`;
return `<div class="img-gen-wrapper">
<div class="img-spinner"><i class="fas fa-image"></i><br><small style="font-size:0.5em; font-family:sans-serif;">Création en cours...</small></div>
<img src="${url}" alt="Generated Image" onload="this.classList.add('loaded'); this.previousElementSibling.style.display='none';" onerror="this.parentElement.innerHTML='<div style=\\'padding:20px;color:var(--danger);\\'>Erreur de chargement</div>'"/>
</div>`;
});
const folderRegex = /!CreateFolder\s*\(\s*([^,]+)\s*,\s*([^)]+)\s*\)/g;
formattedContent = formattedContent.replace(folderRegex, (match, name, path) => {
let cleanName = name.trim().replace(/['"]/g, '');
let cleanPath = path.trim().replace(/['"]/g, '');
let fullPath = cleanPath === '.' || cleanPath === '' ? cleanName : `${cleanPath}/${cleanName}`;
this.state.virtualFiles[fullPath] = { type: 'folder', name: cleanName, path: cleanPath };
return `<div class="file-action"><i class="fas fa-folder-plus"></i> <b>Dossier créé :</b> ${cleanName}</div>`;
});
const fileRegex = /!CreateFile\s*\(\s*([^,]+)\s*,\s*([^)]+)\s*\)\s*([\s\S]*?)!EndFile/g;
formattedContent = formattedContent.replace(fileRegex, (match, name, path, body) => {
let cleanName = name.trim().replace(/['"]/g, '');
let cleanPath = path.trim().replace(/['"]/g, '');
let fullPath = cleanPath === '.' || cleanPath === '' ? cleanName : `${cleanPath}/${cleanName}`;
this.state.virtualFiles[fullPath] = { type: 'file', name: cleanName, path: cleanPath, content: body.trim() };
return `<div class="file-action file-created"><i class="fas fa-file-signature"></i> <b>Fichier créé/modifié :</b> ${cleanName}</div>`;
});
return formattedContent;
},
buildTreeData() {
const root = { name: 'root', type: 'folder', children: {}, isFile: false };
Object.keys(this.state.virtualFiles).forEach(path => {
const parts = path.split('/');
let current = root;
let currentPath = [];
const item = this.state.virtualFiles[path];
for (const part of parts) {
currentPath.push(part);
if (!current.children[part]) current.children[part] = { name: part, type: 'folder', children: {}, path: currentPath.join('/') };
current = current.children[part];
}
if (item.type === 'file') current.children[item.name] = { ...item, isFile: true };
else if (!current.children[item.name]) current.children[item.name] = { ...item, children: {}, isFile: false };
});
return root;
},
renderTreeHTML(node) {
let html = '';
const sortedKeys = Object.keys(node.children || {}).sort((a, b) => {
if (node.children[a].isFile === node.children[b].isFile) return a.localeCompare(b);
return node.children[a].isFile ? 1 : -1;
});
for (const key of sortedKeys) {
const child = node.children[key];
if (child.isFile) {
const escapedPath = (child.path && child.path !== '.' ? child.path + '/' + child.name : child.name).replace(/'/g, "\\'");
html += `<div class="file-item clickable" onclick="app.openFileEditor('${escapedPath}')"><i class="fas fa-file-alt"></i> ${child.name}</div>`;
} else {
html += `<div class="folder-group open">
<div class="file-item folder-toggle" onclick="this.parentElement.classList.toggle('open')"><i class="fas fa-chevron-right folder-arrow"></i> <i class="fas fa-folder"></i> ${child.name}</div>
<div class="folder-content">${this.renderTreeHTML(child)}</div>
</div>`;
}
}
return html;
},
renderFileTree() {
if (!this.state.settings.fileSystem) return;
const treeData = this.buildTreeData();
document.getElementById('fileTree').innerHTML = this.renderTreeHTML(treeData) || '<div style="padding:15px; color:#a6adc8; font-style:italic;">Aucun fichier créé par l\'IA pour le moment.</div>';
},
openFileEditor(path) {
const file = this.state.virtualFiles[path];
if (!file) return;
document.getElementById('editFilePath').value = path;
document.getElementById('editFileTitle').innerText = `Éditer : ${file.name}`;
document.getElementById('editFileContent').value = file.content || '';
document.getElementById('editFileModal').style.display = 'flex';
},
closeFileEditor() { document.getElementById('editFileModal').style.display = 'none'; },
saveFileContent() {
const path = document.getElementById('editFilePath').value;
const body = document.getElementById('editFileContent').value;
if (this.state.virtualFiles[path]) {
this.state.virtualFiles[path].content = body;
this.saveState();
this.renderFileTree();
this.closeFileEditor();
}
},
async downloadZip() {
const zip = new JSZip();
let hasFiles = false;
Object.keys(this.state.virtualFiles).forEach(path => {
const f = this.state.virtualFiles[path];
if (f && f.type === 'file') { zip.file(path, f.content || ''); hasFiles = true; }
});
if (!hasFiles) { alert("Aucun fichier à exporter."); return; }
const content = await zip.generateAsync({ type: "blob" });
const link = document.createElement('a');
link.href = URL.createObjectURL(content);
link.download = `Workspace_Chat_${this.state.activeChatId}.zip`;
link.click();
},
createNewChat() {
const id = Date.now();
this.state.chats.unshift({ id: id, title: 'Nouvelle discussion', messages: [], model: '', provider: '', extraPricing: null });
this.state.activeChatId = id;
this.saveState();
this.renderChats();
this.renderMessages();
},
getActiveChat() { return this.state.chats.find(c => c.id === this.state.activeChatId); },
setActiveChat(id) {
this.state.activeChatId = id;
const chat = this.getActiveChat();
this.saveState();
this.renderChats();
this.renderMessages();
if (chat && chat.model) {
const select = document.getElementById('modelSelect');
if (select) {
select.value = chat.model;
if (!select.value && this.allModelsBackup.some(o => o.value === chat.model)) {
this.filterModels();
select.value = chat.model;
}
}
}
this.closeAllPanels();
},
deleteChat(id, event) {
event.stopPropagation();
if (confirm("Supprimer cette discussion ?")) {
this.state.chats = this.state.chats.filter(c => c.id !== id);
if (!this.state.chats.length) this.createNewChat();
else if (this.state.activeChatId === id) this.state.activeChatId = this.state.chats[0].id;
this.saveState();
this.renderChats();
this.renderMessages();
}
},
renderChats() {
const list = document.getElementById('chatList');
list.innerHTML = this.state.chats.map(c => `
<div class="chat-item ${c.id === this.state.activeChatId ? 'active' : ''}" onclick="app.setActiveChat(${c.id})">
<div class="chat-title"><i class="fas fa-comment-alt" style="margin-right:8px; opacity:0.6;"></i>${c.title}</div>
<div class="chat-actions">
<button class="icon-action delete" onclick="app.deleteChat(${c.id}, event)"><i class="fas fa-trash"></i></button>
</div>
</div>`).join('');
},
// Construit le HTML d'un seul message (utilisé par le rendu complet ET
// par la mise à jour ciblée pendant le streaming, voir updateMessageDOM).
buildMessageHTML(m, index, chat) {
let contentHTML = '';
if (m.isThinking) {
contentHTML = m.searchStatus
? `<div class="search-status"><i class="fas fa-search fa-spin"></i> ${this.escapeHtml(m.searchStatus)}</div>`
: `<div class="typing-indicator"><span>.</span><span>.</span><span>.</span></div>`;
} else if (m.isImageGenerated) {
contentHTML = `<div class="img-gen-wrapper" style="min-height: auto; max-width: 350px;">
<img src="${m.content}" class="loaded" style="max-width: 100%; border-radius: 8px;" alt="Image Générée">
</div>`;
} else {
const shownText = m.displayContent !== undefined ? m.displayContent : (m.content || '');
contentHTML = marked.parse(this.parseFileSystemAndFormat(shownText));
}
let imgHTML = '';
const imagesToShow = m.attachedImages && m.attachedImages.length ? m.attachedImages : (m.attachedImage ? [m.attachedImage] : []);
imagesToShow.forEach(src => { imgHTML += `<img src="${src}" class="attached-img" alt="Image jointe">`; });
let filesHTML = '';
if (m.attachments && m.attachments.length) {
filesHTML = `<div class="file-attachments">${m.attachments.map(a =>
`<div class="file-chip" onclick="app.viewAttachment('${m.id}','${a.id}')" title="Cliquer pour voir le contenu"><i class="fas fa-file-alt"></i> ${this.escapeHtml(a.name)}</div>`
).join('')}</div>`;
}
let footer = '';
let cost = 0;
if (m.role === 'assistant') {
let tokenText = '';
if (m.isThinking) {
const estOut = Math.ceil((m.content || '').length / 3.5);
tokenText = `~${estOut} tokens (est.)`;
} else {
tokenText = m.tokens ? `${m.tokens} tokens` : `${Math.ceil((m.content || '').length / 3.5)} tokens (est.)`;
}
let costText = '';
let currentCost = m.cost || 0;
if (!m.cost && m.extraPricing && m.extraPricing.prompt_token_cost !== undefined) {
const estOut = m.tokens || Math.ceil((m.content || '').length / 3.5);
const estIn = m.promptTokens || Math.ceil(JSON.stringify(chat.messages.slice(0, index)).length / 3.5);
currentCost = (estIn * parseFloat(m.extraPricing.prompt_token_cost)) + (estOut * parseFloat(m.extraPricing.completion_token_cost));
}
if (currentCost > 0) {
cost = currentCost;
costText = ` | Coût: ${currentCost.toFixed(6)} $`;
}
footer = `<div class="msg-footer">
<button class="retry-btn" onclick="app.retryMessage(${index})" ${this.isGenerating ? 'disabled' : ''}><i class="fas fa-redo"></i> Regénérer</button>
<span>${tokenText}${costText}</span>
</div>`;
}
return { html: `<div class="message-wrapper ${m.role}" data-msg-index="${index}"><div class="message ${m.role}">${contentHTML}${imgHTML}${filesHTML}</div>${footer}</div>`, cost };
},
renderMessages() {
const container = document.getElementById('messagesArea');
const chat = this.getActiveChat();
this.updateFileSystemVisibility();
if (!chat) return;
let discussionCost = 0;
container.innerHTML = chat.messages.map((m, index) => {
const { html, cost } = this.buildMessageHTML(m, index, chat);
discussionCost += cost;
return html;
}).join('');
const costBadge = document.getElementById('chatCostDisplay');
if (costBadge) {
if (discussionCost > 0) {
costBadge.innerHTML = `<i class="fas fa-dollar-sign"></i> Discussion: ${discussionCost.toFixed(5)} $`;
costBadge.style.display = 'flex';
} else {
costBadge.style.display = 'none';
}
}
this.renderFileTree();
container.scrollTop = container.scrollHeight;
},
// Mise à jour LÉGÈRE pendant le streaming : ne touche qu'au message en cours
// de génération, sans re-parser ni recréer le DOM des autres messages.
// C'est ce qui évite le freeze du navigateur sur les longues discussions.
updateMessageDOM(aiIndex) {
const container = document.getElementById('messagesArea');
const chat = this.getActiveChat();
if (!chat) return;
const m = chat.messages[aiIndex];
if (!m) return;
const wrapper = container.querySelector(`[data-msg-index="${aiIndex}"]`);
if (!wrapper) { this.renderMessages(); return; }
const bodyEl = wrapper.querySelector('.message');
if (m.isThinking) {
bodyEl.innerHTML = m.searchStatus
? `<div class="search-status"><i class="fas fa-search fa-spin"></i> ${this.escapeHtml(m.searchStatus)}</div>`
: `<div class="typing-indicator"><span>.</span><span>.</span><span>.</span></div>`;
} else if (m.isImageGenerated) {
bodyEl.innerHTML = `<div class="img-gen-wrapper" style="min-height: auto; max-width: 350px;">
<img src="${m.content}" class="loaded" style="max-width: 100%; border-radius: 8px;" alt="Image Générée">
</div>`;
} else {
const shownText = m.displayContent !== undefined ? m.displayContent : (m.content || '');
bodyEl.innerHTML = marked.parse(this.parseFileSystemAndFormat(shownText)) + (m.attachedImage ? `<img src="${m.attachedImage}" class="attached-img" alt="Image jointe">` : '');
}
// Autoscroll uniquement si l'utilisateur était déjà en bas (évite de lui
// arracher le scroll s'il est remonté consulter un message plus haut).
const nearBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 150;
if (nearBottom) container.scrollTop = container.scrollHeight;
},
async fetchModels() {
const select = document.getElementById('modelSelect');
const visionSelect = document.getElementById('visionModelSelect');
const currentVisionVal = this.state.settings.visionModel;
select.innerHTML = '';
visionSelect.innerHTML = '<option value="">-- Aucun modèle Vision détecté --</option>';
let hasModels = false;
let visionModelsFound = [];
const addOption = (id, label, provider, extraData = null) => {
const opt = document.createElement('option');
opt.value = id;
opt.dataset.provider = provider;
let displayLabel = label;
if (extraData && extraData.prompt_token_cost !== undefined && extraData.completion_token_cost !== undefined) {
const inM = parseFloat(extraData.prompt_token_cost) * 1000000;
const outM = parseFloat(extraData.completion_token_cost) * 1000000;
if (inM === 0 && outM === 0) {
displayLabel += " (🟢 Free)";
} else {
displayLabel += ` (In: ${inM.toFixed(2)}$, Out: ${outM.toFixed(2)}$ /M)`;
}
}
opt.textContent = displayLabel;
if (extraData) opt.dataset.extra = JSON.stringify(extraData);
select.appendChild(opt);
if (this.isVisionModel(id, provider)) {
visionModelsFound.push({ id, label: displayLabel, provider, extraData });
}
};
if (this.state.keys.openai) {
try {
const res = await fetch('https://api.openai.com/v1/models', { headers: { 'Authorization': 'Bearer ' + this.state.keys.openai } });
const data = await res.json();
if (data.data) {
data.data.forEach(m => { if(m.id.includes('gpt') || m.id.includes('o1')) addOption(m.id, `[OpenAI] ${m.id}`, 'openai'); });
hasModels = true;
}
} catch(e) {}
}
if (this.state.keys.groq) {
try {
const res = await fetch('https://api.groq.com/openai/v1/models', { headers: { 'Authorization': 'Bearer ' + this.state.keys.groq } });
const data = await res.json();
if (data.data) {
data.data.forEach(m => { addOption(m.id, `[Groq] ${m.id}`, 'groq'); });
hasModels = true;
}
} catch(e) {}
}
if (this.state.keys.gemini) {
try {
const res = await fetch(`https://generativelanguage.googleapis.com/v1beta/models?key=${this.state.keys.gemini}`);
const data = await res.json();
if (data.models) {
data.models.forEach(m => { const cleanId = m.name.replace('models/', ''); addOption(cleanId, `[Gemini] ${cleanId}`, 'gemini'); });
hasModels = true;
}
} catch(e) {}
}
for (const api of this.state.keys.custom) {
if (api.url && api.key) {
try {
let fetchUrl = api.url.replace(/\/$/, '') + '/models';
const res = await fetch(fetchUrl, { headers: { 'Authorization': 'Bearer ' + api.key } });
const data = await res.json();
if (data.data) {
data.data.forEach(m => {
const pricing = m.pricing || {};
addOption(m.id, `[${api.name || 'Custom'}] ${m.name || m.id}`, 'custom', {
baseUrl: api.url,
apiKey: api.key,
prompt_token_cost: pricing.prompt,
completion_token_cost: pricing.completion
});
});
hasModels = true;
}
} catch(e) { console.warn(`Erreur fetch models custom ${api.name}:`, e); }
}
}
if (this.state.keys.deepai) { addOption('deepai', '[DeepAI] Text Generator', 'deepai'); hasModels = true; }
if (!hasModels) select.innerHTML = '<option value="">Configurez vos API dans Paramètres.</option>';
if (visionModelsFound.length > 0) {
visionSelect.innerHTML = '<option value="">-- Sélectionnez un modèle Vision --</option>';
visionModelsFound.forEach(m => {
const opt = document.createElement('option');
opt.value = m.id;
opt.textContent = m.label;
opt.dataset.provider = m.provider;
if (m.extraData) opt.dataset.extra = JSON.stringify(m.extraData);
visionSelect.appendChild(opt);
});
let autoSelect = visionModelsFound.find(m => m.id.toLowerCase().includes('gpt-4o')) ||
visionModelsFound.find(m => m.id.toLowerCase().includes('gemini-1.5')) ||
visionModelsFound.find(m => m.id.toLowerCase().includes('llava') || m.id.toLowerCase().includes('vision')) ||
visionModelsFound[0];
if (autoSelect && !currentVisionVal) {
visionSelect.value = autoSelect.id;
this.state.settings.visionModel = autoSelect.id;
this.saveStateLocal();
} else if (currentVisionVal) {
visionSelect.value = currentVisionVal;
}
}
this.allModelsBackup = Array.from(select.options);
const chat = this.getActiveChat();
if (chat && chat.model) select.value = chat.model;
},
filterModels() {
const query = document.getElementById('modelSearch').value.toLowerCase();
const select = document.getElementById('modelSelect');
if (!this.allModelsBackup || this.allModelsBackup.length === 0) return;
const currentVal = select.value;
select.innerHTML = '';
this.allModelsBackup.forEach(opt => {
if (opt.textContent.toLowerCase().includes(query)) {
const cloned = opt.cloneNode(true);
if (cloned.value === currentVal) cloned.selected = true;
select.appendChild(cloned);
}
});
},
handleFile(input) {
const files = Array.from(input.files || []);
files.forEach(file => {
if (file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onload = (e) => {
this.state.currentAttachments.push({ id: this.uid(), kind: 'image', name: file.name, content: e.target.result });
this.renderAttachmentsPreview();
};
reader.readAsDataURL(file);
} else {
const reader = new FileReader();
reader.onload = (e) => {
this.state.currentAttachments.push({ id: this.uid(), kind: 'text', name: file.name, content: e.target.result });
this.renderAttachmentsPreview();
};
reader.readAsText(file);
}
});
input.value = '';
},
// Affiche les pastilles des pièces jointes en attente d'envoi (avant le clic sur Envoyer).
renderAttachmentsPreview() {
const wrap = document.getElementById('attachmentsPreview');
const atts = this.state.currentAttachments;
if (!atts.length) { wrap.style.display = 'none'; wrap.innerHTML = ''; return; }
wrap.style.display = 'flex';
wrap.innerHTML = atts.map(a => {
if (a.kind === 'image') {
return `<div class="attachment-chip">
<img class="attachment-thumb" src="${a.content}" alt="${this.escapeHtml(a.name)}">
<button class="remove-attachment" onclick="app.removeAttachment('${a.id}')"><i class="fas fa-times"></i></button>
</div>`;
}
return `<div class="attachment-chip">
<i class="fas fa-file-alt" style="font-size: 1.3rem; color: var(--primary);"></i>
<span class="attachment-name">${this.escapeHtml(a.name)}</span>
<button class="remove-attachment" onclick="app.removeAttachment('${a.id}')"><i class="fas fa-times"></i></button>
</div>`;
}).join('');
},
removeAttachment(id) {
this.state.currentAttachments = this.state.currentAttachments.filter(a => a.id !== id);
this.renderAttachmentsPreview();
},
escapeHtml(str) {
return (str || '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
},
// Ouvre la visionneuse en lecture seule pour un fichier texte joint à un message.
viewAttachment(messageId, attachmentId) {
const chat = this.getActiveChat();
if (!chat) return;
const msg = chat.messages.find(m => m.id === messageId);
const att = msg && msg.attachments && msg.attachments.find(a => a.id === attachmentId);
if (!att) return;
document.getElementById('attachmentViewerTitle').innerHTML = `<i class="fas fa-file-alt"></i> ${this.escapeHtml(att.name)}`;
document.getElementById('attachmentViewerContent').textContent = att.content;
document.getElementById('attachmentViewerModal').style.display = 'flex';
},
handleKeyDown(e) {
if (e.key === 'Enter' && !e.shiftKey && window.innerWidth > 768) {
e.preventDefault();
if (!this.isGenerating) this.sendMessage();
}
},
setupAutoresize() {
const txt = document.getElementById('userInput');
txt.addEventListener('input', () => { txt.style.height = 'auto'; txt.style.height = Math.min(txt.scrollHeight, 150) + 'px'; });
},
toggleInputs(disabled) {
document.getElementById('userInput').disabled = disabled;
document.getElementById('sendBtn').style.display = disabled ? 'none' : 'flex';
document.getElementById('stopBtn').style.display = disabled ? 'flex' : 'none';
document.getElementById('attachBtn').disabled = disabled;
},
stopGeneration() {
if (this.abortController) {
this.abortController.abort();
}
if (!auth.isLocal && auth.token && this._activeJobId) {
fetch(API_SERVER + '/api/generate/cancel/' + this._activeJobId, {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + auth.token }
}).catch(() => {});
}
this.isGenerating = false;
this.toggleInputs(false);
},
async sendMessage() {
if (this.isGenerating) return;
const input = document.getElementById('userInput');
const text = input.value.trim();
const attachments = this.state.currentAttachments;
if (!text && attachments.length === 0) return;
const chat = this.getActiveChat();
if (!chat) return;
if (chat.messages.length === 0 && text) {
chat.title = text.slice(0, 30) + (text.length > 30 ? '...' : '');
this.renderChats();
}
const imageAttachments = attachments.filter(a => a.kind === 'image');
const textAttachments = attachments.filter(a => a.kind === 'text');
// Contenu réellement envoyé au modèle (inclut le dump des fichiers texte).
// Il n'est JAMAIS affiché tel quel dans la bulle du chat (voir m.content plus bas) :
// l'utilisateur ne voit que son texte + des pastilles cliquables pour les fichiers.
let rawContent = text;
textAttachments.forEach(a => {
rawContent += `\n\nContenu du fichier joint [${a.name}] :\n\`\`\`\n${a.content}\n\`\`\``;
});
const select = document.getElementById('modelSelect');
let extraPricing = null;
try {
if (select.options[select.selectedIndex]?.dataset.extra) {
extraPricing = JSON.parse(select.options[select.selectedIndex].dataset.extra);
}
} catch(e){}
chat.messages.push({
id: this.uid(),
role: 'user',
content: text,
rawContent: rawContent,
attachedImages: imageAttachments.map(a => a.content),
attachments: textAttachments.map(a => ({ id: a.id, name: a.name, content: a.content }))
});
const userMsgIndex = chat.messages.length - 1;
const imagesForVision = imageAttachments.map(a => a.content);
this.state.currentAttachments = [];
this.renderAttachmentsPreview();
input.value = '';
input.style.height = 'auto';
const vSelect = document.getElementById('visionModelSelect');
const visionModelId = vSelect.value;
if (imagesForVision.length > 0 && visionModelId) {
chat.messages.push({ id: this.uid(), role: 'assistant', content: '', isThinking: true, tokens: 0, extraPricing: extraPricing });
const aiIndex = chat.messages.length - 1;
this.renderMessages();
try {
const provider = vSelect.options[vSelect.selectedIndex].dataset.provider;
const extraDataStr = vSelect.options[vSelect.selectedIndex].dataset.extra;
const descriptions = await Promise.all(imagesForVision.map(async (imgData, i) => {
const desc = await this.describeImage(imgData, visionModelId, provider, extraDataStr);
return { name: (imageAttachments[i] && imageAttachments[i].name) || `image ${i + 1}`, description: desc };
}));
chat.messages[userMsgIndex].imageDescriptions = descriptions;
chat.messages[aiIndex].content = '';
this.renderMessages();
await this.triggerAiResponse(aiIndex);
} catch (error) {
chat.messages[aiIndex].isThinking = false;
chat.messages[aiIndex].content = "❌ Erreur de l'analyse Vision : " + error.message;
this.saveState();
this.renderMessages();
}
} else {
chat.messages.push({ id: this.uid(), role: 'assistant', content: '', isThinking: true, tokens: 0, extraPricing: extraPricing });
const aiIndex = chat.messages.length - 1;
this.renderMessages();
this.triggerAiResponse(aiIndex);
}
},
async retryMessage(index) {
if (this.isGenerating) return;
const chat = this.getActiveChat();
if (!chat) return;
chat.messages = chat.messages.slice(0, index);
const select = document.getElementById('modelSelect');
let extraPricing = null;
try {
if (select.options[select.selectedIndex]?.dataset.extra) {
extraPricing = JSON.parse(select.options[select.selectedIndex].dataset.extra);
}
} catch(e){}
chat.messages.push({ id: this.uid(), role: 'assistant', content: '', isThinking: true, tokens: 0, extraPricing: extraPricing });
this.renderMessages();
this.triggerAiResponse(chat.messages.length - 1);
},
async describeImage(b64Data, modelId, provider, extraDataStr) {
let url = '';
let headers = { 'Content-Type': 'application/json' };
let body = {};
const pureB64 = b64Data.split(',')[1];
const mimeType = b64Data.split(',')[0].split(':')[1].split(';')[0];
const promptText = "Décris cette image en détail pour un autre grand modèle linguistique. Sois exhaustif sur les textes, graphiques, code, UI, couleurs et disposition.";
let apiKey = '';
let baseUrl = '';
if (provider === 'openai') { url = 'https://api.openai.com/v1/chat/completions'; apiKey = this.state.keys.openai; }
else if (provider === 'groq') { url = 'https://api.groq.com/openai/v1/chat/completions'; apiKey = this.state.keys.groq; }
else if (provider === 'gemini') { url = `https://generativelanguage.googleapis.com/v1beta/models/${modelId}:generateContent?key=${this.state.keys.gemini}`; }
else if (provider === 'custom') {
try {
const extra = JSON.parse(extraDataStr || '{}');
baseUrl = extra.baseUrl;
apiKey = extra.apiKey;
url = baseUrl.replace(/\/$/, '') + '/chat/completions';
} catch(e) { throw new Error("Config API Custom invalide pour Vision"); }
} else {
throw new Error("Provider Vision inconnu: " + provider);
}
if (apiKey) headers['Authorization'] = 'Bearer ' + apiKey;
if (provider === 'gemini') {
body = {
contents: [{ parts: [{ text: promptText }, { inlineData: { mimeType: mimeType, data: pureB64 } }] }]
};
} else {
body = {
model: modelId,
messages: [{ role: "user", content: [{ type: "text", text: promptText }, { type: "image_url", image_url: { url: b64Data } }] }],
max_tokens: 1024
};
}
const res = await fetch(url, { method: 'POST', headers: headers, body: JSON.stringify(body) });
if (!res.ok) {
const errText = await res.text();
throw new Error(`HTTP ${res.status}: ${errText}`);
}
const d = await res.json();
if (provider === 'gemini') {
return d.candidates?.[0]?.content?.parts?.[0]?.text || "Aucune description retournée.";
} else {
return d.choices?.[0]?.message?.content || "Aucune description retournée.";
}
},
// --- GÉNÉRATION RÉPONSE AVEC PROMPT SYSTÈME DYNAMIQUE STRICT ---
async triggerAiResponse(aiIndex) {
if (!auth.isLocal && auth.token) {
return this.triggerAiResponseCloud(aiIndex);
}
this.isGenerating = true;
this.toggleInputs(true);
this.abortController = new AbortController();
const select = document.getElementById('modelSelect');
const chat = this.getActiveChat();
const provider = select.options[select.selectedIndex]?.dataset.provider;
const model = select.value;
let url = ''; let headers = { 'Content-Type': 'application/json' };
if (provider === 'openai') { url = 'https://api.openai.com/v1/chat/completions'; headers['Authorization'] = 'Bearer ' + this.state.keys.openai; }
else if (provider === 'groq') { url = 'https://api.groq.com/openai/v1/chat/completions'; headers['Authorization'] = 'Bearer ' + this.state.keys.groq; }
else if (provider === 'gemini') { url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:streamGenerateContent?key=${this.state.keys.gemini}`; }
else if (provider === 'custom') {
try {
const extra = JSON.parse(select.options[select.selectedIndex].dataset.extra);
url = extra.baseUrl.replace(/\/$/, '') + '/chat/completions';
headers['Authorization'] = 'Bearer ' + extra.apiKey;
} catch(e) { url = '/chat/completions'; }
}
if (provider === 'deepai') {
this.toggleInputs(true);
try {
let prompt = "";
chat.messages.slice(0, aiIndex).forEach(m => { prompt += `${m.role}: ${this.buildMessageContentForAI(m)}\n`; });
let formData = new FormData(); formData.append('text', prompt);
const response = await fetch('https://api.deepai.org/api/text-generator', { method: 'POST', headers: { 'api-key': this.state.keys.deepai }, body: formData, signal: this.abortController.signal });
const data = await response.json();
chat.messages[aiIndex].isThinking = false;
chat.messages[aiIndex].content = data.output || data.err || "Erreur DeepAI";
} catch (error) {
chat.messages[aiIndex].isThinking = false;
if (error.name === 'AbortError') chat.messages[aiIndex].content += "\n\n⏱️ *Génération interrompue par l'utilisateur.*";
else chat.messages[aiIndex].content += "\n**Erreur API:** " + error.message;
}
this.isGenerating = false;
this.toggleInputs(false);
this.saveState(); this.renderMessages();
return;
}
// --- CONSTRUCTION PROMPT SYSTÈME SELON MODE ---
let systemMsg = "Tu es un assistant IA expert.";
const fileSystemEnabled = this.state.settings.fileSystem;
if (fileSystemEnabled) {
systemMsg += `\n\nTu as la capacité d'interagir avec un système de fichiers virtuel via des instructions spéciales :
- \`<div class="file-action"><i class="fas fa-folder-plus"></i> <b>Dossier créé :</b> nom</div>\` : Crée un dossier.
- \`<div class="file-action file-created"><i class="fas fa-file-signature"></i> <b>Fichier créé/modifié :</b> nom</div>\` : Crée/écrase un fichier.
**Exemple :**
\`\`\`
<div class="file-action file-created"><i class="fas fa-file-signature"></i> <b>Fichier créé/modifié :</b> main.py</div>
\`\`\`
Structure actuelle du workspace :
${JSON.stringify(Object.keys(this.state.virtualFiles))}`;
}
// Note : en mode local (sans serveur), la recherche web réelle n'est pas
// disponible — pas d'instruction de "simulation" pour éviter les réponses
// inventées ; ce toggle n'a d'effet réel qu'en mode connecté (voir triggerAiResponseCloud).
let history = [];
if (provider !== 'gemini') { history.push({ role: 'system', content: systemMsg }); }
chat.messages.slice(0, aiIndex).forEach(m => {
history.push({ role: m.role, content: app.buildMessageContentForAI(m) });
});
let body = {};
if (provider === 'gemini') {
let contents = [];
history.forEach(h => { contents.push({ role: h.role === 'user' ? 'user' : 'model', parts: [{ text: h.content }] }); });
body = { contents: contents, systemInstruction: { parts: [{ text: systemMsg }] } };
} else {
body = { model: model, messages: history, stream: true, stream_options: { include_usage: true } };
}
try {
const response = await fetch(url, { method: 'POST', headers: headers, body: JSON.stringify(body), signal: this.abortController.signal });
if (!response.ok) { const errData = await response.json().catch(()=>({})); throw new Error(errData.error?.message || `HTTP ${response.status}`); }
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let lines = buffer.split('\n');
buffer = lines.pop();
let updated = false;
for (let line of lines) {
let cleanLine = line.trim();
if (!cleanLine) continue;
if (provider === 'gemini') {
if (cleanLine.startsWith(',')) cleanLine = cleanLine.slice(1);
if (cleanLine.startsWith('[') || cleanLine.endsWith(']')) continue;
try {
const data = JSON.parse(cleanLine);
if (data.candidates && data.candidates[0].content?.parts[0]?.text) {
this.pushSmoothChunk(aiIndex, data.candidates[0].content.parts[0].text);
updated = true;
}
if (data.usageMetadata) {
chat.messages[aiIndex].tokens = data.usageMetadata.candidatesTokenCount || data.usageMetadata.totalTokenCount;
chat.messages[aiIndex].promptTokens = data.usageMetadata.promptTokenCount;
chat.messages[aiIndex].completionTokens = data.usageMetadata.candidatesTokenCount;
}
} catch(e) {}
} else {
if (cleanLine.startsWith('data: ')) {
const dataStr = cleanLine.slice(6).trim();
if (dataStr === '[DONE]') continue;
try {
const data = JSON.parse(dataStr);
if (data.choices && data.choices[0]?.delta?.content) {
this.pushSmoothChunk(aiIndex, data.choices[0].delta.content);
updated = true;
}
if (data.usage) {
if (data.usage.total_tokens) chat.messages[aiIndex].tokens = data.usage.total_tokens;
if (data.usage.prompt_tokens) chat.messages[aiIndex].promptTokens = data.usage.prompt_tokens;
if (data.usage.completion_tokens) chat.messages[aiIndex].completionTokens = data.usage.completion_tokens;
}
} catch(e) {}
}
}
}
}
} catch (error) {
if (error.name === 'AbortError') {
this.pushSmoothChunk(aiIndex, "\n\n⏱️ *Génération interrompue par l'utilisateur.*");
} else {
this.pushSmoothChunk(aiIndex, "\n**Erreur API:** " + error.message);
}
} finally {
this.isGenerating = false;
this.finishSmoothStream(aiIndex);
this.toggleInputs(false);
const msg = chat.messages[aiIndex];
if (msg && msg.extraPricing && msg.extraPricing.prompt_token_cost !== undefined) {
let pTokens = msg.promptTokens || Math.ceil(JSON.stringify(chat.messages.slice(0, aiIndex)).length / 3.5);
let cTokens = msg.completionTokens || msg.tokens || Math.ceil((msg.content || '').length / 3.5);
msg.cost = (pTokens * parseFloat(msg.extraPricing.prompt_token_cost)) + (cTokens * parseFloat(msg.extraPricing.completion_token_cost));
}
this.saveState();
this.renderMessages();
}
},
// --- MODE CLOUD : la requête passe par le serveur HF qui retransmet la réponse ---
// Le serveur garde la génération en mémoire (et la persiste) même si ce device
// se déconnecte ; un autre appareil peut alors la reprendre via /api/generate/active.
resolveProviderCreds(provider, extraDataStr) {
if (provider === 'openai') return { apiKey: this.state.keys.openai };
if (provider === 'groq') return { apiKey: this.state.keys.groq };
if (provider === 'gemini') return { apiKey: this.state.keys.gemini };
if (provider === 'deepai') return { apiKey: this.state.keys.deepai };
if (provider === 'custom') {
try {
const extra = JSON.parse(extraDataStr || '{}');
return { apiKey: extra.apiKey, baseUrl: extra.baseUrl };
} catch(e) { return {}; }
}
return {};
},
buildHistoryPayload(chat, aiIndex) {
const fileSystemEnabled = this.state.settings.fileSystem;
let systemMsg = "Tu es un assistant IA expert.";
if (fileSystemEnabled) {
systemMsg += `\n\nTu as la capacité d'interagir avec un système de fichiers virtuel via des instructions spéciales :
- \`<div class="file-action"><i class="fas fa-folder-plus"></i> <b>Dossier créé :</b> nom</div>\` : Crée un dossier.
- \`<div class="file-action file-created"><i class="fas fa-file-signature"></i> <b>Fichier créé/modifié :</b> nom</div>\` : Crée/écrase un fichier.
**Exemple :**
\`\`\`
<div class="file-action file-created"><i class="fas fa-file-signature"></i> <b>Fichier créé/modifié :</b> main.py</div>
\`\`\`
Structure actuelle du workspace :
${JSON.stringify(Object.keys(this.state.virtualFiles))}`;
}
// La recherche web réelle (agent + MCP victor/websearch) est décidée et exécutée
// côté serveur dans run_job() si webSearchEnabled est transmis — voir plus bas.
const messages = chat.messages.slice(0, aiIndex).map(m => {
return { role: m.role, content: this.buildMessageContentForAI(m) };
});
return { system: systemMsg, messages };
},
async triggerAiResponseCloud(aiIndex) {
this.isGenerating = true;
this.toggleInputs(true);
this.abortController = new AbortController();
const select = document.getElementById('modelSelect');
const chat = this.getActiveChat();
const provider = select.options[select.selectedIndex]?.dataset.provider;
const model = select.value;
const extraDataStr = select.options[select.selectedIndex]?.dataset.extra;
const creds = this.resolveProviderCreds(provider, extraDataStr);
const { system, messages } = this.buildHistoryPayload(chat, aiIndex);
const msg = chat.messages[aiIndex];
try {
const recentMessages = chat.messages.slice(Math.max(0, aiIndex - 1), aiIndex + 1);
const startRes = await fetch(API_SERVER + '/api/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + auth.token },
body: JSON.stringify({
chat_id: chat.id, chat_title: chat.title, message_id: msg.id, provider, model,
apiKey: creds.apiKey || '', baseUrl: creds.baseUrl || '',
system, messages, recent_messages: recentMessages,
webSearchEnabled: !!this.state.settings.webSearch
}),
signal: this.abortController.signal
});
if (!startRes.ok) {
const errData = await startRes.json().catch(() => ({}));
throw new Error(errData.error || `HTTP ${startRes.status}`);
}
const { job_id } = await startRes.json();
msg.jobId = job_id;
this._activeJobId = job_id;
await this.consumeJobStream(aiIndex, job_id);
} catch (error) {
if (error.name === 'AbortError') {
this.pushSmoothChunk(aiIndex, "\n\n⏱️ *Génération interrompue par l'utilisateur.*");
} else {
this.pushSmoothChunk(aiIndex, "\n**Erreur serveur:** " + error.message);
}
this.finishSmoothStream(aiIndex);
} finally {
this.isGenerating = false;
this._activeJobId = null;
this.toggleInputs(false);
this.saveState();
this.renderMessages();
}
},
// Lit le flux SSE du job (POST /api/generate/stream/<job_id>) et applique le lissage.
async consumeJobStream(aiIndex, jobId, isResume = false) {
const chat = this.getActiveChat();
const streamUrl = API_SERVER + '/api/generate/stream/' + jobId;
const response = await fetch(streamUrl, {
headers: { 'Authorization': 'Bearer ' + auth.token },
signal: this.abortController ? this.abortController.signal : undefined
});
if (!response.ok) throw new Error('Connexion au flux impossible (HTTP ' + response.status + ')');
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
let currentEvent = 'message';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let parts = buffer.split('\n\n');
buffer = parts.pop();
for (const part of parts) {
const lines = part.split('\n');
let eventName = 'message';
let dataStr = '';
for (const line of lines) {
if (line.startsWith('event:')) eventName = line.slice(6).trim();
else if (line.startsWith('data:')) dataStr += line.slice(5).trim();
}
if (!dataStr) continue;
let payload;
try { payload = JSON.parse(dataStr); } catch(e) { continue; }
if (eventName === 'sync') {
this.syncSmoothContent(aiIndex, payload.content || '');
if (payload.tokens) chat.messages[aiIndex].tokens = payload.tokens;
} else if (eventName === 'status') {
// Étape agentique invisible (ex: décision + exécution de la recherche web) :
// affiche un statut court à la place des "...", sans jamais montrer le détail des tool calls.
this.setSearchStatus(aiIndex, payload.text || '');
} else if (eventName === 'delta') {
this.setSearchStatus(aiIndex, null);
this.pushSmoothChunk(aiIndex, payload.text || '');
} else if (eventName === 'done') {
this.setSearchStatus(aiIndex, null);
if (payload.tokens) chat.messages[aiIndex].tokens = payload.tokens;
if (payload.promptTokens) chat.messages[aiIndex].promptTokens = payload.promptTokens;
if (payload.completionTokens) chat.messages[aiIndex].completionTokens = payload.completionTokens;
this.finishSmoothStream(aiIndex);
} else if (eventName === 'error') {
this.setSearchStatus(aiIndex, null);
this.pushSmoothChunk(aiIndex, "\n**Erreur API:** " + (payload.message || 'inconnue'));
this.finishSmoothStream(aiIndex);
}
}
}
},
// Appelée au chargement en mode cloud : retrouve les générations en cours
// (démarrées depuis cet appareil ou un autre) et reprend leur affichage.
async resumeActiveJobs() {
if (auth.isLocal || !auth.token) return;
try {
const res = await fetch(API_SERVER + '/api/generate/active', {
headers: { 'Authorization': 'Bearer ' + auth.token }
});
if (!res.ok) return;
const { jobs } = await res.json();
if (!jobs || !jobs.length) return;
for (const job of jobs) {
const chat = this.state.chats.find(c => c.id === job.chat_id);
if (!chat) continue;
let aiIndex = chat.messages.findIndex(m => m.id === job.message_id);
if (aiIndex === -1) continue;
this.state.activeChatId = chat.id;
this.isGenerating = true;
this.toggleInputs(true);
this.abortController = new AbortController();
this._activeJobId = job.job_id;
this.renderChats();
this.renderMessages();
this.consumeJobStream(aiIndex, job.job_id, true)
.catch(e => console.warn('Reprise du flux échouée :', e))
.finally(() => {
this.isGenerating = false;
this._activeJobId = null;
this.toggleInputs(false);
this.saveState();
this.renderMessages();
});
}
} catch(e) { console.warn('Erreur de reprise des générations actives :', e); }
},
copyCode(btn, encodedText) {
const text = decodeURIComponent(escape(atob(encodedText)));
navigator.clipboard.writeText(text).then(() => {
btn.innerHTML = '<i class="fas fa-check"></i> Copié !';
setTimeout(() => { btn.innerHTML = '<i class="fas fa-copy"></i> Copier'; }, 2000);
});
},
toggleSidebar(open) {
const sb = document.getElementById('sidebar');
const overlay = document.getElementById('sidebarOverlay');
if(open) { sb.classList.add('open'); overlay.classList.add('open'); }
else { sb.classList.remove('open'); overlay.classList.remove('open'); }
},
toggleFilePanel() {
const fp = document.getElementById('filePanel');
if (window.innerWidth <= 768) {
fp.classList.toggle('open-mobile');
document.getElementById('sidebarOverlay').classList.toggle('open');
} else {
fp.classList.toggle('hidden');
}
},
closeAllPanels() {
this.toggleSidebar(false);
document.getElementById('filePanel').classList.remove('open-mobile');
document.getElementById('sidebarOverlay').classList.remove('open');
}
};
window.addEventListener('resize', () => {
});
window.onload = () => auth.init();
window.addEventListener('beforeunload', () => auth.flushOnUnload(app.state));
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') auth.flushOnUnload(app.state);
});
</script>
</body>
</html>