moxieTalking/web/app.js
Echo Assistant fee5e4d17b 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
2026-03-31 00:49:38 +00:00

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