feat: redesign Hope voice experience
This commit is contained in:
1 parent
3c4c43f983
commit
16aadcae96
5 files changed
+1033
-496
No files matched your search
+58
-32
@@ -82,7 +82,7 @@ function updateTasksBadge() {
|
|||||||
function renderTasks() {
|
function renderTasks() {
|
||||||
const items = [...tasks.values()].sort((a, b) => (b.created_at || 0) - (a.created_at || 0));
|
const items = [...tasks.values()].sort((a, b) => (b.created_at || 0) - (a.created_at || 0));
|
||||||
if (items.length === 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;
|
return;
|
||||||
}
|
}
|
||||||
tasksList.innerHTML = "";
|
tasksList.innerHTML = "";
|
||||||
@@ -124,8 +124,14 @@ function handleTaskEvent(data) {
|
|||||||
renderTasks();
|
renderTasks();
|
||||||
}
|
}
|
||||||
|
|
||||||
tasksBtn.addEventListener("click", () => tasksPanel.classList.toggle("open"));
|
function setTasksOpen(open) {
|
||||||
closeTasksBtn.addEventListener("click", () => tasksPanel.classList.remove("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) ─────────────────────────────
|
// ── Audio analysers (mic + remote agent audio) ─────────────────────────────
|
||||||
let micCtx = null;
|
let micCtx = null;
|
||||||
@@ -196,11 +202,11 @@ function readAgentAudioLevel() {
|
|||||||
|
|
||||||
// ── State machine ───────────────────────────────────────────────────────────
|
// ── State machine ───────────────────────────────────────────────────────────
|
||||||
const STATE_LABELS = {
|
const STATE_LABELS = {
|
||||||
idle: "Idle",
|
idle: "Ready when you are",
|
||||||
connecting: "Connecting",
|
connecting: "Opening a secure room",
|
||||||
listening: "Listening",
|
listening: "Listening",
|
||||||
thinking: "Thinking",
|
thinking: "Thinking it through",
|
||||||
speaking: "Speaking",
|
speaking: "Hope is speaking",
|
||||||
};
|
};
|
||||||
|
|
||||||
function setState(next) {
|
function setState(next) {
|
||||||
@@ -310,10 +316,21 @@ function persistHistory() {
|
|||||||
|
|
||||||
let history = loadHistory();
|
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) {
|
function renderMessage(msg) {
|
||||||
const wrap = document.createElement("div");
|
const wrap = document.createElement("div");
|
||||||
wrap.className = `message-row ${msg.role}`;
|
wrap.className = `message-row ${msg.role}`;
|
||||||
wrap.dataset.ts = msg.ts || "";
|
wrap.dataset.ts = formatTimestamp(msg.ts);
|
||||||
|
|
||||||
if (msg.role === "agent") {
|
if (msg.role === "agent") {
|
||||||
const dot = document.createElement("span");
|
const dot = document.createElement("span");
|
||||||
@@ -323,7 +340,7 @@ function renderMessage(msg) {
|
|||||||
|
|
||||||
const bubble = document.createElement("div");
|
const bubble = document.createElement("div");
|
||||||
bubble.className = "bubble";
|
bubble.className = "bubble";
|
||||||
bubble.textContent = msg.text || "";
|
bubble.textContent = cleanSpokenText(msg.text);
|
||||||
if (msg.role === "agent") {
|
if (msg.role === "agent") {
|
||||||
const viz = document.createElement("span");
|
const viz = document.createElement("span");
|
||||||
viz.className = "audio-viz";
|
viz.className = "audio-viz";
|
||||||
@@ -350,7 +367,7 @@ function addPartial(role, text) {
|
|||||||
let last = null;
|
let last = null;
|
||||||
for (const r of rows) last = r;
|
for (const r of rows) last = r;
|
||||||
if (last && last.dataset.role === role) {
|
if (last && last.dataset.role === role) {
|
||||||
last.querySelector(".bubble").textContent = text;
|
last.querySelector(".bubble").textContent = cleanSpokenText(text);
|
||||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -364,7 +381,7 @@ function addPartial(role, text) {
|
|||||||
}
|
}
|
||||||
const bubble = document.createElement("div");
|
const bubble = document.createElement("div");
|
||||||
bubble.className = "bubble partial";
|
bubble.className = "bubble partial";
|
||||||
bubble.textContent = text;
|
bubble.textContent = cleanSpokenText(text);
|
||||||
wrap.appendChild(bubble);
|
wrap.appendChild(bubble);
|
||||||
messagesEl.appendChild(wrap);
|
messagesEl.appendChild(wrap);
|
||||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||||
@@ -451,19 +468,18 @@ function renderVoiceList() {
|
|||||||
name.className = "voice-name";
|
name.className = "voice-name";
|
||||||
name.textContent = v.name + (v.tag ? ` (${v.tag})` : "");
|
name.textContent = v.name + (v.tag ? ` (${v.tag})` : "");
|
||||||
|
|
||||||
const previewBtn = document.createElement("button");
|
const selected = v.id === active;
|
||||||
previewBtn.className = "btn btn-ghost voice-preview";
|
card.setAttribute("role", "radio");
|
||||||
previewBtn.setAttribute("aria-label", `Preview ${v.name}`);
|
card.setAttribute("aria-checked", String(selected));
|
||||||
previewBtn.title = "Preview (coming soon)";
|
card.tabIndex = 0;
|
||||||
// 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.`);
|
|
||||||
});
|
|
||||||
|
|
||||||
card.appendChild(name);
|
card.appendChild(name);
|
||||||
card.appendChild(previewBtn);
|
|
||||||
card.addEventListener("click", () => selectVoice(v.id));
|
card.addEventListener("click", () => selectVoice(v.id));
|
||||||
|
card.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
|
event.preventDefault();
|
||||||
|
selectVoice(v.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
voiceListEl.appendChild(card);
|
voiceListEl.appendChild(card);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -482,16 +498,26 @@ function openSheet() {
|
|||||||
renderVoiceList();
|
renderVoiceList();
|
||||||
sheetBackdrop.hidden = false;
|
sheetBackdrop.hidden = false;
|
||||||
settingsSheet.classList.add("open");
|
settingsSheet.classList.add("open");
|
||||||
|
settingsBtn.setAttribute("aria-expanded", "true");
|
||||||
|
settingsSheet.querySelector(".voice-card")?.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeSheet() {
|
function closeSheet() {
|
||||||
settingsSheet.classList.remove("open");
|
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);
|
settingsBtn.addEventListener("click", openSheet);
|
||||||
closeSheetBtn.addEventListener("click", closeSheet);
|
closeSheetBtn.addEventListener("click", closeSheet);
|
||||||
sheetBackdrop.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) => {
|
document.addEventListener("keydown", (e) => {
|
||||||
if (e.key === "Escape") closeSheet();
|
if (e.key === "Escape") closeSheet();
|
||||||
});
|
});
|
||||||
@@ -536,7 +562,7 @@ function findAgent(rm) {
|
|||||||
|
|
||||||
function markAgentFound(rm, participant) {
|
function markAgentFound(rm, participant) {
|
||||||
agentParticipant = participant;
|
agentParticipant = participant;
|
||||||
statusEl.textContent = "Agent connected — speak now";
|
statusEl.textContent = "Secure room · Microphone on";
|
||||||
// Send current voice selection to the agent
|
// Send current voice selection to the agent
|
||||||
sendVoiceMessage(currentVoiceId(), rm);
|
sendVoiceMessage(currentVoiceId(), rm);
|
||||||
}
|
}
|
||||||
@@ -599,7 +625,7 @@ function createRoom(token) {
|
|||||||
// clobber UI state of the active one.
|
// clobber UI state of the active one.
|
||||||
newRoom.on(RoomEvent.ConnectionQualityChanged, (participant, quality) => {
|
newRoom.on(RoomEvent.ConnectionQualityChanged, (participant, quality) => {
|
||||||
if (participant === newRoom.localParticipant && newRoom === room) {
|
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();
|
teardownAudio();
|
||||||
stopVisualLoop();
|
stopVisualLoop();
|
||||||
releaseWakeLock();
|
releaseWakeLock();
|
||||||
statusEl.textContent = "Disconnected";
|
statusEl.textContent = "Conversation ended";
|
||||||
setState("idle");
|
setState("idle");
|
||||||
startBtn.disabled = false;
|
startBtn.disabled = false;
|
||||||
stopBtn.disabled = true;
|
stopBtn.disabled = true;
|
||||||
@@ -646,7 +672,7 @@ function waitForAgent(timeoutMs) {
|
|||||||
startBtn.addEventListener("click", async () => {
|
startBtn.addEventListener("click", async () => {
|
||||||
try {
|
try {
|
||||||
setState("connecting");
|
setState("connecting");
|
||||||
statusEl.textContent = "Connecting...";
|
statusEl.textContent = "Opening your secure room…";
|
||||||
await acquireWakeLock();
|
await acquireWakeLock();
|
||||||
|
|
||||||
// Get a signed access token from the token endpoint
|
// 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
|
// 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),
|
// (e.g. the server just started and the worker wasn't ready yet),
|
||||||
// reconnect once to trigger a fresh dispatch.
|
// reconnect once to trigger a fresh dispatch.
|
||||||
statusEl.textContent = "Waiting for assistant...";
|
statusEl.textContent = "Inviting Hope into the room…";
|
||||||
if (!(await waitForAgent(10000))) {
|
if (!(await waitForAgent(10000))) {
|
||||||
statusEl.textContent = "Assistant not ready, retrying...";
|
statusEl.textContent = "Hope is taking a moment · Retrying…";
|
||||||
const stale = room;
|
const stale = room;
|
||||||
room = null; // detach first so its Disconnected handler
|
room = null; // detach first so its Disconnected handler
|
||||||
agentParticipant = null; // cannot clobber the UI
|
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);
|
await room.localParticipant.setMicrophoneEnabled(true);
|
||||||
|
|
||||||
statusEl.textContent = "Connected — speak now";
|
statusEl.textContent = "Secure room · Microphone on";
|
||||||
startBtn.disabled = true;
|
startBtn.disabled = true;
|
||||||
stopBtn.disabled = false;
|
stopBtn.disabled = false;
|
||||||
muteBtn.disabled = false;
|
muteBtn.disabled = false;
|
||||||
@@ -703,7 +729,7 @@ stopBtn.addEventListener("click", () => {
|
|||||||
stopVisualLoop();
|
stopVisualLoop();
|
||||||
releaseWakeLock();
|
releaseWakeLock();
|
||||||
document.querySelectorAll('[id^="audio-"]').forEach(el => el.remove());
|
document.querySelectorAll('[id^="audio-"]').forEach(el => el.remove());
|
||||||
statusEl.textContent = "Disconnected";
|
statusEl.textContent = "Conversation ended";
|
||||||
setState("idle");
|
setState("idle");
|
||||||
startBtn.disabled = false;
|
startBtn.disabled = false;
|
||||||
stopBtn.disabled = true;
|
stopBtn.disabled = true;
|
||||||
|
|||||||
+5
-7
@@ -1,9 +1,7 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
<defs>
|
<rect width="64" height="64" rx="16" fill="#08111f"/>
|
||||||
<radialGradient id="g" cx="35%" cy="30%" r="75%">
|
<circle cx="31" cy="33" r="21" fill="none" stroke="#8ba7ff" stroke-width="2"/>
|
||||||
<stop offset="0%" stop-color="#60a5fa"/>
|
<ellipse cx="31" cy="33" rx="18" ry="11" fill="none" stroke="#526cae" stroke-width="1.5" transform="rotate(-18 31 33)"/>
|
||||||
<stop offset="100%" stop-color="#a78bfa"/>
|
<circle cx="31" cy="33" r="5" fill="#8ba7ff"/>
|
||||||
</radialGradient>
|
<circle cx="49" cy="18" r="5" fill="#ffb38e"/>
|
||||||
</defs>
|
|
||||||
<circle cx="32" cy="32" r="28" fill="url(#g)"/>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 309 B After Width: | Height: | Size: 426 B |
+82
-36
@@ -3,81 +3,127 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||||
<meta name="theme-color" content="#09090b">
|
<meta name="theme-color" content="#08111f">
|
||||||
<title>Hope</title>
|
<title>Hope · Voice companion</title>
|
||||||
<link rel="manifest" href="manifest.json">
|
<link rel="manifest" href="manifest.json">
|
||||||
<link rel="icon" type="image/svg+xml" href="favicon.svg">
|
<link rel="icon" type="image/svg+xml" href="favicon.svg">
|
||||||
<link rel="stylesheet" href="style.css">
|
<link rel="stylesheet" href="style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<a class="skip-link" href="#conversation">Skip to conversation</a>
|
||||||
<header>
|
|
||||||
|
<main class="app-shell">
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="brand">
|
||||||
|
<span class="brand-glyph" aria-hidden="true"><i></i></span>
|
||||||
|
<div>
|
||||||
<h1>Hope</h1>
|
<h1>Hope</h1>
|
||||||
<p class="subtitle">Your voice assistant</p>
|
<p>Voice companion</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="topbar-actions">
|
||||||
|
<div class="identity-chip" aria-label="Current profile: Shane">
|
||||||
|
<span class="presence-dot" aria-hidden="true"></span>
|
||||||
|
<span>Private to <strong>Shane</strong></span>
|
||||||
|
</div>
|
||||||
|
<button id="tasksBtn" class="btn btn-secondary tasks-trigger" aria-label="Background tasks" title="Background tasks">
|
||||||
|
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 6h11M8 12h11M8 18h7"></path><path d="m3 6 1 1 2-2M3 12l1 1 2-2M3 18l1 1 2-2"></path></svg>
|
||||||
|
<span>Tasks</span>
|
||||||
|
<span class="tasks-badge" id="tasksBadge" hidden>0</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<div class="workspace">
|
||||||
|
<section class="voice-console" aria-labelledby="voiceHeading">
|
||||||
|
<div class="console-intro">
|
||||||
|
<h2 id="voiceHeading">I'm here.</h2>
|
||||||
|
<p>Speak naturally. I’ll listen, remember, and answer in real time.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="orb-stage">
|
<div class="orb-stage">
|
||||||
<div class="orb" id="orb" data-state="idle">
|
<div class="orb" id="orb" data-state="idle" aria-hidden="true">
|
||||||
<div class="orb-core"></div>
|
<div class="orb-orbit orbit-one"></div>
|
||||||
|
<div class="orb-orbit orbit-two"></div>
|
||||||
|
<div class="orb-core"><span class="orb-shimmer"></span></div>
|
||||||
<div class="orb-ring"></div>
|
<div class="orb-ring"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="orb-labels">
|
<div class="orb-labels" aria-live="polite">
|
||||||
<span class="state-label" id="stateLabel">Idle</span>
|
<span class="state-label" id="stateLabel">Ready when you are</span>
|
||||||
<span class="thinking-chip" id="thinkingChip" hidden>thinking…</span>
|
<span class="thinking-chip" id="thinkingChip" hidden>Thinking…</span>
|
||||||
<span class="barge-hint" id="bargeHint" hidden>tap or speak to interrupt</span>
|
<span class="barge-hint" id="bargeHint" hidden>Speak to interrupt</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="controls">
|
<div class="controls" aria-label="Conversation controls">
|
||||||
<button id="startBtn" class="btn btn-primary">Start Conversation</button>
|
<button id="startBtn" class="btn btn-primary">
|
||||||
<button id="stopBtn" class="btn btn-danger" disabled>Stop</button>
|
<span class="button-spark" aria-hidden="true"></span>
|
||||||
|
Start conversation
|
||||||
|
</button>
|
||||||
|
<button id="stopBtn" class="btn btn-danger" disabled>End conversation</button>
|
||||||
<button id="muteBtn" class="btn btn-icon" aria-label="Mute microphone" title="Mute microphone" disabled>
|
<button id="muteBtn" class="btn btn-icon" aria-label="Mute microphone" title="Mute microphone" disabled>
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"></path><path d="M19 10v2a7 7 0 0 1-14 0v-2"></path><line x1="8" y1="22" x2="16" y2="22"></line></svg>
|
<svg viewBox="0 0 24 24" width="19" height="19" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"></path><path d="M19 10v2a7 7 0 0 1-14 0v-2"></path><line x1="8" y1="22" x2="16" y2="22"></line></svg>
|
||||||
</button>
|
</button>
|
||||||
<button id="settingsBtn" class="btn btn-icon" aria-label="Voice settings" title="Voice settings">
|
<button id="settingsBtn" class="btn btn-icon" aria-label="Voice settings" title="Voice settings">
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
|
<svg viewBox="0 0 24 24" width="19" height="19" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7h10M18 7h2M4 17h2M10 17h10"></path><circle cx="16" cy="7" r="2"></circle><circle cx="8" cy="17" r="2"></circle></svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="status" id="status">Disconnected</div>
|
<div class="connection-strip">
|
||||||
|
<div class="status" id="status">Secure connection ready</div>
|
||||||
<div class="mic-meter" id="micLevel" aria-hidden="true"><span class="mic-meter-fill" id="micMeterFill"></span></div>
|
<div class="mic-meter" id="micLevel" aria-hidden="true"><span class="mic-meter-fill" id="micMeterFill"></span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="transcript" id="transcript">
|
<p class="privacy-note">
|
||||||
|
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><rect x="5" y="10" width="14" height="10" rx="2"></rect><path d="M8 10V7a4 4 0 0 1 8 0v3"></path></svg>
|
||||||
|
Your memory stays in Shane’s private profile.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="transcript" id="conversation" aria-labelledby="conversationHeading">
|
||||||
<div class="transcript-header">
|
<div class="transcript-header">
|
||||||
<span>Conversation</span>
|
<div>
|
||||||
|
<h2 id="conversationHeading">Conversation</h2>
|
||||||
|
<p>Live transcript</p>
|
||||||
|
</div>
|
||||||
<button id="clearBtn" class="btn btn-ghost" title="Clear conversation">Clear</button>
|
<button id="clearBtn" class="btn btn-ghost" title="Clear conversation">Clear</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="messages"></div>
|
<div id="messages" aria-live="polite"></div>
|
||||||
</div>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
<!-- Settings sheet (voice picker) -->
|
|
||||||
<div class="sheet-backdrop" id="sheetBackdrop" hidden></div>
|
<div class="sheet-backdrop" id="sheetBackdrop" hidden></div>
|
||||||
<div class="settings-sheet" id="settingsSheet" role="dialog" aria-modal="true" aria-label="Voice settings">
|
<div class="settings-sheet" id="settingsSheet" role="dialog" aria-modal="true" aria-label="Voice settings">
|
||||||
<div class="sheet-handle"></div>
|
<div class="sheet-handle" aria-hidden="true"></div>
|
||||||
<div class="sheet-header">
|
<div class="sheet-header">
|
||||||
<h2>Voice</h2>
|
<div>
|
||||||
<button id="closeSheetBtn" class="btn btn-ghost" aria-label="Close">
|
<h2>Choose Hope’s voice</h2>
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"></path></svg>
|
<p>Changes apply to this conversation.</p>
|
||||||
|
</div>
|
||||||
|
<button id="closeSheetBtn" class="btn btn-icon" aria-label="Close voice settings">
|
||||||
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"></path></svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="voice-list" id="voiceList"></div>
|
<div class="voice-list" id="voiceList"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Task panel (background tasks) -->
|
<div class="tasks-panel" id="tasksPanel" aria-label="Background tasks">
|
||||||
<button id="tasksBtn" class="btn btn-icon tasks-fab" aria-label="Background tasks" title="Background tasks">
|
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"></rect><path d="M9 9h6M9 13h6M9 17h4"></path></svg>
|
|
||||||
<span class="tasks-badge" id="tasksBadge" hidden>0</span>
|
|
||||||
</button>
|
|
||||||
<div class="tasks-panel" id="tasksPanel">
|
|
||||||
<div class="tasks-header">
|
<div class="tasks-header">
|
||||||
<h2>Background Tasks</h2>
|
<div>
|
||||||
<button id="closeTasksBtn" class="btn btn-ghost" aria-label="Close">
|
<h2>Background tasks</h2>
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"></path></svg>
|
<p>Longer work continues here.</p>
|
||||||
|
</div>
|
||||||
|
<button id="closeTasksBtn" class="btn btn-icon" aria-label="Close background tasks">
|
||||||
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"></path></svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="tasks-list" id="tasksList">
|
<div class="tasks-list" id="tasksList">
|
||||||
<p class="tasks-empty">No tasks yet. Ask Hope to research something.</p>
|
<div class="tasks-empty">
|
||||||
|
<strong>Nothing running</strong>
|
||||||
|
<span>Ask Hope to research or compare something.</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+5
-5
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "Voice Assistant",
|
"name": "Hope Voice Companion",
|
||||||
"short_name": "Voice",
|
"short_name": "Hope",
|
||||||
"description": "Real-time voice assistant — Azure Speech + Gemma LLM",
|
"description": "A private, real-time voice companion with personal memory.",
|
||||||
"start_url": "/",
|
"start_url": "/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"background_color": "#09090b",
|
"background_color": "#08111f",
|
||||||
"theme_color": "#09090b",
|
"theme_color": "#08111f",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "favicon.svg",
|
"src": "favicon.svg",
|
||||||
|
|||||||
+883
-416
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user