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();
+88
View File
@@ -0,0 +1,88 @@
// Cookie sessions only: never retain the bearer returned by login.
(() => {
let user = null, epoch = 0, timer = null, refreshing = false, provisioning = false;
const listeners = [], el = id => document.getElementById(id);
const say = (id, text) => { el(id).textContent = text; };
function setUser(next) {
const changed = String(user?.id) !== String(next?.id);
user = next; if (changed) epoch++;
el('loginGate').hidden = !!user; el('authenticatedApp').hidden = !user;
el('sessionActions').hidden = !user; el('adminBtn').hidden = user?.role !== 'admin';
el('identityName').textContent = user?.display_name || user?.username || '';
el('identityChip').setAttribute('aria-label', `Current account: ${user?.display_name || user?.username || ''}`);
say('privacyIdentity', user ? `Your memory stays in ${user.display_name || user.username}’s private profile.` : '');
say('accountStatus', !user || user.status === 'ready' ? '' : `${user.status || 'Provisioning'}: ${user.error || 'Your private assistant is being prepared. Conversation is available when ready.'}`);
if (!user || user.role !== 'admin') { el('adminSection').hidden = true; el('accountDialog').close(); }
clearTimeout(timer); listeners.forEach(fn => fn(user, changed)); if (user) poll();
}
async function request(path, options = {}) {
const requestEpoch = epoch;
const response = await fetch(path, { ...options, credentials: 'same-origin', headers: { ...(options.body ? { 'Content-Type': 'application/json' } : {}), ...options.headers } });
if (response.status === 401 && requestEpoch === epoch) { setUser(null); say('loginMessage', 'Your session ended. Sign in again to continue.'); }
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(typeof data.error === 'string' ? data.error : typeof data.detail === 'string' ? data.detail : `Request failed (${response.status}).`);
return data;
}
function poll() {
timer = setTimeout(async () => {
const version = epoch;
try { const data = await request('/api/auth/me'); if (version !== epoch) return; setUser(data.user); if (user?.role === 'admin' && !el('adminSection').hidden) await refreshUsers(); }
catch (error) { if (user) { say('accountStatus', 'Could not refresh account status. Retrying…'); poll(); } }
}, user?.status === 'ready' && !provisioning ? 10000 : 2500);
}
el('loginForm').addEventListener('submit', async event => {
event.preventDefault(); el('loginSubmit').disabled = true; say('loginMessage', 'Signing in…');
try { const data = await request('/api/auth/login', { method: 'POST', body: JSON.stringify({ username: el('loginUsername').value.trim(), password: el('loginPassword').value }) }); setUser(data.user); say('loginMessage', ''); }
catch (error) { say('loginMessage', error.message); }
finally { el('loginPassword').value = ''; el('loginSubmit').disabled = false; }
});
async function logout() {
el('logoutBtn').disabled = true; setUser(null);
try { await request('/api/auth/logout', { method: 'POST' }); say('loginMessage', 'Signed out.'); el('retryLogout').hidden = true; }
catch (error) { say('loginMessage', 'Disconnected locally, but server sign-out failed. Retry before leaving a shared device.'); el('retryLogout').hidden = false; }
finally { el('logoutBtn').disabled = false; el('loginUsername').focus(); }
}
el('logoutBtn').addEventListener('click', logout); el('retryLogout').addEventListener('click', logout);
function cell(text) { const td = document.createElement('td'); td.textContent = text || '—'; return td; }
function action(label, handler) { const button = document.createElement('button'); button.type = 'button'; button.className = 'btn btn-secondary'; button.textContent = label; button.addEventListener('click', handler); return button; }
async function refreshUsers() {
if (refreshing || user?.role !== 'admin') return; refreshing = true; const version = epoch;
try {
const data = await request('/api/admin/users'); if (version !== epoch || user?.role !== 'admin') return;
provisioning = (data.users || []).some(account => !['ready', 'error'].includes(account.status));
clearTimeout(timer); if (user) poll();
el('usersBody').replaceChildren();
for (const account of data.users || []) {
const row = document.createElement('tr');
row.append(cell(`${account.display_name || account.username} (@${account.username})`), cell(account.role), cell(account.status), cell(account.container_name), cell(account.error));
const actions = cell(''); actions.textContent = ''; actions.className = 'user-actions';
actions.append(action('Reset password', () => openDialog(account, 'password')), action('Delete', () => openDialog(account, 'delete'))); row.append(actions); el('usersBody').append(row);
}
say('adminMessage', 'Account and container status refresh automatically.');
} catch (error) { say('adminMessage', error.message); } finally { refreshing = false; }
}
el('adminBtn').addEventListener('click', () => { el('adminSection').hidden = !el('adminSection').hidden; el('adminBtn').setAttribute('aria-expanded', String(!el('adminSection').hidden)); if (!el('adminSection').hidden) { refreshUsers(); el('adminHeading').focus(); } });
el('refreshUsers').addEventListener('click', refreshUsers);
el('createUserForm').addEventListener('submit', async event => {
event.preventDefault(); el('createUserSubmit').disabled = true; const version = epoch;
try { await request('/api/admin/users', { method: 'POST', body: JSON.stringify({ username: el('newUsername').value.trim(), display_name: el('newDisplayName').value.trim(), password: el('newPassword').value, role: el('newRole').value }) }); if (version !== epoch) return; el('createUserForm').reset(); await refreshUsers(); say('createUserMessage', 'Account created. Watch its provisioning status above.'); }
catch (error) { say('createUserMessage', error.message); } finally { el('newPassword').value = ''; el('createUserSubmit').disabled = false; }
});
let selected = null, mode = null;
function openDialog(account, nextMode) {
selected = account; mode = nextMode;
say('accountDialogTitle', mode === 'delete' ? `Delete @${account.username}?` : `Reset password for @${account.username}`);
say('accountDialogDescription', mode === 'delete' ? 'This revokes access and removes the private assistant container. Retained private data is not erased.' : 'Existing sessions will be revoked. Share the new password securely.');
el('resetPasswordField').hidden = mode !== 'password'; el('resetPassword').required = mode === 'password'; el('confirmAccountAction').textContent = mode === 'delete' ? 'Delete account' : 'Reset password'; say('accountDialogMessage', ''); el('accountDialog').showModal();
}
el('cancelAccountAction').addEventListener('click', () => el('accountDialog').close());
el('accountDialog').addEventListener('close', () => { el('resetPassword').value = ''; selected = null; });
el('accountActionForm').addEventListener('submit', async event => {
event.preventDefault(); if (!selected || user?.role !== 'admin') return; el('confirmAccountAction').disabled = true;
const id = encodeURIComponent(selected.id);
try { await request(`/api/admin/users/${id}${mode === 'password' ? '/password' : ''}`, mode === 'password' ? { method: 'POST', body: JSON.stringify({ password: el('resetPassword').value }) } : { method: 'DELETE' }); el('accountDialog').close(); await refreshUsers(); }
catch (error) { say('accountDialogMessage', error.message); } finally { el('resetPassword').value = ''; el('confirmAccountAction').disabled = false; }
});
window.HopeAuth = { get user() { return user; }, request, onChange(fn) { listeners.push(fn); fn(user, true); }, signOut() { setUser(null); say('loginMessage', 'Your session ended. Sign in again to continue.'); } };
request('/api/auth/me').then(data => setUser(data.user)).catch(error => say('loginMessage', error.message)).finally(() => { el('loginSubmit').disabled = false; });
})();
+53 -20
View File
@@ -15,7 +15,18 @@
<body data-mode="voice">
<a class="skip-link" href="#conversation">Skip to conversation</a>
<main class="app-shell">
<section id="loginGate" class="login-gate" aria-labelledby="loginHeading">
<h1 id="loginHeading">Welcome to Hope</h1>
<p>Sign in to your private voice and text companion.</p>
<form id="loginForm" class="account-form">
<label for="loginUsername">Username</label><input id="loginUsername" autocomplete="username" required maxlength="64">
<label for="loginPassword">Password</label><input id="loginPassword" type="password" autocomplete="current-password" required>
<button id="loginSubmit" class="btn btn-primary" disabled>Sign in</button>
</form>
<p id="loginMessage" role="status" aria-live="polite">Checking your session…</p>
<button id="retryLogout" class="btn btn-secondary" hidden>Retry sign out</button>
</section>
<main class="app-shell" id="authenticatedApp" hidden>
<header class="topbar">
<div class="brand">
<span class="brand-glyph" aria-hidden="true"><i></i></span>
@@ -25,7 +36,7 @@
</div>
</div>
<div class="topbar-actions">
<div class="topbar-actions" id="sessionActions" hidden>
<div class="mode-switch" role="group" aria-label="Conversation mode">
<button id="voiceModeBtn" class="mode-option active" type="button" aria-pressed="true">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="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></svg>
@@ -36,17 +47,35 @@
<span>Text</span>
</button>
</div>
<div class="identity-chip" aria-label="Current profile: Shane">
<div class="identity-chip" id="identityChip" aria-label="Current account">
<span class="presence-dot" aria-hidden="true"></span>
<span>Private to <strong>Shane</strong></span>
<span>Private to <strong id="identityName"></strong></span>
</div>
<button id="tasksBtn" class="btn btn-secondary tasks-trigger" aria-label="Background tasks" title="Background tasks">
<button id="tasksBtn" class="btn btn-secondary tasks-trigger" aria-label="Assistant activity" title="Assistant activity">
<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>Activity</span>
<span class="tasks-badge" id="tasksBadge" hidden>0</span>
</button>
</div>
</header>
<div class="account-toolbar">
<p id="accountStatus" role="status"></p>
<button id="adminBtn" class="btn btn-secondary" hidden aria-expanded="false" aria-controls="adminSection">Manage accounts</button>
<button id="logoutBtn" class="btn btn-secondary">Sign out</button>
</div>
<section id="adminSection" class="admin-section" hidden aria-labelledby="adminHeading">
<div class="admin-header"><div><h2 id="adminHeading" tabindex="-1">Accounts</h2><p>Create a private assistant for each person.</p></div><button id="refreshUsers" class="btn btn-secondary">Refresh status</button></div>
<p id="adminMessage" role="status">Loading accounts…</p>
<div class="table-scroll"><table><caption class="sr-only">Accounts and private assistant provisioning status</caption><thead><tr><th scope="col">Account</th><th scope="col">Role</th><th scope="col">Status</th><th scope="col">Container</th><th scope="col">Error</th><th scope="col">Actions</th></tr></thead><tbody id="usersBody"></tbody></table></div>
<h3>Create account</h3>
<form id="createUserForm" class="create-user-form account-form">
<div><label for="newUsername">Username</label><input id="newUsername" required autocomplete="off" maxlength="64"></div>
<div><label for="newDisplayName">Display name</label><input id="newDisplayName" required autocomplete="off" maxlength="120"></div>
<div><label for="newPassword">Initial password</label><input id="newPassword" type="password" required minlength="12" autocomplete="new-password"></div>
<div><label for="newRole">Role</label><select id="newRole"><option value="user">User</option><option value="admin">Administrator</option></select></div>
<button id="createUserSubmit" class="btn btn-primary">Create account</button>
</form><p id="createUserMessage" role="status"></p>
</section>
<div class="workspace">
<section class="voice-console" aria-labelledby="voiceHeading">
@@ -74,19 +103,19 @@
<button id="muteBtn" class="btn btn-icon" aria-label="Mute microphone" title="Mute microphone" disabled>
<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 id="settingsBtn" class="btn btn-icon" aria-label="Voice and brain settings" title="Voice and brain settings">
<button id="settingsBtn" class="btn btn-icon" aria-label="Voice settings" title="Voice settings">
<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>
</div>
<div class="connection-strip">
<div class="status" id="status">Secure connection ready</div>
<div class="status" id="status" role="status">Secure connection ready</div>
<div class="mic-meter" id="micLevel" aria-hidden="true"><span class="mic-meter-fill" id="micMeterFill"></span></div>
</div>
<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.
<span id="privacyIdentity"></span>
</p>
</section>
@@ -112,12 +141,12 @@
</main>
<div class="sheet-backdrop" id="sheetBackdrop" hidden></div>
<div class="settings-sheet" id="settingsSheet" role="dialog" aria-modal="true" aria-label="Hope settings">
<div class="settings-sheet" id="settingsSheet" hidden inert role="dialog" aria-modal="true" aria-label="Hope settings">
<div class="sheet-handle" aria-hidden="true"></div>
<div class="sheet-header">
<div>
<h2>Hope’s settings</h2>
<p>Voice and brain — changes apply to this conversation.</p>
<p>Your voice preference applies to this conversation. Reasoning is managed by your private assistant.</p>
</div>
<button id="closeSheetBtn" class="btn btn-icon" aria-label="Close 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>
@@ -125,30 +154,34 @@
</div>
<h3 class="sheet-section-title">Voice</h3>
<div class="voice-list" id="voiceList"></div>
<h3 class="sheet-section-title">Brain</h3>
<div class="voice-list" id="modelList"></div>
<p class="sheet-note" id="modelNote" hidden></p>
</div>
<div class="tasks-panel" id="tasksPanel" aria-label="Background tasks">
<div class="tasks-panel" id="tasksPanel" hidden inert aria-label="Assistant activity">
<div class="tasks-header">
<div>
<h2>Background tasks</h2>
<p>Longer work continues here.</p>
<h2>Assistant activity</h2>
<p>Progress from your private assistant.</p>
</div>
<button id="closeTasksBtn" class="btn btn-icon" aria-label="Close background tasks">
<button id="closeTasksBtn" class="btn btn-icon" aria-label="Close activity">
<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>
</div>
<div class="tasks-list" id="tasksList">
<div class="tasks-empty">
<strong>Nothing running</strong>
<span>Ask Hope to research or compare something.</span>
<strong>No activity yet</strong>
<span>Progress appears here while Hope works. Replies stay in your conversation.</span>
</div>
</div>
</div>
<script src="livekit-client.umd.js"></script>
<dialog id="accountDialog" aria-labelledby="accountDialogTitle" aria-describedby="accountDialogDescription">
<form id="accountActionForm" class="account-form"><h2 id="accountDialogTitle"></h2><p id="accountDialogDescription"></p>
<div id="resetPasswordField"><label for="resetPassword">New password</label><input id="resetPassword" type="password" minlength="12" autocomplete="new-password"></div>
<p id="accountDialogMessage" role="status"></p><div class="dialog-actions"><button id="cancelAccountAction" type="button" class="btn btn-secondary">Cancel</button><button id="confirmAccountAction" class="btn btn-danger">Confirm</button></div>
</form>
</dialog>
<script src="auth.js"></script>
<script src="app.js"></script>
</body>
</html>
+47
View File
@@ -1335,3 +1335,50 @@ body[data-mode="text"] .mic-meter {
transition-duration: 0.01ms !important;
}
}
/* Account surfaces share the conversation's existing tokens. */
.login-gate { width: min(440px, calc(100% - 2rem)); margin: 12vh auto 3rem; padding: 2rem; border: 1px solid var(--line-strong); border-radius: 16px; background: var(--surface); }
.login-gate h1 { font-size: 1.6rem; letter-spacing: -.025em; }
.login-gate > p { margin: .75rem 0 1.5rem; color: var(--ink-soft); line-height: 1.6; }
.account-form { display: flex; flex-direction: column; gap: .65rem; }
.account-form label { display: block; font-size: .85rem; color: var(--ink-soft); margin-bottom: .3rem; }
.account-form input, .account-form select { width: 100%; min-height: 44px; padding: .65rem .75rem; border: 1px solid var(--line-strong); border-radius: 8px; color: var(--ink); background: var(--canvas); }
.account-form input:focus-visible, .account-form select:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
.account-form .btn { margin-top: .5rem; }
.account-form .btn:disabled { display: inline-block; }
.account-toolbar { display: flex; align-items: center; gap: .65rem; margin: 0 0 1rem; padding: 0 .15rem; }
.account-toolbar p { flex: 1; color: var(--warm); font-size: .85rem; line-height: 1.5; }
.admin-section { margin-bottom: 1.5rem; padding: 1.5rem; border: 1px solid var(--line); border-radius: 12px; background: var(--surface); }
.admin-header { display: flex; justify-content: space-between; align-items: center; gap: 1rem; }
.admin-header h2 { font-size: 1.2rem; }
.admin-header p, #adminMessage, #createUserMessage { margin: .5rem 0 1rem; color: var(--ink-soft); font-size: .85rem; line-height: 1.5; }
.table-scroll { overflow-x: auto; margin-bottom: 1.5rem; }
.admin-section table { width: 100%; border-collapse: collapse; text-align: left; font-size: .8rem; }
.admin-section th, .admin-section td { padding: .8rem .6rem; border-bottom: 1px solid var(--line); vertical-align: top; }
.admin-section th { color: var(--ink-soft); font-weight: 600; }
.admin-section td { max-width: 28ch; overflow-wrap: anywhere; }
.user-actions { min-width: 190px; }
.user-actions .btn { margin: 0 .3rem .4rem 0; font-size: .75rem; }
.admin-section h3 { font-size: 1rem; margin-bottom: .8rem; }
.create-user-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; align-items: end; }
.create-user-form .btn { justify-self: start; }
#accountDialog { margin: auto; padding: 1.5rem; width: min(480px, calc(100% - 2rem)); border: 1px solid var(--line-strong); border-radius: 12px; color: var(--ink); background: var(--surface); }
#accountDialog::backdrop { background: rgba(2, 7, 14, .75); }
#accountDialog h2 { font-size: 1.2rem; overflow-wrap: anywhere; }
#accountDialog p { color: var(--ink-soft); line-height: 1.55; font-size: .9rem; }
.dialog-actions { display: flex; gap: .75rem; justify-content: flex-end; }
.task-card p { margin-top: .5rem; line-height: 1.5; overflow-wrap: anywhere; }
.audio-unlock { position: fixed; bottom: 2rem; left: 50%; transform: translateX(-50%); z-index: var(--z-sheet); }
.topbar-actions { flex-wrap: wrap; }
.identity-chip { max-width: 28ch; overflow-wrap: anywhere; }
@media (max-width: 560px) {
.topbar { flex-wrap: wrap; }
.topbar-actions { width: 100%; justify-content: flex-start; }
.identity-chip { display: flex; }
.account-toolbar { padding: 0 .85rem; flex-wrap: wrap; }
.account-toolbar p { flex-basis: 100%; }
.admin-section { padding: 1rem; border-radius: 0; }
.create-user-form { grid-template-columns: 1fr; }
.admin-header { flex-wrap: wrap; }
.login-gate { padding: 1.5rem; margin-top: 7vh; }
}
+58 -100
View File
@@ -1,15 +1,5 @@
#!/usr/bin/env python3
"""Tiny token endpoint for the voice UI.
Serves POST /token -> { "token": "<signed JWT>" }
Signs a LiveKit access token with HS256 using the API key/secret from env.
The token carries a roomConfig claim requesting the voice-assistant agent, so
LiveKit dispatches the agent when the participant joins. Without this, the
auto-created room would get no agent at all.
Runs as a separate supervisord process; nginx proxies /token to it.
"""
"""Authenticated room bootstrap. All identity/routing is resolved server-side."""
import base64
import hashlib
import hmac
@@ -18,22 +8,17 @@ import os
import time
import uuid
from http.server import BaseHTTPRequestHandler, HTTPServer
import httpx
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
API_KEY = os.environ.get("LIVEKIT_API_KEY", "devkey")
API_SECRET = os.environ.get("LIVEKIT_API_SECRET", "devsecret")
AGENT_NAME = os.environ.get("VOICE_AGENT_NAME", "voice-assistant")
USERS_FILE = os.environ.get("VOICE_USERS_FILE", "/etc/hope/users.json")
DEFAULT_USER = os.environ.get("VOICE_DEFAULT_USER", "shane")
def load_default_user() -> tuple[str, dict]:
with open(USERS_FILE, encoding="utf-8") as users_file:
registry = json.load(users_file)
profile = registry.get("users", {}).get(DEFAULT_USER)
if not isinstance(profile, dict):
raise ValueError(f"unknown default voice user: {DEFAULT_USER}")
return DEFAULT_USER, profile
API_KEY = os.getenv("LIVEKIT_API_KEY", "")
API_SECRET = os.getenv("LIVEKIT_API_SECRET", "")
AGENT_NAME = os.getenv("VOICE_AGENT_NAME", "voice-assistant")
CONTROL_URL = os.getenv("CONTROL_URL", "http://127.0.0.1:8092").rstrip("/")
CONTROL_SECRET = os.getenv("CONTROL_SERVICE_SECRET", "")
COOKIE_NAME = "hope_session"
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
def b64url(data: bytes) -> str:
@@ -41,82 +26,55 @@ def b64url(data: bytes) -> str:
def make_token(room_name: str, identity: str) -> str:
if not API_KEY or not API_SECRET:
raise RuntimeError("LiveKit credentials are not configured")
now = int(time.time())
header = {"alg": "HS256", "typ": "JWT"}
payload = {
"iss": API_KEY,
"sub": identity,
"nbf": now - 5,
"exp": now + 3600,
"jti": str(uuid.uuid4()),
"identity": identity,
"video": {
"roomJoin": True,
"room": room_name,
"canPublish": True,
"canSubscribe": True,
"canPublishData": True,
},
"roomConfig": {
"agents": [{"agentName": AGENT_NAME, "jobType": "JT_ROOM"}],
},
}
h = b64url(json.dumps(header).encode())
p = b64url(json.dumps(payload).encode())
signing_input = f"{h}.{p}".encode()
sig = hmac.new(API_SECRET.encode(), signing_input, hashlib.sha256).digest()
return f"{h}.{p}.{b64url(sig)}"
payload = {"iss": API_KEY, "sub": identity, "nbf": now - 5, "exp": now + 300,
"jti": str(uuid.uuid4()), "video": {"roomJoin": True, "room": room_name,
"canPublish": True, "canSubscribe": True, "canPublishData": True,
"canPublishSources": ["microphone"]},
"roomConfig": {"agents": [{"agentName": AGENT_NAME, "jobType": "JT_ROOM"}]}}
head = b64url(json.dumps({"alg": "HS256", "typ": "JWT"}).encode())
body = b64url(json.dumps(payload).encode())
sig = hmac.new(API_SECRET.encode(), f"{head}.{body}".encode(), hashlib.sha256).digest()
return f"{head}.{body}.{b64url(sig)}"
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
if self.path != "/token":
self.send_response(404)
self.end_headers()
return
length = int(self.headers.get("Content-Length", 0))
# Consume the body for protocol compatibility, but never trust a
# browser-supplied room or identity. This LAN bootstrap deployment is
# pinned to VOICE_DEFAULT_USER until the planned login flow is added.
self.rfile.read(length)
try:
user_id, profile = load_default_user()
except (OSError, ValueError, json.JSONDecodeError) as exc:
resp = json.dumps({"error": str(exc)}).encode()
self.send_response(503)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(resp)))
self.end_headers()
self.wfile.write(resp)
return
room_name = f"voice-{user_id}-{uuid.uuid4().hex[:8]}"
identity = profile.get("livekit_identity") or f"user:{user_id}"
token = make_token(room_name, identity)
resp = json.dumps(
{
"token": token,
# Returned for diagnostics and reconnects. Music deliberately
# uses its own stable scope in the separate Hope Music service.
"room": room_name,
"user": {
"id": user_id,
"display_name": profile.get("display_name") or user_id,
},
}
).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(resp)))
self.end_headers()
self.wfile.write(resp)
def log_message(self, format, *args):
pass # silence request logging
@app.post("/token")
async def token(request: Request):
headers = {"Cache-Control": "no-store"}
if not CONTROL_SECRET or not API_KEY or not API_SECRET:
return JSONResponse({"error": "Service configuration unavailable"}, 503, headers=headers)
auth = request.headers.get("authorization", "")
bearer = auth[7:] if auth.startswith("Bearer ") else ""
credential = bearer or request.cookies.get(COOKIE_NAME, "")
if not credential or len(credential) > 512:
return JSONResponse({"error": "Sign in required"}, 401, headers=headers)
if not bearer:
origin = request.headers.get("origin", "")
allowed = set(filter(None, os.getenv("CONTROL_ALLOWED_ORIGINS", "").split(",")))
if origin not in allowed:
return JSONResponse({"error": "Invalid request origin"}, 403, headers=headers)
try:
async with httpx.AsyncClient(timeout=10) as client:
control_headers = {"X-Control-Secret": CONTROL_SECRET}
response = await client.post(f"{CONTROL_URL}/internal/resolve-session", headers=control_headers,
json={"token": credential})
if response.status_code != 200:
status = response.status_code if response.status_code in (401,403,503) else 503
return JSONResponse({"error": "Sign in required" if status==401 else "Your private agent is not ready"}, status, headers=headers)
account = response.json()
user = account["user"]
room_name = f"voice-{user['id']}-{uuid.uuid4().hex}"
grant = await client.post(f"{CONTROL_URL}/internal/rooms", headers=control_headers,
json={"user_id": user["id"], "room_name": room_name, "ttl": 86400,
"session_token": credential})
grant.raise_for_status()
return JSONResponse({"token": make_token(room_name, f"user:{user['id']}"), "room": room_name,
"user": {"id": user["id"], "display_name": user["display_name"]}}, headers=headers)
except (httpx.HTTPError, ValueError, KeyError):
return JSONResponse({"error": "Account service unavailable"}, 503, headers=headers)
if __name__ == "__main__":
server = HTTPServer(("127.0.0.1", 8091), Handler)
print("token endpoint listening on 127.0.0.1:8091")
server.serve_forever()
import uvicorn
uvicorn.run(app, host="127.0.0.1", port=8091, access_log=False)