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 <noreply@anthropic.com>
This commit is contained in:
egregore 2026-02-02 11:37:41 +00:00
commit ff03fc7f43
9 changed files with 2244 additions and 0 deletions

43
.gitignore vendored Normal file
View file

@ -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

9
__init__.py Normal file
View file

@ -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
"""

234
main.py Normal file
View file

@ -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)

1113
static/app.js Normal file

File diff suppressed because it is too large Load diff

10
static/icon.svg Normal file
View file

@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<rect fill="#1e1033" width="100" height="100" rx="20"/>
<circle cx="50" cy="50" r="35" fill="none" stroke="#a855f7" stroke-width="3"/>
<circle cx="50" cy="50" r="18" fill="none" stroke="#c084fc" stroke-width="2"/>
<circle cx="50" cy="50" r="6" fill="#e9d5ff"/>
<line x1="50" y1="15" x2="50" y2="32" stroke="#a855f7" stroke-width="2"/>
<line x1="50" y1="68" x2="50" y2="85" stroke="#a855f7" stroke-width="2"/>
<line x1="15" y1="50" x2="32" y2="50" stroke="#a855f7" stroke-width="2"/>
<line x1="68" y1="50" x2="85" y2="50" stroke="#a855f7" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 643 B

145
static/index.html Normal file
View file

@ -0,0 +1,145 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="theme-color" content="#1e1033">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>Egregore</title>
<link rel="manifest" href="/static/manifest.json">
<link rel="icon" type="image/svg+xml" href="/static/icon.svg">
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/highlight.js@11/styles/github-dark.min.css">
<script src="https://cdn.jsdelivr.net/npm/highlight.js@11"></script>
<link rel="stylesheet" href="/static/style.css">
</head>
<body class="bg-purple-950 text-gray-100 h-screen flex flex-col">
<input type="hidden" id="model-select" value="claude-sonnet-4-20250514">
<!-- Header (mobile) -->
<header class="bg-purple-900 border-b border-purple-800 px-4 py-3 flex items-center justify-between shrink-0">
<h1 class="text-lg font-semibold">Egregore</h1>
<div class="flex items-center gap-1">
<button id="notify-btn" class="p-2 hover:bg-purple-800 rounded-lg" title="Notifications">
<svg id="notify-icon" class="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"></path>
</svg>
</button>
<button id="search-btn" class="p-2 hover:bg-purple-800 rounded-lg" title="Search">
<svg class="w-5 h-5 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
</button>
</div>
</header>
<!-- Search Modal -->
<div id="search-modal" class="hidden fixed inset-0 z-50 bg-black/70 backdrop-blur-sm">
<div class="flex flex-col h-full max-w-2xl mx-auto p-4">
<div class="flex items-center gap-3 bg-purple-900 rounded-xl p-3 border border-purple-700">
<svg class="w-5 h-5 text-gray-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
<input
type="text"
id="search-input"
class="flex-1 bg-transparent focus:outline-none placeholder-gray-500"
placeholder="Search messages..."
autocomplete="off"
>
<button id="search-close" class="p-1 hover:bg-purple-800 rounded">
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<div id="search-results" class="flex-1 overflow-y-auto mt-4 space-y-2">
<div id="search-empty" class="text-center text-gray-500 mt-8">
<p>Type to search</p>
</div>
</div>
</div>
</div>
<!-- Context Menu (Desktop only) -->
<div id="context-menu" class="hidden fixed z-50 bg-purple-900 rounded-lg border border-purple-700 py-1 shadow-xl min-w-[140px]">
<button id="ctx-copy" class="w-full px-4 py-2 text-left text-sm hover:bg-purple-800 flex items-center gap-2">
<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"></path>
</svg>
Copy
</button>
</div>
<!-- Mobile Menu (Full screen) -->
<div id="mobile-menu" class="hidden fixed inset-0 z-50 bg-purple-950/95 backdrop-blur-sm flex flex-col">
<div class="flex-1 flex flex-col items-center justify-center gap-4 p-8">
<div id="mobile-menu-preview" class="text-gray-400 text-sm text-center max-w-xs mb-4 line-clamp-3"></div>
<button id="mobile-copy" class="w-full max-w-xs bg-purple-800 hover:bg-purple-700 rounded-xl py-4 px-6 flex items-center justify-center gap-3 text-lg">
<svg class="w-6 h-6" 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"></path>
</svg>
Copy Message
</button>
</div>
<button id="mobile-menu-close" class="p-6 text-gray-400 text-center">
Tap anywhere to close
</button>
</div>
<!-- Messages -->
<main id="messages-scroll" class="flex-1 overflow-y-auto flex flex-col-reverse">
<div id="messages" class="p-4 space-y-4"></div>
</main>
<!-- Typing Indicator -->
<div id="typing-indicator" class="hidden px-4 py-2 text-sm text-gray-500">
<div class="flex items-center gap-2">
<div class="typing-dots flex gap-1">
<span></span><span></span><span></span>
</div>
<span>Egregore is thinking...</span>
</div>
</div>
<!-- Input Footer -->
<footer class="bg-purple-900 border-t border-purple-800 p-3 shrink-0">
<form id="chat-form" class="flex gap-2 items-end">
<button
type="button"
id="voice-btn"
class="bg-purple-800 hover:bg-purple-700 rounded-full p-3 transition-colors relative shrink-0"
title="Voice input"
>
<svg id="mic-icon" class="w-5 h-5 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"></path>
</svg>
<div id="mic-pulse" class="hidden absolute inset-0 rounded-full bg-red-500 animate-pulse opacity-50"></div>
</button>
<textarea
id="message-input"
class="flex-1 bg-purple-800 rounded-2xl px-4 py-3 resize-none focus:outline-none focus:ring-2 focus:ring-purple-400 placeholder-purple-400 text-base"
placeholder="Message..."
rows="1"
autofocus
></textarea>
<button
type="submit"
id="send-btn"
class="bg-purple-600 hover:bg-purple-500 disabled:bg-purple-900 disabled:cursor-not-allowed rounded-full p-3 transition-colors shrink-0"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"></path>
</svg>
</button>
</form>
</footer>
<!-- Toast Container -->
<div id="toast-container" class="fixed bottom-24 left-1/2 -translate-x-1/2 z-50 flex flex-col gap-2 pointer-events-none"></div>
<script src="/static/app.js"></script>
</body>
</html>

18
static/manifest.json Normal file
View file

@ -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"
}
]
}

610
static/style.css Normal file
View file

@ -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;
}

62
static/sw.js Normal file
View file

@ -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);
})
);
});