- 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
595 lines
16 KiB
JavaScript
595 lines
16 KiB
JavaScript
// =========================================================================
|
|
// 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);
|
|
}
|
|
});
|