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();
|
||||
+88
@@ -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
@@ -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>
|
||||
@@ -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
@@ -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)
|
||||
Reference in new issue
Block a user