| |
| |
| |
|
|
| (function () { |
| let selectedLanguage = null; |
| let mediaRecorder = null; |
| let recordedChunks = []; |
| let recordedBlob = null; |
| let currentInteractionId = null; |
| let recordingTimerInterval = null; |
| let recordingSeconds = 0; |
|
|
| const statusBanner = document.getElementById("status-banner"); |
| const languageGrid = document.getElementById("language-grid"); |
| const toStep2Btn = document.getElementById("to-step-2"); |
| const backToStep1Btn = document.getElementById("back-to-step-1"); |
| const submitInputBtn = document.getElementById("submit-input"); |
| const recordBtn = document.getElementById("record-btn"); |
| const stopRecordingBtn = document.getElementById("stop-recording-btn"); |
| const inputPanel = document.getElementById("input-panel"); |
| const listeningPanel = document.getElementById("listening-panel"); |
| const transcribingPanel = document.getElementById("transcribing-panel"); |
| const reviewPanel = document.getElementById("review-panel"); |
| const reviewText = document.getElementById("review-text"); |
| const recordAgainBtn = document.getElementById("record-again-btn"); |
| const recordTimer = document.getElementById("record-timer"); |
| const recordStatus = document.getElementById("record-status"); |
| const symptomChips = document.getElementById("symptom-chips"); |
| const checkResponseBtn = document.getElementById("check-response-btn"); |
| const newConversationBtn = document.getElementById("new-conversation-btn"); |
| const historyCard = document.getElementById("history-card"); |
| const historyList = document.getElementById("history-list"); |
| const openHistoryBtn = document.getElementById("open-history-btn"); |
| const historyBackBtn = document.getElementById("history-back-btn"); |
| const historyFullList = document.getElementById("history-full-list"); |
| const historyEmptyText = document.getElementById("history-empty-text"); |
| const historySearchInput = document.getElementById("history-search"); |
| const chatThread = document.getElementById("chat-thread"); |
| const waitingCard = document.getElementById("waiting-card"); |
| const toast = document.getElementById("toast"); |
|
|
| |
| const responseAudioEl = document.getElementById("response-audio"); |
| const audioPlayBtn = document.getElementById("audio-play-btn"); |
| const audioReplayBtn = document.getElementById("audio-replay-btn"); |
| const audioSeek = document.getElementById("audio-seek"); |
| const audioCurrentTimeEl = document.getElementById("audio-current-time"); |
| const audioDurationEl = document.getElementById("audio-duration"); |
| const audioDownloadBtn = document.getElementById("audio-download-btn"); |
| const iconPlay = audioPlayBtn.querySelector(".icon-play"); |
| const iconPause = audioPlayBtn.querySelector(".icon-pause"); |
|
|
| |
| |
| let pollTimer = null; |
|
|
| function showStep(stepNumber) { |
| document.querySelectorAll(".step").forEach(function (section) { |
| section.classList.toggle("active", section.dataset.step === String(stepNumber)); |
| }); |
| } |
|
|
| function showError(message) { |
| statusBanner.innerHTML = '<div class="status-banner error">' + escapeHtml(message) + "</div>"; |
| } |
|
|
| function clearBanner() { |
| statusBanner.innerHTML = ""; |
| } |
|
|
| function escapeHtml(text) { |
| const div = document.createElement("div"); |
| div.textContent = text == null ? "" : String(text); |
| return div.innerHTML; |
| } |
|
|
| function showToast(message) { |
| toast.textContent = message; |
| toast.classList.add("visible"); |
| setTimeout(function () { |
| toast.classList.remove("visible"); |
| }, 2200); |
| } |
|
|
| function setButtonLoading(button, isLoading, loadingText) { |
| const spinner = button.querySelector(".btn-spinner"); |
| const icon = button.querySelector(".btn-icon"); |
| const label = button.querySelector(".btn-label"); |
| button.disabled = isLoading; |
| if (spinner) spinner.style.display = isLoading ? "inline-block" : "none"; |
| if (icon) icon.style.display = isLoading ? "none" : "inline-flex"; |
| if (label && loadingText) label.textContent = isLoading ? loadingText : label.dataset.defaultText; |
| } |
|
|
| document.querySelectorAll(".btn-label").forEach(function (el) { |
| el.dataset.defaultText = el.textContent; |
| }); |
|
|
| function setSubmitLabel(text) { |
| const label = submitInputBtn.querySelector(".btn-label"); |
| label.textContent = text; |
| label.dataset.defaultText = text; |
| } |
|
|
| |
| languageGrid.addEventListener("click", function (event) { |
| const option = event.target.closest(".language-option"); |
| if (!option) return; |
|
|
| languageGrid.querySelectorAll(".language-option").forEach(function (el) { |
| el.setAttribute("aria-pressed", "false"); |
| }); |
| option.setAttribute("aria-pressed", "true"); |
| selectedLanguage = option.dataset.language; |
| toStep2Btn.disabled = false; |
| }); |
|
|
| toStep2Btn.addEventListener("click", function () { |
| showStep(2); |
| }); |
|
|
| backToStep1Btn.addEventListener("click", function () { |
| reviewPanel.style.display = "none"; |
| transcribingPanel.style.display = "none"; |
| listeningPanel.style.display = "none"; |
| inputPanel.style.display = "block"; |
| recordedBlob = null; |
| setSubmitLabel("Send to provider"); |
| showStep(1); |
| }); |
|
|
| |
| symptomChips.addEventListener("click", function (event) { |
| const chip = event.target.closest(".chip"); |
| if (!chip) return; |
| const textarea = document.getElementById("patient-text"); |
| textarea.value = textarea.value ? textarea.value + ". " + chip.dataset.text : chip.dataset.text; |
| textarea.focus(); |
| }); |
|
|
| function formatTimer(totalSeconds) { |
| const minutes = Math.floor(totalSeconds / 60); |
| const seconds = totalSeconds % 60; |
| return minutes + ":" + String(seconds).padStart(2, "0"); |
| } |
|
|
| function startRecording() { |
| recordBtn.classList.add("tapped"); |
| setTimeout(function () { recordBtn.classList.remove("tapped"); }, 500); |
|
|
| navigator.mediaDevices.getUserMedia({ audio: true }).then(function (stream) { |
| recordedChunks = []; |
| mediaRecorder = new MediaRecorder(stream); |
|
|
| mediaRecorder.addEventListener("dataavailable", function (event) { |
| if (event.data.size > 0) recordedChunks.push(event.data); |
| }); |
|
|
| mediaRecorder.addEventListener("stop", function () { |
| recordedBlob = new Blob(recordedChunks, { type: "audio/webm" }); |
| stream.getTracks().forEach(function (track) { track.stop(); }); |
| clearInterval(recordingTimerInterval); |
| listeningPanel.style.display = "none"; |
| transcribeRecording(); |
| }); |
|
|
| mediaRecorder.start(); |
| recordingSeconds = 0; |
| recordTimer.textContent = "\u00A00:00"; |
| inputPanel.style.display = "none"; |
| listeningPanel.style.display = "block"; |
| recordingTimerInterval = setInterval(function () { |
| recordingSeconds += 1; |
| recordTimer.textContent = "\u00A0" + formatTimer(recordingSeconds); |
| }, 1000); |
| }).catch(function () { |
| showError("Couldn't access your microphone. You can type your message instead."); |
| }); |
| } |
|
|
| recordBtn.addEventListener("click", startRecording); |
|
|
| stopRecordingBtn.addEventListener("click", function () { |
| if (mediaRecorder && mediaRecorder.state === "recording") { |
| mediaRecorder.stop(); |
| } |
| }); |
|
|
| async function transcribeRecording() { |
| clearBanner(); |
| transcribingPanel.style.display = "block"; |
|
|
| const formData = new FormData(); |
| formData.append("language", selectedLanguage); |
| formData.append("audio", recordedBlob, "recording.webm"); |
|
|
| try { |
| const response = await fetch("/transcribe", { method: "POST", body: formData }); |
| const data = await response.json(); |
|
|
| transcribingPanel.style.display = "none"; |
|
|
| if (!response.ok) { |
| showError(data.error || "Couldn't hear that clearly, try again or type your message instead."); |
| recordedBlob = null; |
| inputPanel.style.display = "block"; |
| return; |
| } |
|
|
| reviewText.value = data.patient_text; |
| reviewPanel.style.display = "block"; |
| setSubmitLabel("Confirm and send"); |
| reviewText.focus(); |
| } catch (error) { |
| transcribingPanel.style.display = "none"; |
| recordedBlob = null; |
| inputPanel.style.display = "block"; |
| showError("Couldn't reach the server. Check your connection and try again."); |
| } |
| } |
|
|
| recordAgainBtn.addEventListener("click", function () { |
| reviewPanel.style.display = "none"; |
| recordedBlob = null; |
| inputPanel.style.display = "block"; |
| recordStatus.textContent = ""; |
| setSubmitLabel("Send to provider"); |
| }); |
|
|
| |
| submitInputBtn.addEventListener("click", async function () { |
| clearBanner(); |
|
|
| const isReviewing = reviewPanel.style.display === "block"; |
| const text = isReviewing |
| ? reviewText.value.trim() |
| : document.getElementById("patient-text").value.trim(); |
|
|
| if (!text) { |
| showError(isReviewing ? "Your message can't be empty." : "Type a message, tap a quick option, or record your voice first."); |
| return; |
| } |
|
|
| setButtonLoading(submitInputBtn, true, "Sending..."); |
|
|
| const formData = new FormData(); |
| formData.append("language", selectedLanguage); |
| formData.append("text", text); |
|
|
| try { |
| const response = await fetch("/patient-input", { method: "POST", body: formData }); |
| const data = await response.json(); |
|
|
| if (!response.ok) { |
| showError(data.error || "Something went wrong, please try again."); |
| return; |
| } |
|
|
| currentInteractionId = data.interaction_id; |
| document.getElementById("interaction-id-display").textContent = currentInteractionId; |
| document.getElementById("heard-text").textContent = data.patient_text; |
|
|
| |
| |
| chatThread.querySelectorAll(".chat-bubble.from-provider").forEach(function (el) { el.remove(); }); |
| document.getElementById("response-card").style.display = "none"; |
| waitingCard.style.display = "block"; |
|
|
| showStep(3); |
| loadHistory(); |
| startPolling(); |
| } catch (error) { |
| showError("Couldn't reach the server. Check your connection and try again."); |
| } finally { |
| setButtonLoading(submitInputBtn, false, "Sending..."); |
| } |
| }); |
|
|
| |
| |
| |
| async function checkForResponse(isManual) { |
| if (!currentInteractionId) return; |
|
|
| if (isManual) { |
| clearBanner(); |
| setButtonLoading(checkResponseBtn, true, "Checking..."); |
| } |
|
|
| try { |
| const response = await fetch("/interaction/" + currentInteractionId); |
| const data = await response.json(); |
|
|
| if (data.translated_response) { |
| stopPolling(); |
|
|
| const bubble = document.createElement("div"); |
| bubble.className = "chat-bubble from-provider list-item-in"; |
| bubble.innerHTML = '<span class="chat-label">Provider</span>' + escapeHtml(data.translated_response); |
| chatThread.appendChild(bubble); |
|
|
| waitingCard.style.display = "none"; |
|
|
| if (data.audio_url) { |
| document.getElementById("response-card").style.display = "block"; |
| setAudioSource(data.audio_url); |
| } |
|
|
| |
| |
| loadHistory(); |
| } else if (isManual) { |
| showError("No reply yet, try again in a moment."); |
| } |
| } catch (error) { |
| |
| |
| if (isManual) showError("Couldn't reach the server. Check your connection and try again."); |
| } finally { |
| if (isManual) setButtonLoading(checkResponseBtn, false, "Checking..."); |
| } |
| } |
|
|
| checkResponseBtn.addEventListener("click", function () { |
| checkForResponse(true); |
| }); |
|
|
| function startPolling() { |
| stopPolling(); |
| pollTimer = setInterval(function () { checkForResponse(false); }, 15000); |
| } |
|
|
| function stopPolling() { |
| if (pollTimer) { |
| clearInterval(pollTimer); |
| pollTimer = null; |
| } |
| } |
|
|
| |
| |
| document.addEventListener("visibilitychange", function () { |
| if (document.hidden) { |
| stopPolling(); |
| } else if (currentInteractionId && waitingCard.style.display !== "none") { |
| checkForResponse(false); |
| startPolling(); |
| } |
| }); |
|
|
| |
| function formatAudioTime(totalSeconds) { |
| if (!isFinite(totalSeconds) || totalSeconds < 0) return "0:00"; |
| const minutes = Math.floor(totalSeconds / 60); |
| const seconds = Math.floor(totalSeconds % 60); |
| return minutes + ":" + String(seconds).padStart(2, "0"); |
| } |
|
|
| function setAudioSource(url) { |
| responseAudioEl.src = url; |
| audioDownloadBtn.href = url; |
| audioSeek.value = 0; |
| audioCurrentTimeEl.textContent = "0:00"; |
| audioDurationEl.textContent = "0:00"; |
| iconPlay.style.display = "inline"; |
| iconPause.style.display = "none"; |
| } |
|
|
| audioPlayBtn.addEventListener("click", function () { |
| if (responseAudioEl.paused) { |
| responseAudioEl.play(); |
| } else { |
| responseAudioEl.pause(); |
| } |
| }); |
|
|
| audioReplayBtn.addEventListener("click", function () { |
| responseAudioEl.currentTime = 0; |
| responseAudioEl.play(); |
| }); |
|
|
| responseAudioEl.addEventListener("play", function () { |
| iconPlay.style.display = "none"; |
| iconPause.style.display = "inline"; |
| }); |
|
|
| responseAudioEl.addEventListener("pause", function () { |
| iconPlay.style.display = "inline"; |
| iconPause.style.display = "none"; |
| }); |
|
|
| responseAudioEl.addEventListener("ended", function () { |
| iconPlay.style.display = "inline"; |
| iconPause.style.display = "none"; |
| }); |
|
|
| responseAudioEl.addEventListener("loadedmetadata", function () { |
| audioSeek.max = responseAudioEl.duration || 0; |
| audioDurationEl.textContent = formatAudioTime(responseAudioEl.duration); |
| }); |
|
|
| responseAudioEl.addEventListener("timeupdate", function () { |
| audioSeek.value = responseAudioEl.currentTime; |
| audioCurrentTimeEl.textContent = formatAudioTime(responseAudioEl.currentTime); |
| }); |
|
|
| audioSeek.addEventListener("input", function () { |
| responseAudioEl.currentTime = Number(audioSeek.value); |
| }); |
|
|
| |
| let cachedHistoryItems = []; |
|
|
| function buildHistoryRow(item, index) { |
| const row = document.createElement("div"); |
| row.className = "result-block history-row list-item-in"; |
| row.style.animationDelay = (index * 40) + "ms"; |
| row.setAttribute("role", "button"); |
| row.setAttribute("tabindex", "0"); |
| const answered = Boolean(item.translated_response); |
| const badgeClass = answered ? "answered" : "waiting"; |
| const badgeLabel = answered ? "Answered" : "Waiting for reply"; |
| const timeLabel = window.formatRelativeTime ? window.formatRelativeTime(item.timestamp) : ""; |
| row.innerHTML = |
| '<div class="result-label">#' + escapeHtml(item.id) + |
| ' <span class="status-badge ' + badgeClass + '">' + badgeLabel + "</span>" + |
| ' <span class="timestamp">' + escapeHtml(timeLabel) + "</span></div>" + |
| '<div class="result-text">' + escapeHtml(item.input_text) + "</div>"; |
| row.addEventListener("click", function () { |
| restoreInteraction(item.id); |
| }); |
| row.addEventListener("keydown", function (event) { |
| if (event.key === "Enter" || event.key === " ") { |
| event.preventDefault(); |
| restoreInteraction(item.id); |
| } |
| }); |
| return row; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| async function loadHistory() { |
| try { |
| const response = await fetch("/history"); |
| const items = await response.json(); |
| cachedHistoryItems = items; |
|
|
| if (!items.length) { |
| historyCard.style.display = "none"; |
| } else { |
| historyCard.style.display = "block"; |
| historyList.innerHTML = ""; |
| items.forEach(function (item, index) { |
| historyList.appendChild(buildHistoryRow(item, index)); |
| }); |
| } |
| } catch (error) { |
| historyCard.style.display = "none"; |
| cachedHistoryItems = []; |
| } |
|
|
| renderFullHistoryList(historySearchInput.value); |
| } |
|
|
| |
| |
| function renderFullHistoryList(filterText) { |
| const query = (filterText || "").trim().toLowerCase(); |
| const filtered = query |
| ? cachedHistoryItems.filter(function (item) { |
| return (item.input_text || "").toLowerCase().indexOf(query) !== -1; |
| }) |
| : cachedHistoryItems; |
|
|
| historyFullList.innerHTML = ""; |
|
|
| if (!cachedHistoryItems.length) { |
| historyEmptyText.textContent = "You haven't sent any messages yet."; |
| historyEmptyText.style.display = "block"; |
| return; |
| } |
|
|
| if (!filtered.length) { |
| historyEmptyText.textContent = "No messages match your search."; |
| historyEmptyText.style.display = "block"; |
| return; |
| } |
|
|
| historyEmptyText.style.display = "none"; |
| filtered.forEach(function (item, index) { |
| historyFullList.appendChild(buildHistoryRow(item, index)); |
| }); |
| } |
|
|
| |
| |
| |
| async function restoreInteraction(interactionId) { |
| clearBanner(); |
| try { |
| const response = await fetch("/interaction/" + interactionId); |
| const data = await response.json(); |
|
|
| if (!response.ok) { |
| showError(data.error || "Couldn't load that consultation."); |
| return; |
| } |
|
|
| stopPolling(); |
| currentInteractionId = data.id; |
| document.getElementById("interaction-id-display").textContent = data.id; |
| document.getElementById("heard-text").textContent = data.input_text; |
|
|
| chatThread.querySelectorAll(".chat-bubble.from-provider").forEach(function (el) { el.remove(); }); |
| document.getElementById("response-card").style.display = "none"; |
|
|
| if (data.translated_response) { |
| const bubble = document.createElement("div"); |
| bubble.className = "chat-bubble from-provider"; |
| bubble.innerHTML = '<span class="chat-label">Provider</span>' + escapeHtml(data.translated_response); |
| chatThread.appendChild(bubble); |
| waitingCard.style.display = "none"; |
|
|
| if (data.audio_url) { |
| document.getElementById("response-card").style.display = "block"; |
| setAudioSource(data.audio_url); |
| } |
| } else { |
| |
| |
| waitingCard.style.display = "block"; |
| startPolling(); |
| } |
|
|
| showStep(3); |
| } catch (error) { |
| showError("Couldn't reach the server. Check your connection and try again."); |
| } |
| } |
|
|
| openHistoryBtn.addEventListener("click", function () { |
| clearBanner(); |
| showStep("history"); |
| loadHistory(); |
| }); |
|
|
| historyBackBtn.addEventListener("click", function () { |
| showStep(1); |
| }); |
|
|
| historySearchInput.addEventListener("input", function () { |
| renderFullHistoryList(historySearchInput.value); |
| }); |
|
|
| |
| |
| newConversationBtn.addEventListener("click", async function () { |
| stopPolling(); |
|
|
| try { |
| await fetch("/end-session", { method: "POST" }); |
| } catch (error) { |
| |
| } |
|
|
| selectedLanguage = null; |
| recordedBlob = null; |
| currentInteractionId = null; |
| cachedHistoryItems = []; |
| document.getElementById("patient-text").value = ""; |
| document.getElementById("response-card").style.display = "none"; |
| waitingCard.style.display = "block"; |
| responseAudioEl.pause(); |
| responseAudioEl.removeAttribute("src"); |
| reviewPanel.style.display = "none"; |
| transcribingPanel.style.display = "none"; |
| listeningPanel.style.display = "none"; |
| inputPanel.style.display = "block"; |
| historyCard.style.display = "none"; |
| setSubmitLabel("Send to provider"); |
| languageGrid.querySelectorAll(".language-option").forEach(function (el) { |
| el.setAttribute("aria-pressed", "false"); |
| }); |
| toStep2Btn.disabled = true; |
| clearBanner(); |
| showStep(1); |
| |
| |
| |
| showToast("New conversation started"); |
| }); |
|
|
| loadHistory(); |
| })(); |
|
|