feat: add web server (FastAPI) and web UI on port 8001

- server.py: FastAPI + WebSocket server wrapping Brain, TTS, Actions
  - WS /ws/chat: streaming chat with token-by-token delivery
  - GET /api/audio/{filename}: serve generated TTS audio
  - GET /api/health: server status check
  - Serves static web UI from web/ directory
- web/: self-contained HTML/CSS/JS frontend
  - Responsive chat interface with message bubbles
  - WebSocket client for real-time streaming
  - Voice input via Web Speech API (mic button)
  - TTS audio playback (auto + manual replay)
  - Conversation sidebar with history
  - Settings panel (voice, TTS, sidebar toggles)
  - Dark mode support via prefers-color-scheme
- Updated requirements.txt with fastapi, uvicorn, websockets
- Updated .env.example with SERVER_HOST/PORT config
This commit is contained in:
Echo Assistant 2026-03-31 00:49:38 +00:00
parent 19a283ec0f
commit fee5e4d17b
6 changed files with 1681 additions and 1 deletions

View File

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

View File

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

267
server.py Normal file
View File

@ -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("<h1>Echo Web UI not found — place files in web/ directory</h1>")
# 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",
)

594
web/app.js Normal file
View File

@ -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 = `<div class="bubble">${msg.content}</div>`;
} else {
contentHtml = `<div class="bubble">${escapeHtml(msg.content)}</div>`;
}
// Add speak button for assistant messages
if (msg.role === "assistant" && !msg.isHtml) {
const speakBtn = `<button class="speak-btn" onclick="replayMessage(this)" title="Play audio">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/>
<path d="M15.54 8.46a5 5 0 0 1 0 7.07"/>
</svg>
</button>`;
contentHtml = speakBtn + contentHtml;
}
div.innerHTML = avatar + contentHtml;
messagesEl.appendChild(div);
}
function userAvatar() {
return `<div class="avatar">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
</div>`;
}
function botAvatar() {
return `<div class="avatar">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2a2 2 0 0 1 2 2c0 .74-.4 1.39-1 1.73V7h1a7 7 0 0 1 7 7h1a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-1v1a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-1H3a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1h1a7 7 0 0 1 7-7h1V5.73A2 2 0 0 1 12 2z"/>
</svg>
</div>`;
}
// ---- Streaming ----
let streamingEl = null;
function createStreamingBubble() {
const div = document.createElement("div");
div.className = "message assistant";
div.id = "streaming-msg";
div.innerHTML = `
${botAvatar()}
<div class="bubble">
<span class="typing-indicator">
<span></span><span></span><span></span>
</span>
</div>
`;
messagesEl.appendChild(div);
streamingEl = div.querySelector(".bubble");
scrollToBottom();
}
function updateStreamingBubble() {
if (!streamingEl) return;
streamingEl.innerHTML = escapeHtml(state.streamingContent) + '<span class="streaming-cursor"></span>';
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 = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/>
<path d="M15.54 8.46a5 5 0 0 1 0 7.07"/>
</svg>`;
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 = '<p class="empty-state">No conversations yet</p>';
return;
}
historyList.innerHTML = state.conversations.map(conv => `
<div class="history-item ${conv.id === state.activeConversationId ? 'active' : ''}" onclick="loadConversation('${conv.id}')">
<span class="title">${escapeHtml(conv.title)}</span>
<span class="time">${timeAgo(conv.time)}</span>
<button class="delete-btn" onclick="event.stopPropagation(); deleteConversation('${conv.id}')">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
`).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);
}
});

184
web/index.html Normal file
View File

@ -0,0 +1,184 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Echo — Voice Assistant</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div id="app">
<!-- Sidebar -->
<aside id="sidebar" class="sidebar">
<div class="sidebar-header">
<div class="logo">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/>
<path d="M19 10v2a7 7 0 0 1-14 0v-2"/>
<line x1="12" y1="19" x2="12" y2="23"/>
<line x1="8" y1="23" x2="16" y2="23"/>
</svg>
<span>Echo</span>
</div>
<button id="sidebar-close" class="icon-btn" onclick="toggleSidebar()">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 19l-7-7 7-7"/><path d="M18 5l-7 7 7 7"/>
</svg>
</button>
</div>
<button id="new-chat-btn" class="new-chat-btn" onclick="newChat()">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
New Conversation
</button>
<div id="history-list" class="history-list">
<p class="empty-state">No conversations yet</p>
</div>
<div class="sidebar-footer">
<div id="status-indicator" class="status-dot offline" title="Disconnected"></div>
<span id="status-text" class="status-text">Connecting...</span>
</div>
</aside>
<!-- Main -->
<main class="main">
<!-- Header -->
<header class="header">
<button id="sidebar-toggle" class="icon-btn" onclick="toggleSidebar()">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2">
<line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/>
</svg>
</button>
<div class="header-center">
<h1 id="header-title">Echo</h1>
<p id="header-subtitle" class="header-subtitle">Ready</p>
</div>
<div class="header-right">
<button id="mic-badge" class="badge" onclick="toggleMic()">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/>
<path d="M19 10v2a7 7 0 0 1-14 0v-2"/>
</svg>
<span>Mic</span>
</button>
<button id="tts-badge" class="badge active" onclick="toggleTTS()">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/>
<path d="M15.54 8.46a5 5 0 0 1 0 7.07"/>
<path d="M19.07 4.93a10 10 0 0 1 0 14.14"/>
</svg>
<span>TTS</span>
</button>
<button class="icon-btn" onclick="toggleSettings()">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
</button>
</div>
</header>
<!-- Chat area -->
<div id="chat-area" class="chat-area">
<div id="welcome" class="welcome">
<div class="welcome-icon">
<svg viewBox="0 0 24 24" width="40" height="40" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/>
<path d="M19 10v2a7 7 0 0 1-14 0v-2"/>
<line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/>
</svg>
</div>
<h2>Echo Voice Assistant</h2>
<p>Type a message, or click the microphone to use your voice.</p>
</div>
<div id="messages" class="messages"></div>
</div>
<!-- Input area -->
<div class="input-area">
<form id="chat-form" class="chat-form" onsubmit="sendMessage(event)">
<button type="button" id="mic-btn" class="mic-btn" onclick="toggleMic()">
<svg id="mic-icon" viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/>
<path d="M19 10v2a7 7 0 0 1-14 0v-2"/>
</svg>
<svg id="mic-stop" class="hidden" viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
<rect x="6" y="6" width="12" height="12" rx="2"/>
</svg>
</button>
<div class="input-wrapper">
<textarea
id="message-input"
placeholder="Type a message or use the mic..."
rows="1"
onkeydown="handleKeyDown(event)"
oninput="autoResize(this)"
></textarea>
</div>
<button type="submit" id="send-btn" class="send-btn" disabled>
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2">
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
</svg>
</button>
</form>
<div id="recording-indicator" class="recording-indicator hidden">
<span class="rec-dot"></span>
<span>Listening...</span>
</div>
</div>
</main>
<!-- Settings panel -->
<div id="settings-overlay" class="overlay hidden" onclick="toggleSettings()"></div>
<div id="settings-panel" class="settings-panel hidden">
<div class="settings-header">
<h2>Settings</h2>
<button class="icon-btn" onclick="toggleSettings()">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div class="settings-body">
<div class="setting-group">
<h3>Voice</h3>
<label class="setting-row">
<div>
<span class="setting-label">Voice Input</span>
<span class="setting-desc">Enable microphone for speech recognition</span>
</div>
<input type="checkbox" id="setting-voice" checked onchange="settings.voiceEnabled=this.checked">
</label>
<label class="setting-row">
<div>
<span class="setting-label">Auto-Speak Responses</span>
<span class="setting-desc">Play TTS audio when Echo responds</span>
</div>
<input type="checkbox" id="setting-tts" checked onchange="settings.autoSpeak=this.checked">
</label>
</div>
<hr class="divider">
<div class="setting-group">
<h3>Interface</h3>
<label class="setting-row">
<div>
<span class="setting-label">Show Sidebar</span>
<span class="setting-desc">Display conversation history</span>
</div>
<input type="checkbox" id="setting-sidebar" checked onchange="toggleSidebar(this.checked)">
</label>
</div>
<hr class="divider">
<div class="setting-group">
<h3>About</h3>
<p class="about-text">
Echo is a modular, private-first voice assistant using Vosk for local listening, OpenRouter for reasoning, and Qwen3-TTS for speech.
</p>
</div>
</div>
</div>
</div>
<script src="app.js"></script>
</body>
</html>

624
web/style.css Normal file
View File

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