diff --git a/.env.example b/.env.example
index 4f1d57c..a61a9ae 100644
--- a/.env.example
+++ b/.env.example
@@ -22,3 +22,8 @@ WAKE_WORD=echo
QWEN_TTS_MODEL=Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice
QWEN_TTS_VOICE=voices/echo_voice.wav
QWEN_TTS_INSTRUCT=Speak clearly with a warm, friendly tone. Be natural and conversational.
+
+# --- Web Server (optional) ---
+# Run with: python server.py
+SERVER_HOST=0.0.0.0
+SERVER_PORT=8001
diff --git a/requirements.txt b/requirements.txt
index f2b996a..28b191f 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -7,7 +7,7 @@
# support first: https://pytorch.org/get-started/locally/
# ===========================================================
-# --- Core ---
+# --- Core (STT) ---
vosk>=0.3.45
pyaudio>=0.2.14
openwakeword>=0.5.0
@@ -16,6 +16,12 @@ openwakeword>=0.5.0
openai>=1.30.0
python-dotenv>=1.0.0
+# --- Web Server ---
+fastapi>=0.110.0
+uvicorn[standard]>=0.29.0
+websockets>=12.0
+python-multipart>=0.0.9
+
# --- TTS (Qwen3-TTS) ---
# Install from source or PyPI once available:
# pip install qwen-tts
diff --git a/server.py b/server.py
new file mode 100644
index 0000000..f173736
--- /dev/null
+++ b/server.py
@@ -0,0 +1,267 @@
+"""
+server.py — Echo Voice Assistant Web Server (FastAPI + WebSocket)
+
+Starts a web server on port 8001 that serves:
+ - Web UI (static files from web/)
+ - WebSocket endpoint for streaming chat
+ - REST API for health, audio, and settings
+
+Usage:
+ python server.py
+ # Then open http://localhost:8001 in your browser
+
+Environment Variables (see .env.example):
+ OPENROUTER_API_KEY — required for LLM responses
+ SERVER_PORT — port to run on (default: 8001)
+ SERVER_HOST — host to bind to (default: 0.0.0.0)
+
+Dependencies:
+ pip install fastapi uvicorn python-multipart websockets
+"""
+
+import asyncio
+import json
+import logging
+import os
+import uuid
+from pathlib import Path
+
+from dotenv import load_dotenv
+from fastapi import FastAPI, WebSocket, WebSocketDisconnect
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.responses import FileResponse, HTMLResponse
+from fastapi.staticfiles import StaticFiles
+
+from brain import Brain
+from tts import TTSEngine
+from actions import execute as execute_action
+
+# ---------------------------------------------------------------------------
+# Logging
+# ---------------------------------------------------------------------------
+logging.basicConfig(
+ level=logging.INFO,
+ format="%(asctime)s │ %(name)-18s │ %(levelname)-7s │ %(message)s",
+ datefmt="%H:%M:%S",
+)
+logger = logging.getLogger("echo.server")
+
+# ---------------------------------------------------------------------------
+# Config
+# ---------------------------------------------------------------------------
+load_dotenv(Path(__file__).parent / ".env")
+
+SERVER_HOST = os.environ.get("SERVER_HOST", "0.0.0.0")
+SERVER_PORT = int(os.environ.get("SERVER_PORT", "8001"))
+WEB_DIR = Path(__file__).parent / "web"
+
+# ---------------------------------------------------------------------------
+# FastAPI app
+# ---------------------------------------------------------------------------
+app = FastAPI(title="Echo Voice Assistant", version="1.0.0")
+
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=["*"],
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
+
+# ---------------------------------------------------------------------------
+# Initialize engines
+# ---------------------------------------------------------------------------
+brain = Brain(
+ api_key=os.environ.get("OPENROUTER_API_KEY"),
+ model=os.environ.get("OPENROUTER_MODEL", "qwen/qwen-3-235b-a22b"),
+)
+
+tts = TTSEngine(
+ model_name=os.environ.get("QWEN_TTS_MODEL", "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice"),
+ voice_sample=os.environ.get("QWEN_TTS_VOICE", "voices/echo_voice.wav"),
+ instruction=os.environ.get(
+ "QWEN_TTS_INSTRUCT",
+ "Speak clearly with a warm, friendly tone. Be natural and conversational.",
+ ),
+)
+
+# ---------------------------------------------------------------------------
+# Per-session state
+# ---------------------------------------------------------------------------
+sessions: dict[str, dict] = {}
+
+
+def get_session(ws_id: str) -> dict:
+ if ws_id not in sessions:
+ sessions[ws_id] = {"id": ws_id, "history": []}
+ return sessions[ws_id]
+
+
+# ---------------------------------------------------------------------------
+# Routes — Web UI
+# ---------------------------------------------------------------------------
+@app.get("/")
+async def serve_index():
+ index = WEB_DIR / "index.html"
+ if index.exists():
+ return FileResponse(index)
+ return HTMLResponse("
Echo Web UI not found — place files in web/ directory
")
+
+
+# Serve any static files from web/
+@app.get("/{path:path}")
+async def serve_static(path: str):
+ file_path = WEB_DIR / path
+ if file_path.exists() and file_path.is_file():
+ return FileResponse(file_path)
+ return HTMLResponse("Not found", status_code=404)
+
+
+# ---------------------------------------------------------------------------
+# Routes — API
+# ---------------------------------------------------------------------------
+@app.get("/api/health")
+async def health():
+ voice_ok = Path(os.environ.get("QWEN_TTS_VOICE", "voices/echo_voice.wav")).exists()
+ return {
+ "status": "ok",
+ "voice_sample": "loaded" if voice_ok else "missing",
+ "model": os.environ.get("OPENROUTER_MODEL", "qwen/qwen-3-235b-a22b"),
+ }
+
+
+@app.get("/api/audio/{filename}")
+async def get_audio(filename: str):
+ """Serve a generated audio file."""
+ audio_path = Path("audio_output") / filename
+ if audio_path.exists():
+ return FileResponse(audio_path, media_type="audio/wav")
+ return HTMLResponse("Not found", status_code=404)
+
+
+# ---------------------------------------------------------------------------
+# WebSocket — Streaming Chat
+# ---------------------------------------------------------------------------
+@app.websocket("/ws/chat")
+async def websocket_chat(ws: WebSocket):
+ await ws.accept()
+ ws_id = str(uuid.uuid4())[:8]
+ session = get_session(ws_id)
+ logger.info("Client connected: session=%s", ws_id)
+
+ try:
+ while True:
+ data = json.loads(await ws.receive_text())
+ msg_type = data.get("type", "chat")
+ payload = data.get("payload", {})
+
+ if msg_type == "chat":
+ message = payload.get("message", "").strip()
+ if not message:
+ await ws.send_json({"type": "error", "text": "Empty message"})
+ continue
+
+ session["history"].append({"role": "user", "content": message})
+
+ # Stream tokens from the brain
+ full_text = ""
+ audio_url = None
+ pending_command = None
+
+ try:
+ async for event in brain.think(message):
+ if event["type"] == "token":
+ full_text += event["text"]
+ await ws.send_json({
+ "type": "token",
+ "text": event["text"],
+ })
+
+ elif event["type"] == "command":
+ pending_command = event["command"]
+
+ elif event["type"] == "done":
+ spoken = event["text"]
+
+ # Send completion with final text
+ await ws.send_json({
+ "type": "done",
+ "text": spoken,
+ "full_text": full_text,
+ })
+
+ # Store in session history
+ session["history"].append({
+ "role": "assistant",
+ "content": spoken,
+ })
+ # Keep last 20 messages
+ if len(session["history"]) > 20:
+ session["history"] = session["history"][-20:]
+
+ # Execute any local command
+ if pending_command:
+ action_name = pending_command.get("action", "")
+ action_params = pending_command.get("params", {})
+ logger.info(
+ "Executing action: %s %s", action_name, action_params
+ )
+ action_result = execute_action(action_name, action_params)
+ await ws.send_json({
+ "type": "action",
+ "action": action_name,
+ "result": action_result,
+ })
+
+ # Generate TTS audio (async, non-blocking)
+ try:
+ wav_path = await tts.generate(spoken)
+ if wav_path:
+ audio_url = f"/api/audio/{wav_path.name}"
+ await ws.send_json({
+ "type": "audio",
+ "url": audio_url,
+ })
+ except Exception as exc:
+ logger.warning("TTS generation skipped: %s", exc)
+
+ await ws.send_json({"type": "ready"})
+
+ except Exception as exc:
+ logger.exception("Error processing chat")
+ await ws.send_json({
+ "type": "error",
+ "text": f"Error: {exc}",
+ })
+ await ws.send_json({"type": "ready"})
+
+ elif msg_type == "clear":
+ session["history"] = []
+ await ws.send_json({"type": "cleared"})
+
+ except WebSocketDisconnect:
+ logger.info("Client disconnected: session=%s", ws_id)
+ except Exception:
+ logger.exception("WebSocket error for session=%s", ws_id)
+ finally:
+ if ws_id in sessions:
+ del sessions[ws_id]
+
+
+# ---------------------------------------------------------------------------
+# Entry point
+# ---------------------------------------------------------------------------
+if __name__ == "__main__":
+ import uvicorn
+
+ logger.info("=" * 50)
+ logger.info(" ECHO VOICE ASSISTANT — Web Server")
+ logger.info(" http://%s:%d", SERVER_HOST, SERVER_PORT)
+ logger.info("=" * 50)
+
+ uvicorn.run(
+ app,
+ host=SERVER_HOST,
+ port=SERVER_PORT,
+ log_level="warning",
+ )
diff --git a/web/app.js b/web/app.js
new file mode 100644
index 0000000..d676f7f
--- /dev/null
+++ b/web/app.js
@@ -0,0 +1,594 @@
+// =========================================================================
+// Echo Voice Assistant — Web UI Client
+// =========================================================================
+
+// ---- State ----
+const state = {
+ ws: null,
+ connected: false,
+ isGenerating: false,
+ isRecording: false,
+ isSpeaking: false,
+ currentAudio: null,
+ conversations: [],
+ activeConversationId: null,
+ messages: [], // { role, content, audioUrl? }
+ streamingContent: "",
+};
+
+const settings = {
+ voiceEnabled: true,
+ autoSpeak: true,
+ sidebarOpen: true,
+};
+
+// ---- DOM refs ----
+const $ = (sel) => document.querySelector(sel);
+const messagesEl = $("#messages");
+const inputEl = $("#message-input");
+const sendBtn = $("#send-btn");
+const welcomeEl = $("#welcome");
+const sidebarEl = $("#sidebar");
+const micBtn = $("#mic-btn");
+const micIcon = $("#mic-icon");
+const micStop = $("#mic-stop");
+const micBadge = $("#mic-badge");
+const ttsBadge = $("#tts-badge");
+const recIndicator = $("#recording-indicator");
+const statusDot = $("#status-indicator");
+const statusText = $("#status-text");
+const headerTitle = $("#header-title");
+const headerSubtitle = $("#header-subtitle");
+const historyList = $("#history-list");
+
+// ---- WebSocket ----
+function connect() {
+ const proto = location.protocol === "https:" ? "wss:" : "ws:";
+ const url = `${proto}//${location.host}/ws/chat`;
+
+ state.ws = new WebSocket(url);
+
+ state.ws.onopen = () => {
+ state.connected = true;
+ statusDot.className = "status-dot online";
+ statusText.textContent = "Connected";
+ setSubtitle("Ready");
+ };
+
+ state.ws.onclose = () => {
+ state.connected = false;
+ statusDot.className = "status-dot offline";
+ statusText.textContent = "Disconnected";
+ setSubtitle("Disconnected — will reconnect...");
+ setTimeout(connect, 3000);
+ };
+
+ state.ws.onerror = () => {
+ state.connected = false;
+ };
+
+ state.ws.onmessage = (event) => {
+ const data = JSON.parse(event.data);
+ handleMessage(data);
+ };
+}
+
+function send(data) {
+ if (state.ws && state.ws.readyState === WebSocket.OPEN) {
+ state.ws.send(JSON.stringify(data));
+ }
+}
+
+function handleMessage(data) {
+ switch (data.type) {
+ case "token":
+ state.streamingContent += data.text;
+ updateStreamingBubble();
+ break;
+
+ case "done":
+ state.isGenerating = false;
+ finalizeStreaming(data.text);
+ if (data.full_text) {
+ updateHistoryItem(data.full_text);
+ }
+ setSubtitle("Ready");
+ break;
+
+ case "audio":
+ if (settings.autoSpeak) {
+ playAudio(data.url);
+ }
+ // Attach audio to last assistant message
+ if (state.messages.length > 0) {
+ const last = state.messages[state.messages.length - 1];
+ if (last.role === "assistant") {
+ last.audioUrl = data.url;
+ updateSpeakButton(last);
+ }
+ }
+ break;
+
+ case "action":
+ // Show action result as a system message
+ addMessage("system", data.result, true);
+ break;
+
+ case "error":
+ state.isGenerating = false;
+ finalizeStreaming("");
+ addMessage("assistant", data.text || "Something went wrong.");
+ setSubtitle("Error");
+ break;
+
+ case "ready":
+ state.isGenerating = false;
+ inputEl.disabled = false;
+ updateSendButton();
+ break;
+
+ case "cleared":
+ state.messages = [];
+ state.streamingContent = "";
+ renderMessages();
+ break;
+ }
+}
+
+// ---- Chat ----
+function sendMessage(e) {
+ e.preventDefault();
+ const text = inputEl.value.trim();
+ if (!text || state.isGenerating) return;
+
+ // Hide welcome
+ welcomeEl.classList.add("hidden");
+
+ // Add user message
+ addMessage("user", text);
+ inputEl.value = "";
+ inputEl.style.height = "auto";
+ updateSendButton();
+
+ // Send to server
+ state.isGenerating = true;
+ state.streamingContent = "";
+ inputEl.disabled = true;
+ setSubtitle("Thinking...");
+
+ // Create streaming bubble
+ createStreamingBubble();
+ send({ type: "chat", payload: { message: text } });
+}
+
+function addMessage(role, content, isHtml = false) {
+ const msg = { role, content, audioUrl: null, isHtml };
+ state.messages.push(msg);
+ appendMessageDOM(msg);
+ scrollToBottom();
+}
+
+function appendMessageDOM(msg) {
+ const div = document.createElement("div");
+ div.className = `message ${msg.role}`;
+ div.dataset.role = msg.role;
+
+ const avatar = msg.role === "user" ? userAvatar() : botAvatar();
+
+ let contentHtml = "";
+ if (msg.isHtml) {
+ contentHtml = `${msg.content}
`;
+ } else {
+ contentHtml = `${escapeHtml(msg.content)}
`;
+ }
+
+ // Add speak button for assistant messages
+ if (msg.role === "assistant" && !msg.isHtml) {
+ const speakBtn = ``;
+ contentHtml = speakBtn + contentHtml;
+ }
+
+ div.innerHTML = avatar + contentHtml;
+ messagesEl.appendChild(div);
+}
+
+function userAvatar() {
+ return ``;
+}
+
+function botAvatar() {
+ return ``;
+}
+
+// ---- Streaming ----
+let streamingEl = null;
+
+function createStreamingBubble() {
+ const div = document.createElement("div");
+ div.className = "message assistant";
+ div.id = "streaming-msg";
+
+ div.innerHTML = `
+ ${botAvatar()}
+
+
+
+
+
+ `;
+ messagesEl.appendChild(div);
+ streamingEl = div.querySelector(".bubble");
+ scrollToBottom();
+}
+
+function updateStreamingBubble() {
+ if (!streamingEl) return;
+ streamingEl.innerHTML = escapeHtml(state.streamingContent) + '';
+ scrollToBottom();
+}
+
+function finalizeStreaming(text) {
+ if (streamingEl) {
+ if (text) {
+ streamingEl.innerHTML = escapeHtml(text);
+ // Update the streaming message to a normal one
+ const msgDiv = document.getElementById("streaming-msg");
+ if (msgDiv) msgDiv.removeAttribute("id");
+ state.messages.push({ role: "assistant", content: text, audioUrl: null, isHtml: false });
+
+ // Add speak button
+ const speakBtn = document.createElement("button");
+ speakBtn.className = "speak-btn";
+ speakBtn.onclick = function () { replayMessage(this); };
+ speakBtn.title = "Play audio";
+ speakBtn.innerHTML = ``;
+ streamingEl.parentElement.insertBefore(speakBtn, streamingEl);
+ } else {
+ streamingEl.parentElement.remove();
+ }
+ streamingEl = null;
+ }
+ scrollToBottom();
+}
+
+// ---- Audio ----
+function playAudio(url) {
+ if (state.currentAudio) {
+ state.currentAudio.pause();
+ state.currentAudio = null;
+ }
+ state.currentAudio = new Audio(url);
+ state.isSpeaking = true;
+ ttsBadge.classList.add("active");
+
+ state.currentAudio.onended = () => {
+ state.isSpeaking = false;
+ ttsBadge.classList.remove("active");
+ };
+ state.currentAudio.onerror = () => {
+ state.isSpeaking = false;
+ ttsBadge.classList.remove("active");
+ };
+ state.currentAudio.play();
+}
+
+function replayMessage(btn) {
+ // Find the closest message div
+ const msgDiv = btn.closest(".message");
+ if (!msgDiv) return;
+
+ // Find the message in state
+ const bubbleText = msgDiv.querySelector(".bubble").textContent;
+ const msg = state.messages.find(m => m.content === bubbleText && m.role === "assistant");
+
+ if (msg && msg.audioUrl) {
+ if (state.isSpeaking) {
+ state.currentAudio?.pause();
+ state.isSpeaking = false;
+ ttsBadge.classList.remove("active");
+ return;
+ }
+ playAudio(msg.audioUrl);
+ } else {
+ // No server audio — use browser TTS
+ if ("speechSynthesis" in window) {
+ if (state.isSpeaking) {
+ speechSynthesis.cancel();
+ state.isSpeaking = false;
+ return;
+ }
+ const utterance = new SpeechSynthesisUtterance(bubbleText);
+ utterance.onstart = () => { state.isSpeaking = true; };
+ utterance.onend = () => { state.isSpeaking = false; };
+ utterance.onerror = () => { state.isSpeaking = false; };
+ speechSynthesis.speak(utterance);
+ }
+ }
+}
+
+function updateSpeakButton(msg) {
+ // Find the last assistant message DOM element
+ const allMsgs = messagesEl.querySelectorAll(".message.assistant");
+ if (allMsgs.length === 0) return;
+ const lastMsg = allMsgs[allMsgs.length - 1];
+ const existingBtn = lastMsg.querySelector(".speak-btn");
+ if (existingBtn) {
+ existingBtn.style.opacity = "1";
+ }
+}
+
+// ---- Voice Input (Web Speech API) ----
+let recognition = null;
+
+function toggleMic() {
+ if (state.isRecording) {
+ stopRecording();
+ } else {
+ startRecording();
+ }
+}
+
+function startRecording() {
+ if (!settings.voiceEnabled) {
+ alert("Voice input is disabled. Enable it in Settings.");
+ return;
+ }
+
+ const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
+ if (!SpeechRecognition) {
+ alert("Speech recognition is not supported in this browser. Try Chrome.");
+ return;
+ }
+
+ recognition = new SpeechRecognition();
+ recognition.continuous = false;
+ recognition.interimResults = true;
+ recognition.lang = "en-US";
+
+ let finalTranscript = "";
+
+ recognition.onresult = (event) => {
+ let interim = "";
+ for (let i = event.resultIndex; i < event.results.length; i++) {
+ if (event.results[i].isFinal) {
+ finalTranscript += event.results[i][0].transcript;
+ } else {
+ interim += event.results[i][0].transcript;
+ }
+ }
+ inputEl.value = finalTranscript || interim;
+ updateSendButton();
+ };
+
+ recognition.onend = () => {
+ state.isRecording = false;
+ setRecordingUI(false);
+ if (finalTranscript.trim()) {
+ // Auto-send
+ inputEl.value = finalTranscript.trim();
+ sendMessage(new Event("submit"));
+ }
+ recognition = null;
+ };
+
+ recognition.onerror = (event) => {
+ console.error("Speech recognition error:", event.error);
+ state.isRecording = false;
+ setRecordingUI(false);
+ recognition = null;
+ };
+
+ recognition.start();
+ state.isRecording = true;
+ setRecordingUI(true);
+}
+
+function stopRecording() {
+ if (recognition) {
+ recognition.stop();
+ }
+}
+
+function setRecordingUI(recording) {
+ micBtn.classList.toggle("recording", recording);
+ micIcon.classList.toggle("hidden", recording);
+ micStop.classList.toggle("hidden", !recording);
+ recIndicator.classList.toggle("hidden", !recording);
+ micBadge.classList.toggle("recording", recording);
+ if (recording) {
+ inputEl.placeholder = "Listening...";
+ setSubtitle("Listening...");
+ } else {
+ inputEl.placeholder = "Type a message or use the mic...";
+ }
+}
+
+// ---- TTS Toggle ----
+function toggleTTS() {
+ settings.autoSpeak = !settings.autoSpeak;
+ ttsBadge.classList.toggle("active", settings.autoSpeak);
+ document.getElementById("setting-tts").checked = settings.autoSpeak;
+}
+
+// ---- Sidebar ----
+function toggleSidebar(force) {
+ const show = force !== undefined ? force : !settings.sidebarOpen;
+ settings.sidebarOpen = show;
+ sidebarEl.classList.toggle("collapsed", !show);
+}
+
+// ---- Settings ----
+function toggleSettings() {
+ const panel = $("#settings-panel");
+ const overlay = $("#settings-overlay");
+ panel.classList.toggle("hidden");
+ overlay.classList.toggle("hidden");
+}
+
+// ---- Conversations (local only for now) ----
+function newChat() {
+ // Clear current messages
+ state.messages = [];
+ state.streamingContent = "";
+ messagesEl.innerHTML = "";
+
+ // Send clear to server
+ send({ type: "clear" });
+
+ // Show welcome
+ welcomeEl.classList.remove("hidden");
+ headerTitle.textContent = "Echo";
+
+ // Create local history entry
+ const id = Date.now().toString();
+ const conv = { id, title: "New Conversation", time: new Date() };
+ state.conversations.unshift(conv);
+ state.activeConversationId = id;
+ renderHistory();
+}
+
+function updateHistoryItem(text) {
+ const conv = state.conversations.find(c => c.id === state.activeConversationId);
+ if (conv && conv.title === "New Conversation") {
+ conv.title = text.slice(0, 40) + (text.length > 40 ? "..." : "");
+ renderHistory();
+ }
+}
+
+function renderHistory() {
+ if (state.conversations.length === 0) {
+ historyList.innerHTML = 'No conversations yet
';
+ return;
+ }
+ historyList.innerHTML = state.conversations.map(conv => `
+
+ ${escapeHtml(conv.title)}
+ ${timeAgo(conv.time)}
+
+
+ `).join("");
+}
+
+function loadConversation(id) {
+ // For now, conversations are in-memory only
+ state.activeConversationId = id;
+ renderHistory();
+ const conv = state.conversations.find(c => c.id === id);
+ if (conv) {
+ headerTitle.textContent = conv.title;
+ }
+}
+
+function deleteConversation(id) {
+ state.conversations = state.conversations.filter(c => c.id !== id);
+ if (state.activeConversationId === id) {
+ state.activeConversationId = null;
+ headerTitle.textContent = "Echo";
+ }
+ renderHistory();
+}
+
+// ---- Helpers ----
+function escapeHtml(text) {
+ const div = document.createElement("div");
+ div.textContent = text;
+ return div.innerHTML;
+}
+
+function scrollToBottom() {
+ const area = $(".chat-area");
+ requestAnimationFrame(() => {
+ area.scrollTop = area.scrollHeight;
+ });
+}
+
+function setSubtitle(text) {
+ headerSubtitle.textContent = text;
+}
+
+function updateSendButton() {
+ sendBtn.disabled = !inputEl.value.trim() || state.isGenerating;
+}
+
+function handleKeyDown(e) {
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault();
+ sendMessage(e);
+ }
+}
+
+function autoResize(el) {
+ el.style.height = "auto";
+ el.style.height = Math.min(el.scrollHeight, 160) + "px";
+ updateSendButton();
+}
+
+function timeAgo(date) {
+ const seconds = Math.floor((new Date() - date) / 1000);
+ if (seconds < 60) return "Just now";
+ const minutes = Math.floor(seconds / 60);
+ if (minutes < 60) return `${minutes}m ago`;
+ const hours = Math.floor(minutes / 60);
+ if (hours < 24) return `${hours}h ago`;
+ return `${Math.floor(hours / 24)}d ago`;
+}
+
+function renderMessages() {
+ messagesEl.innerHTML = "";
+ if (state.messages.length === 0) {
+ welcomeEl.classList.remove("hidden");
+ } else {
+ welcomeEl.classList.add("hidden");
+ state.messages.forEach(msg => appendMessageDOM(msg));
+ scrollToBottom();
+ }
+}
+
+// ---- Theme (dark mode) ----
+function initTheme() {
+ const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
+ if (prefersDark) {
+ document.body.classList.add("dark");
+ }
+ window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", (e) => {
+ document.body.classList.toggle("dark", e.matches);
+ });
+}
+
+// ---- Init ----
+document.addEventListener("DOMContentLoaded", () => {
+ initTheme();
+ connect();
+
+ // Focus input on click in chat area
+ $(".chat-area").addEventListener("click", (e) => {
+ if (e.target.closest(".bubble") || e.target.closest(".speak-btn")) return;
+ inputEl.focus();
+ });
+
+ // Mobile: collapse sidebar by default
+ if (window.innerWidth < 768) {
+ toggleSidebar(false);
+ }
+});
diff --git a/web/index.html b/web/index.html
new file mode 100644
index 0000000..f7c36bc
--- /dev/null
+++ b/web/index.html
@@ -0,0 +1,184 @@
+
+
+
+
+
+ Echo — Voice Assistant
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Echo Voice Assistant
+
Type a message, or click the microphone to use your voice.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Voice
+
+
+
+
+
+
Interface
+
+
+
+
+
About
+
+ Echo is a modular, private-first voice assistant using Vosk for local listening, OpenRouter for reasoning, and Qwen3-TTS for speech.
+
+
+
+
+
+
+
+
+
diff --git a/web/style.css b/web/style.css
new file mode 100644
index 0000000..2d630a8
--- /dev/null
+++ b/web/style.css
@@ -0,0 +1,624 @@
+/* =========================================================================
+ Echo Voice Assistant — Web UI Styles
+ ========================================================================= */
+
+:root {
+ --bg: #ffffff;
+ --bg-secondary: #f8f9fa;
+ --bg-tertiary: #f1f3f5;
+ --surface: #ffffff;
+ --border: #e5e7eb;
+ --border-light: #f0f0f0;
+ --text: #111827;
+ --text-secondary: #6b7280;
+ --text-muted: #9ca3af;
+ --primary: #111827;
+ --primary-fg: #ffffff;
+ --accent: #10b981;
+ --accent-hover: #059669;
+ --danger: #ef4444;
+ --danger-bg: #fef2f2;
+ --shadow-sm: 0 1px 2px rgba(0,0,0,.05);
+ --shadow-md: 0 4px 12px rgba(0,0,0,.08);
+ --shadow-lg: 0 8px 24px rgba(0,0,0,.12);
+ --radius: 12px;
+ --radius-lg: 16px;
+ --transition: .2s ease;
+ --sidebar-width: 272px;
+ --header-height: 56px;
+}
+
+.dark {
+ --bg: #0f0f0f;
+ --bg-secondary: #1a1a1a;
+ --bg-tertiary: #252525;
+ --surface: #1a1a1a;
+ --border: #2a2a2a;
+ --border-light: #222;
+ --text: #f0f0f0;
+ --text-secondary: #999;
+ --text-muted: #666;
+ --primary: #e5e5e5;
+ --primary-fg: #111;
+ --accent: #10b981;
+ --accent-hover: #34d399;
+ --danger: #f87171;
+ --danger-bg: #2a1515;
+ --shadow-sm: 0 1px 2px rgba(0,0,0,.2);
+ --shadow-md: 0 4px 12px rgba(0,0,0,.3);
+ --shadow-lg: 0 8px 24px rgba(0,0,0,.4);
+}
+
+* { margin: 0; padding: 0; box-sizing: border-box; }
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+ background: var(--bg);
+ color: var(--text);
+ height: 100vh;
+ overflow: hidden;
+}
+
+/* ---- Layout ---- */
+#app {
+ display: flex;
+ height: 100vh;
+}
+
+.hidden { display: none !important; }
+
+/* ---- Sidebar ---- */
+.sidebar {
+ width: var(--sidebar-width);
+ min-width: var(--sidebar-width);
+ background: var(--surface);
+ border-right: 1px solid var(--border);
+ display: flex;
+ flex-direction: column;
+ transition: margin-left var(--transition), opacity var(--transition);
+ z-index: 20;
+}
+
+.sidebar.collapsed {
+ margin-left: calc(-1 * var(--sidebar-width));
+ opacity: 0;
+ pointer-events: none;
+}
+
+.sidebar-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 16px;
+ border-bottom: 1px solid var(--border);
+}
+
+.logo {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ font-weight: 700;
+ font-size: 15px;
+ color: var(--accent);
+}
+
+.new-chat-btn {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin: 12px;
+ padding: 10px 16px;
+ border: 1px dashed var(--border);
+ border-radius: var(--radius);
+ background: transparent;
+ color: var(--text);
+ font-size: 13px;
+ cursor: pointer;
+ transition: all var(--transition);
+}
+.new-chat-btn:hover {
+ background: var(--bg-tertiary);
+ border-color: var(--accent);
+ color: var(--accent);
+}
+
+.history-list {
+ flex: 1;
+ overflow-y: auto;
+ padding: 4px 8px;
+}
+.history-list::-webkit-scrollbar { width: 4px; }
+.history-list::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
+
+.history-item {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 12px;
+ border-radius: 8px;
+ cursor: pointer;
+ transition: background var(--transition);
+ margin-bottom: 2px;
+}
+.history-item:hover { background: var(--bg-tertiary); }
+.history-item.active { background: var(--primary); color: var(--primary-fg); }
+
+.history-item .title {
+ flex: 1;
+ font-size: 13px;
+ font-weight: 500;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.history-item .time {
+ font-size: 11px;
+ opacity: .5;
+ white-space: nowrap;
+}
+.history-item .delete-btn {
+ opacity: 0;
+ background: none;
+ border: none;
+ color: inherit;
+ cursor: pointer;
+ padding: 2px;
+ transition: opacity var(--transition);
+}
+.history-item:hover .delete-btn { opacity: .6; }
+.history-item .delete-btn:hover { opacity: 1; }
+
+.sidebar-footer {
+ padding: 12px 16px;
+ border-top: 1px solid var(--border);
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 12px;
+}
+
+.status-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--text-muted);
+}
+.status-dot.online { background: var(--accent); }
+.status-dot.offline { background: var(--danger); }
+
+.status-text { color: var(--text-secondary); }
+
+.empty-state {
+ text-align: center;
+ color: var(--text-muted);
+ font-size: 13px;
+ padding: 32px 16px;
+}
+
+/* ---- Main ---- */
+.main {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+}
+
+/* ---- Header ---- */
+.header {
+ height: var(--header-height);
+ border-bottom: 1px solid var(--border);
+ display: flex;
+ align-items: center;
+ padding: 0 16px;
+ gap: 12px;
+ background: var(--surface);
+ flex-shrink: 0;
+}
+
+.header-center { flex: 1; min-width: 0; }
+.header-center h1 { font-size: 15px; font-weight: 600; }
+.header-subtitle { font-size: 12px; color: var(--text-secondary); }
+
+.header-right { display: flex; align-items: center; gap: 4px; }
+
+.icon-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 36px;
+ height: 36px;
+ border: none;
+ border-radius: 8px;
+ background: transparent;
+ color: var(--text-secondary);
+ cursor: pointer;
+ transition: all var(--transition);
+}
+.icon-btn:hover { background: var(--bg-tertiary); color: var(--text); }
+
+.badge {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 12px;
+ border: 1px solid var(--border);
+ border-radius: 20px;
+ background: transparent;
+ color: var(--text-secondary);
+ font-size: 12px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all var(--transition);
+}
+.badge:hover { border-color: var(--text-secondary); }
+.badge.active { border-color: var(--accent); color: var(--accent); }
+.badge.recording {
+ border-color: var(--danger);
+ color: var(--danger);
+ background: var(--danger-bg);
+ animation: pulse-badge 1.5s ease infinite;
+}
+
+@keyframes pulse-badge {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: .7; }
+}
+
+/* ---- Chat Area ---- */
+.chat-area {
+ flex: 1;
+ overflow-y: auto;
+ padding: 16px;
+}
+.chat-area::-webkit-scrollbar { width: 6px; }
+.chat-area::-webkit-scrollbar-thumb { background: var(--border); border-radius: 6px; }
+
+.welcome {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ text-align: center;
+ gap: 12px;
+ padding: 32px;
+}
+.welcome-icon {
+ width: 72px;
+ height: 72px;
+ border-radius: 20px;
+ background: linear-gradient(135deg, var(--accent), #06b6d4);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: white;
+ margin-bottom: 8px;
+}
+.welcome h2 { font-size: 22px; font-weight: 700; }
+.welcome p { font-size: 14px; color: var(--text-secondary); max-width: 320px; line-height: 1.6; }
+
+.messages {
+ max-width: 720px;
+ margin: 0 auto;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+/* ---- Message Bubbles ---- */
+.message {
+ display: flex;
+ gap: 10px;
+ padding: 8px 0;
+ animation: msg-in .25s ease;
+}
+@keyframes msg-in {
+ from { opacity: 0; transform: translateY(8px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+.message.user { flex-direction: row-reverse; }
+
+.avatar {
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ font-size: 14px;
+}
+.message.user .avatar { background: var(--primary); color: var(--primary-fg); }
+.message.assistant .avatar { background: var(--bg-tertiary); color: var(--text-secondary); }
+
+.bubble {
+ max-width: 75%;
+ padding: 10px 16px;
+ border-radius: 18px;
+ font-size: 14px;
+ line-height: 1.6;
+ position: relative;
+}
+.message.user .bubble {
+ background: var(--primary);
+ color: var(--primary-fg);
+ border-bottom-right-radius: 4px;
+}
+.message.assistant .bubble {
+ background: var(--bg-tertiary);
+ color: var(--text);
+ border-bottom-left-radius: 4px;
+}
+
+.bubble .speak-btn {
+ position: absolute;
+ top: 50%;
+ transform: translateY(-50%);
+ opacity: 0;
+ transition: opacity var(--transition);
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: 50%;
+ width: 28px;
+ height: 28px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ color: var(--text-secondary);
+}
+.message.assistant .bubble .speak-btn { left: -36px; }
+.bubble:hover .speak-btn { opacity: 1; }
+
+/* Typing indicator */
+.typing-indicator {
+ display: flex;
+ gap: 5px;
+ padding: 4px 0;
+}
+.typing-indicator span {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--text-muted);
+ animation: typing-bounce 1.4s ease-in-out infinite;
+}
+.typing-indicator span:nth-child(2) { animation-delay: .2s; }
+.typing-indicator span:nth-child(3) { animation-delay: .4s; }
+@keyframes typing-bounce {
+ 0%, 60%, 100% { transform: translateY(0); }
+ 30% { transform: translateY(-6px); }
+}
+
+/* Streaming cursor */
+.streaming-cursor {
+ display: inline-block;
+ width: 2px;
+ height: 16px;
+ background: var(--text-secondary);
+ vertical-align: text-bottom;
+ animation: blink .8s ease infinite;
+ margin-left: 2px;
+}
+@keyframes blink {
+ 0%, 50% { opacity: 1; }
+ 51%, 100% { opacity: 0; }
+}
+
+/* Action card */
+.action-card {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 14px;
+ margin-top: 8px;
+ border-radius: 10px;
+ background: var(--accent);
+ color: white;
+ font-size: 12px;
+ font-weight: 500;
+}
+.action-card svg { flex-shrink: 0; }
+
+/* ---- Input Area ---- */
+.input-area {
+ border-top: 1px solid var(--border);
+ padding: 12px 16px;
+ background: var(--surface);
+}
+
+.chat-form {
+ max-width: 720px;
+ margin: 0 auto;
+ display: flex;
+ align-items: flex-end;
+ gap: 10px;
+}
+
+.mic-btn {
+ width: 42px;
+ height: 42px;
+ border-radius: 50%;
+ border: 1px solid var(--border);
+ background: transparent;
+ color: var(--text-secondary);
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ transition: all var(--transition);
+}
+.mic-btn:hover { border-color: var(--text-secondary); color: var(--text); }
+.mic-btn.recording {
+ background: var(--danger);
+ border-color: var(--danger);
+ color: white;
+ animation: pulse-mic 1.5s ease infinite;
+}
+@keyframes pulse-mic {
+ 0%, 100% { box-shadow: 0 0 0 0 rgba(239,68,68,.4); }
+ 50% { box-shadow: 0 0 0 10px rgba(239,68,68,0); }
+}
+
+.input-wrapper {
+ flex: 1;
+ position: relative;
+}
+
+#message-input {
+ width: 100%;
+ padding: 10px 16px;
+ border: 1px solid var(--border);
+ border-radius: 22px;
+ background: var(--bg-secondary);
+ color: var(--text);
+ font-size: 14px;
+ font-family: inherit;
+ resize: none;
+ outline: none;
+ line-height: 1.5;
+ max-height: 160px;
+ transition: border-color var(--transition);
+}
+#message-input:focus { border-color: var(--accent); }
+#message-input::placeholder { color: var(--text-muted); }
+
+.send-btn {
+ width: 42px;
+ height: 42px;
+ border-radius: 50%;
+ border: none;
+ background: var(--primary);
+ color: var(--primary-fg);
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ transition: all var(--transition);
+ opacity: .4;
+}
+.send-btn:not(:disabled) { opacity: 1; }
+.send-btn:not(:disabled):hover { transform: scale(1.05); }
+
+.recording-indicator {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ margin-top: 6px;
+ font-size: 12px;
+ color: var(--danger);
+}
+.rec-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--danger);
+ animation: blink 1s ease infinite;
+}
+
+/* ---- Settings Panel ---- */
+.overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(0,0,0,.4);
+ z-index: 40;
+ backdrop-filter: blur(2px);
+}
+
+.settings-panel {
+ position: fixed;
+ top: 0;
+ right: 0;
+ width: 360px;
+ max-width: 90vw;
+ height: 100vh;
+ background: var(--surface);
+ border-left: 1px solid var(--border);
+ z-index: 50;
+ display: flex;
+ flex-direction: column;
+ box-shadow: var(--shadow-lg);
+}
+
+.settings-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 16px 20px;
+ border-bottom: 1px solid var(--border);
+}
+.settings-header h2 { font-size: 16px; font-weight: 600; }
+
+.settings-body {
+ flex: 1;
+ overflow-y: auto;
+ padding: 20px;
+}
+
+.setting-group h3 {
+ font-size: 12px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: .05em;
+ color: var(--text-muted);
+ margin-bottom: 12px;
+}
+
+.setting-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 10px 0;
+ cursor: pointer;
+}
+
+.setting-label { font-size: 14px; font-weight: 500; display: block; }
+.setting-desc { font-size: 12px; color: var(--text-muted); display: block; margin-top: 2px; }
+
+.setting-row input[type="checkbox"] {
+ width: 40px;
+ height: 22px;
+ accent-color: var(--accent);
+ cursor: pointer;
+}
+
+.divider {
+ border: none;
+ border-top: 1px solid var(--border);
+ margin: 16px 0;
+}
+
+.about-text {
+ font-size: 13px;
+ color: var(--text-secondary);
+ line-height: 1.6;
+}
+
+/* ---- Responsive ---- */
+@media (max-width: 768px) {
+ .sidebar {
+ position: fixed;
+ left: 0;
+ top: 0;
+ height: 100vh;
+ z-index: 30;
+ box-shadow: var(--shadow-lg);
+ }
+ .sidebar.collapsed {
+ margin-left: 0;
+ transform: translateX(-100%);
+ }
+ .bubble { max-width: 85%; }
+ .badge span { display: none; }
+ .settings-panel { width: 100%; }
+}
+
+/* ---- Theme toggle (top-right of settings) ---- */
+.theme-toggle {
+ margin-top: auto;
+ padding-top: 16px;
+}