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_MODEL=Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice
|
||||||
QWEN_TTS_VOICE=voices/echo_voice.wav
|
QWEN_TTS_VOICE=voices/echo_voice.wav
|
||||||
QWEN_TTS_INSTRUCT=Speak clearly with a warm, friendly tone. Be natural and conversational.
|
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/
|
# support first: https://pytorch.org/get-started/locally/
|
||||||
# ===========================================================
|
# ===========================================================
|
||||||
|
|
||||||
# --- Core ---
|
# --- Core (STT) ---
|
||||||
vosk>=0.3.45
|
vosk>=0.3.45
|
||||||
pyaudio>=0.2.14
|
pyaudio>=0.2.14
|
||||||
openwakeword>=0.5.0
|
openwakeword>=0.5.0
|
||||||
@ -16,6 +16,12 @@ openwakeword>=0.5.0
|
|||||||
openai>=1.30.0
|
openai>=1.30.0
|
||||||
python-dotenv>=1.0.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) ---
|
# --- TTS (Qwen3-TTS) ---
|
||||||
# Install from source or PyPI once available:
|
# Install from source or PyPI once available:
|
||||||
# pip install qwen-tts
|
# 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