/** * Hermes Hub — Client Application & Native Chat Engine */ document.addEventListener('DOMContentLoaded', () => { // Elements const sidebar = document.getElementById('hub-sidebar'); const btnToggleSidebar = document.getElementById('btn-toggle-sidebar'); const universeBtns = document.querySelectorAll('.hub-universe-btn'); const activeTitle = document.getElementById('active-universe-name'); const activeScope = document.getElementById('active-universe-scope'); const activeTagline = document.getElementById('active-universe-tagline'); const nabilModeToggle = document.getElementById('nabil-mode-toggle'); const tabSession = document.getElementById('tab-session'); const tabFiles = document.getElementById('tab-files'); // Containers const nativeChatLayout = document.getElementById('hub-native-chat'); const iframeWrapper = document.getElementById('hub-iframe-wrapper'); const iframe = document.getElementById('workspace-iframe'); const loader = document.getElementById('hub-loader'); // Native Chat Elements const btnNewChat = document.getElementById('btn-new-chat'); const convSearch = document.getElementById('hub-conv-search'); const convList = document.getElementById('hub-conv-list'); const chatHeaderTitle = document.getElementById('current-chat-title'); const chatHeaderStatus = document.getElementById('current-chat-status'); const messagesContainer = document.getElementById('hub-messages-container'); const chatForm = document.getElementById('hub-chat-form'); const chatInput = document.getElementById('hub-chat-input'); const btnSend = document.getElementById('btn-send-msg'); const btnStop = document.getElementById('btn-stop-msg'); const emptyStateName = document.getElementById('empty-state-name'); const btnPinActive = document.getElementById('btn-pin-active'); const btnRenameActive = document.getElementById('btn-rename-active'); const btnDeleteActive = document.getElementById('btn-delete-active'); // Modals const personaModal = document.getElementById('persona-modal'); const cloneModal = document.getElementById('clone-modal'); const btnEditPersona = document.getElementById('btn-edit-persona'); const btnCloneUniverse = document.getElementById('btn-clone-universe'); const btnSavePersona = document.getElementById('btn-save-persona'); const btnSubmitClone = document.getElementById('btn-submit-clone'); // Application State let currentUniverse = 'tt'; let currentSessionKey = null; let currentConversations = []; let isStreaming = false; let currentAbortController = null; let currentMode = 'chat'; // 'chat' or 'files' // Sidebar Collapse Persistence const isCollapsed = localStorage.getItem('hermes_hub_sidebar_collapsed') === 'true'; if (isCollapsed && sidebar) { sidebar.classList.add('collapsed'); } if (btnToggleSidebar && sidebar) { btnToggleSidebar.addEventListener('click', (e) => { e.stopPropagation(); sidebar.classList.toggle('collapsed'); localStorage.setItem('hermes_hub_sidebar_collapsed', sidebar.classList.contains('collapsed')); }); } // Accent Mapping const ACCENT_MAP = { 'tt': { accent: 'var(--accent-tt)', glow: 'var(--accent-tt-glow)' }, 'nyora': { accent: 'var(--accent-nyora)', glow: 'var(--accent-nyora-glow)' }, 'perso': { accent: 'var(--accent-perso)', glow: 'var(--accent-perso-glow)' }, 'nabil': { accent: 'var(--accent-nabil)', glow: 'var(--accent-nabil-glow)' }, 'dsh': { accent: 'var(--accent-dsh)', glow: 'var(--accent-dsh-glow)' } }; function updateThemeAccent(universeId) { const config = ACCENT_MAP[universeId] || ACCENT_MAP['tt']; document.documentElement.style.setProperty('--hub-accent-current', config.accent); document.documentElement.style.setProperty('--hub-accent-glow-current', config.glow); } // Escape HTML helper function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text || ''; return div.innerHTML; } // Basic Markdown Parser (Code blocks, bold, italic, line breaks) function renderMarkdown(text) { if (!text) return ''; let html = escapeHtml(text); // Code blocks with syntax box html = html.replace(/```([a-zA-Z0-9_\-\.]*)\n([\s\S]*?)```/g, (match, lang, code) => { return `
${code}
`; }); // Inline code html = html.replace(/`([^`]+)`/g, '$1'); // Bold html = html.replace(/\*\*([^*]+)\*\*/g, '$1'); // Italic html = html.replace(/\*([^*]+)\*/g, '$1'); // Line breaks html = html.replace(/\n/g, '
'); return html; } // Switch Universe function switchUniverse(universeId) { currentUniverse = universeId; currentSessionKey = null; currentMode = 'chat'; // Update active button universeBtns.forEach(btn => { const isTarget = btn.getAttribute('data-universe') === universeId; btn.classList.toggle('active', isTarget); if (isTarget) { activeTitle.textContent = btn.getAttribute('data-name'); activeScope.textContent = `Scope: ${btn.getAttribute('data-scope')}`; activeTagline.textContent = btn.getAttribute('data-tagline'); if (emptyStateName) emptyStateName.textContent = btn.getAttribute('data-name'); } }); updateThemeAccent(universeId); // Show/hide Nabil Files Toggle if (universeId === 'nabil') { nabilModeToggle.style.display = 'inline-flex'; tabSession.classList.add('active'); tabFiles.classList.remove('active'); } else { nabilModeToggle.style.display = 'none'; } // Determine layout: DSH uses embedded client, others use Native Chat if (universeId === 'dsh') { showIframeView(`https://dsh-hub.yesminedor.tn/`); } else { showNativeChatView(); loadConversations(); } } function showNativeChatView() { nativeChatLayout.style.display = 'flex'; iframeWrapper.style.display = 'none'; if (iframe) iframe.src = 'about:blank'; } function showIframeView(url) { nativeChatLayout.style.display = 'none'; iframeWrapper.style.display = 'block'; if (loader) loader.classList.remove('hidden'); if (iframe) { iframe.src = url; iframe.onload = () => { if (loader) loader.classList.add('hidden'); }; } } // Load Conversations List async function loadConversations() { convList.innerHTML = '
Chargement...
'; try { const res = await fetch(`/api/chat/${currentUniverse}/conversations`); const data = await res.json(); currentConversations = data.conversations || []; renderConversationsList(); if (currentConversations.length > 0 && !currentSessionKey) { selectConversation(currentConversations[0].session_key); } else if (!currentSessionKey) { startNewConversation(false); } } catch (err) { convList.innerHTML = '
Erreur de chargement
'; } } function renderConversationsList() { const filter = (convSearch ? convSearch.value.trim().toLowerCase() : ''); const filtered = currentConversations.filter(c => !filter || (c.title && c.title.toLowerCase().includes(filter))); if (filtered.length === 0) { convList.innerHTML = '
Aucune discussion
'; return; } const pinned = filtered.filter(c => c.pinned); const recents = filtered.filter(c => !c.pinned); let html = ''; if (pinned.length > 0) { html += '
📌 Épinglées
'; pinned.forEach(c => { html += renderConvItem(c); }); } if (recents.length > 0) { if (pinned.length > 0) html += '
Discussions récentes
'; recents.forEach(c => { html += renderConvItem(c); }); } convList.innerHTML = html; // Attach click events convList.querySelectorAll('.hub-conv-item').forEach(item => { item.addEventListener('click', (e) => { if (e.target.closest('.hub-conv-action-btn')) return; const key = item.getAttribute('data-session-key'); selectConversation(key); }); }); // Attach actions convList.querySelectorAll('.btn-pin-conv').forEach(btn => { btn.addEventListener('click', async (e) => { e.stopPropagation(); const key = btn.getAttribute('data-session-key'); const isPinned = btn.getAttribute('data-pinned') === '1'; await togglePin(key, !isPinned); }); }); convList.querySelectorAll('.btn-rename-conv').forEach(btn => { btn.addEventListener('click', async (e) => { e.stopPropagation(); const key = btn.getAttribute('data-session-key'); const currentT = btn.getAttribute('data-title'); const newTitle = prompt('Nouveau titre de la discussion :', currentT); if (newTitle && newTitle.trim() && newTitle.trim() !== currentT) { await renameConv(key, newTitle.trim()); } }); }); convList.querySelectorAll('.btn-delete-conv').forEach(btn => { btn.addEventListener('click', async (e) => { e.stopPropagation(); const key = btn.getAttribute('data-session-key'); if (confirm('Supprimer définitivement cette discussion ?')) { await deleteConv(key); } }); }); } function renderConvItem(c) { const isActive = c.session_key === currentSessionKey; const timeStr = c.updated_at ? new Date(c.updated_at).toLocaleDateString('fr-FR', { month: 'short', day: 'numeric' }) : ''; return `
${c.pinned ? '📌 ' : ''}${escapeHtml(c.title || 'Discussion')}
${timeStr}
`; } // Select Conversation async function selectConversation(sessionKey) { currentSessionKey = sessionKey; renderConversationsList(); const conv = currentConversations.find(c => c.session_key === sessionKey); if (conv) { chatHeaderTitle.textContent = conv.title || 'Discussion'; if (btnPinActive) btnPinActive.textContent = conv.pinned ? '📍' : '📌'; } messagesContainer.innerHTML = '
Chargement des messages...
'; try { const res = await fetch(`/api/chat/${currentUniverse}/conversations/${sessionKey}/messages`); const data = await res.json(); renderMessages(data.messages || []); } catch (err) { messagesContainer.innerHTML = '
Erreur de chargement des messages
'; } } // Start New Conversation async function startNewConversation(focus = true) { try { const res = await fetch(`/api/chat/${currentUniverse}/conversations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: 'Nouvelle conversation' }) }); const data = await res.json(); if (data.ok && data.conversation) { currentSessionKey = data.conversation.session_key; currentConversations.unshift(data.conversation); renderConversationsList(); chatHeaderTitle.textContent = 'Nouvelle conversation'; renderMessages([]); if (focus && chatInput) chatInput.focus(); } } catch (err) { console.error('Failed to create new conversation:', err); } } // Render Messages function renderMessages(messages) { if (!messages || messages.length === 0) { messagesContainer.innerHTML = `
💬

Discussion avec ${escapeHtml(activeTitle.textContent)}

Posez une question ou donnez une instruction pour démarrer la session.

`; return; } let html = ''; messages.forEach(m => { const isUser = m.role === 'user'; const avatarText = isUser ? 'VOUS' : currentUniverse.toUpperCase().slice(0, 2); let reasoningHtml = ''; if (m.reasoning) { reasoningHtml = `
Raisonnement de l'agent
${escapeHtml(m.reasoning)}
`; } html += `
${avatarText}
${reasoningHtml}
${renderMarkdown(m.content)}
`; }); messagesContainer.innerHTML = html; scrollToBottom(); } function scrollToBottom() { messagesContainer.scrollTop = messagesContainer.scrollHeight; } // Send Message & Handle SSE Streaming async function sendMessage() { const text = chatInput.value.trim(); if (!text || isStreaming) return; if (!currentSessionKey) { await startNewConversation(false); } // Append user message immediately const userMsgHtml = `
VOUS
${renderMarkdown(text)}
`; // Remove empty state if present const emptyState = messagesContainer.querySelector('.hub-empty-state'); if (emptyState) emptyState.remove(); messagesContainer.insertAdjacentHTML('beforeend', userMsgHtml); chatInput.value = ''; chatInput.style.height = 'auto'; scrollToBottom(); // Prepare assistant message bubble with streaming cursor const assistantId = `msg-stream-${Date.now()}`; const assistantMsgHtml = `
${currentUniverse.toUpperCase().slice(0, 2)}
`; messagesContainer.insertAdjacentHTML('beforeend', assistantMsgHtml); scrollToBottom(); const streamBubble = document.getElementById(assistantId); const streamTextEl = streamBubble.querySelector('.stream-text'); const cursorEl = streamBubble.querySelector('.hub-streaming-cursor'); // UI Streaming state setStreamingState(true); chatHeaderStatus.textContent = 'En train de répondre...'; currentAbortController = new AbortController(); try { const response = await fetch(`/api/chat/${currentUniverse}/conversations/${currentSessionKey}/send`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: text }), signal: currentAbortController.signal }); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; let accumulatedText = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop(); // Keep last partial line for (const line of lines) { if (line.startsWith('data: ')) { const rawData = line.slice(6).trim(); if (!rawData) continue; try { const data = JSON.parse(rawData); if (data.fullReplace && data.text !== undefined) { // Workspace chunk with fullReplace: true accumulatedText = data.text; streamTextEl.innerHTML = renderMarkdown(accumulatedText); } else if (data.text) { accumulatedText = data.text; streamTextEl.innerHTML = renderMarkdown(accumulatedText); } else if (data.chunk) { accumulatedText += data.chunk; streamTextEl.innerHTML = renderMarkdown(accumulatedText); } scrollToBottom(); } catch (e) {} } } } // Final rendering if (cursorEl) cursorEl.remove(); streamTextEl.innerHTML = renderMarkdown(accumulatedText || 'Message reçu.'); chatHeaderStatus.textContent = 'Prêt'; // Refresh conversations list to update title / timestamp loadConversations(); } catch (err) { if (err.name === 'AbortError') { if (cursorEl) cursorEl.remove(); streamTextEl.innerHTML += '
[Réponse interrompue]'; } else { if (cursorEl) cursorEl.remove(); streamTextEl.innerHTML = `Erreur lors de la réponse (${err.message}). Vérifiez l'instance.`; } chatHeaderStatus.textContent = 'Prêt'; } finally { setStreamingState(false); currentAbortController = null; scrollToBottom(); } } function setStreamingState(streaming) { isStreaming = streaming; btnSend.style.display = streaming ? 'none' : 'flex'; btnStop.style.display = streaming ? 'flex' : 'none'; } function stopStreaming() { if (currentAbortController) { currentAbortController.abort(); } } // Conversation Actions (Pin, Rename, Delete) async function togglePin(sessionKey, pinned) { try { await fetch(`/api/chat/${currentUniverse}/conversations/${sessionKey}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pinned: pinned }) }); loadConversations(); } catch (err) { console.error('Failed to toggle pin:', err); } } async function renameConv(sessionKey, title) { try { await fetch(`/api/chat/${currentUniverse}/conversations/${sessionKey}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: title }) }); if (sessionKey === currentSessionKey) { chatHeaderTitle.textContent = title; } loadConversations(); } catch (err) { console.error('Failed to rename conversation:', err); } } async function deleteConv(sessionKey) { try { await fetch(`/api/chat/${currentUniverse}/conversations/${sessionKey}`, { method: 'DELETE' }); if (sessionKey === currentSessionKey) { currentSessionKey = null; } loadConversations(); } catch (err) { console.error('Failed to delete conversation:', err); } } // Event Listeners if (btnNewChat) { btnNewChat.addEventListener('click', () => startNewConversation(true)); } if (convSearch) { convSearch.addEventListener('input', () => renderConversationsList()); } if (chatForm) { chatForm.addEventListener('submit', (e) => { e.preventDefault(); sendMessage(); }); } if (chatInput) { // Auto-resize textarea chatInput.addEventListener('input', () => { chatInput.style.height = 'auto'; chatInput.style.height = Math.min(chatInput.scrollHeight, 160) + 'px'; }); // Enter to submit (Shift+Enter for newline) chatInput.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } }); } if (btnStop) { btnStop.addEventListener('click', () => stopStreaming()); } if (btnPinActive) { btnPinActive.addEventListener('click', () => { if (!currentSessionKey) return; const conv = currentConversations.find(c => c.session_key === currentSessionKey); if (conv) togglePin(currentSessionKey, !conv.pinned); }); } if (btnRenameActive) { btnRenameActive.addEventListener('click', () => { if (!currentSessionKey) return; const conv = currentConversations.find(c => c.session_key === currentSessionKey); const currentT = conv ? conv.title : 'Discussion'; const newTitle = prompt('Nouveau titre de la discussion :', currentT); if (newTitle && newTitle.trim()) renameConv(currentSessionKey, newTitle.trim()); }); } if (btnDeleteActive) { btnDeleteActive.addEventListener('click', () => { if (!currentSessionKey) return; if (confirm('Supprimer définitivement cette discussion ?')) { deleteConv(currentSessionKey); } }); } // Nabil Mode Switcher (Chat Natif vs Fichiers DSH) if (tabSession) { tabSession.addEventListener('click', () => { tabSession.classList.add('active'); tabFiles.classList.remove('active'); showNativeChatView(); }); } if (tabFiles) { tabFiles.addEventListener('click', () => { tabFiles.classList.add('active'); tabSession.classList.remove('active'); showIframeView(`https://dsh-hub.yesminedor.tn/`); }); } // Universe Switcher Buttons universeBtns.forEach(btn => { btn.addEventListener('click', () => { const uId = btn.getAttribute('data-universe'); switchUniverse(uId); }); }); // Persona Modal Handlers if (btnEditPersona && personaModal) { btnEditPersona.addEventListener('click', async () => { try { const res = await fetch(`/api/personas/${currentUniverse}`); const data = await res.json(); document.getElementById('persona-name').value = data.name || ''; document.getElementById('persona-tagline').value = data.tagline || ''; document.getElementById('persona-tone').value = data.tone || ''; document.getElementById('persona-style').value = data.style || ''; document.getElementById('persona-principles').value = (data.principles || []).join('\n'); document.getElementById('persona-prompt').value = data.system_prompt || ''; personaModal.classList.add('active'); } catch (err) { alert('Impossible de charger le profil de cet univers.'); } }); } if (btnSavePersona && personaModal) { btnSavePersona.addEventListener('click', async () => { const payload = { name: document.getElementById('persona-name').value.trim(), tagline: document.getElementById('persona-tagline').value.trim(), tone: document.getElementById('persona-tone').value.trim(), style: document.getElementById('persona-style').value.trim(), principles: document.getElementById('persona-principles').value.split('\n').map(l => l.trim()).filter(Boolean), system_prompt: document.getElementById('persona-prompt').value.trim() }; try { const res = await fetch(`/api/personas/${currentUniverse}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (res.ok) { personaModal.classList.remove('active'); alert('Personnalité mise à jour avec succès.'); } } catch (err) { alert('Erreur lors de la sauvegarde.'); } }); } // Clone Modal Handlers if (btnCloneUniverse && cloneModal) { btnCloneUniverse.addEventListener('click', () => { document.getElementById('clone-source-name').textContent = activeTitle.textContent; document.getElementById('clone-name').value = ''; cloneModal.classList.add('active'); }); } if (btnSubmitClone && cloneModal) { btnSubmitClone.addEventListener('click', async () => { const cloneName = document.getElementById('clone-name').value.trim(); if (!cloneName) return; try { const res = await fetch(`/api/clones`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ source_universe_id: currentUniverse, clone_name: cloneName }) }); if (res.ok) { cloneModal.classList.remove('active'); alert('Clone créé avec succès.'); } } catch (err) { alert('Erreur lors du clonage.'); } }); } // Close modals document.querySelectorAll('.hub-modal-close').forEach(btn => { btn.addEventListener('click', () => { if (personaModal) personaModal.classList.remove('active'); if (cloneModal) cloneModal.classList.remove('active'); }); }); // Universe Health Monitoring Probe async function probeUniverseStatuses() { try { const res = await fetch('/api/universes'); if (!res.ok) return; const data = await res.json(); data.forEach(u => { const dot = document.getElementById(`status-dot-${u.id}`); if (dot) { dot.className = `hub-status-dot ${u.status}`; dot.title = `Statut: ${u.status} (${u.status_code || 'N/A'})`; } }); } catch (e) {} } // Initialize probeUniverseStatuses(); setInterval(probeUniverseStatuses, 15000); switchUniverse('tt'); });