// ========================================================================= // Echo Voice Assistant — Web UI Client // ========================================================================= // ---- State ---- const state = { ws: null, connected: false, isGenerating: false, isRecording: false, isSpeaking: false, currentAudio: null, conversations: [], activeConversationId: null, messages: [], // { role, content, audioUrl? } streamingContent: "", }; const settings = { voiceEnabled: true, autoSpeak: true, sidebarOpen: true, }; // ---- DOM refs ---- const $ = (sel) => document.querySelector(sel); const messagesEl = $("#messages"); const inputEl = $("#message-input"); const sendBtn = $("#send-btn"); const welcomeEl = $("#welcome"); const sidebarEl = $("#sidebar"); const micBtn = $("#mic-btn"); const micIcon = $("#mic-icon"); const micStop = $("#mic-stop"); const micBadge = $("#mic-badge"); const ttsBadge = $("#tts-badge"); const recIndicator = $("#recording-indicator"); const statusDot = $("#status-indicator"); const statusText = $("#status-text"); const headerTitle = $("#header-title"); const headerSubtitle = $("#header-subtitle"); const historyList = $("#history-list"); // ---- WebSocket ---- function connect() { const proto = location.protocol === "https:" ? "wss:" : "ws:"; const url = `${proto}//${location.host}/ws/chat`; state.ws = new WebSocket(url); state.ws.onopen = () => { state.connected = true; statusDot.className = "status-dot online"; statusText.textContent = "Connected"; setSubtitle("Ready"); }; state.ws.onclose = () => { state.connected = false; statusDot.className = "status-dot offline"; statusText.textContent = "Disconnected"; setSubtitle("Disconnected — will reconnect..."); setTimeout(connect, 3000); }; state.ws.onerror = () => { state.connected = false; }; state.ws.onmessage = (event) => { const data = JSON.parse(event.data); handleMessage(data); }; } function send(data) { if (state.ws && state.ws.readyState === WebSocket.OPEN) { state.ws.send(JSON.stringify(data)); } } function handleMessage(data) { switch (data.type) { case "token": state.streamingContent += data.text; updateStreamingBubble(); break; case "done": state.isGenerating = false; finalizeStreaming(data.text); if (data.full_text) { updateHistoryItem(data.full_text); } setSubtitle("Ready"); break; case "audio": if (settings.autoSpeak) { playAudio(data.url); } // Attach audio to last assistant message if (state.messages.length > 0) { const last = state.messages[state.messages.length - 1]; if (last.role === "assistant") { last.audioUrl = data.url; updateSpeakButton(last); } } break; case "action": // Show action result as a system message addMessage("system", data.result, true); break; case "error": state.isGenerating = false; finalizeStreaming(""); addMessage("assistant", data.text || "Something went wrong."); setSubtitle("Error"); break; case "ready": state.isGenerating = false; inputEl.disabled = false; updateSendButton(); break; case "cleared": state.messages = []; state.streamingContent = ""; renderMessages(); break; } } // ---- Chat ---- function sendMessage(e) { e.preventDefault(); const text = inputEl.value.trim(); if (!text || state.isGenerating) return; // Hide welcome welcomeEl.classList.add("hidden"); // Add user message addMessage("user", text); inputEl.value = ""; inputEl.style.height = "auto"; updateSendButton(); // Send to server state.isGenerating = true; state.streamingContent = ""; inputEl.disabled = true; setSubtitle("Thinking..."); // Create streaming bubble createStreamingBubble(); send({ type: "chat", payload: { message: text } }); } function addMessage(role, content, isHtml = false) { const msg = { role, content, audioUrl: null, isHtml }; state.messages.push(msg); appendMessageDOM(msg); scrollToBottom(); } function appendMessageDOM(msg) { const div = document.createElement("div"); div.className = `message ${msg.role}`; div.dataset.role = msg.role; const avatar = msg.role === "user" ? userAvatar() : botAvatar(); let contentHtml = ""; if (msg.isHtml) { contentHtml = `
${msg.content}
`; } else { contentHtml = `
${escapeHtml(msg.content)}
`; } // Add speak button for assistant messages if (msg.role === "assistant" && !msg.isHtml) { const speakBtn = ``; contentHtml = speakBtn + contentHtml; } div.innerHTML = avatar + contentHtml; messagesEl.appendChild(div); } function userAvatar() { return `
`; } function botAvatar() { return `
`; } // ---- Streaming ---- let streamingEl = null; function createStreamingBubble() { const div = document.createElement("div"); div.className = "message assistant"; div.id = "streaming-msg"; div.innerHTML = ` ${botAvatar()}
`; messagesEl.appendChild(div); streamingEl = div.querySelector(".bubble"); scrollToBottom(); } function updateStreamingBubble() { if (!streamingEl) return; streamingEl.innerHTML = escapeHtml(state.streamingContent) + ''; scrollToBottom(); } function finalizeStreaming(text) { if (streamingEl) { if (text) { streamingEl.innerHTML = escapeHtml(text); // Update the streaming message to a normal one const msgDiv = document.getElementById("streaming-msg"); if (msgDiv) msgDiv.removeAttribute("id"); state.messages.push({ role: "assistant", content: text, audioUrl: null, isHtml: false }); // Add speak button const speakBtn = document.createElement("button"); speakBtn.className = "speak-btn"; speakBtn.onclick = function () { replayMessage(this); }; speakBtn.title = "Play audio"; speakBtn.innerHTML = ` `; streamingEl.parentElement.insertBefore(speakBtn, streamingEl); } else { streamingEl.parentElement.remove(); } streamingEl = null; } scrollToBottom(); } // ---- Audio ---- function playAudio(url) { if (state.currentAudio) { state.currentAudio.pause(); state.currentAudio = null; } state.currentAudio = new Audio(url); state.isSpeaking = true; ttsBadge.classList.add("active"); state.currentAudio.onended = () => { state.isSpeaking = false; ttsBadge.classList.remove("active"); }; state.currentAudio.onerror = () => { state.isSpeaking = false; ttsBadge.classList.remove("active"); }; state.currentAudio.play(); } function replayMessage(btn) { // Find the closest message div const msgDiv = btn.closest(".message"); if (!msgDiv) return; // Find the message in state const bubbleText = msgDiv.querySelector(".bubble").textContent; const msg = state.messages.find(m => m.content === bubbleText && m.role === "assistant"); if (msg && msg.audioUrl) { if (state.isSpeaking) { state.currentAudio?.pause(); state.isSpeaking = false; ttsBadge.classList.remove("active"); return; } playAudio(msg.audioUrl); } else { // No server audio — use browser TTS if ("speechSynthesis" in window) { if (state.isSpeaking) { speechSynthesis.cancel(); state.isSpeaking = false; return; } const utterance = new SpeechSynthesisUtterance(bubbleText); utterance.onstart = () => { state.isSpeaking = true; }; utterance.onend = () => { state.isSpeaking = false; }; utterance.onerror = () => { state.isSpeaking = false; }; speechSynthesis.speak(utterance); } } } function updateSpeakButton(msg) { // Find the last assistant message DOM element const allMsgs = messagesEl.querySelectorAll(".message.assistant"); if (allMsgs.length === 0) return; const lastMsg = allMsgs[allMsgs.length - 1]; const existingBtn = lastMsg.querySelector(".speak-btn"); if (existingBtn) { existingBtn.style.opacity = "1"; } } // ---- Voice Input (Web Speech API) ---- let recognition = null; function toggleMic() { if (state.isRecording) { stopRecording(); } else { startRecording(); } } function startRecording() { if (!settings.voiceEnabled) { alert("Voice input is disabled. Enable it in Settings."); return; } const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; if (!SpeechRecognition) { alert("Speech recognition is not supported in this browser. Try Chrome."); return; } recognition = new SpeechRecognition(); recognition.continuous = false; recognition.interimResults = true; recognition.lang = "en-US"; let finalTranscript = ""; recognition.onresult = (event) => { let interim = ""; for (let i = event.resultIndex; i < event.results.length; i++) { if (event.results[i].isFinal) { finalTranscript += event.results[i][0].transcript; } else { interim += event.results[i][0].transcript; } } inputEl.value = finalTranscript || interim; updateSendButton(); }; recognition.onend = () => { state.isRecording = false; setRecordingUI(false); if (finalTranscript.trim()) { // Auto-send inputEl.value = finalTranscript.trim(); sendMessage(new Event("submit")); } recognition = null; }; recognition.onerror = (event) => { console.error("Speech recognition error:", event.error); state.isRecording = false; setRecordingUI(false); recognition = null; }; recognition.start(); state.isRecording = true; setRecordingUI(true); } function stopRecording() { if (recognition) { recognition.stop(); } } function setRecordingUI(recording) { micBtn.classList.toggle("recording", recording); micIcon.classList.toggle("hidden", recording); micStop.classList.toggle("hidden", !recording); recIndicator.classList.toggle("hidden", !recording); micBadge.classList.toggle("recording", recording); if (recording) { inputEl.placeholder = "Listening..."; setSubtitle("Listening..."); } else { inputEl.placeholder = "Type a message or use the mic..."; } } // ---- TTS Toggle ---- function toggleTTS() { settings.autoSpeak = !settings.autoSpeak; ttsBadge.classList.toggle("active", settings.autoSpeak); document.getElementById("setting-tts").checked = settings.autoSpeak; } // ---- Sidebar ---- function toggleSidebar(force) { const show = force !== undefined ? force : !settings.sidebarOpen; settings.sidebarOpen = show; sidebarEl.classList.toggle("collapsed", !show); } // ---- Settings ---- function toggleSettings() { const panel = $("#settings-panel"); const overlay = $("#settings-overlay"); panel.classList.toggle("hidden"); overlay.classList.toggle("hidden"); } // ---- Conversations (local only for now) ---- function newChat() { // Clear current messages state.messages = []; state.streamingContent = ""; messagesEl.innerHTML = ""; // Send clear to server send({ type: "clear" }); // Show welcome welcomeEl.classList.remove("hidden"); headerTitle.textContent = "Echo"; // Create local history entry const id = Date.now().toString(); const conv = { id, title: "New Conversation", time: new Date() }; state.conversations.unshift(conv); state.activeConversationId = id; renderHistory(); } function updateHistoryItem(text) { const conv = state.conversations.find(c => c.id === state.activeConversationId); if (conv && conv.title === "New Conversation") { conv.title = text.slice(0, 40) + (text.length > 40 ? "..." : ""); renderHistory(); } } function renderHistory() { if (state.conversations.length === 0) { historyList.innerHTML = '

No conversations yet

'; return; } historyList.innerHTML = state.conversations.map(conv => `
${escapeHtml(conv.title)} ${timeAgo(conv.time)}
`).join(""); } function loadConversation(id) { // For now, conversations are in-memory only state.activeConversationId = id; renderHistory(); const conv = state.conversations.find(c => c.id === id); if (conv) { headerTitle.textContent = conv.title; } } function deleteConversation(id) { state.conversations = state.conversations.filter(c => c.id !== id); if (state.activeConversationId === id) { state.activeConversationId = null; headerTitle.textContent = "Echo"; } renderHistory(); } // ---- Helpers ---- function escapeHtml(text) { const div = document.createElement("div"); div.textContent = text; return div.innerHTML; } function scrollToBottom() { const area = $(".chat-area"); requestAnimationFrame(() => { area.scrollTop = area.scrollHeight; }); } function setSubtitle(text) { headerSubtitle.textContent = text; } function updateSendButton() { sendBtn.disabled = !inputEl.value.trim() || state.isGenerating; } function handleKeyDown(e) { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendMessage(e); } } function autoResize(el) { el.style.height = "auto"; el.style.height = Math.min(el.scrollHeight, 160) + "px"; updateSendButton(); } function timeAgo(date) { const seconds = Math.floor((new Date() - date) / 1000); if (seconds < 60) return "Just now"; const minutes = Math.floor(seconds / 60); if (minutes < 60) return `${minutes}m ago`; const hours = Math.floor(minutes / 60); if (hours < 24) return `${hours}h ago`; return `${Math.floor(hours / 24)}d ago`; } function renderMessages() { messagesEl.innerHTML = ""; if (state.messages.length === 0) { welcomeEl.classList.remove("hidden"); } else { welcomeEl.classList.add("hidden"); state.messages.forEach(msg => appendMessageDOM(msg)); scrollToBottom(); } } // ---- Theme (dark mode) ---- function initTheme() { const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; if (prefersDark) { document.body.classList.add("dark"); } window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", (e) => { document.body.classList.toggle("dark", e.matches); }); } // ---- Init ---- document.addEventListener("DOMContentLoaded", () => { initTheme(); connect(); // Focus input on click in chat area $(".chat-area").addEventListener("click", (e) => { if (e.target.closest(".bubble") || e.target.closest(".speak-btn")) return; inputEl.focus(); }); // Mobile: collapse sidebar by default if (window.innerWidth < 768) { toggleSidebar(false); } });