feat: provision private Hermes assistants for authenticated Hope accounts
This commit is contained in:
1 parent
da65da68b3
commit
2aedea84b5
33 files changed
+3063
-1743
No files matched your search
+147
-318
@@ -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();
|
||||
Reference in new issue
Block a user