feat: redesign Hope voice experience
This commit is contained in:
1 parent
3c4c43f983
commit
16aadcae96
5 files changed
+1055
-518
No files matched your search
+58
-32
@@ -82,7 +82,7 @@ function updateTasksBadge() {
|
||||
function renderTasks() {
|
||||
const items = [...tasks.values()].sort((a, b) => (b.created_at || 0) - (a.created_at || 0));
|
||||
if (items.length === 0) {
|
||||
tasksList.innerHTML = '<p class="tasks-empty">No tasks yet. Ask Hope to research something.</p>';
|
||||
tasksList.innerHTML = '<div class="tasks-empty"><strong>Nothing running</strong><span>Ask Hope to research or compare something.</span></div>';
|
||||
return;
|
||||
}
|
||||
tasksList.innerHTML = "";
|
||||
@@ -124,8 +124,14 @@ function handleTaskEvent(data) {
|
||||
renderTasks();
|
||||
}
|
||||
|
||||
tasksBtn.addEventListener("click", () => tasksPanel.classList.toggle("open"));
|
||||
closeTasksBtn.addEventListener("click", () => tasksPanel.classList.remove("open"));
|
||||
function setTasksOpen(open) {
|
||||
tasksPanel.classList.toggle("open", open);
|
||||
tasksBtn.setAttribute("aria-expanded", String(open));
|
||||
}
|
||||
|
||||
tasksBtn.setAttribute("aria-expanded", "false");
|
||||
tasksBtn.addEventListener("click", () => setTasksOpen(!tasksPanel.classList.contains("open")));
|
||||
closeTasksBtn.addEventListener("click", () => setTasksOpen(false));
|
||||
|
||||
// ── Audio analysers (mic + remote agent audio) ─────────────────────────────
|
||||
let micCtx = null;
|
||||
@@ -196,11 +202,11 @@ function readAgentAudioLevel() {
|
||||
|
||||
// ── State machine ───────────────────────────────────────────────────────────
|
||||
const STATE_LABELS = {
|
||||
idle: "Idle",
|
||||
connecting: "Connecting",
|
||||
idle: "Ready when you are",
|
||||
connecting: "Opening a secure room",
|
||||
listening: "Listening",
|
||||
thinking: "Thinking",
|
||||
speaking: "Speaking",
|
||||
thinking: "Thinking it through",
|
||||
speaking: "Hope is speaking",
|
||||
};
|
||||
|
||||
function setState(next) {
|
||||
@@ -310,10 +316,21 @@ function persistHistory() {
|
||||
|
||||
let history = loadHistory();
|
||||
|
||||
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;
|
||||
|
||||
function cleanSpokenText(text) {
|
||||
return String(text || "").replace(DELIVERY_MARKERS, "").trim();
|
||||
}
|
||||
|
||||
function formatTimestamp(ts) {
|
||||
if (!ts) return "";
|
||||
return new Date(ts).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
|
||||
}
|
||||
|
||||
function renderMessage(msg) {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = `message-row ${msg.role}`;
|
||||
wrap.dataset.ts = msg.ts || "";
|
||||
wrap.dataset.ts = formatTimestamp(msg.ts);
|
||||
|
||||
if (msg.role === "agent") {
|
||||
const dot = document.createElement("span");
|
||||
@@ -323,7 +340,7 @@ function renderMessage(msg) {
|
||||
|
||||
const bubble = document.createElement("div");
|
||||
bubble.className = "bubble";
|
||||
bubble.textContent = msg.text || "";
|
||||
bubble.textContent = cleanSpokenText(msg.text);
|
||||
if (msg.role === "agent") {
|
||||
const viz = document.createElement("span");
|
||||
viz.className = "audio-viz";
|
||||
@@ -350,7 +367,7 @@ function addPartial(role, text) {
|
||||
let last = null;
|
||||
for (const r of rows) last = r;
|
||||
if (last && last.dataset.role === role) {
|
||||
last.querySelector(".bubble").textContent = text;
|
||||
last.querySelector(".bubble").textContent = cleanSpokenText(text);
|
||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||
return;
|
||||
}
|
||||
@@ -364,7 +381,7 @@ function addPartial(role, text) {
|
||||
}
|
||||
const bubble = document.createElement("div");
|
||||
bubble.className = "bubble partial";
|
||||
bubble.textContent = text;
|
||||
bubble.textContent = cleanSpokenText(text);
|
||||
wrap.appendChild(bubble);
|
||||
messagesEl.appendChild(wrap);
|
||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||
@@ -451,19 +468,18 @@ function renderVoiceList() {
|
||||
name.className = "voice-name";
|
||||
name.textContent = v.name + (v.tag ? ` (${v.tag})` : "");
|
||||
|
||||
const previewBtn = document.createElement("button");
|
||||
previewBtn.className = "btn btn-ghost voice-preview";
|
||||
previewBtn.setAttribute("aria-label", `Preview ${v.name}`);
|
||||
previewBtn.title = "Preview (coming soon)";
|
||||
// TODO: play a short Azure TTS sample for this voice.
|
||||
previewBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
addMessage("system", `Preview for ${v.name} is not wired up yet.`);
|
||||
});
|
||||
|
||||
const selected = v.id === active;
|
||||
card.setAttribute("role", "radio");
|
||||
card.setAttribute("aria-checked", String(selected));
|
||||
card.tabIndex = 0;
|
||||
card.appendChild(name);
|
||||
card.appendChild(previewBtn);
|
||||
card.addEventListener("click", () => selectVoice(v.id));
|
||||
card.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
selectVoice(v.id);
|
||||
}
|
||||
});
|
||||
voiceListEl.appendChild(card);
|
||||
}
|
||||
}
|
||||
@@ -482,16 +498,26 @@ function openSheet() {
|
||||
renderVoiceList();
|
||||
sheetBackdrop.hidden = false;
|
||||
settingsSheet.classList.add("open");
|
||||
settingsBtn.setAttribute("aria-expanded", "true");
|
||||
settingsSheet.querySelector(".voice-card")?.focus();
|
||||
}
|
||||
|
||||
function closeSheet() {
|
||||
settingsSheet.classList.remove("open");
|
||||
sheetBackdrop.hidden = true;
|
||||
settingsBtn.setAttribute("aria-expanded", "false");
|
||||
window.setTimeout(() => { sheetBackdrop.hidden = true; }, 240);
|
||||
settingsBtn.focus();
|
||||
}
|
||||
|
||||
settingsBtn.setAttribute("aria-expanded", "false");
|
||||
settingsBtn.addEventListener("click", openSheet);
|
||||
closeSheetBtn.addEventListener("click", closeSheet);
|
||||
sheetBackdrop.addEventListener("click", closeSheet);
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key !== "Escape") return;
|
||||
if (settingsSheet.classList.contains("open")) closeSheet();
|
||||
if (tasksPanel.classList.contains("open")) setTasksOpen(false);
|
||||
});
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape") closeSheet();
|
||||
});
|
||||
@@ -536,7 +562,7 @@ function findAgent(rm) {
|
||||
|
||||
function markAgentFound(rm, participant) {
|
||||
agentParticipant = participant;
|
||||
statusEl.textContent = "Agent connected — speak now";
|
||||
statusEl.textContent = "Secure room · Microphone on";
|
||||
// Send current voice selection to the agent
|
||||
sendVoiceMessage(currentVoiceId(), rm);
|
||||
}
|
||||
@@ -599,7 +625,7 @@ function createRoom(token) {
|
||||
// clobber UI state of the active one.
|
||||
newRoom.on(RoomEvent.ConnectionQualityChanged, (participant, quality) => {
|
||||
if (participant === newRoom.localParticipant && newRoom === room) {
|
||||
statusEl.textContent = `Connected (${quality} quality)`;
|
||||
statusEl.textContent = `Secure room · ${quality} connection`;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -608,7 +634,7 @@ function createRoom(token) {
|
||||
teardownAudio();
|
||||
stopVisualLoop();
|
||||
releaseWakeLock();
|
||||
statusEl.textContent = "Disconnected";
|
||||
statusEl.textContent = "Conversation ended";
|
||||
setState("idle");
|
||||
startBtn.disabled = false;
|
||||
stopBtn.disabled = true;
|
||||
@@ -646,7 +672,7 @@ function waitForAgent(timeoutMs) {
|
||||
startBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
setState("connecting");
|
||||
statusEl.textContent = "Connecting...";
|
||||
statusEl.textContent = "Opening your secure room…";
|
||||
await acquireWakeLock();
|
||||
|
||||
// Get a signed access token from the token endpoint
|
||||
@@ -658,9 +684,9 @@ startBtn.addEventListener("click", async () => {
|
||||
// The agent is dispatched when we join. If it misses the dispatch
|
||||
// (e.g. the server just started and the worker wasn't ready yet),
|
||||
// reconnect once to trigger a fresh dispatch.
|
||||
statusEl.textContent = "Waiting for assistant...";
|
||||
statusEl.textContent = "Inviting Hope into the room…";
|
||||
if (!(await waitForAgent(10000))) {
|
||||
statusEl.textContent = "Assistant not ready, retrying...";
|
||||
statusEl.textContent = "Hope is taking a moment · Retrying…";
|
||||
const stale = room;
|
||||
room = null; // detach first so its Disconnected handler
|
||||
agentParticipant = null; // cannot clobber the UI
|
||||
@@ -672,10 +698,10 @@ startBtn.addEventListener("click", async () => {
|
||||
}
|
||||
}
|
||||
|
||||
statusEl.textContent = "Requesting microphone...";
|
||||
statusEl.textContent = "Waiting for microphone permission…";
|
||||
await room.localParticipant.setMicrophoneEnabled(true);
|
||||
|
||||
statusEl.textContent = "Connected — speak now";
|
||||
statusEl.textContent = "Secure room · Microphone on";
|
||||
startBtn.disabled = true;
|
||||
stopBtn.disabled = false;
|
||||
muteBtn.disabled = false;
|
||||
@@ -703,7 +729,7 @@ stopBtn.addEventListener("click", () => {
|
||||
stopVisualLoop();
|
||||
releaseWakeLock();
|
||||
document.querySelectorAll('[id^="audio-"]').forEach(el => el.remove());
|
||||
statusEl.textContent = "Disconnected";
|
||||
statusEl.textContent = "Conversation ended";
|
||||
setState("idle");
|
||||
startBtn.disabled = false;
|
||||
stopBtn.disabled = true;
|
||||
|
||||
Reference in new issue
Block a user