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:
parent
19a283ec0f
commit
fee5e4d17b
@ -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
|
||||
|
||||
@ -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
267
server.py
Normal 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
594
web/app.js
Normal 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
184
web/index.html
Normal 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
624
web/style.css
Normal 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;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user