feat: provision private Hermes assistants for authenticated Hope accounts

This commit is contained in:
snowspeeder committed 2026-10-01 13:30:18 -04:00
1 parent da65da68b3
commit 2aedea84b5
33 files changed
+3063 -1743

No files matched your search

+147 -318
View File
@@ -6,7 +6,6 @@ const { Room, RoomEvent } = LivekitClient;
// /livekit -> localhost:7880). Keeps a single HTTPS port and avoids mixed
// content when the UI is served over HTTPS.
const LIVEKIT_URL = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/livekit`;
const ROOM_NAME = "voice-room";
const AGENT_NAME = "voice-assistant";
const VOICES = [
@@ -24,10 +23,9 @@ const VOICES = [
{ id: "en-US-SteffanNeural", name: "Steffan" },
];
const HISTORY_KEY = "voice_history";
let historyKey = null;
const HISTORY_MAX = 50;
const MODE_KEY = "hope_conversation_mode";
const MODEL_KEY = "preferred_model";
// ── State ───────────────────────────────────────────────────────────────────
let room = null;
@@ -41,10 +39,6 @@ let wakeLock = null;
let conversationMode = localStorage.getItem(MODE_KEY) === "text" ? "text" : "voice";
let connectionPromise = null;
// ── Model (brain) state — populated from the agent's data channel ──────────
let availableModels = []; // ids published by the agent
let selectedModel = localStorage.getItem(MODEL_KEY) || null;
function escapeHtml(s) {
const d = document.createElement("div");
d.textContent = s || "";
@@ -68,8 +62,6 @@ const settingsSheet = document.getElementById("settingsSheet");
const sheetBackdrop = document.getElementById("sheetBackdrop");
const closeSheetBtn = document.getElementById("closeSheetBtn");
const voiceListEl = document.getElementById("voiceList");
const modelListEl = document.getElementById("modelList");
const modelNoteEl = document.getElementById("modelNote");
const clearBtn = document.getElementById("clearBtn");
const tasksBtn = document.getElementById("tasksBtn");
const tasksPanel = document.getElementById("tasksPanel");
@@ -84,69 +76,30 @@ const chatComposer = document.getElementById("chatComposer");
const chatInput = document.getElementById("chatInput");
const sendChatBtn = document.getElementById("sendChatBtn");
// ── Background tasks state ──────────────────────────────────────────────────
const tasks = new Map(); // task_id -> {id, description, status, steps: [], result, error}
function updateTasksBadge() {
const active = [...tasks.values()].filter(t => t.status === "running" || t.status === "pending").length;
if (active > 0) {
tasksBadge.hidden = false;
tasksBadge.textContent = String(active);
} else {
tasksBadge.hidden = true;
// Activity is progress only, never a second assistant transcript.
const progress = new Map();
function handleProgress(data) {
if (typeof data.request_id !== "string" || !["working", "completed", "failed"].includes(data.status) || typeof data.message !== "string") return;
progress.set(data.request_id, { ...data, updated: Date.now() });
while (progress.size > 50) progress.delete(progress.keys().next().value);
const working = [...progress.values()].filter(item => item.status === "working").length;
tasksBadge.hidden = !working; tasksBadge.textContent = String(working);
tasksList.replaceChildren();
for (const item of [...progress.values()].sort((a, b) => b.updated - a.updated)) {
const row = document.createElement("div"); row.className = "task-card";
const state = document.createElement("strong"); state.textContent = { working: "Working", completed: "Completed", failed: "Failed" }[item.status];
const text = document.createElement("p"); text.textContent = item.message;
row.append(state, text); tasksList.append(row);
}
statusEl.textContent = data.message || { working: "Hope is working…", completed: "Hope finished", failed: "Hope could not complete this request" }[data.status];
setState(working ? "thinking" : "listening");
}
function renderTasks() {
const items = [...tasks.values()].sort((a, b) => (b.created_at || 0) - (a.created_at || 0));
if (items.length === 0) {
tasksList.innerHTML = '<div class="tasks-empty"><strong>Nothing running</strong><span>Ask Hope to research or compare something.</span></div>';
return;
}
tasksList.innerHTML = "";
for (const t of items) {
const card = document.createElement("div");
card.className = "task-card";
const statusClass = t.status || "pending";
card.innerHTML = `
<div class="task-card-header">
<span class="task-status-dot ${statusClass}"></span>
<span class="task-desc">${escapeHtml(t.description)}</span>
</div>
<ul class="task-steps">${t.steps.map(s => `<li class="task-step ${s.role}">${escapeHtml(s.content)}</li>`).join("")}</ul>
${t.result ? `<div class="task-result">${escapeHtml(t.result)}</div>` : ""}
${t.error ? `<div class="task-error">${escapeHtml(t.error)}</div>` : ""}
`;
tasksList.appendChild(card);
}
}
function handleTaskEvent(data) {
const { task_id, event, role, content, result, error, task } = data;
if (event === "created" && task) {
tasks.set(task_id, { ...task, steps: [] });
} else if (event === "step") {
const t = tasks.get(task_id);
if (t) {
t.steps.push({ role, content });
t.status = "running";
}
} else if (event === "completed") {
const t = tasks.get(task_id);
if (t) { t.status = "completed"; t.result = result; }
} else if (event === "failed") {
const t = tasks.get(task_id);
if (t) { t.status = "failed"; t.error = error; }
}
updateTasksBadge();
renderTasks();
}
function setTasksOpen(open) {
tasksPanel.hidden = !open; tasksPanel.inert = !open;
tasksPanel.classList.toggle("open", open);
tasksBtn.setAttribute("aria-expanded", String(open));
if (open) closeTasksBtn.focus(); else tasksBtn.focus();
}
tasksBtn.setAttribute("aria-expanded", "false");
tasksBtn.addEventListener("click", () => setTasksOpen(!tasksPanel.classList.contains("open")));
closeTasksBtn.addEventListener("click", () => setTasksOpen(false));
@@ -188,7 +141,7 @@ async function setConversationMode(mode) {
}, 0);
}
if (!room || !room.localParticipant) return;
if (!room || !agentReady || !room.localParticipant) return;
sendModeMessage(mode);
if (mode === "text") {
await room.localParticipant.setMicrophoneEnabled(false);
@@ -476,20 +429,22 @@ function stopVisualLoop() {
// ── Conversation history (localStorage) ─────────────────────────────────────
function loadHistory() {
try {
const raw = localStorage.getItem(HISTORY_KEY);
if (!historyKey) return [];
const raw = localStorage.getItem(historyKey);
if (!raw) return [];
const arr = JSON.parse(raw);
return Array.isArray(arr) ? arr.slice(-HISTORY_MAX) : [];
return Array.isArray(arr) ? arr.filter(msg => msg && ["user", "agent", "system"].includes(msg.role) && typeof msg.text === "string").slice(-HISTORY_MAX) : [];
} catch (e) { return []; }
}
function persistHistory() {
try {
localStorage.setItem(HISTORY_KEY, JSON.stringify(history.slice(-HISTORY_MAX)));
if (!historyKey) return;
localStorage.setItem(historyKey, JSON.stringify(history.slice(-HISTORY_MAX)));
} catch (e) { /* storage full or unavailable */ }
}
let history = loadHistory();
let history = [];
const DELIVERY_MARKERS = /\[(?:appreciative|calm|concerned|curious|encouraging|excited|happy|joking|quiet|reassuring|reflective|sad|serious|surprised|sympathetic|urgent|whispering|laughter|sighing|breathing|neutral)\]\s*/gi;
@@ -587,7 +542,9 @@ function registerTranscriptionStream(rm) {
try {
rm.registerTextStreamHandler("lk.transcription", async (reader, participantInfo) => {
const text = await reader.readAll();
if (!text || !text.trim()) return;
if (rm !== room || !HopeAuth.user || !text || !text.trim()) return;
const sender = rm.remoteParticipants.get(participantInfo?.identity);
if (!sender || !isAgent(sender)) return;
const attrs = (reader.info && reader.info.attributes) || {};
const trackId = attrs["lk.transcribed_track_id"] || "";
// Distinguish user vs agent by the transcribed track's owner.
@@ -614,36 +571,16 @@ function registerTranscriptionStream(rm) {
function handleDataPacket(payload, participant, topic) {
try {
const msg = JSON.parse(new TextDecoder().decode(payload));
if (msg.type === "agent_ready") {
if (!HopeAuth.user || !participant || !isAgent(participant)) return;
if (msg.type === "agent_ready" && topic === "conversation-control") {
agentReady = true;
} else if (msg.type === "transcript") {
addMessage(msg.role || "agent", msg.text);
if ((msg.role || "agent") === "agent") {
if (conversationMode === "text") {
setState("listening");
chatInput.focus();
} else if (uiState === "thinking") {
// Text arrives even when headless playback or an analyser is
// unavailable; use it to release the thinking state.
setState("speaking");
}
}
} else if (msg.type === "task_event") {
handleTaskEvent(msg);
} else if (msg.type === "models") {
availableModels = Array.isArray(msg.models) ? msg.models : [];
// Don't clobber a saved preference — the agent acks the applied
// model separately via model_changed.
if (!selectedModel && msg.selected) selectedModel = msg.selected;
renderModelList();
} else if (msg.type === "model_changed") {
if (msg.model) {
selectedModel = msg.model;
localStorage.setItem(MODEL_KEY, selectedModel);
renderModelList();
}
} else if (msg.type === "transcript" && topic === "transcript") {
if (!["agent", "user"].includes(msg.role) || typeof msg.text !== "string") return;
addMessage(msg.role, msg.text);
if (msg.role === "agent") setState(conversationMode === "text" ? "listening" : "speaking");
} else if (msg.type === "assistant_progress" && topic === "assistant-progress") {
handleProgress(msg);
}
// set_voice/set_model messages flow the other way; nothing else to do client-side.
} catch (e) {
console.warn("bad data packet", e);
}
@@ -693,61 +630,10 @@ function selectVoice(voiceId) {
}
}
// ── Model (brain) settings ──────────────────────────────────────────────────
function currentModelId() {
if (availableModels.length === 0) return selectedModel;
if (selectedModel && availableModels.includes(selectedModel)) return selectedModel;
return availableModels[0];
}
function renderModelList() {
modelListEl.innerHTML = "";
if (!availableModels.length) {
modelNoteEl.hidden = false;
modelNoteEl.textContent = room
? "Loading available models…"
: "Start a conversation to load the model list.";
return;
}
modelNoteEl.hidden = true;
const active = currentModelId();
for (const id of availableModels) {
const card = document.createElement("div");
card.className = "voice-card" + (id === active ? " active" : "");
card.dataset.model = id;
card.setAttribute("role", "radio");
card.setAttribute("aria-checked", String(id === active));
card.tabIndex = 0;
const name = document.createElement("span");
name.className = "voice-name";
name.textContent = id;
card.appendChild(name);
card.addEventListener("click", () => selectModel(id));
card.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
selectModel(id);
}
});
modelListEl.appendChild(card);
}
}
function selectModel(modelId) {
selectedModel = modelId;
localStorage.setItem(MODEL_KEY, modelId);
renderModelList();
if (room && agentParticipant) {
sendModelMessage(modelId);
addMessage("system", `Brain changed to ${modelId}`);
}
}
function openSheet() {
renderVoiceList();
renderModelList();
settingsSheet.hidden = false; settingsSheet.inert = false;
document.getElementById("authenticatedApp").inert = true;
sheetBackdrop.hidden = false;
settingsSheet.classList.add("open");
settingsBtn.setAttribute("aria-expanded", "true");
@@ -756,8 +642,10 @@ function openSheet() {
function closeSheet() {
settingsSheet.classList.remove("open");
settingsSheet.hidden = true; settingsSheet.inert = true;
document.getElementById("authenticatedApp").inert = false;
settingsBtn.setAttribute("aria-expanded", "false");
window.setTimeout(() => { sheetBackdrop.hidden = true; }, 240);
sheetBackdrop.hidden = true;
settingsBtn.focus();
}
@@ -766,6 +654,12 @@ settingsBtn.addEventListener("click", openSheet);
closeSheetBtn.addEventListener("click", closeSheet);
sheetBackdrop.addEventListener("click", closeSheet);
document.addEventListener("keydown", (event) => {
if (event.key === "Tab" && settingsSheet.classList.contains("open")) {
const focusable = [...settingsSheet.querySelectorAll('button:not(:disabled), [tabindex="0"]')];
const first = focusable[0], last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); }
}
if (event.key !== "Escape") return;
if (settingsSheet.classList.contains("open")) closeSheet();
if (tasksPanel.classList.contains("open")) setTasksOpen(false);
@@ -796,8 +690,7 @@ function isAgent(participant) {
if (participant.isAgent !== undefined && participant.isAgent !== null) {
return participant.isAgent;
}
return participant.identity === AGENT_NAME
|| participant.kind === LivekitClient.ParticipantKind.AGENT;
return participant.kind === (LivekitClient.ParticipantKind?.AGENT ?? 4);
}
function findAgent(rm) {
@@ -809,32 +702,33 @@ function findAgent(rm) {
}
function markAgentFound(rm, participant) {
if (rm !== room || !HopeAuth.user) return;
agentParticipant = participant;
statusEl.textContent = conversationMode === "text"
? "Hope joined · Text chat ready"
: "Hope joined · Preparing microphone…";
// Synchronize voice, brain and interaction mode when the agent joins.
// Synchronize voice and interaction mode when the agent joins.
sendVoiceMessage(currentVoiceId(), rm);
if (selectedModel) sendModelMessage(selectedModel, rm);
sendModeMessage(conversationMode, rm);
}
function createRoom(token) {
async function createRoom(token, attempt) {
const newRoom = new Room({
adaptiveStream: true,
dynacast: true,
});
room = newRoom;
// Listen for the agent joining after us
newRoom.on(RoomEvent.ParticipantConnected, (participant) => {
if (isAgent(participant)) markAgentFound(newRoom, participant);
if (newRoom === room && isAgent(participant)) markAgentFound(newRoom, participant);
});
// Attach and play incoming audio tracks (agent TTS)
newRoom.on(RoomEvent.TrackSubscribed, (track, publication, participant) => {
if (track.kind === LivekitClient.Track.Kind.Audio) {
if (newRoom === room && isAgent(participant) && track.kind === LivekitClient.Track.Kind.Audio) {
const el = track.attach();
el.id = `audio-${participant.identity}`;
el.id = `audio-${participant.identity}-${publication.trackSid}`;
document.body.appendChild(el);
// Remote audio analyser drives the orb + visualizer while speaking
setupRemoteAudioAnalyser(el);
@@ -848,6 +742,7 @@ function createRoom(token) {
// Browsers block audio playback until a user gesture; show an unlock
// button if the page loads without one.
newRoom.on(RoomEvent.AudioPlaybackStatusChanged, () => {
if (newRoom !== room) return;
if (!newRoom.canPlaybackAudio) {
const existing = document.getElementById("audioUnlockBtn");
if (!existing) {
@@ -855,7 +750,7 @@ function createRoom(token) {
btn.id = "audioUnlockBtn";
btn.className = "btn btn-primary";
btn.textContent = "Tap to enable audio";
btn.style.cssText = "position:fixed;bottom:2rem;left:50%;transform:translateX(-50%);z-index:999;padding:1rem 2rem;font-size:1.1rem;border-radius:999px;box-shadow:0 4px 24px rgba(0,0,0,.5)";
btn.classList.add("audio-unlock");
btn.onclick = () => { newRoom.startAudio(); btn.remove(); };
document.body.appendChild(btn);
}
@@ -870,143 +765,97 @@ function createRoom(token) {
// Data channel: transcripts (secondary source) + voice control
newRoom.on(RoomEvent.DataReceived, (payload, participant, kind, topic) => {
handleDataPacket(payload, participant, topic);
if (newRoom === room) handleDataPacket(payload, participant, topic);
});
// Guard with `=== room` so a stale (retry-discarded) connection cannot
// clobber UI state of the active one.
newRoom.on(RoomEvent.ConnectionQualityChanged, (participant, quality) => {
if (participant === newRoom.localParticipant && newRoom === room) {
statusEl.textContent = `Secure room · ${quality} connection`;
if (![...progress.values()].some(item => item.status === "working")) statusEl.textContent = `Secure room · ${quality} connection`;
}
});
newRoom.on(RoomEvent.Disconnected, () => {
if (newRoom !== room) return;
teardownAudio();
stopVisualLoop();
releaseWakeLock();
statusEl.textContent = "Conversation ended";
setState("idle");
startBtn.disabled = false;
stopBtn.disabled = true;
agentParticipant = null;
agentReady = false;
});
return newRoom.connect(LIVEKIT_URL, token, {
autoSubscribe: true,
}).then(() => {
// Pick up an agent that was already in the room before we joined
const existing = findAgent(newRoom);
if (existing) markAgentFound(newRoom, existing);
return newRoom;
stopConversation();
});
await newRoom.connect(LIVEKIT_URL, token, { autoSubscribe: true });
checkAttempt(attempt);
const existing = findAgent(newRoom);
if (existing) markAgentFound(newRoom, existing);
return newRoom;
}
function waitForAgentReady(timeoutMs) {
return new Promise((resolve) => {
if (agentReady) return resolve(true);
const deadline = Date.now() + timeoutMs;
const timer = setInterval(() => {
if (agentReady) {
clearInterval(timer);
resolve(true);
} else if (Date.now() > deadline) {
clearInterval(timer);
resolve(false);
}
}, 100);
let connectionEpoch = 0;
let tokenAbort = null;
function checkAttempt(attempt) {
if (attempt !== connectionEpoch || !HopeAuth.user || HopeAuth.user.status !== "ready") throw new Error("Connection cancelled");
}
function waitFor(condition, timeout, attempt) {
return new Promise((resolve, reject) => {
const deadline = Date.now() + timeout;
const tick = () => {
try { checkAttempt(attempt); } catch (error) { reject(error); return; }
if (condition()) resolve(true);
else if (Date.now() >= deadline) resolve(false);
else setTimeout(tick, 100);
};
tick();
});
}
function waitForAgent(timeoutMs) {
return new Promise((resolve) => {
if (findAgent(room)) return resolve(true);
const deadline = Date.now() + timeoutMs;
const timer = setInterval(() => {
const found = findAgent(room);
if (found) {
if (!agentParticipant) markAgentFound(room, found);
clearInterval(timer);
resolve(true);
} else if (Date.now() > deadline) {
clearInterval(timer);
resolve(false);
}
}, 250);
});
function cleanupRoom() {
const stale = room; room = null; agentParticipant = null; agentReady = false;
if (stale) stale.disconnect().catch(() => {});
teardownAudio(); stopVisualLoop(); releaseWakeLock();
document.querySelectorAll('[id^="audio-"]').forEach(el => el.remove());
document.getElementById("audioUnlockBtn")?.remove();
muteBtn.disabled = true; micMuted = false; updateMuteIcon();
}
function stopConversation() {
connectionEpoch++; tokenAbort?.abort(); tokenAbort = null; connectionPromise = null;
cleanupRoom(); statusEl.textContent = "Conversation ended"; setState("idle");
startBtn.disabled = !HopeAuth.user || HopeAuth.user.status !== "ready"; stopBtn.disabled = true;
for (const item of progress.values()) if (item.status === "working") { item.status = "failed"; item.message = "Conversation ended before completion."; }
tasksBadge.hidden = true;
}
async function connectConversation() {
if (room && agentParticipant) return room;
if (!HopeAuth.user || HopeAuth.user.status !== "ready") throw new Error("Sign in with a ready account to connect.");
if (connectionPromise) return connectionPromise;
connectionPromise = (async () => {
if (room && agentReady) return room;
const attempt = ++connectionEpoch;
startBtn.disabled = true; stopBtn.disabled = false;
const promise = (async () => {
try {
setState("connecting");
statusEl.textContent = "Opening your secure room…";
await acquireWakeLock();
// Get a signed access token from the token endpoint.
const tokenData = await fetchToken(ROOM_NAME);
room = await createRoom(tokenData.token);
// The agent is dispatched when we join. If it misses the dispatch
// (for example during a server restart), reconnect once.
setState("connecting"); statusEl.textContent = "Opening your secure room…";
checkAttempt(attempt);
const tokenData = await fetchToken(attempt); checkAttempt(attempt);
await createRoom(tokenData.token, attempt); checkAttempt(attempt);
statusEl.textContent = "Inviting Hope into the room…";
if (!(await waitForAgent(10000))) {
statusEl.textContent = "Hope is taking a moment · Retrying…";
const stale = room;
room = null;
agentParticipant = null;
agentReady = false;
await stale.disconnect();
const freshTokenData = await fetchToken(ROOM_NAME);
room = await createRoom(freshTokenData.token);
if (!(await waitForAgent(15000))) {
throw new Error("Assistant did not join — reload the page and try again");
}
}
statusEl.textContent = "Hope joined · Finishing setup…";
if (!(await waitForAgentReady(10000))) {
throw new Error("Assistant joined but was not ready — try again");
}
sendModeMessage(conversationMode);
if (!(await waitFor(() => !!findAgent(room), 15000, attempt))) throw new Error("Assistant did not join — try again");
if (!agentParticipant) markAgentFound(room, findAgent(room));
if (!(await waitFor(() => agentReady, 10000, attempt))) throw new Error("Assistant was not ready — try again");
checkAttempt(attempt); sendModeMessage(conversationMode);
const active = room;
if (conversationMode === "voice") {
await room.startAudio();
await active.startAudio(); checkAttempt(attempt);
statusEl.textContent = "Waiting for microphone permission…";
await room.localParticipant.setMicrophoneEnabled(true);
micMuted = false;
setupMicAnalyser();
startVisualLoop();
await active.localParticipant.setMicrophoneEnabled(true);
if (attempt !== connectionEpoch) { await active.localParticipant.setMicrophoneEnabled(false); throw new Error("Connection cancelled"); }
checkAttempt(attempt); micMuted = false; setupMicAnalyser(); startVisualLoop();
statusEl.textContent = "Secure room · Microphone on";
} else {
await room.localParticipant.setMicrophoneEnabled(false);
micMuted = true;
stopVisualLoop();
statusEl.textContent = "Secure room · Text mode";
await active.localParticipant.setMicrophoneEnabled(false); checkAttempt(attempt);
micMuted = true; stopVisualLoop(); statusEl.textContent = "Secure room · Text mode";
}
startBtn.disabled = true;
stopBtn.disabled = false;
muteBtn.disabled = conversationMode !== "voice";
updateMuteIcon();
setState("listening");
return room;
} catch (err) {
console.error("Connection failed:", err);
statusEl.textContent = `Error: ${err.message}`;
setState("idle");
releaseWakeLock();
throw err;
} finally {
connectionPromise = null;
}
muteBtn.disabled = conversationMode !== "voice"; updateMuteIcon(); setState("listening"); return room;
} catch (error) {
if (attempt === connectionEpoch) { cleanupRoom(); statusEl.textContent = `Error: ${error.message}`; setState("idle"); startBtn.disabled = !HopeAuth.user || HopeAuth.user.status !== "ready"; stopBtn.disabled = true; }
throw error;
} finally { if (attempt === connectionEpoch) connectionPromise = null; }
})();
return connectionPromise;
connectionPromise = promise;
return promise;
}
startBtn.addEventListener("click", () => {
@@ -1034,7 +883,7 @@ chatInput.addEventListener("keydown", (event) => {
chatComposer.addEventListener("submit", async (event) => {
event.preventDefault();
const text = chatInput.value.trim();
if (!text) return;
if (!text || !HopeAuth.user || HopeAuth.user.status !== "ready") return;
sendChatBtn.disabled = true;
chatInput.disabled = true;
@@ -1048,34 +897,16 @@ chatComposer.addEventListener("submit", async (event) => {
} catch (err) {
console.error("Text message failed:", err);
statusEl.textContent = `Message not sent: ${err.message}`;
setState("listening");
setState(room ? "listening" : "idle");
} finally {
chatInput.disabled = false;
sendChatBtn.disabled = !chatInput.value.trim();
chatInput.disabled = !HopeAuth.user || HopeAuth.user.status !== "ready";
sendChatBtn.disabled = !HopeAuth.user || !chatInput.value.trim();
chatInput.focus();
}
});
// ── Stop ────────────────────────────────────────────────────────────────────
stopBtn.addEventListener("click", () => {
if (room) {
room.disconnect();
room = null;
agentParticipant = null;
agentReady = false;
}
teardownAudio();
stopVisualLoop();
releaseWakeLock();
document.querySelectorAll('[id^="audio-"]').forEach(el => el.remove());
statusEl.textContent = "Conversation ended";
setState("idle");
startBtn.disabled = false;
stopBtn.disabled = true;
muteBtn.disabled = true;
micMuted = false;
updateMuteIcon();
});
stopBtn.addEventListener("click", stopConversation);
// ── Mic mute toggle ─────────────────────────────────────────────────────────
muteBtn.addEventListener("click", async () => {
@@ -1098,15 +929,13 @@ function updateMuteIcon() {
}
// ── Helper: fetch a signed access token from the token endpoint ────────────
async function fetchToken(roomName) {
const resp = await fetch("/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ room: roomName }),
});
if (!resp.ok) throw new Error(`Token endpoint returned ${resp.status}`);
const data = await resp.json();
return { token: data.token, room: data.room || null };
async function fetchToken(attempt) {
checkAttempt(attempt);
tokenAbort = new AbortController();
const data = await HopeAuth.request("/token", { method: "POST", body: JSON.stringify({}), signal: tokenAbort.signal });
checkAttempt(attempt);
if (!data.token || typeof data.token !== "string") throw new Error("Token endpoint did not return a room token");
return data;
}
// ── Helper: send voice selection to the agent via data channel ─────────────
@@ -1117,7 +946,7 @@ function sendVoiceMessage(voice, targetRoom) {
r.localParticipant.publishData(payload, {
topic: "voice-control",
reliable: true,
});
}).catch(() => {});
}
function sendModeMessage(mode, targetRoom) {
@@ -1127,18 +956,18 @@ function sendModeMessage(mode, targetRoom) {
r.localParticipant.publishData(payload, {
topic: "conversation-control",
reliable: true,
});
}
// ── Helper: send model selection to the agent via data channel ─────────────
function sendModelMessage(model, targetRoom) {
const r = targetRoom || room;
if (!r || !r.localParticipant || !model) return;
const payload = new TextEncoder().encode(JSON.stringify({ type: "set_model", model }));
r.localParticipant.publishData(payload, {
topic: "voice-control",
reliable: true,
});
}).catch(() => {});
}
HopeAuth.onChange((user, changed) => {
if (changed || !user || user.status !== "ready") stopConversation();
if (changed) {
historyKey = user ? `hope_history:${location.origin}:${encodeURIComponent(user.id)}` : null;
history = loadHistory(); renderHistory(); progress.clear(); tasksList.replaceChildren(); tasksBadge.hidden = true;
chatInput.value = ""; sendChatBtn.disabled = true;
setTasksOpen(false); closeSheet();
}
startBtn.disabled = !user || user.status !== "ready" || !!room || !!connectionPromise;
chatInput.disabled = !user || user.status !== "ready";
});
buildLedWall();