converse/static/app.js

1112 lines
36 KiB
JavaScript
Raw Permalink Normal View History

// 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 = '<div class="loading-spinner"></div>';
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 = '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg><span class="font-medium">' + escapeHtml(toolName) + '</span>';
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 = '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>';
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 = '<div class="loading-spinner"></div>';
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 = '<span class="text-gray-600 text-xs">Beginning of conversation</span>';
}
} catch (err) {
console.error('Failed to load older messages:', err);
sentinel.innerHTML = '<span class="text-red-500 text-xs">Failed to load</span>';
}
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 = '<div class="text-center text-gray-500 mt-8"><p>No results found</p></div>';
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 = '<div class="text-center text-red-500 mt-8"><p>Search failed</p></div>';
}
}
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 = '<div class="flex items-center gap-2 text-sm text-gray-400 mb-2"><span>' + roleIcon + '</span><span>' + result.role + '</span><span class="text-gray-600">•</span><span>' + timeStr + '</span></div><div class="text-gray-200 text-sm search-preview">' + escapeHtml(preview) + '</div>';
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();