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

+

Ready

+
+
+ + + +
+
+ + +
+
+
+ + + + + +
+

Echo Voice Assistant

+

Type a message, or click the microphone to use your voice.

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