fix: give realtime voice a dedicated conversation view
Rebuild & Deploy / rebuild (push) Successful in 3m7s
Rebuild & Deploy / rebuild (push) Successful in 3m7s
This commit is contained in:
1 parent
257ab48f0f
commit
8905dc2c2a
9 files changed
+302
-101
No files matched your search
+32
-29
@@ -34,6 +34,7 @@ let tokenAbort = null;
|
||||
let voiceConversationId = null;
|
||||
let voiceSessionActive = false;
|
||||
let lastVoiceState = "";
|
||||
let voiceError = null;
|
||||
|
||||
function escapeHtml(s) {
|
||||
const d = document.createElement("div");
|
||||
@@ -140,7 +141,7 @@ async function setConversationMode(mode) {
|
||||
if (mode === "voice" && previousMode === "text") {
|
||||
chatInput.blur();
|
||||
window.setTimeout(() => {
|
||||
document.querySelector(".workspace").scrollIntoView({ block: "start", behavior: "smooth" });
|
||||
document.querySelector(".workspace").scrollIntoView({ block: "start", behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth" });
|
||||
}, 0);
|
||||
}
|
||||
|
||||
@@ -470,7 +471,15 @@ function formatTimestamp(ts) {
|
||||
return new Date(ts).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
|
||||
}
|
||||
|
||||
function renderMessage(msg) {
|
||||
function transcriptFollows() {
|
||||
const transcript = document.getElementById("conversation");
|
||||
return transcript.scrollHeight - transcript.scrollTop - transcript.clientHeight < 100;
|
||||
}
|
||||
function scrollTranscript(follow) {
|
||||
document.getElementById("voiceTranscriptHint").hidden = !!messagesEl.children.length;
|
||||
if (follow) {const transcript = document.getElementById("conversation"); transcript.scrollTop = transcript.scrollHeight;}
|
||||
}
|
||||
function renderMessage(msg, follow = transcriptFollows()) {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = `message-row ${msg.role}`;
|
||||
wrap.dataset.ts = formatTimestamp(msg.ts);
|
||||
@@ -492,26 +501,28 @@ function renderMessage(msg) {
|
||||
}
|
||||
wrap.appendChild(bubble);
|
||||
messagesEl.appendChild(wrap);
|
||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||
scrollTranscript(follow);
|
||||
}
|
||||
|
||||
function addMessage(role, text) {
|
||||
const follow = transcriptFollows();
|
||||
clearPartials();
|
||||
const msg = { role, text, ts: Date.now() };
|
||||
history.push(msg);
|
||||
persistHistory();
|
||||
renderMessage(msg);
|
||||
renderMessage(msg, follow);
|
||||
return msg;
|
||||
}
|
||||
|
||||
// Partial transcripts render greyed and update in place until a final arrives.
|
||||
function addPartial(role, text) {
|
||||
const follow = transcriptFollows();
|
||||
const rows = messagesEl.querySelectorAll(".message-row.partial");
|
||||
let last = null;
|
||||
for (const r of rows) last = r;
|
||||
if (last && last.dataset.role === role) {
|
||||
last.querySelector(".bubble").textContent = cleanSpokenText(text);
|
||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||
scrollTranscript(follow);
|
||||
return;
|
||||
}
|
||||
const wrap = document.createElement("div");
|
||||
@@ -527,7 +538,7 @@ function addPartial(role, text) {
|
||||
bubble.textContent = cleanSpokenText(text);
|
||||
wrap.appendChild(bubble);
|
||||
messagesEl.appendChild(wrap);
|
||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||
scrollTranscript(follow);
|
||||
}
|
||||
|
||||
function clearPartials() {
|
||||
@@ -536,8 +547,8 @@ function clearPartials() {
|
||||
|
||||
function renderHistory() {
|
||||
messagesEl.innerHTML = "";
|
||||
for (const msg of history) renderMessage(msg);
|
||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||
for (const msg of history) renderMessage(msg, false);
|
||||
scrollTranscript(true);
|
||||
}
|
||||
|
||||
clearBtn.addEventListener("click", () => {
|
||||
@@ -1062,7 +1073,7 @@ function voiceState() {
|
||||
const ready = agent_connected && agentReady && microphone_enabled && !micMuted;
|
||||
return { active: voiceSessionActive, state: voiceSessionActive ? (ready ? uiState : (agent_connected && agentReady && micMuted ? "muted" : "connecting")) : "idle",
|
||||
ready, muted: micMuted, connected, agent_connected, microphone_enabled,
|
||||
conversation_id: voiceConversationId, cid: voiceConversationId };
|
||||
conversation_id: voiceConversationId, cid: voiceConversationId, error: voiceError };
|
||||
}
|
||||
function publishVoiceState() {
|
||||
const detail = voiceState();
|
||||
@@ -1112,6 +1123,7 @@ async function connectConversation() {
|
||||
const attempt = ++connectionEpoch;
|
||||
if (window.HopeChat?.running) throw new Error('Wait for the current response or stop it before starting voice.');
|
||||
voiceConversationId = window.HopeChat?.conversationId || null;
|
||||
voiceError = null; history = []; messagesEl.replaceChildren(); scrollTranscript(true);
|
||||
voiceSessionActive = true; publishVoiceState();
|
||||
startBtn.disabled = true; stopBtn.disabled = false;
|
||||
const promise = (async () => {
|
||||
@@ -1139,7 +1151,7 @@ async function connectConversation() {
|
||||
}
|
||||
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; }
|
||||
if (attempt === connectionEpoch) { voiceError = error.message; 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; }
|
||||
})();
|
||||
@@ -1181,24 +1193,13 @@ chatComposer.addEventListener("submit", async (event) => {
|
||||
const text = chatInput.value.trim();
|
||||
if (!text || !HopeAuth.user || HopeAuth.user.status !== "ready") return;
|
||||
|
||||
sendChatBtn.disabled = true;
|
||||
chatInput.disabled = true;
|
||||
try {
|
||||
if (conversationMode !== "text") await setConversationMode("text");
|
||||
await connectConversation();
|
||||
setState("thinking");
|
||||
await room.localParticipant.sendText(text, { topic: "lk.chat" });
|
||||
chatInput.value = "";
|
||||
resizeChatInput();
|
||||
} catch (err) {
|
||||
console.error("Text message failed:", err);
|
||||
statusEl.textContent = `Message not sent: ${err.message}`;
|
||||
setState(room ? "listening" : "idle");
|
||||
} finally {
|
||||
chatInput.disabled = !HopeAuth.user || HopeAuth.user.status !== "ready";
|
||||
sendChatBtn.disabled = !HopeAuth.user || !chatInput.value.trim();
|
||||
chatInput.focus();
|
||||
}
|
||||
// Compatibility composer delegates to durable HTTP chat, never voice transport.
|
||||
const input = document.getElementById("primaryInput");
|
||||
if (voiceSessionActive || input.disabled || input.value.trim()) return;
|
||||
input.value = text;
|
||||
input.dispatchEvent(new Event("input", {bubbles:true}));
|
||||
document.getElementById("primaryComposer").requestSubmit();
|
||||
chatInput.value = ""; resizeChatInput();
|
||||
});
|
||||
|
||||
// ── Stop ────────────────────────────────────────────────────────────────────
|
||||
@@ -1225,6 +1226,8 @@ muteBtn.addEventListener("click", toggleMute);
|
||||
function updateMuteIcon() {
|
||||
publishVoiceState();
|
||||
const muted = muteBtn.classList.toggle("muted", micMuted);
|
||||
muteBtn.textContent = muted ? "Unmute" : "Mute";
|
||||
muteBtn.setAttribute("aria-pressed", String(muted));
|
||||
muteBtn.setAttribute("aria-label", muted ? "Unmute microphone" : "Mute microphone");
|
||||
muteBtn.title = muted ? "Unmute microphone" : "Mute microphone";
|
||||
}
|
||||
@@ -1274,7 +1277,7 @@ HopeAuth.onChange((user, changed) => {
|
||||
voiceSearchEl.value = "";
|
||||
if (user) loadVoiceCatalog();
|
||||
historyKey = user ? `hope_history:${location.origin}:${encodeURIComponent(user.id)}` : null;
|
||||
history = loadHistory(); renderHistory(); progress.clear(); tasksList.replaceChildren(); tasksBadge.hidden = true;
|
||||
history = []; renderHistory(); progress.clear(); tasksList.replaceChildren(); tasksBadge.hidden = true;
|
||||
chatInput.value = ""; sendChatBtn.disabled = true;
|
||||
setTasksOpen(false); closeSheet();
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user