// 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'); // 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 }) }); 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

'; return; } for (var i = 0; i < data.results.length; i++) { var resultEl = createSearchResult(data.results[i]); searchResults.appendChild(resultEl); } } catch (err) { console.error('Search error:', err); searchResults.innerHTML = '

Search failed

'; } } function createSearchResult(result) { const div = document.createElement('div'); div.className = 'search-result bg-gray-800 rounded-lg p-4 cursor-pointer hover:bg-gray-750 transition-colors border border-gray-700'; const roleIcon = result.role === 'user' ? '👤' : '🤖'; const timeStr = formatTime(result.timestamp); // Use snippet if available, otherwise extract text from content var preview = result.snippet || ''; if (!preview || preview.indexOf('[{"type"') === 0) { var textContent = extractTextContent(result.content); preview = textContent.substring(0, 150); if (textContent.length > 150) preview += '...'; } div.innerHTML = '
' + roleIcon + '' + result.role + '•' + timeStr + '
' + escapeHtml(preview) + '
'; div.addEventListener('click', function() { closeSearch(); scrollToMessage(result.id); }); return div; } function scrollToMessage(messageId) { const msgEl = document.querySelector('[data-message-id="' + messageId + '"]'); if (msgEl) { msgEl.scrollIntoView({ behavior: 'smooth', block: 'center' }); msgEl.classList.add('search-highlight'); setTimeout(function() { msgEl.classList.remove('search-highlight'); }, 2000); } } // ===================== // Typing Indicator // ===================== const typingIndicator = document.getElementById('typing-indicator'); function showTyping() { if (typingIndicator) typingIndicator.classList.remove('hidden'); } function hideTyping() { if (typingIndicator) typingIndicator.classList.add('hidden'); } // ===================== // Context Menu (Desktop) & Swipe Menu (Mobile) // ===================== const contextMenu = document.getElementById('context-menu'); const ctxCopy = document.getElementById('ctx-copy'); const mobileMenu = document.getElementById('mobile-menu'); const mobileMenuPreview = document.getElementById('mobile-menu-preview'); const mobileCopy = document.getElementById('mobile-copy'); const mobileMenuClose = document.getElementById('mobile-menu-close'); let contextMessageText = ''; let isMobile = window.innerWidth <= 640; // Update on resize window.addEventListener('resize', function() { isMobile = window.innerWidth <= 640; }); // Desktop context menu function showContextMenu(x, y, messageText) { contextMessageText = messageText; contextMenu.style.left = Math.min(x, window.innerWidth - 150) + 'px'; contextMenu.style.top = Math.min(y, window.innerHeight - 50) + 'px'; contextMenu.classList.remove('hidden'); } function hideContextMenu() { contextMenu.classList.add('hidden'); } // Mobile full-screen menu function showMobileMenu(messageText) { contextMessageText = messageText; if (mobileMenuPreview) { var preview = messageText.substring(0, 100); if (messageText.length > 100) preview += '...'; mobileMenuPreview.textContent = preview; } if (mobileMenu) { mobileMenu.classList.remove('hidden'); document.body.style.overflow = 'hidden'; } } function hideMobileMenu() { if (mobileMenu) { mobileMenu.classList.add('hidden'); document.body.style.overflow = ''; } } // Desktop: right-click context menu document.addEventListener('click', hideContextMenu); document.addEventListener('contextmenu', function(e) { if (isMobile) return; // Skip on mobile const msgWrapper = e.target.closest('[data-message-id]'); if (msgWrapper) { e.preventDefault(); const contentEl = msgWrapper.querySelector('.message-content'); const textContent = (contentEl && contentEl.textContent) || ''; showContextMenu(e.clientX, e.clientY, textContent); } }); if (ctxCopy) ctxCopy.addEventListener('click', function() { if (contextMessageText) { navigator.clipboard.writeText(contextMessageText); showToast('Copied', 'success'); } hideContextMenu(); }); // Mobile: swipe gesture on messages var touchStartX = 0; var touchStartY = 0; var touchMsgWrapper = null; var swipeThreshold = 80; messagesEl.addEventListener('touchstart', function(e) { if (!isMobile) return; var touch = e.touches[0]; touchStartX = touch.clientX; touchStartY = touch.clientY; touchMsgWrapper = e.target.closest('[data-message-id]'); }, { passive: true }); messagesEl.addEventListener('touchend', function(e) { if (!isMobile || !touchMsgWrapper) return; var touch = e.changedTouches[0]; var deltaX = touch.clientX - touchStartX; var deltaY = Math.abs(touch.clientY - touchStartY); // Horizontal swipe (left or right) with minimal vertical movement if (Math.abs(deltaX) > swipeThreshold && deltaY < 50) { var contentEl = touchMsgWrapper.querySelector('.message-content'); var textContent = (contentEl && contentEl.textContent) || ''; if (textContent) { showMobileMenu(textContent); } } touchMsgWrapper = null; }, { passive: true }); // Mobile menu handlers if (mobileCopy) mobileCopy.addEventListener('click', function() { if (contextMessageText) { navigator.clipboard.writeText(contextMessageText); showToast('Copied', 'success'); } hideMobileMenu(); }); if (mobileMenuClose) mobileMenuClose.addEventListener('click', hideMobileMenu); if (mobileMenu) mobileMenu.addEventListener('click', function(e) { if (e.target === mobileMenu) hideMobileMenu(); }); // ===================== // Voice Recording (Chromium MediaRecorder) // ===================== const voiceBtn = document.getElementById('voice-btn'); const micIcon = document.getElementById('mic-icon'); const micPulse = document.getElementById('mic-pulse'); let mediaRecorder = null; let audioChunks = []; let recordingStream = null; let recordingStartTime = null; let recordingTimer = null; let isListening = false; let handsFreeMode = false; let synthesis = window.speechSynthesis; function startRecording() { navigator.mediaDevices.getUserMedia({ audio: true }) .then(function(stream) { recordingStream = stream; audioChunks = []; mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm' }); mediaRecorder.ondataavailable = function(e) { if (e.data.size > 0) { audioChunks.push(e.data); } }; mediaRecorder.onstop = async function() { if (recordingTimer) { clearInterval(recordingTimer); recordingTimer = null; } if (recordingStream) { recordingStream.getTracks().forEach(function(track) { track.stop(); }); recordingStream = null; } micPulse.classList.add('hidden'); micIcon.classList.remove('text-red-400'); micIcon.classList.add('text-gray-300'); if (audioChunks.length === 0) { showToast('No audio', 'error'); isListening = false; input.placeholder = 'Message...'; return; } input.placeholder = 'Transcribing...'; const audioBlob = new Blob(audioChunks, { type: 'audio/webm' }); console.log('Audio size:', audioBlob.size); const formData = new FormData(); formData.append('audio', audioBlob, 'recording.webm'); try { const res = await fetch('/api/transcribe', { method: 'POST', credentials: 'include', body: formData }); if (!res.ok) { const err = await res.json(); throw new Error(err.detail || 'Transcription failed'); } const data = await res.json(); if (data.text) { input.value = data.text; showToast('Done', 'success'); if (handsFreeMode) { setTimeout(function() { form.requestSubmit(); }, 300); } } else { showToast('No speech detected', 'info'); } } catch (e) { console.error('Transcription error:', e); showToast('Failed: ' + e.message, 'error'); } isListening = false; input.placeholder = 'Message...'; }; mediaRecorder.start(); isListening = true; micPulse.classList.remove('hidden'); micIcon.classList.add('text-red-400'); micIcon.classList.remove('text-gray-300'); recordingStartTime = Date.now(); recordingTimer = setInterval(function() { const elapsed = Math.floor((Date.now() - recordingStartTime) / 1000); input.placeholder = 'Recording ' + elapsed + 's...'; }, 1000); }) .catch(function(err) { console.error('Microphone error:', err); showToast('Mic access denied', 'error'); }); } function stopRecording() { if (mediaRecorder && mediaRecorder.state === 'recording') { mediaRecorder.stop(); } } function toggleVoice() { if (isListening) { stopRecording(); } else { startRecording(); } } if (voiceBtn) voiceBtn.addEventListener('click', toggleVoice); function speakText(text) { if (!synthesis) return; var cleanText = text .replace(/```[\s\S]*?```/g, 'code block') .replace(/`[^`]+`/g, '') .replace(/\*\*([^*]+)\*\*/g, '$1') .replace(/\*([^*]+)\*/g, '$1') .replace(/#{1,6}\s+/g, '') .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') .replace(/[-*]\s+/g, '') .replace(/\n+/g, '. ') .trim(); if (!cleanText) return; if (cleanText.length > 500) { cleanText = cleanText.substring(0, 500) + '...'; } var utterance = new SpeechSynthesisUtterance(cleanText); utterance.rate = 1.0; synthesis.speak(utterance); } // ===================== // Toast Notifications // ===================== const toastContainer = document.getElementById('toast-container'); function showToast(message, type, duration) { type = type || 'info'; duration = duration || 2000; const toast = document.createElement('div'); var bgClass = type === 'error' ? 'bg-red-600' : (type === 'success' ? 'bg-green-600' : 'bg-gray-700'); toast.className = 'toast px-4 py-2 rounded-lg text-sm pointer-events-auto ' + bgClass; toast.textContent = message; toastContainer.appendChild(toast); setTimeout(function() { toast.classList.add('toast-fade-out'); setTimeout(function() { toast.remove(); }, 200); }, duration); } // ===================== // Mobile Layout Fix // ===================== function updateMobileLayout() { if (window.innerWidth <= 640) { const header = document.querySelector('header'); const footer = document.querySelector('footer'); if (header && footer) { const headerHeight = header.offsetHeight; const footerHeight = footer.offsetHeight; // Set CSS custom properties document.documentElement.style.setProperty('--header-height', headerHeight + 'px'); document.documentElement.style.setProperty('--footer-height', footerHeight + 'px'); } } } // Update layout on load and resize window.addEventListener('load', updateMobileLayout); window.addEventListener('resize', updateMobileLayout); // Update layout when content changes (like textarea resize) const resizeObserver = new ResizeObserver(updateMobileLayout); resizeObserver.observe(document.querySelector('footer')); // ===================== // Init // ===================== loadHistory();