Spaces:
Running
Running
| <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, '<').replace(/>/g, '>'); | |
| 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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> | |