// Egregore - Mobile-First Chromium App const scrollContainer = document.getElementById('messages-scroll'); const messagesEl = document.getElementById('messages'); const form = document.getElementById('chat-form'); const input = document.getElementById('message-input'); const sendBtn = document.getElementById('send-btn'); const modelSelect = document.getElementById('model-select'); // State let isLoading = false; let hasMore = true; let oldestMessageId = null; let notificationsEnabled = false; // Notifications function initNotifications() { if (!('Notification' in window)) { console.log('Notifications not supported'); return; } // Check existing permission if (Notification.permission === 'granted') { notificationsEnabled = true; } } function requestNotificationPermission() { if (!('Notification' in window)) return Promise.resolve(false); if (Notification.permission === 'granted') { notificationsEnabled = true; return Promise.resolve(true); } if (Notification.permission === 'denied') { return Promise.resolve(false); } return Notification.requestPermission().then(function(permission) { notificationsEnabled = (permission === 'granted'); return notificationsEnabled; }); } function showNotification(title, body) { if (!notificationsEnabled) return; if (document.visibilityState === 'visible') return; // Don't notify if app is visible var options = { body: body.substring(0, 100) + (body.length > 100 ? '...' : ''), icon: '/static/icon.svg', badge: '/static/icon.svg', tag: 'egregore-response', renotify: true, requireInteraction: false }; try { var notification = new Notification(title, options); notification.onclick = function() { window.focus(); notification.close(); }; // Auto-close after 5 seconds setTimeout(function() { notification.close(); }, 5000); } catch (e) { console.log('Notification failed:', e); } } initNotifications(); // Notification button handler var notifyBtn = document.getElementById('notify-btn'); var notifyIcon = document.getElementById('notify-icon'); function updateNotifyIcon() { if (notifyIcon) { if (notificationsEnabled) { notifyIcon.classList.remove('text-gray-500'); notifyIcon.classList.add('text-purple-400'); } else { notifyIcon.classList.remove('text-purple-400'); notifyIcon.classList.add('text-gray-500'); } } } if (notifyBtn) { notifyBtn.addEventListener('click', function() { if (notificationsEnabled) { // Toggle off notificationsEnabled = false; updateNotifyIcon(); showToast('Notifications off', 'info'); } else { // Request permission requestNotificationPermission().then(function(granted) { updateNotifyIcon(); if (granted) { showToast('Notifications on', 'success'); } else { showToast('Notifications blocked', 'error'); } }); } }); } // Update icon on load updateNotifyIcon(); // Configure marked marked.setOptions({ highlight: function(code, lang) { if (lang && hljs.getLanguage(lang)) { return hljs.highlight(code, { language: lang }).value; } return hljs.highlightAuto(code).value; }, breaks: true, gfm: true }); // Auto-resize textarea input.addEventListener('input', () => { input.style.height = 'auto'; input.style.height = Math.min(input.scrollHeight, 200) + 'px'; }); // Handle Enter key input.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); form.requestSubmit(); } }); // Infinite scroll - load older messages when near top const loadMoreObserver = new IntersectionObserver((entries) => { if (entries[0].isIntersecting && hasMore && !isLoading) { loadOlderMessages(); } }, { threshold: 0.1 }); // Create sentinel element for infinite scroll const sentinel = document.createElement('div'); sentinel.id = 'load-sentinel'; sentinel.className = 'h-8 flex items-center justify-center'; // Load initial history async function loadHistory() { isLoading = true; try { const res = await fetch('/api/history?limit=50', { credentials: 'include' }); const data = await res.json(); if (data.messages && data.messages.length > 0) { hasMore = data.has_more; oldestMessageId = data.messages[0].id; messagesEl.prepend(sentinel); if (hasMore) { sentinel.innerHTML = '
'; loadMoreObserver.observe(sentinel); } // Group messages by group_id for rendering renderMessageGroups(data.messages, messagesEl); } } catch (err) { console.error('Failed to load history:', err); } isLoading = false; } // Group messages by group_id and render them together function renderMessageGroups(messages, container) { let currentGroup = null; let currentGroupMessages = []; for (var i = 0; i < messages.length; i++) { var msg = messages[i]; if (msg.role === 'user') { // Flush any pending assistant group if (currentGroupMessages.length > 0) { container.appendChild(createAssistantGroup(currentGroupMessages)); currentGroupMessages = []; currentGroup = null; } // Render user message container.appendChild(createMessageElement(msg)); } else if (msg.role === 'assistant') { // Group assistant messages by group_id if (msg.group_id !== currentGroup) { // Flush previous group if (currentGroupMessages.length > 0) { container.appendChild(createAssistantGroup(currentGroupMessages)); } currentGroup = msg.group_id; currentGroupMessages = [msg]; } else { currentGroupMessages.push(msg); } } } // Flush final group if (currentGroupMessages.length > 0) { container.appendChild(createAssistantGroup(currentGroupMessages)); } } // Create an assistant message group container function createAssistantGroup(messages) { if (messages.length === 0) return document.createDocumentFragment(); const wrapper = document.createElement('div'); wrapper.className = 'message-wrapper message-wrapper-assistant'; wrapper.dataset.groupId = messages[0].group_id || ''; wrapper.dataset.messageId = messages[0].id; wrapper.dataset.timestamp = messages[0].timestamp; const container = document.createElement('div'); container.className = 'message message-assistant px-4 py-3 space-y-3'; for (var i = 0; i < messages.length; i++) { var msg = messages[i]; var el = createBlockElement(msg); if (el) container.appendChild(el); } const timeDiv = document.createElement('div'); timeDiv.className = 'text-xs text-gray-500 mt-1'; timeDiv.textContent = formatTime(messages[0].timestamp); wrapper.appendChild(container); wrapper.appendChild(timeDiv); return wrapper; } // Create element for a single message/block based on type function createBlockElement(msg) { switch (msg.type) { case 'text': return createTextBlock(msg.content); case 'tool_use': return createToolUseBlock(msg); case 'tool_result': return createToolResultBlock(msg); case 'question': return createQuestionBlock(msg.content); case 'error': return createErrorBlock(msg.content); case 'thinking': return createThinkingBlock(msg.content); case 'mode_change': return createModeChangeBlock(msg.content); default: return createTextBlock(msg.content); } } function createTextBlock(content) { const div = document.createElement('div'); div.className = 'message-content'; div.innerHTML = marked.parse(content || ''); highlightCodeBlocks(div); return div; } function createToolUseBlock(msg) { const div = document.createElement('div'); div.className = 'tool-block bg-gray-900 rounded-lg p-3 border border-purple-900'; const metadata = msg.metadata || {}; const toolName = metadata.tool_name || 'tool'; let toolInput = {}; try { toolInput = JSON.parse(msg.content); } catch (e) { toolInput = msg.content; } const header = document.createElement('div'); header.className = 'flex items-center gap-2 text-sm text-purple-400 mb-2'; header.innerHTML = '' + escapeHtml(toolName) + ''; const inputPre = document.createElement('pre'); inputPre.className = 'text-xs bg-black/30 rounded p-2 overflow-x-auto text-gray-300'; inputPre.textContent = typeof toolInput === 'string' ? toolInput : JSON.stringify(toolInput, null, 2); div.appendChild(header); div.appendChild(inputPre); return div; } function createToolResultBlock(msg) { const div = document.createElement('div'); div.className = 'tool-result bg-gray-950 rounded-lg p-3 border border-gray-800 ml-4'; const metadata = msg.metadata || {}; const toolName = metadata.tool_name || 'tool'; const header = document.createElement('div'); header.className = 'flex items-center justify-between text-xs text-gray-500 mb-2'; const labelSpan = document.createElement('span'); labelSpan.textContent = 'Result from ' + toolName; const copyBtn = document.createElement('button'); copyBtn.className = 'copy-result-btn hover:text-gray-300'; copyBtn.title = 'Copy'; copyBtn.innerHTML = ''; header.appendChild(labelSpan); header.appendChild(copyBtn); const output = document.createElement('pre'); output.className = 'terminal-output text-xs bg-black/50 rounded p-2 overflow-x-auto max-h-64 overflow-y-auto'; output.textContent = msg.content; copyBtn.addEventListener('click', function() { navigator.clipboard.writeText(msg.content); showToast('Copied', 'success'); }); div.appendChild(header); div.appendChild(output); return div; } function createQuestionBlock(content) { const div = document.createElement('div'); div.className = 'message-content question-block border-l-4 border-yellow-500 pl-3'; div.innerHTML = marked.parse(content || ''); highlightCodeBlocks(div); return div; } function createErrorBlock(content) { const div = document.createElement('div'); div.className = 'message-content error-block text-red-400 border-l-4 border-red-500 pl-3'; div.textContent = content || 'An error occurred'; return div; } function createThinkingBlock(content) { const details = document.createElement('details'); details.className = 'thinking-block text-gray-500 text-sm'; const summary = document.createElement('summary'); summary.className = 'cursor-pointer hover:text-gray-400'; summary.textContent = 'Thinking...'; const contentDiv = document.createElement('div'); contentDiv.className = 'mt-2 pl-4 border-l border-gray-700'; contentDiv.innerHTML = marked.parse(content || ''); details.appendChild(summary); details.appendChild(contentDiv); return details; } function createModeChangeBlock(content) { const div = document.createElement('div'); div.className = 'mode-change-block text-xs text-gray-600 italic text-center py-1'; div.textContent = content || ''; return div; } // Load older messages async function loadOlderMessages() { if (!hasMore || isLoading || !oldestMessageId) return; isLoading = true; sentinel.innerHTML = ''; try { const res = await fetch('/api/history?before=' + oldestMessageId + '&limit=30', { credentials: 'include' }); const data = await res.json(); if (data.messages && data.messages.length > 0) { hasMore = data.has_more; oldestMessageId = data.messages[0].id; const fragment = document.createDocumentFragment(); renderMessageGroups(data.messages, fragment); sentinel.after(fragment); } if (!hasMore) { loadMoreObserver.unobserve(sentinel); sentinel.innerHTML = 'Beginning of conversation'; } } catch (err) { console.error('Failed to load older messages:', err); sentinel.innerHTML = 'Failed to load'; } isLoading = false; } // Format timestamp function formatTime(timestamp) { const date = new Date(timestamp); const now = new Date(); const yesterday = new Date(now); yesterday.setDate(yesterday.getDate() - 1); const time = date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }); if (date.toDateString() === now.toDateString()) { return time; } else if (date.toDateString() === yesterday.toDateString()) { return 'Yesterday, ' + time; } else if (date.getFullYear() === now.getFullYear()) { const dateStr = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); return dateStr + ', ' + time; } else { const dateStr = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); return dateStr + ', ' + time; } } // Extract text content from old JSON format or plain text function extractTextContent(content) { if (!content) return ''; // Try parsing as JSON (old format) try { var blocks = JSON.parse(content); if (Array.isArray(blocks)) { var text = ''; for (var i = 0; i < blocks.length; i++) { if (blocks[i].type === 'text' && blocks[i].content) { text += blocks[i].content; } } return text || content; } } catch (e) { // Not JSON, use as-is } return content; } // Create message element for a single message row (new v2 format) function createMessageElement(msg) { const wrapper = document.createElement('div'); wrapper.dataset.messageId = msg.id; wrapper.dataset.timestamp = msg.timestamp; if (msg.group_id) wrapper.dataset.groupId = msg.group_id; if (msg.role === 'user') { wrapper.className = 'message-wrapper message-wrapper-user flex flex-col items-end'; const div = document.createElement('div'); div.className = 'message message-user px-4 py-3'; const contentDiv = document.createElement('div'); contentDiv.className = 'message-content'; contentDiv.textContent = msg.content; div.appendChild(contentDiv); const timeDiv = document.createElement('div'); timeDiv.className = 'text-xs text-gray-500 mt-1 text-right'; timeDiv.textContent = formatTime(msg.timestamp); wrapper.appendChild(div); wrapper.appendChild(timeDiv); } // Assistant messages are grouped and rendered via createAssistantGroup return wrapper; } function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } function scrollToBottom() { scrollContainer.scrollTop = 0; } function addUserMessage(message) { const wrapper = document.createElement('div'); wrapper.className = 'message-wrapper message-wrapper-user flex flex-col items-end'; const div = document.createElement('div'); div.className = 'message message-user px-4 py-3'; const contentDiv = document.createElement('div'); contentDiv.className = 'message-content'; contentDiv.textContent = message; div.appendChild(contentDiv); const timeDiv = document.createElement('div'); timeDiv.className = 'text-xs text-gray-500 mt-1'; timeDiv.textContent = formatTime(new Date().toISOString()); wrapper.appendChild(div); wrapper.appendChild(timeDiv); messagesEl.appendChild(wrapper); } function addAssistantMessage(message) { const wrapper = document.createElement('div'); wrapper.className = 'message-wrapper message-wrapper-assistant'; const div = document.createElement('div'); div.className = 'message message-assistant px-4 py-3'; const contentDiv = document.createElement('div'); contentDiv.className = 'message-content'; contentDiv.innerHTML = marked.parse(message); // Highlight code blocks var codeBlocks = contentDiv.querySelectorAll('pre code'); for (var i = 0; i < codeBlocks.length; i++) { hljs.highlightElement(codeBlocks[i]); } div.appendChild(contentDiv); const timeDiv = document.createElement('div'); timeDiv.className = 'text-xs text-gray-500 mt-1'; timeDiv.textContent = formatTime(new Date().toISOString()); wrapper.appendChild(div); wrapper.appendChild(timeDiv); messagesEl.appendChild(wrapper); } // Add assistant message with tool blocks (legacy fallback) function addAssistantBlocks(blocks) { // Convert legacy block format to new message format var messages = blocks.map(function(block, idx) { return { id: 'legacy_' + idx, role: 'assistant', type: block.type, content: block.type === 'text' ? block.content : block.type === 'tool_use' ? JSON.stringify(block.input) : block.content, metadata: block.type === 'tool_use' ? { tool_name: block.name, tool_id: block.id } : block.type === 'tool_result' ? { tool_name: block.tool_name, tool_use_id: block.tool_use_id } : null, timestamp: new Date().toISOString() }; }); var wrapper = createAssistantGroup(messages); messagesEl.appendChild(wrapper); } function highlightCodeBlocks(element) { var codeBlocks = element.querySelectorAll('pre code'); for (var i = 0; i < codeBlocks.length; i++) { hljs.highlightElement(codeBlocks[i]); } } // Send message form.addEventListener('submit', async function(e) { e.preventDefault(); const message = input.value.trim(); if (!message) return; // Request notification permission on first interaction if (!notificationsEnabled && Notification.permission === 'default') { requestNotificationPermission(); } input.value = ''; input.style.height = 'auto'; addUserMessage(message); scrollToBottom(); sendBtn.disabled = true; input.disabled = true; showTyping(); try { const res = await fetch('/api/chat', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: message, model: modelSelect.value }) }); hideTyping(); if (!res.ok) { var errData = await res.json(); addAssistantMessage('Error: ' + (errData.detail || 'Request failed')); } else { var data = await res.json(); // Handle new messages array format if (data.messages && Array.isArray(data.messages)) { var wrapper = createAssistantGroup(data.messages); messagesEl.appendChild(wrapper); // Show notification based on priority (>= 2 triggers notification) if (data.max_priority >= 2 && data.text) { var title = data.max_priority >= 3 ? 'Egregore (Question)' : 'Egregore'; showNotification(title, data.text); } if (handsFreeMode && data.text) { speakText(data.text); } } else if (Array.isArray(data.response)) { // Legacy block format fallback addAssistantBlocks(data.response); var notifyText = data.text || ''; showNotification('Egregore', notifyText); if (handsFreeMode && notifyText) { speakText(notifyText); } } else { addAssistantMessage(data.response || 'No response'); } } } catch (err) { console.error('Chat error:', err); hideTyping(); addAssistantMessage('Error: ' + err.message); } scrollToBottom(); sendBtn.disabled = false; input.disabled = false; input.focus(); }); // Register service worker if ('serviceWorker' in navigator) { navigator.serviceWorker.register('/static/sw.js') .then(function(reg) { console.log('SW registered'); }) .catch(function(err) { console.log('SW failed:', err); }); } // ===================== // Search // ===================== const searchModal = document.getElementById('search-modal'); const searchInput = document.getElementById('search-input'); const searchResults = document.getElementById('search-results'); const searchEmpty = document.getElementById('search-empty'); const searchBtn = document.getElementById('search-btn'); const searchClose = document.getElementById('search-close'); let searchDebounce = null; function openSearch() { searchModal.classList.remove('hidden'); searchInput.focus(); document.body.style.overflow = 'hidden'; } function closeSearch() { searchModal.classList.add('hidden'); searchInput.value = ''; searchResults.innerHTML = ''; searchResults.appendChild(searchEmpty); searchEmpty.classList.remove('hidden'); document.body.style.overflow = ''; } if (searchBtn) searchBtn.addEventListener('click', openSearch); document.addEventListener('keydown', function(e) { if ((e.metaKey || e.ctrlKey) && e.key === 'k') { e.preventDefault(); if (searchModal.classList.contains('hidden')) { openSearch(); } else { closeSearch(); } } if (e.key === 'Escape' && !searchModal.classList.contains('hidden')) { closeSearch(); } }); if (searchClose) searchClose.addEventListener('click', closeSearch); if (searchModal) searchModal.addEventListener('click', function(e) { if (e.target === searchModal) closeSearch(); }); if (searchInput) searchInput.addEventListener('input', function() { clearTimeout(searchDebounce); searchDebounce = setTimeout(performSearch, 300); }); async function performSearch() { const query = searchInput.value.trim(); if (query.length < 2) { searchResults.innerHTML = ''; searchResults.appendChild(searchEmpty); searchEmpty.classList.remove('hidden'); return; } try { const res = await fetch('/api/search?q=' + encodeURIComponent(query), { credentials: 'include' }); const data = await res.json(); searchResults.innerHTML = ''; if (data.results.length === 0) { searchResults.innerHTML = 'No results found
Search failed