From ff03fc7f43472c67c403d6f21d8e114f0cff7bf4 Mon Sep 17 00:00:00 2001 From: egregore Date: Mon, 2 Feb 2026 11:37:41 +0000 Subject: [PATCH] Initial commit: Egregore web service FastAPI HTTP server with auth, static files, and reverse proxy to brain/db services. Co-Authored-By: Claude Opus 4.5 --- .gitignore | 43 ++ __init__.py | 9 + main.py | 234 +++++++++ static/app.js | 1113 ++++++++++++++++++++++++++++++++++++++++++ static/icon.svg | 10 + static/index.html | 145 ++++++ static/manifest.json | 18 + static/style.css | 610 +++++++++++++++++++++++ static/sw.js | 62 +++ 9 files changed, 2244 insertions(+) create mode 100644 .gitignore create mode 100644 __init__.py create mode 100644 main.py create mode 100644 static/app.js create mode 100644 static/icon.svg create mode 100644 static/index.html create mode 100644 static/manifest.json create mode 100644 static/style.css create mode 100644 static/sw.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..be46f05 --- /dev/null +++ b/.gitignore @@ -0,0 +1,43 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +*.egg-info/ +.eggs/ +dist/ +build/ + +# Environment +.env +.env.* +*.local + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# Secrets - BE PARANOID +*.pem +*.key +*.crt +*.p12 +credentials* +secrets* +tokens* +*_secret* +*_token* +*.credentials + +# Logs and data +*.log +*.db +*.sqlite +*.backup + +# OS +.DS_Store +Thumbs.db diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..76cedae --- /dev/null +++ b/__init__.py @@ -0,0 +1,9 @@ +""" +Egregore Web - FastAPI chat interface + +This module handles: +- HTTP routing and authentication +- Serving the static frontend +- API endpoints for chat, history, search +- Audio transcription +""" diff --git a/main.py b/main.py new file mode 100644 index 0000000..ae4b26b --- /dev/null +++ b/main.py @@ -0,0 +1,234 @@ +#!/usr/bin/env python3 +""" +Egregore Web Service - HTTP frontend + +Serves the chat UI and proxies requests to brain and db services. +Runs on port 8080. +""" + +import os +import secrets +import uuid +import tempfile + +from fastapi import FastAPI, HTTPException, Depends, UploadFile, File, Query +from fastapi.responses import HTMLResponse +from fastapi.staticfiles import StaticFiles +from fastapi.security import HTTPBasic, HTTPBasicCredentials +from pydantic import BaseModel +from dotenv import load_dotenv +import httpx +import openai +import aiofiles + +# Load environment +load_dotenv("/home/admin/.env") + +OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") +CHAT_USERNAME = os.getenv("CHAT_USERNAME", "admin") +CHAT_PASSWORD = os.getenv("CHAT_PASSWORD", "changeme") + +# Service URLs +BRAIN_URL = os.getenv("BRAIN_URL", "http://127.0.0.1:8081") +DB_URL = os.getenv("DB_URL", "http://127.0.0.1:8082") + +app = FastAPI(title="Egregore", docs_url=None, redoc_url=None) +security = HTTPBasic() + +# Static files path +STATIC_PATH = "/home/admin/services/web/static" + +# HTTP client for internal services +http_client = httpx.AsyncClient(timeout=120.0) + + +# Auth dependency +def verify_credentials(credentials: HTTPBasicCredentials = Depends(security)): + correct_username = secrets.compare_digest(credentials.username, CHAT_USERNAME) + correct_password = secrets.compare_digest(credentials.password, CHAT_PASSWORD) + if not (correct_username and correct_password): + raise HTTPException( + status_code=401, + detail="Invalid credentials", + headers={"WWW-Authenticate": "Basic"}, + ) + return credentials.username + + +# Models +class ChatMessage(BaseModel): + message: str + model: str = "claude-sonnet-4-20250514" + + +# Routes +@app.get("/", response_class=HTMLResponse) +async def root(username: str = Depends(verify_credentials)): + """Serve the chat interface""" + async with aiofiles.open(f"{STATIC_PATH}/index.html") as f: + return await f.read() + + +@app.post("/api/chat") +async def chat(msg: ChatMessage, username: str = Depends(verify_credentials)): + """Send a message and get a response""" + group_id = str(uuid.uuid4()) + + # Save user message to db service + await http_client.post(f"{DB_URL}/messages", json={ + "role": "user", + "content": msg.message, + "msg_type": "text", + "group_id": group_id + }) + + # Get conversation history from db service + history_resp = await http_client.get(f"{DB_URL}/messages/history") + history_data = history_resp.json() + history = history_data.get("history", []) + + # Process conversation with brain service + try: + brain_resp = await http_client.post(f"{BRAIN_URL}/process", json={ + "model": msg.model, + "history": history, + "max_iterations": 10 + }) + + if brain_resp.status_code != 200: + error_detail = brain_resp.json().get("detail", "Brain service error") + raise HTTPException(status_code=500, detail=error_detail) + + brain_data = brain_resp.json() + response_blocks = brain_data.get("blocks", []) + + except httpx.RequestError as e: + raise HTTPException(status_code=503, detail=f"Brain service unavailable: {e}") + + # Save response blocks to db service + save_resp = await http_client.post(f"{DB_URL}/messages/blocks", json={ + "blocks": response_blocks, + "group_id": group_id + }) + save_data = save_resp.json() + saved_messages = save_data.get("messages", []) + + # Calculate max priority for notification decision + max_priority = max((m["priority"] for m in saved_messages), default=0) + + # Extract final text for notifications/TTS + final_text = " ".join( + m["content"] for m in saved_messages + if m["type"] == "text" and m["content"] + ) + + return { + "messages": saved_messages, + "max_priority": max_priority, + "text": final_text, + "group_id": group_id + } + + +@app.get("/api/history") +async def history( + before: int = None, + limit: int = 50, + msg_type: str = Query(None, alias="type"), + username: str = Depends(verify_credentials) +): + """Get chat history with pagination""" + params = {"limit": min(limit, 100)} + if before: + params["before"] = before + if msg_type: + params["type"] = msg_type + + resp = await http_client.get(f"{DB_URL}/messages", params=params) + return resp.json() + + +@app.post("/api/transcribe") +async def transcribe_audio( + audio: UploadFile = File(...), + username: str = Depends(verify_credentials) +): + """Transcribe audio using OpenAI Whisper API""" + if not OPENAI_API_KEY: + raise HTTPException(status_code=500, detail="OpenAI API key not configured") + + try: + audio_data = await audio.read() + openai_client = openai.OpenAI(api_key=OPENAI_API_KEY) + + with tempfile.NamedTemporaryFile(suffix=".webm", delete=False) as tmp: + tmp.write(audio_data) + tmp_path = tmp.name + + try: + with open(tmp_path, "rb") as audio_file: + transcript = openai_client.audio.transcriptions.create( + model="whisper-1", + file=audio_file, + language="en", + response_format="text" + ) + return {"text": transcript.strip(), "success": True} + finally: + os.unlink(tmp_path) + + except openai.APIError as e: + raise HTTPException(status_code=500, detail=f"Transcription failed: {str(e)}") + + +@app.get("/api/search") +async def search( + q: str, + limit: int = 20, + msg_type: str = Query(None, alias="type"), + username: str = Depends(verify_credentials) +): + """Search messages by content""" + params = {"q": q, "limit": limit} + if msg_type: + params["type"] = msg_type + + resp = await http_client.get(f"{DB_URL}/messages/search", params=params) + return resp.json() + + +@app.get("/health") +async def health(): + """Health check with dependency status""" + brain_ok = False + db_ok = False + + try: + resp = await http_client.get(f"{BRAIN_URL}/health", timeout=2.0) + brain_ok = resp.status_code == 200 + except: + pass + + try: + resp = await http_client.get(f"{DB_URL}/health", timeout=2.0) + db_ok = resp.status_code == 200 + except: + pass + + return { + "status": "ok" if (brain_ok and db_ok) else "degraded", + "service": "web", + "dependencies": { + "brain": "ok" if brain_ok else "unavailable", + "db": "ok" if db_ok else "unavailable" + } + } + + +# Mount static files +app.mount("/static", StaticFiles(directory=STATIC_PATH), name="static") + + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="127.0.0.1", port=8080) diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..e90b3fc --- /dev/null +++ b/static/app.js @@ -0,0 +1,1113 @@ +// Egregore - Mobile-First Chromium App + +const scrollContainer = document.getElementById('messages-scroll'); +const messagesEl = document.getElementById('messages'); +const form = document.getElementById('chat-form'); +const input = document.getElementById('message-input'); +const sendBtn = document.getElementById('send-btn'); +const modelSelect = document.getElementById('model-select'); + +// State +let isLoading = false; +let hasMore = true; +let oldestMessageId = null; +let notificationsEnabled = false; + +// Notifications +function initNotifications() { + if (!('Notification' in window)) { + console.log('Notifications not supported'); + return; + } + + // Check existing permission + if (Notification.permission === 'granted') { + notificationsEnabled = true; + } +} + +function requestNotificationPermission() { + if (!('Notification' in window)) return Promise.resolve(false); + + if (Notification.permission === 'granted') { + notificationsEnabled = true; + return Promise.resolve(true); + } + + if (Notification.permission === 'denied') { + return Promise.resolve(false); + } + + return Notification.requestPermission().then(function(permission) { + notificationsEnabled = (permission === 'granted'); + return notificationsEnabled; + }); +} + +function showNotification(title, body) { + if (!notificationsEnabled) return; + if (document.visibilityState === 'visible') return; // Don't notify if app is visible + + var options = { + body: body.substring(0, 100) + (body.length > 100 ? '...' : ''), + icon: '/static/icon.svg', + badge: '/static/icon.svg', + tag: 'egregore-response', + renotify: true, + requireInteraction: false + }; + + try { + var notification = new Notification(title, options); + notification.onclick = function() { + window.focus(); + notification.close(); + }; + // Auto-close after 5 seconds + setTimeout(function() { notification.close(); }, 5000); + } catch (e) { + console.log('Notification failed:', e); + } +} + +initNotifications(); + +// Notification button handler +var notifyBtn = document.getElementById('notify-btn'); +var notifyIcon = document.getElementById('notify-icon'); + +function updateNotifyIcon() { + if (notifyIcon) { + if (notificationsEnabled) { + notifyIcon.classList.remove('text-gray-500'); + notifyIcon.classList.add('text-purple-400'); + } else { + notifyIcon.classList.remove('text-purple-400'); + notifyIcon.classList.add('text-gray-500'); + } + } +} + +if (notifyBtn) { + notifyBtn.addEventListener('click', function() { + if (notificationsEnabled) { + // Toggle off + notificationsEnabled = false; + updateNotifyIcon(); + showToast('Notifications off', 'info'); + } else { + // Request permission + requestNotificationPermission().then(function(granted) { + updateNotifyIcon(); + if (granted) { + showToast('Notifications on', 'success'); + } else { + showToast('Notifications blocked', 'error'); + } + }); + } + }); +} + +// Update icon on load +updateNotifyIcon(); + +// Configure marked +marked.setOptions({ + highlight: function(code, lang) { + if (lang && hljs.getLanguage(lang)) { + return hljs.highlight(code, { language: lang }).value; + } + return hljs.highlightAuto(code).value; + }, + breaks: true, + gfm: true +}); + +// Auto-resize textarea +input.addEventListener('input', () => { + input.style.height = 'auto'; + input.style.height = Math.min(input.scrollHeight, 200) + 'px'; +}); + +// Handle Enter key +input.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + form.requestSubmit(); + } +}); + +// Infinite scroll - load older messages when near top +const loadMoreObserver = new IntersectionObserver((entries) => { + if (entries[0].isIntersecting && hasMore && !isLoading) { + loadOlderMessages(); + } +}, { threshold: 0.1 }); + +// Create sentinel element for infinite scroll +const sentinel = document.createElement('div'); +sentinel.id = 'load-sentinel'; +sentinel.className = 'h-8 flex items-center justify-center'; + +// Load initial history +async function loadHistory() { + isLoading = true; + try { + const res = await fetch('/api/history?limit=50', { credentials: 'include' }); + const data = await res.json(); + + if (data.messages && data.messages.length > 0) { + hasMore = data.has_more; + oldestMessageId = data.messages[0].id; + + messagesEl.prepend(sentinel); + if (hasMore) { + sentinel.innerHTML = '
'; + loadMoreObserver.observe(sentinel); + } + + // Group messages by group_id for rendering + renderMessageGroups(data.messages, messagesEl); + } + } catch (err) { + console.error('Failed to load history:', err); + } + isLoading = false; +} + +// Group messages by group_id and render them together +function renderMessageGroups(messages, container) { + let currentGroup = null; + let currentGroupMessages = []; + + for (var i = 0; i < messages.length; i++) { + var msg = messages[i]; + + if (msg.role === 'user') { + // Flush any pending assistant group + if (currentGroupMessages.length > 0) { + container.appendChild(createAssistantGroup(currentGroupMessages)); + currentGroupMessages = []; + currentGroup = null; + } + // Render user message + container.appendChild(createMessageElement(msg)); + } else if (msg.role === 'assistant') { + // Group assistant messages by group_id + if (msg.group_id !== currentGroup) { + // Flush previous group + if (currentGroupMessages.length > 0) { + container.appendChild(createAssistantGroup(currentGroupMessages)); + } + currentGroup = msg.group_id; + currentGroupMessages = [msg]; + } else { + currentGroupMessages.push(msg); + } + } + } + + // Flush final group + if (currentGroupMessages.length > 0) { + container.appendChild(createAssistantGroup(currentGroupMessages)); + } +} + +// Create an assistant message group container +function createAssistantGroup(messages) { + if (messages.length === 0) return document.createDocumentFragment(); + + const wrapper = document.createElement('div'); + wrapper.className = 'message-wrapper message-wrapper-assistant'; + wrapper.dataset.groupId = messages[0].group_id || ''; + wrapper.dataset.messageId = messages[0].id; + wrapper.dataset.timestamp = messages[0].timestamp; + + const container = document.createElement('div'); + container.className = 'message message-assistant px-4 py-3 space-y-3'; + + for (var i = 0; i < messages.length; i++) { + var msg = messages[i]; + var el = createBlockElement(msg); + if (el) container.appendChild(el); + } + + const timeDiv = document.createElement('div'); + timeDiv.className = 'text-xs text-gray-500 mt-1'; + timeDiv.textContent = formatTime(messages[0].timestamp); + + wrapper.appendChild(container); + wrapper.appendChild(timeDiv); + return wrapper; +} + +// Create element for a single message/block based on type +function createBlockElement(msg) { + switch (msg.type) { + case 'text': + return createTextBlock(msg.content); + case 'tool_use': + return createToolUseBlock(msg); + case 'tool_result': + return createToolResultBlock(msg); + case 'question': + return createQuestionBlock(msg.content); + case 'error': + return createErrorBlock(msg.content); + case 'thinking': + return createThinkingBlock(msg.content); + case 'mode_change': + return createModeChangeBlock(msg.content); + default: + return createTextBlock(msg.content); + } +} + +function createTextBlock(content) { + const div = document.createElement('div'); + div.className = 'message-content'; + div.innerHTML = marked.parse(content || ''); + highlightCodeBlocks(div); + return div; +} + +function createToolUseBlock(msg) { + const div = document.createElement('div'); + div.className = 'tool-block bg-gray-900 rounded-lg p-3 border border-purple-900'; + + const metadata = msg.metadata || {}; + const toolName = metadata.tool_name || 'tool'; + let toolInput = {}; + try { + toolInput = JSON.parse(msg.content); + } catch (e) { + toolInput = msg.content; + } + + const header = document.createElement('div'); + header.className = 'flex items-center gap-2 text-sm text-purple-400 mb-2'; + header.innerHTML = '' + escapeHtml(toolName) + ''; + + const inputPre = document.createElement('pre'); + inputPre.className = 'text-xs bg-black/30 rounded p-2 overflow-x-auto text-gray-300'; + inputPre.textContent = typeof toolInput === 'string' ? toolInput : JSON.stringify(toolInput, null, 2); + + div.appendChild(header); + div.appendChild(inputPre); + return div; +} + +function createToolResultBlock(msg) { + const div = document.createElement('div'); + div.className = 'tool-result bg-gray-950 rounded-lg p-3 border border-gray-800 ml-4'; + + const metadata = msg.metadata || {}; + const toolName = metadata.tool_name || 'tool'; + + const header = document.createElement('div'); + header.className = 'flex items-center justify-between text-xs text-gray-500 mb-2'; + + const labelSpan = document.createElement('span'); + labelSpan.textContent = 'Result from ' + toolName; + + const copyBtn = document.createElement('button'); + copyBtn.className = 'copy-result-btn hover:text-gray-300'; + copyBtn.title = 'Copy'; + copyBtn.innerHTML = ''; + + header.appendChild(labelSpan); + header.appendChild(copyBtn); + + const output = document.createElement('pre'); + output.className = 'terminal-output text-xs bg-black/50 rounded p-2 overflow-x-auto max-h-64 overflow-y-auto'; + output.textContent = msg.content; + + copyBtn.addEventListener('click', function() { + navigator.clipboard.writeText(msg.content); + showToast('Copied', 'success'); + }); + + div.appendChild(header); + div.appendChild(output); + return div; +} + +function createQuestionBlock(content) { + const div = document.createElement('div'); + div.className = 'message-content question-block border-l-4 border-yellow-500 pl-3'; + div.innerHTML = marked.parse(content || ''); + highlightCodeBlocks(div); + return div; +} + +function createErrorBlock(content) { + const div = document.createElement('div'); + div.className = 'message-content error-block text-red-400 border-l-4 border-red-500 pl-3'; + div.textContent = content || 'An error occurred'; + return div; +} + +function createThinkingBlock(content) { + const details = document.createElement('details'); + details.className = 'thinking-block text-gray-500 text-sm'; + + const summary = document.createElement('summary'); + summary.className = 'cursor-pointer hover:text-gray-400'; + summary.textContent = 'Thinking...'; + + const contentDiv = document.createElement('div'); + contentDiv.className = 'mt-2 pl-4 border-l border-gray-700'; + contentDiv.innerHTML = marked.parse(content || ''); + + details.appendChild(summary); + details.appendChild(contentDiv); + return details; +} + +function createModeChangeBlock(content) { + const div = document.createElement('div'); + div.className = 'mode-change-block text-xs text-gray-600 italic text-center py-1'; + div.textContent = content || ''; + return div; +} + +// Load older messages +async function loadOlderMessages() { + if (!hasMore || isLoading || !oldestMessageId) return; + + isLoading = true; + sentinel.innerHTML = '
'; + + try { + const res = await fetch('/api/history?before=' + oldestMessageId + '&limit=30', { credentials: 'include' }); + const data = await res.json(); + + if (data.messages && data.messages.length > 0) { + hasMore = data.has_more; + oldestMessageId = data.messages[0].id; + + const fragment = document.createDocumentFragment(); + renderMessageGroups(data.messages, fragment); + sentinel.after(fragment); + } + + if (!hasMore) { + loadMoreObserver.unobserve(sentinel); + sentinel.innerHTML = 'Beginning of conversation'; + } + } catch (err) { + console.error('Failed to load older messages:', err); + sentinel.innerHTML = 'Failed to load'; + } + + isLoading = false; +} + +// Format timestamp +function formatTime(timestamp) { + const date = new Date(timestamp); + const now = new Date(); + const yesterday = new Date(now); + yesterday.setDate(yesterday.getDate() - 1); + + const time = date.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true + }); + + if (date.toDateString() === now.toDateString()) { + return time; + } else if (date.toDateString() === yesterday.toDateString()) { + return 'Yesterday, ' + time; + } else if (date.getFullYear() === now.getFullYear()) { + const dateStr = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + return dateStr + ', ' + time; + } else { + const dateStr = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); + return dateStr + ', ' + time; + } +} + +// Extract text content from old JSON format or plain text +function extractTextContent(content) { + if (!content) return ''; + + // Try parsing as JSON (old format) + try { + var blocks = JSON.parse(content); + if (Array.isArray(blocks)) { + var text = ''; + for (var i = 0; i < blocks.length; i++) { + if (blocks[i].type === 'text' && blocks[i].content) { + text += blocks[i].content; + } + } + return text || content; + } + } catch (e) { + // Not JSON, use as-is + } + return content; +} + +// Create message element for a single message row (new v2 format) +function createMessageElement(msg) { + const wrapper = document.createElement('div'); + wrapper.dataset.messageId = msg.id; + wrapper.dataset.timestamp = msg.timestamp; + if (msg.group_id) wrapper.dataset.groupId = msg.group_id; + + if (msg.role === 'user') { + wrapper.className = 'message-wrapper message-wrapper-user flex flex-col items-end'; + const div = document.createElement('div'); + div.className = 'message message-user px-4 py-3'; + const contentDiv = document.createElement('div'); + contentDiv.className = 'message-content'; + contentDiv.textContent = msg.content; + div.appendChild(contentDiv); + + const timeDiv = document.createElement('div'); + timeDiv.className = 'text-xs text-gray-500 mt-1 text-right'; + timeDiv.textContent = formatTime(msg.timestamp); + + wrapper.appendChild(div); + wrapper.appendChild(timeDiv); + } + // Assistant messages are grouped and rendered via createAssistantGroup + + return wrapper; +} + +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +function scrollToBottom() { + scrollContainer.scrollTop = 0; +} + +function addUserMessage(message) { + const wrapper = document.createElement('div'); + wrapper.className = 'message-wrapper message-wrapper-user flex flex-col items-end'; + + const div = document.createElement('div'); + div.className = 'message message-user px-4 py-3'; + const contentDiv = document.createElement('div'); + contentDiv.className = 'message-content'; + contentDiv.textContent = message; + div.appendChild(contentDiv); + + const timeDiv = document.createElement('div'); + timeDiv.className = 'text-xs text-gray-500 mt-1'; + timeDiv.textContent = formatTime(new Date().toISOString()); + + wrapper.appendChild(div); + wrapper.appendChild(timeDiv); + messagesEl.appendChild(wrapper); +} + +function addAssistantMessage(message) { + const wrapper = document.createElement('div'); + wrapper.className = 'message-wrapper message-wrapper-assistant'; + + const div = document.createElement('div'); + div.className = 'message message-assistant px-4 py-3'; + const contentDiv = document.createElement('div'); + contentDiv.className = 'message-content'; + contentDiv.innerHTML = marked.parse(message); + + // Highlight code blocks + var codeBlocks = contentDiv.querySelectorAll('pre code'); + for (var i = 0; i < codeBlocks.length; i++) { + hljs.highlightElement(codeBlocks[i]); + } + + div.appendChild(contentDiv); + + const timeDiv = document.createElement('div'); + timeDiv.className = 'text-xs text-gray-500 mt-1'; + timeDiv.textContent = formatTime(new Date().toISOString()); + + wrapper.appendChild(div); + wrapper.appendChild(timeDiv); + messagesEl.appendChild(wrapper); +} + +// Add assistant message with tool blocks (legacy fallback) +function addAssistantBlocks(blocks) { + // Convert legacy block format to new message format + var messages = blocks.map(function(block, idx) { + return { + id: 'legacy_' + idx, + role: 'assistant', + type: block.type, + content: block.type === 'text' ? block.content : + block.type === 'tool_use' ? JSON.stringify(block.input) : block.content, + metadata: block.type === 'tool_use' ? { tool_name: block.name, tool_id: block.id } : + block.type === 'tool_result' ? { tool_name: block.tool_name, tool_use_id: block.tool_use_id } : null, + timestamp: new Date().toISOString() + }; + }); + var wrapper = createAssistantGroup(messages); + messagesEl.appendChild(wrapper); +} + +function highlightCodeBlocks(element) { + var codeBlocks = element.querySelectorAll('pre code'); + for (var i = 0; i < codeBlocks.length; i++) { + hljs.highlightElement(codeBlocks[i]); + } +} + +// Send message +form.addEventListener('submit', async function(e) { + e.preventDefault(); + + const message = input.value.trim(); + if (!message) return; + + // Request notification permission on first interaction + if (!notificationsEnabled && Notification.permission === 'default') { + requestNotificationPermission(); + } + + input.value = ''; + input.style.height = 'auto'; + + addUserMessage(message); + scrollToBottom(); + + sendBtn.disabled = true; + input.disabled = true; + showTyping(); + + try { + const res = await fetch('/api/chat', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + message: message, + model: modelSelect.value + }) + }); + + hideTyping(); + + if (!res.ok) { + var errData = await res.json(); + addAssistantMessage('Error: ' + (errData.detail || 'Request failed')); + } else { + var data = await res.json(); + + // Handle new messages array format + if (data.messages && Array.isArray(data.messages)) { + var wrapper = createAssistantGroup(data.messages); + messagesEl.appendChild(wrapper); + + // Show notification based on priority (>= 2 triggers notification) + if (data.max_priority >= 2 && data.text) { + var title = data.max_priority >= 3 ? 'Egregore (Question)' : 'Egregore'; + showNotification(title, data.text); + } + + if (handsFreeMode && data.text) { + speakText(data.text); + } + } else if (Array.isArray(data.response)) { + // Legacy block format fallback + addAssistantBlocks(data.response); + var notifyText = data.text || ''; + showNotification('Egregore', notifyText); + if (handsFreeMode && notifyText) { + speakText(notifyText); + } + } else { + addAssistantMessage(data.response || 'No response'); + } + } + + } catch (err) { + console.error('Chat error:', err); + hideTyping(); + addAssistantMessage('Error: ' + err.message); + } + + scrollToBottom(); + sendBtn.disabled = false; + input.disabled = false; + input.focus(); +}); + +// Register service worker +if ('serviceWorker' in navigator) { + navigator.serviceWorker.register('/static/sw.js') + .then(function(reg) { console.log('SW registered'); }) + .catch(function(err) { console.log('SW failed:', err); }); +} + +// ===================== +// Search +// ===================== + +const searchModal = document.getElementById('search-modal'); +const searchInput = document.getElementById('search-input'); +const searchResults = document.getElementById('search-results'); +const searchEmpty = document.getElementById('search-empty'); +const searchBtn = document.getElementById('search-btn'); +const searchClose = document.getElementById('search-close'); + +let searchDebounce = null; + +function openSearch() { + searchModal.classList.remove('hidden'); + searchInput.focus(); + document.body.style.overflow = 'hidden'; +} + +function closeSearch() { + searchModal.classList.add('hidden'); + searchInput.value = ''; + searchResults.innerHTML = ''; + searchResults.appendChild(searchEmpty); + searchEmpty.classList.remove('hidden'); + document.body.style.overflow = ''; +} + +if (searchBtn) searchBtn.addEventListener('click', openSearch); + +document.addEventListener('keydown', function(e) { + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { + e.preventDefault(); + if (searchModal.classList.contains('hidden')) { + openSearch(); + } else { + closeSearch(); + } + } + if (e.key === 'Escape' && !searchModal.classList.contains('hidden')) { + closeSearch(); + } +}); + +if (searchClose) searchClose.addEventListener('click', closeSearch); +if (searchModal) searchModal.addEventListener('click', function(e) { + if (e.target === searchModal) closeSearch(); +}); + +if (searchInput) searchInput.addEventListener('input', function() { + clearTimeout(searchDebounce); + searchDebounce = setTimeout(performSearch, 300); +}); + +async function performSearch() { + const query = searchInput.value.trim(); + + if (query.length < 2) { + searchResults.innerHTML = ''; + searchResults.appendChild(searchEmpty); + searchEmpty.classList.remove('hidden'); + return; + } + + try { + const res = await fetch('/api/search?q=' + encodeURIComponent(query), { credentials: 'include' }); + const data = await res.json(); + + searchResults.innerHTML = ''; + + if (data.results.length === 0) { + searchResults.innerHTML = '

No results found

'; + 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(); diff --git a/static/icon.svg b/static/icon.svg new file mode 100644 index 0000000..ec074aa --- /dev/null +++ b/static/icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..7ec8789 --- /dev/null +++ b/static/index.html @@ -0,0 +1,145 @@ + + + + + + + + + Egregore + + + + + + + + + + + + +
+

Egregore

+
+ + +
+
+ + + + + + + + + + + +
+
+
+ + + + + +
+
+ + + +
+
+ + +
+ + + + diff --git a/static/manifest.json b/static/manifest.json new file mode 100644 index 0000000..b7f6b09 --- /dev/null +++ b/static/manifest.json @@ -0,0 +1,18 @@ +{ + "name": "Egregore", + "short_name": "Egregore", + "description": "Commune with the Egregore", + "start_url": "/", + "display": "standalone", + "background_color": "#1e1033", + "theme_color": "#1e1033", + "orientation": "portrait-primary", + "icons": [ + { + "src": "/static/icon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any maskable" + } + ] +} diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..c0ade3c --- /dev/null +++ b/static/style.css @@ -0,0 +1,610 @@ +/* Egregore Chat - Infinite Scroll DM Style */ + +/* Scrollbar styling */ +::-webkit-scrollbar { + width: 6px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: #7c3aed; + border-radius: 3px; +} + +::-webkit-scrollbar-thumb:hover { + background: #8b5cf6; +} + +/* Message wrappers */ +.message-wrapper { + animation: fadeIn 0.2s ease-out; + -webkit-user-select: none; + user-select: none; +} + +.message-wrapper-user { + display: flex; + flex-direction: column; + align-items: flex-end; +} + +/* Message bubbles */ +.message { + max-width: 85%; + -webkit-user-select: text; + user-select: text; +} + +/* Contain selection within each message */ +.message-content { + -webkit-user-select: text; + user-select: text; +} + +#messages { + -webkit-user-select: none; + user-select: none; +} + +.message-user { + background: #7c3aed; + border-radius: 1.25rem 1.25rem 0.25rem 1.25rem; + color: white; +} + +.message-assistant { + background: #2e1065; + border-radius: 1.25rem 1.25rem 1.25rem 0.25rem; + border: 1px solid #581c87; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Markdown content styling */ +.message-content { + line-height: 1.6; + word-wrap: break-word; +} + +.message-content p { + margin-bottom: 0.75rem; +} + +.message-content p:last-child { + margin-bottom: 0; +} + +.message-content pre { + background: #0d1117; + border-radius: 0.5rem; + padding: 1rem; + overflow-x: auto; + margin: 0.75rem 0; + border: 1px solid #30363d; +} + +.message-content code { + font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; + font-size: 0.85rem; +} + +.message-content :not(pre) > code { + background: #0d1117; + padding: 0.15rem 0.4rem; + border-radius: 0.25rem; + font-size: 0.9em; +} + +.message-content ul, .message-content ol { + margin: 0.5rem 0; + padding-left: 1.5rem; +} + +.message-content li { + margin: 0.25rem 0; +} + +.message-content blockquote { + border-left: 3px solid #7c3aed; + padding-left: 1rem; + margin: 0.75rem 0; + color: #c4b5fd; +} + +.message-content a { + color: #c4b5fd; + text-decoration: underline; +} + +.message-content img { + max-width: 100%; + border-radius: 0.5rem; + margin: 0.5rem 0; +} + +.message-content table { + border-collapse: collapse; + margin: 0.75rem 0; + width: 100%; + font-size: 0.9rem; +} + +.message-content th, .message-content td { + border: 1px solid #374151; + padding: 0.5rem; + text-align: left; +} + +.message-content th { + background: #1f2937; +} + +/* Code block wrapper for copy button */ +.code-block-wrapper { + position: relative; +} + +.code-block-wrapper pre { + margin: 0; +} + +.copy-btn { + z-index: 10; +} + +/* Textarea */ +#message-input { + max-height: 200px; + min-height: 48px; + scrollbar-width: thin; +} + +/* Loading spinner */ +.loading-spinner { + width: 20px; + height: 20px; + border: 2px solid #581c87; + border-top-color: #c4b5fd; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* Typing indicator */ +.typing-indicator { + display: flex; + gap: 4px; + padding: 0.5rem; +} + +.typing-indicator span { + width: 8px; + height: 8px; + background: #a855f7; + border-radius: 50%; + animation: bounce 1.4s infinite ease-in-out; +} + +.typing-indicator span:nth-child(1) { animation-delay: -0.32s; } +.typing-indicator span:nth-child(2) { animation-delay: -0.16s; } + +@keyframes bounce { + 0%, 80%, 100% { transform: scale(0); } + 40% { transform: scale(1); } +} + +/* Mobile adjustments */ +@media (max-width: 640px) { + .message { + max-width: 92%; + } + + #messages { + padding: 0.75rem; + } + + /* Fixed header and footer on mobile */ + body { + position: fixed; + width: 100%; + height: 100vh; + height: 100dvh; /* Use dynamic viewport height if supported */ + overflow: hidden; + /* Prevent iOS bounce scroll */ + -webkit-overflow-scrolling: auto; + overscroll-behavior: none; + } + + /* Prevent touch scrolling on the document */ + html { + overflow: hidden; + height: 100vh; + height: 100dvh; + } + + header { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 40; + /* Add a subtle backdrop to ensure visibility */ + backdrop-filter: blur(8px); + background: rgba(29, 16, 51, 0.95); + } + + footer { + position: fixed; + bottom: 0; + left: 0; + right: 0; + z-index: 40; + padding: 0.75rem; + /* Add a subtle backdrop to ensure visibility */ + backdrop-filter: blur(8px); + background: rgba(29, 16, 51, 0.95); + } + + /* Adjust main content to account for fixed header/footer */ + main#messages-scroll { + position: fixed; + top: 0; + bottom: 0; + left: 0; + right: 0; + padding-top: var(--header-height, 60px); + padding-bottom: var(--footer-height, 80px); + box-sizing: border-box; + } + + /* Adjust typing indicator position */ + #typing-indicator { + position: fixed; + bottom: var(--footer-height, 80px); + left: 0; + right: 0; + z-index: 30; + background: rgba(29, 16, 51, 0.95); + backdrop-filter: blur(4px); + } + + /* Toast container adjustment for fixed layout */ + #toast-container { + bottom: calc(var(--footer-height, 80px) + 1rem); + } +} + +/* PWA standalone mode */ +@media (display-mode: standalone) { + header { + padding-top: env(safe-area-inset-top); + } + + footer { + padding-bottom: calc(0.75rem + env(safe-area-inset-bottom)); + } + + @media (max-width: 640px) { + main#messages-scroll { + padding-top: calc(60px + env(safe-area-inset-top)); + padding-bottom: calc(80px + env(safe-area-inset-bottom)); + } + + #typing-indicator { + bottom: calc(80px + env(safe-area-inset-bottom)); + } + } +} + +/* Tool blocks */ +.tool-block { + animation: fadeIn 0.2s ease-out; +} + +.tool-block pre { + margin: 0; + white-space: pre-wrap; + word-break: break-word; +} + +/* Tool result widget */ +.tool-result { + animation: fadeIn 0.2s ease-out; + border-left: 2px solid #7c3aed; +} + +.tool-result pre { + margin: 0; + white-space: pre-wrap; + word-break: break-word; +} + +/* Scrollbar for tool output */ +.tool-result pre::-webkit-scrollbar { + width: 4px; + height: 4px; +} + +.tool-result pre::-webkit-scrollbar-thumb { + background: #4c1d95; + border-radius: 2px; +} + +.bg-gray-750 { + background-color: #3b0764; +} + +/* Thinking blocks */ +.thinking-block { + animation: fadeIn 0.2s ease-out; +} + +.thinking-block summary { + cursor: pointer; +} + +.thinking-block summary::marker { + color: #9ca3af; +} + +/* Question blocks - highlighted for attention */ +.question-block { + background: linear-gradient(90deg, rgba(234, 179, 8, 0.1), transparent); + animation: fadeIn 0.2s ease-out; +} + +/* Error blocks */ +.error-block { + animation: fadeIn 0.2s ease-out; + background: rgba(239, 68, 68, 0.1); +} + +/* Mode change blocks */ +.mode-change-block { + animation: fadeIn 0.2s ease-out; + opacity: 0.7; +} + +/* Terminal output */ +.terminal-output { + font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; + color: #e2e8f0; +} + +/* Diff output */ +.diff-output { + font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; +} + +.diff-output code { + white-space: pre; +} + +/* Assistant response container */ +.assistant-response { + animation: fadeIn 0.2s ease-out; +} + +/* Details styling */ +details summary { + list-style: none; +} + +details summary::-webkit-details-marker { + display: none; +} + +details summary::before { + content: 'â–¶ '; + font-size: 0.75em; + margin-right: 0.25rem; + display: inline-block; + transition: transform 0.2s; +} + +details[open] summary::before { + transform: rotate(90deg); +} + +/* Input focus ring */ +#message-input:focus { + box-shadow: 0 0 0 2px rgba(168, 85, 247, 0.5); +} + +/* Send button animation */ +#send-btn:not(:disabled):hover { + transform: scale(1.05); +} + +#send-btn:not(:disabled):active { + transform: scale(0.95); +} + +#send-btn { + transition: all 0.15s ease; +} + +/* Smooth scroll behavior */ +#messages-scroll { + scroll-behavior: smooth; +} + +/* Selection color */ +::selection { + background: rgba(168, 85, 247, 0.4); +} + +/* Search */ +.search-result mark { + background: rgba(168, 85, 247, 0.3); + color: inherit; + padding: 0.1em 0.2em; + border-radius: 0.2em; +} + +.search-preview { + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +.search-highlight { + animation: highlightPulse 2s ease-out; +} + +@keyframes highlightPulse { + 0%, 100% { background: transparent; } + 25% { background: rgba(168, 85, 247, 0.2); } + 50% { background: rgba(168, 85, 247, 0.15); } + 75% { background: rgba(168, 85, 247, 0.1); } +} + +/* Search modal scrollbar */ +#search-results::-webkit-scrollbar { + width: 6px; +} + +#search-results::-webkit-scrollbar-track { + background: transparent; +} + +#search-results::-webkit-scrollbar-thumb { + background: #7c3aed; + border-radius: 3px; +} + +/* Typing indicator dots */ +.typing-dots span { + width: 6px; + height: 6px; + background: #a855f7; + border-radius: 50%; + animation: typingBounce 1.4s infinite ease-in-out; +} + +.typing-dots span:nth-child(1) { animation-delay: -0.32s; } +.typing-dots span:nth-child(2) { animation-delay: -0.16s; } +.typing-dots span:nth-child(3) { animation-delay: 0s; } + +@keyframes typingBounce { + 0%, 80%, 100% { transform: scale(0.8); opacity: 0.5; } + 40% { transform: scale(1); opacity: 1; } +} + +/* Context menu */ +#context-menu { + animation: contextMenuIn 0.15s ease-out; +} + +@keyframes contextMenuIn { + from { + opacity: 0; + transform: scale(0.95); + } + to { + opacity: 1; + transform: scale(1); + } +} + +/* Mobile menu */ +#mobile-menu { + animation: mobileMenuIn 0.2s ease-out; +} + +@keyframes mobileMenuIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +#mobile-menu button { + transition: transform 0.1s, background-color 0.15s; +} + +#mobile-menu button:active { + transform: scale(0.98); +} + +.line-clamp-3 { + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} + +/* Message delete animation */ +.message-wrapper { + transition: opacity 0.2s, transform 0.2s; +} + +/* Toast notifications */ +.toast { + animation: toastIn 0.2s ease-out; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); +} + +.toast-fade-out { + animation: toastOut 0.2s ease-out forwards; +} + +@keyframes toastIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes toastOut { + from { + opacity: 1; + transform: translateY(0); + } + to { + opacity: 0; + transform: translateY(-10px); + } +} + +/* Voice mode */ +#voice-btn { + position: relative; + overflow: hidden; +} + +#mic-pulse { + animation: micPulse 1.5s ease-in-out infinite; +} + +@keyframes micPulse { + 0%, 100% { opacity: 0.3; } + 50% { opacity: 0.6; } +} + +/* Hands-free indicator */ +#voice-btn.bg-red-600 #mic-icon { + color: white; +} diff --git a/static/sw.js b/static/sw.js new file mode 100644 index 0000000..29b4370 --- /dev/null +++ b/static/sw.js @@ -0,0 +1,62 @@ +// Service Worker for Egregore PWA + +const CACHE_NAME = 'egregore-v8'; +const OFFLINE_URL = '/offline.html'; + +// Assets to cache +const ASSETS = [ + '/', + '/static/style.css', + '/static/app.js', + '/static/manifest.json', + '/static/icon.svg' +]; + +// Install event +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => { + return cache.addAll(ASSETS); + }) + ); + self.skipWaiting(); +}); + +// Activate event +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((cacheNames) => { + return Promise.all( + cacheNames + .filter((name) => name !== CACHE_NAME) + .map((name) => caches.delete(name)) + ); + }) + ); + self.clients.claim(); +}); + +// Fetch event - network first, fallback to cache +self.addEventListener('fetch', (event) => { + // Skip non-GET requests + if (event.request.method !== 'GET') return; + + // Skip API requests (we want those to always go to network) + if (event.request.url.includes('/api/')) return; + + event.respondWith( + fetch(event.request) + .then((response) => { + // Clone the response and cache it + const responseClone = response.clone(); + caches.open(CACHE_NAME).then((cache) => { + cache.put(event.request, responseClone); + }); + return response; + }) + .catch(() => { + // Fallback to cache + return caches.match(event.request); + }) + ); +});