FastAPI HTTP server with auth, static files, and reverse proxy to brain/db services. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1113 lines
36 KiB
JavaScript
1113 lines
36 KiB
JavaScript
// 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 = '<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,
|
|
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 = '<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();
|