Upload 2 files
Browse files- static/js/patient.js +139 -23
- static/js/provider.js +346 -36
static/js/patient.js
CHANGED
|
@@ -31,6 +31,11 @@
|
|
| 31 |
const newConversationBtn = document.getElementById("new-conversation-btn");
|
| 32 |
const historyCard = document.getElementById("history-card");
|
| 33 |
const historyList = document.getElementById("history-list");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
const chatThread = document.getElementById("chat-thread");
|
| 35 |
const waitingCard = document.getElementById("waiting-card");
|
| 36 |
const toast = document.getElementById("toast");
|
|
@@ -405,43 +410,154 @@
|
|
| 405 |
});
|
| 406 |
|
| 407 |
// Past messages for this session.
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 413 |
async function loadHistory() {
|
| 414 |
try {
|
| 415 |
const response = await fetch("/history");
|
| 416 |
const items = await response.json();
|
|
|
|
| 417 |
|
| 418 |
if (!items.length) {
|
| 419 |
historyCard.style.display = "none";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 420 |
return;
|
| 421 |
}
|
| 422 |
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
const
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 440 |
} catch (error) {
|
| 441 |
-
|
| 442 |
}
|
| 443 |
}
|
| 444 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 445 |
// Start a new conversation: ends the current session server-side and
|
| 446 |
// resets the page back to language selection.
|
| 447 |
newConversationBtn.addEventListener("click", async function () {
|
|
|
|
| 31 |
const newConversationBtn = document.getElementById("new-conversation-btn");
|
| 32 |
const historyCard = document.getElementById("history-card");
|
| 33 |
const historyList = document.getElementById("history-list");
|
| 34 |
+
const openHistoryBtn = document.getElementById("open-history-btn");
|
| 35 |
+
const historyBackBtn = document.getElementById("history-back-btn");
|
| 36 |
+
const historyFullList = document.getElementById("history-full-list");
|
| 37 |
+
const historyEmptyText = document.getElementById("history-empty-text");
|
| 38 |
+
const historySearchInput = document.getElementById("history-search");
|
| 39 |
const chatThread = document.getElementById("chat-thread");
|
| 40 |
const waitingCard = document.getElementById("waiting-card");
|
| 41 |
const toast = document.getElementById("toast");
|
|
|
|
| 410 |
});
|
| 411 |
|
| 412 |
// Past messages for this session.
|
| 413 |
+
let cachedHistoryItems = [];
|
| 414 |
+
|
| 415 |
+
function buildHistoryRow(item, index) {
|
| 416 |
+
const row = document.createElement("div");
|
| 417 |
+
row.className = "result-block history-row list-item-in";
|
| 418 |
+
row.style.animationDelay = (index * 40) + "ms";
|
| 419 |
+
row.setAttribute("role", "button");
|
| 420 |
+
row.setAttribute("tabindex", "0");
|
| 421 |
+
const answered = Boolean(item.translated_response);
|
| 422 |
+
const badgeClass = answered ? "answered" : "waiting";
|
| 423 |
+
const badgeLabel = answered ? "Answered" : "Waiting for reply";
|
| 424 |
+
const timeLabel = window.formatRelativeTime ? window.formatRelativeTime(item.timestamp) : "";
|
| 425 |
+
row.innerHTML =
|
| 426 |
+
'<div class="result-label">#' + escapeHtml(item.id) +
|
| 427 |
+
' <span class="status-badge ' + badgeClass + '">' + badgeLabel + "</span>" +
|
| 428 |
+
' <span class="timestamp">' + escapeHtml(timeLabel) + "</span></div>" +
|
| 429 |
+
'<div class="result-text">' + escapeHtml(item.input_text) + "</div>";
|
| 430 |
+
row.addEventListener("click", function () {
|
| 431 |
+
restoreInteraction(item.id);
|
| 432 |
+
});
|
| 433 |
+
row.addEventListener("keydown", function (event) {
|
| 434 |
+
if (event.key === "Enter" || event.key === " ") {
|
| 435 |
+
event.preventDefault();
|
| 436 |
+
restoreInteraction(item.id);
|
| 437 |
+
}
|
| 438 |
+
});
|
| 439 |
+
return row;
|
| 440 |
+
}
|
| 441 |
+
|
| 442 |
+
// Deliberately does NOT show the teaser card or a loading skeleton up
|
| 443 |
+
// front: a brand new visitor (or anyone right after "Start a new
|
| 444 |
+
// conversation") has no history yet, so an optimistic reveal would just
|
| 445 |
+
// flash open and immediately hide itself once the empty response came
|
| 446 |
+
// back. The card only appears once a response has confirmed there's
|
| 447 |
+
// something to show.
|
| 448 |
async function loadHistory() {
|
| 449 |
try {
|
| 450 |
const response = await fetch("/history");
|
| 451 |
const items = await response.json();
|
| 452 |
+
cachedHistoryItems = items;
|
| 453 |
|
| 454 |
if (!items.length) {
|
| 455 |
historyCard.style.display = "none";
|
| 456 |
+
} else {
|
| 457 |
+
historyCard.style.display = "block";
|
| 458 |
+
historyList.innerHTML = "";
|
| 459 |
+
items.forEach(function (item, index) {
|
| 460 |
+
historyList.appendChild(buildHistoryRow(item, index));
|
| 461 |
+
});
|
| 462 |
+
}
|
| 463 |
+
} catch (error) {
|
| 464 |
+
historyCard.style.display = "none";
|
| 465 |
+
cachedHistoryItems = [];
|
| 466 |
+
}
|
| 467 |
+
|
| 468 |
+
renderFullHistoryList(historySearchInput.value);
|
| 469 |
+
}
|
| 470 |
+
|
| 471 |
+
// Dedicated history view: renders from the cache above (instant), so
|
| 472 |
+
// opening it never flashes empty while a fresh fetch is in flight.
|
| 473 |
+
function renderFullHistoryList(filterText) {
|
| 474 |
+
const query = (filterText || "").trim().toLowerCase();
|
| 475 |
+
const filtered = query
|
| 476 |
+
? cachedHistoryItems.filter(function (item) {
|
| 477 |
+
return (item.input_text || "").toLowerCase().indexOf(query) !== -1;
|
| 478 |
+
})
|
| 479 |
+
: cachedHistoryItems;
|
| 480 |
+
|
| 481 |
+
historyFullList.innerHTML = "";
|
| 482 |
+
|
| 483 |
+
if (!cachedHistoryItems.length) {
|
| 484 |
+
historyEmptyText.textContent = "You haven't sent any messages yet.";
|
| 485 |
+
historyEmptyText.style.display = "block";
|
| 486 |
+
return;
|
| 487 |
+
}
|
| 488 |
+
|
| 489 |
+
if (!filtered.length) {
|
| 490 |
+
historyEmptyText.textContent = "No messages match your search.";
|
| 491 |
+
historyEmptyText.style.display = "block";
|
| 492 |
+
return;
|
| 493 |
+
}
|
| 494 |
+
|
| 495 |
+
historyEmptyText.style.display = "none";
|
| 496 |
+
filtered.forEach(function (item, index) {
|
| 497 |
+
historyFullList.appendChild(buildHistoryRow(item, index));
|
| 498 |
+
});
|
| 499 |
+
}
|
| 500 |
+
|
| 501 |
+
// Reopens a past consultation (from either history list) into the same
|
| 502 |
+
// step-3 view a brand new message lands on, reusing its chat thread,
|
| 503 |
+
// waiting card, and audio player rather than building a second one.
|
| 504 |
+
async function restoreInteraction(interactionId) {
|
| 505 |
+
clearBanner();
|
| 506 |
+
try {
|
| 507 |
+
const response = await fetch("/interaction/" + interactionId);
|
| 508 |
+
const data = await response.json();
|
| 509 |
+
|
| 510 |
+
if (!response.ok) {
|
| 511 |
+
showError(data.error || "Couldn't load that consultation.");
|
| 512 |
return;
|
| 513 |
}
|
| 514 |
|
| 515 |
+
stopPolling();
|
| 516 |
+
currentInteractionId = data.id;
|
| 517 |
+
document.getElementById("interaction-id-display").textContent = data.id;
|
| 518 |
+
document.getElementById("heard-text").textContent = data.input_text;
|
| 519 |
+
|
| 520 |
+
chatThread.querySelectorAll(".chat-bubble.from-provider").forEach(function (el) { el.remove(); });
|
| 521 |
+
document.getElementById("response-card").style.display = "none";
|
| 522 |
+
|
| 523 |
+
if (data.translated_response) {
|
| 524 |
+
const bubble = document.createElement("div");
|
| 525 |
+
bubble.className = "chat-bubble from-provider";
|
| 526 |
+
bubble.innerHTML = '<span class="chat-label">Provider</span>' + escapeHtml(data.translated_response);
|
| 527 |
+
chatThread.appendChild(bubble);
|
| 528 |
+
waitingCard.style.display = "none";
|
| 529 |
+
|
| 530 |
+
if (data.audio_url) {
|
| 531 |
+
document.getElementById("response-card").style.display = "block";
|
| 532 |
+
setAudioSource(data.audio_url);
|
| 533 |
+
}
|
| 534 |
+
} else {
|
| 535 |
+
// Still unanswered: resume checking for this one instead of the
|
| 536 |
+
// most recently submitted interaction.
|
| 537 |
+
waitingCard.style.display = "block";
|
| 538 |
+
startPolling();
|
| 539 |
+
}
|
| 540 |
+
|
| 541 |
+
showStep(3);
|
| 542 |
} catch (error) {
|
| 543 |
+
showError("Couldn't reach the server. Check your connection and try again.");
|
| 544 |
}
|
| 545 |
}
|
| 546 |
|
| 547 |
+
openHistoryBtn.addEventListener("click", function () {
|
| 548 |
+
clearBanner();
|
| 549 |
+
showStep("history");
|
| 550 |
+
loadHistory();
|
| 551 |
+
});
|
| 552 |
+
|
| 553 |
+
historyBackBtn.addEventListener("click", function () {
|
| 554 |
+
showStep(1);
|
| 555 |
+
});
|
| 556 |
+
|
| 557 |
+
historySearchInput.addEventListener("input", function () {
|
| 558 |
+
renderFullHistoryList(historySearchInput.value);
|
| 559 |
+
});
|
| 560 |
+
|
| 561 |
// Start a new conversation: ends the current session server-side and
|
| 562 |
// resets the page back to language selection.
|
| 563 |
newConversationBtn.addEventListener("click", async function () {
|
static/js/provider.js
CHANGED
|
@@ -1,18 +1,53 @@
|
|
| 1 |
// TalkToDoc provider page logic.
|
| 2 |
-
// Lists interactions
|
| 3 |
-
//
|
|
|
|
| 4 |
|
| 5 |
(function () {
|
| 6 |
let selectedInteractionId = null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
const statusBanner = document.getElementById("status-banner");
|
| 9 |
const pendingList = document.getElementById("pending-list");
|
| 10 |
const emptyPending = document.getElementById("empty-pending");
|
| 11 |
const queueHeading = document.getElementById("queue-heading");
|
|
|
|
| 12 |
const backToListBtn = document.getElementById("back-to-list");
|
| 13 |
const submitResponseBtn = document.getElementById("submit-response");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
const toast = document.getElementById("toast");
|
| 15 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
function showStep(stepName) {
|
| 17 |
document.querySelectorAll(".step").forEach(function (section) {
|
| 18 |
section.classList.toggle("active", section.dataset.step === stepName);
|
|
@@ -55,18 +90,107 @@
|
|
| 55 |
el.dataset.defaultText = el.textContent;
|
| 56 |
});
|
| 57 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
async function loadPending() {
|
| 59 |
-
|
|
|
|
|
|
|
| 60 |
|
| 61 |
try {
|
| 62 |
const response = await fetch("/pending-interactions");
|
| 63 |
const interactions = await response.json();
|
|
|
|
| 64 |
|
| 65 |
-
|
| 66 |
-
const isEmpty = interactions.length === 0;
|
| 67 |
-
emptyPending.style.display = isEmpty ? "block" : "none";
|
| 68 |
-
|
| 69 |
-
const newHeadingText = isEmpty
|
| 70 |
? "All caught up"
|
| 71 |
: interactions.length === 1
|
| 72 |
? "1 message waiting for you"
|
|
@@ -78,58 +202,137 @@
|
|
| 78 |
void queueHeading.offsetWidth;
|
| 79 |
queueHeading.style.animation = "";
|
| 80 |
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
row.innerHTML =
|
| 88 |
-
'<
|
| 89 |
-
'<span class="
|
| 90 |
-
'<span class="
|
| 91 |
-
'<
|
| 92 |
-
|
| 93 |
-
'<span class="timestamp">' + escapeHtml(timeLabel) + '</span>' +
|
| 94 |
-
'<span class="status-badge waiting">#' + escapeHtml(item.id) + '</span>' +
|
| 95 |
-
'</span>' +
|
| 96 |
-
'</span>' +
|
| 97 |
-
'<span class="queue-preview">' + escapeHtml(item.translated_text) + '</span>' +
|
| 98 |
-
'</span>';
|
| 99 |
-
row.addEventListener("click", function () {
|
| 100 |
-
openInteraction(item);
|
| 101 |
-
});
|
| 102 |
-
pendingList.appendChild(row);
|
| 103 |
});
|
|
|
|
| 104 |
} catch (error) {
|
| 105 |
-
|
|
|
|
| 106 |
}
|
| 107 |
}
|
| 108 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
function openInteraction(item) {
|
| 110 |
clearBanner();
|
| 111 |
selectedInteractionId = item.id;
|
|
|
|
|
|
|
| 112 |
document.getElementById("detail-placeholder").style.display = "none";
|
| 113 |
const detailContent = document.getElementById("detail-content");
|
| 114 |
detailContent.style.display = "block";
|
| 115 |
detailContent.classList.remove("list-item-in");
|
| 116 |
void detailContent.offsetWidth;
|
| 117 |
detailContent.classList.add("list-item-in");
|
|
|
|
| 118 |
document.getElementById("respond-interaction-id").textContent = item.id;
|
| 119 |
document.getElementById("respond-language-label").textContent =
|
| 120 |
-
"Patient (" +
|
| 121 |
document.getElementById("respond-translated-text").textContent = item.translated_text;
|
| 122 |
document.getElementById("respond-nlu-summary").textContent = item.nlu_summary || "No summary available.";
|
| 123 |
-
document.getElementById("
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
showStep("respond");
|
|
|
|
|
|
|
| 125 |
}
|
| 126 |
|
| 127 |
document.getElementById("quick-reply-chips").addEventListener("click", function (event) {
|
| 128 |
const chip = event.target.closest(".chip");
|
| 129 |
if (!chip) return;
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
});
|
| 134 |
|
| 135 |
backToListBtn.addEventListener("click", function () {
|
|
@@ -139,9 +342,104 @@
|
|
| 139 |
loadPending();
|
| 140 |
});
|
| 141 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
submitResponseBtn.addEventListener("click", async function () {
|
| 143 |
clearBanner();
|
| 144 |
-
const responseText =
|
| 145 |
|
| 146 |
if (!responseText) {
|
| 147 |
showError("Type a reply before sending.");
|
|
@@ -154,6 +452,16 @@
|
|
| 154 |
formData.append("interaction_id", selectedInteractionId);
|
| 155 |
formData.append("response_text", responseText);
|
| 156 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
try {
|
| 158 |
const response = await fetch("/provider-response", { method: "POST", body: formData });
|
| 159 |
const data = await response.json();
|
|
@@ -163,6 +471,8 @@
|
|
| 163 |
return;
|
| 164 |
}
|
| 165 |
|
|
|
|
|
|
|
| 166 |
showStep("list");
|
| 167 |
document.getElementById("detail-placeholder").style.display = "block";
|
| 168 |
document.getElementById("detail-content").style.display = "none";
|
|
|
|
| 1 |
// TalkToDoc provider page logic.
|
| 2 |
+
// Lists interactions in three tabs (Waiting / Active / Completed), and
|
| 3 |
+
// lets the provider open one to read full context and reply, with a real
|
| 4 |
+
// preview of the translation and speech before actually sending.
|
| 5 |
|
| 6 |
(function () {
|
| 7 |
let selectedInteractionId = null;
|
| 8 |
+
// The single interaction currently open in the workspace, if any. This
|
| 9 |
+
// is the only honest meaning "Active" can have here: there's no
|
| 10 |
+
// multi-provider login or claim system, so it's a client-side slot,
|
| 11 |
+
// not a backend state. It survives "Back" (so a provider can return to
|
| 12 |
+
// an in-progress reply) and is only cleared once that reply is sent.
|
| 13 |
+
let activeItem = null;
|
| 14 |
+
let currentTab = "waiting";
|
| 15 |
+
let pendingItems = [];
|
| 16 |
+
let completedItems = [];
|
| 17 |
+
let completedLoaded = false;
|
| 18 |
+
// Set on a successful preview, cleared the moment the reply text
|
| 19 |
+
// changes again - only ever valid for the exact text it was generated
|
| 20 |
+
// from, so Send only reuses it when nothing has been edited since.
|
| 21 |
+
let lastPreviewText = null;
|
| 22 |
+
let lastPreviewTranslation = null;
|
| 23 |
|
| 24 |
const statusBanner = document.getElementById("status-banner");
|
| 25 |
const pendingList = document.getElementById("pending-list");
|
| 26 |
const emptyPending = document.getElementById("empty-pending");
|
| 27 |
const queueHeading = document.getElementById("queue-heading");
|
| 28 |
+
const queueSearchInput = document.getElementById("queue-search");
|
| 29 |
const backToListBtn = document.getElementById("back-to-list");
|
| 30 |
const submitResponseBtn = document.getElementById("submit-response");
|
| 31 |
+
const previewBtn = document.getElementById("preview-response-btn");
|
| 32 |
+
const previewBlock = document.getElementById("preview-block");
|
| 33 |
+
const previewTextBubble = document.getElementById("preview-text-bubble");
|
| 34 |
+
const responseTextInput = document.getElementById("response-text-input");
|
| 35 |
+
const patientHistoryCard = document.getElementById("patient-history-card");
|
| 36 |
+
const patientHistoryList = document.getElementById("patient-history-list");
|
| 37 |
const toast = document.getElementById("toast");
|
| 38 |
|
| 39 |
+
// Preview audio player (separate instance from the patient page's, same
|
| 40 |
+
// pattern: play/pause/replay/seek/time, no download - a preview isn't
|
| 41 |
+
// the final artifact worth keeping).
|
| 42 |
+
const previewAudioEl = document.getElementById("preview-audio");
|
| 43 |
+
const previewPlayBtn = document.getElementById("preview-audio-play-btn");
|
| 44 |
+
const previewReplayBtn = document.getElementById("preview-audio-replay-btn");
|
| 45 |
+
const previewSeek = document.getElementById("preview-audio-seek");
|
| 46 |
+
const previewCurrentTimeEl = document.getElementById("preview-audio-current-time");
|
| 47 |
+
const previewDurationEl = document.getElementById("preview-audio-duration");
|
| 48 |
+
const previewIconPlay = previewPlayBtn.querySelector(".icon-play");
|
| 49 |
+
const previewIconPause = previewPlayBtn.querySelector(".icon-pause");
|
| 50 |
+
|
| 51 |
function showStep(stepName) {
|
| 52 |
document.querySelectorAll(".step").forEach(function (section) {
|
| 53 |
section.classList.toggle("active", section.dataset.step === stepName);
|
|
|
|
| 90 |
el.dataset.defaultText = el.textContent;
|
| 91 |
});
|
| 92 |
|
| 93 |
+
function capitalize(text) {
|
| 94 |
+
return text ? text.charAt(0).toUpperCase() + text.slice(1) : text;
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
// ---- Queue (Waiting / Active / Completed tabs) ----
|
| 98 |
+
|
| 99 |
+
function getTabItems() {
|
| 100 |
+
if (currentTab === "waiting") {
|
| 101 |
+
return pendingItems.filter(function (item) {
|
| 102 |
+
return !activeItem || item.id !== activeItem.id;
|
| 103 |
+
});
|
| 104 |
+
}
|
| 105 |
+
if (currentTab === "active") {
|
| 106 |
+
return activeItem ? [activeItem] : [];
|
| 107 |
+
}
|
| 108 |
+
return completedItems;
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
function matchesQuery(item, query) {
|
| 112 |
+
if (!query) return true;
|
| 113 |
+
const haystack = (
|
| 114 |
+
(item.detected_language || "") + " " +
|
| 115 |
+
(item.translated_text || "") + " " +
|
| 116 |
+
(item.input_text || "")
|
| 117 |
+
).toLowerCase();
|
| 118 |
+
return haystack.indexOf(query) !== -1;
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
function emptyStateText(query) {
|
| 122 |
+
if (query) return "No messages match your search.";
|
| 123 |
+
if (currentTab === "waiting") return "Nothing waiting right now.";
|
| 124 |
+
if (currentTab === "active") return "Nothing active right now, open a message below to start on it.";
|
| 125 |
+
return "No completed consultations yet.";
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
function buildQueueRow(item, index) {
|
| 129 |
+
const row = document.createElement("button");
|
| 130 |
+
row.type = "button";
|
| 131 |
+
row.className = "queue-card list-item-in";
|
| 132 |
+
row.style.animationDelay = (index * 50) + "ms";
|
| 133 |
+
const timeLabel = window.formatRelativeTime ? window.formatRelativeTime(item.timestamp) : "";
|
| 134 |
+
const answered = Boolean(item.translated_response);
|
| 135 |
+
const isActive = Boolean(activeItem) && activeItem.id === item.id;
|
| 136 |
+
const badgeClass = answered ? "answered" : "waiting";
|
| 137 |
+
const badgeLabel = answered ? "Answered" : (isActive ? "In progress" : "#" + item.id);
|
| 138 |
+
row.innerHTML =
|
| 139 |
+
'<span class="queue-avatar">' + escapeHtml(item.detected_language.charAt(0).toUpperCase()) + '</span>' +
|
| 140 |
+
'<span class="queue-body">' +
|
| 141 |
+
'<span class="queue-top-row">' +
|
| 142 |
+
'<span class="queue-lang">' + escapeHtml(item.detected_language) + '</span>' +
|
| 143 |
+
'<span class="queue-top-right">' +
|
| 144 |
+
'<span class="timestamp">' + escapeHtml(timeLabel) + '</span>' +
|
| 145 |
+
'<span class="status-badge ' + badgeClass + '">' + escapeHtml(badgeLabel) + '</span>' +
|
| 146 |
+
'</span>' +
|
| 147 |
+
'</span>' +
|
| 148 |
+
'<span class="queue-preview">' + escapeHtml(item.translated_text) + '</span>' +
|
| 149 |
+
'</span>';
|
| 150 |
+
row.addEventListener("click", function () {
|
| 151 |
+
openInteraction(item);
|
| 152 |
+
});
|
| 153 |
+
return row;
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
function renderQueueList() {
|
| 157 |
+
const query = queueSearchInput.value.trim().toLowerCase();
|
| 158 |
+
const items = getTabItems().filter(function (item) {
|
| 159 |
+
return matchesQuery(item, query);
|
| 160 |
+
});
|
| 161 |
+
|
| 162 |
+
pendingList.innerHTML = "";
|
| 163 |
+
const isEmpty = items.length === 0;
|
| 164 |
+
emptyPending.style.display = isEmpty ? "block" : "none";
|
| 165 |
+
emptyPending.textContent = emptyStateText(query);
|
| 166 |
+
|
| 167 |
+
items.forEach(function (item, index) {
|
| 168 |
+
pendingList.appendChild(buildQueueRow(item, index));
|
| 169 |
+
});
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
function updateTabCounts() {
|
| 173 |
+
const waitingCount = pendingItems.filter(function (item) {
|
| 174 |
+
return !activeItem || item.id !== activeItem.id;
|
| 175 |
+
}).length;
|
| 176 |
+
document.getElementById("tab-count-waiting").textContent = waitingCount;
|
| 177 |
+
document.getElementById("tab-count-active").textContent = activeItem ? "1" : "0";
|
| 178 |
+
if (completedLoaded) {
|
| 179 |
+
document.getElementById("tab-count-completed").textContent = completedItems.length;
|
| 180 |
+
}
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
async function loadPending() {
|
| 184 |
+
if (currentTab === "waiting") {
|
| 185 |
+
pendingList.innerHTML = '<div class="skeleton-row"></div><div class="skeleton-row"></div><div class="skeleton-row"></div>';
|
| 186 |
+
}
|
| 187 |
|
| 188 |
try {
|
| 189 |
const response = await fetch("/pending-interactions");
|
| 190 |
const interactions = await response.json();
|
| 191 |
+
pendingItems = interactions;
|
| 192 |
|
| 193 |
+
const newHeadingText = interactions.length === 0
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
? "All caught up"
|
| 195 |
: interactions.length === 1
|
| 196 |
? "1 message waiting for you"
|
|
|
|
| 202 |
void queueHeading.offsetWidth;
|
| 203 |
queueHeading.style.animation = "";
|
| 204 |
|
| 205 |
+
renderQueueList();
|
| 206 |
+
updateTabCounts();
|
| 207 |
+
} catch (error) {
|
| 208 |
+
showError("Couldn't load pending messages. Check your connection.");
|
| 209 |
+
}
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
async function loadCompleted() {
|
| 213 |
+
pendingList.innerHTML = '<div class="skeleton-row"></div><div class="skeleton-row"></div>';
|
| 214 |
+
try {
|
| 215 |
+
const response = await fetch("/completed-interactions");
|
| 216 |
+
completedItems = await response.json();
|
| 217 |
+
completedLoaded = true;
|
| 218 |
+
renderQueueList();
|
| 219 |
+
updateTabCounts();
|
| 220 |
+
} catch (error) {
|
| 221 |
+
showError("Couldn't load completed messages. Check your connection.");
|
| 222 |
+
}
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
document.querySelectorAll(".queue-tab").forEach(function (tabBtn) {
|
| 226 |
+
tabBtn.addEventListener("click", function () {
|
| 227 |
+
currentTab = tabBtn.dataset.tab;
|
| 228 |
+
document.querySelectorAll(".queue-tab").forEach(function (t) {
|
| 229 |
+
t.classList.toggle("active", t === tabBtn);
|
| 230 |
+
t.setAttribute("aria-selected", t === tabBtn ? "true" : "false");
|
| 231 |
+
});
|
| 232 |
+
if (currentTab === "completed" && !completedLoaded) {
|
| 233 |
+
loadCompleted();
|
| 234 |
+
} else {
|
| 235 |
+
renderQueueList();
|
| 236 |
+
}
|
| 237 |
+
});
|
| 238 |
+
});
|
| 239 |
+
|
| 240 |
+
queueSearchInput.addEventListener("input", function () {
|
| 241 |
+
renderQueueList();
|
| 242 |
+
});
|
| 243 |
+
|
| 244 |
+
// ---- Workspace (respond step) ----
|
| 245 |
+
|
| 246 |
+
async function loadPatientHistoryForWorkspace(item) {
|
| 247 |
+
patientHistoryCard.style.display = "none";
|
| 248 |
+
try {
|
| 249 |
+
const response = await fetch("/patient-history/" + item.user_id);
|
| 250 |
+
const items = await response.json();
|
| 251 |
+
const others = items.filter(function (h) {
|
| 252 |
+
return h.id !== item.id;
|
| 253 |
+
});
|
| 254 |
+
|
| 255 |
+
if (!others.length) return;
|
| 256 |
+
|
| 257 |
+
patientHistoryList.innerHTML = "";
|
| 258 |
+
others.forEach(function (h, index) {
|
| 259 |
+
const row = document.createElement("div");
|
| 260 |
+
row.className = "result-block history-row-static list-item-in";
|
| 261 |
+
row.style.animationDelay = (index * 40) + "ms";
|
| 262 |
+
const answered = Boolean(h.translated_response);
|
| 263 |
+
const badgeClass = answered ? "answered" : "waiting";
|
| 264 |
+
const badgeLabel = answered ? "Answered" : "Waiting for reply";
|
| 265 |
+
const timeLabel = window.formatRelativeTime ? window.formatRelativeTime(h.timestamp) : "";
|
| 266 |
row.innerHTML =
|
| 267 |
+
'<div class="result-label">#' + escapeHtml(h.id) +
|
| 268 |
+
' <span class="status-badge ' + badgeClass + '">' + badgeLabel + "</span>" +
|
| 269 |
+
' <span class="timestamp">' + escapeHtml(timeLabel) + "</span></div>" +
|
| 270 |
+
'<div class="result-text">' + escapeHtml(h.translated_text || h.input_text) + "</div>";
|
| 271 |
+
patientHistoryList.appendChild(row);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 272 |
});
|
| 273 |
+
patientHistoryCard.style.display = "block";
|
| 274 |
} catch (error) {
|
| 275 |
+
// Supplementary context only, not the critical path - fail quietly
|
| 276 |
+
// rather than blocking the provider from responding.
|
| 277 |
}
|
| 278 |
}
|
| 279 |
|
| 280 |
+
function invalidatePreview() {
|
| 281 |
+
previewBlock.style.display = "none";
|
| 282 |
+
previewAudioEl.pause();
|
| 283 |
+
lastPreviewText = null;
|
| 284 |
+
lastPreviewTranslation = null;
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
function openInteraction(item) {
|
| 288 |
clearBanner();
|
| 289 |
selectedInteractionId = item.id;
|
| 290 |
+
activeItem = item;
|
| 291 |
+
|
| 292 |
document.getElementById("detail-placeholder").style.display = "none";
|
| 293 |
const detailContent = document.getElementById("detail-content");
|
| 294 |
detailContent.style.display = "block";
|
| 295 |
detailContent.classList.remove("list-item-in");
|
| 296 |
void detailContent.offsetWidth;
|
| 297 |
detailContent.classList.add("list-item-in");
|
| 298 |
+
|
| 299 |
document.getElementById("respond-interaction-id").textContent = item.id;
|
| 300 |
document.getElementById("respond-language-label").textContent =
|
| 301 |
+
"Patient (" + capitalize(item.detected_language) + ")";
|
| 302 |
document.getElementById("respond-translated-text").textContent = item.translated_text;
|
| 303 |
document.getElementById("respond-nlu-summary").textContent = item.nlu_summary || "No summary available.";
|
| 304 |
+
document.getElementById("respond-original-language").textContent = capitalize(item.detected_language);
|
| 305 |
+
document.getElementById("respond-original-text").textContent = item.input_text;
|
| 306 |
+
|
| 307 |
+
const timeLabel = window.formatRelativeTime ? window.formatRelativeTime(item.timestamp) : item.timestamp;
|
| 308 |
+
document.getElementById("respond-meta-line").textContent = "Submitted " + timeLabel;
|
| 309 |
+
|
| 310 |
+
const statusBadge = document.getElementById("respond-status-badge");
|
| 311 |
+
const answered = Boolean(item.translated_response);
|
| 312 |
+
statusBadge.className = "status-badge " + (answered ? "answered" : "waiting");
|
| 313 |
+
statusBadge.textContent = answered ? "Answered" : "Waiting for your reply";
|
| 314 |
+
|
| 315 |
+
responseTextInput.value = "";
|
| 316 |
+
invalidatePreview();
|
| 317 |
+
loadPatientHistoryForWorkspace(item);
|
| 318 |
+
|
| 319 |
showStep("respond");
|
| 320 |
+
renderQueueList();
|
| 321 |
+
updateTabCounts();
|
| 322 |
}
|
| 323 |
|
| 324 |
document.getElementById("quick-reply-chips").addEventListener("click", function (event) {
|
| 325 |
const chip = event.target.closest(".chip");
|
| 326 |
if (!chip) return;
|
| 327 |
+
responseTextInput.value = responseTextInput.value
|
| 328 |
+
? responseTextInput.value + " " + chip.dataset.text
|
| 329 |
+
: chip.dataset.text;
|
| 330 |
+
responseTextInput.focus();
|
| 331 |
+
invalidatePreview();
|
| 332 |
+
});
|
| 333 |
+
|
| 334 |
+
responseTextInput.addEventListener("input", function () {
|
| 335 |
+
invalidatePreview();
|
| 336 |
});
|
| 337 |
|
| 338 |
backToListBtn.addEventListener("click", function () {
|
|
|
|
| 342 |
loadPending();
|
| 343 |
});
|
| 344 |
|
| 345 |
+
// ---- Response composer: preview, then send ----
|
| 346 |
+
|
| 347 |
+
function formatAudioTime(totalSeconds) {
|
| 348 |
+
if (!isFinite(totalSeconds) || totalSeconds < 0) return "0:00";
|
| 349 |
+
const minutes = Math.floor(totalSeconds / 60);
|
| 350 |
+
const seconds = Math.floor(totalSeconds % 60);
|
| 351 |
+
return minutes + ":" + String(seconds).padStart(2, "0");
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
function setPreviewAudioSource(url) {
|
| 355 |
+
previewAudioEl.src = url;
|
| 356 |
+
previewSeek.value = 0;
|
| 357 |
+
previewCurrentTimeEl.textContent = "0:00";
|
| 358 |
+
previewDurationEl.textContent = "0:00";
|
| 359 |
+
previewIconPlay.style.display = "inline";
|
| 360 |
+
previewIconPause.style.display = "none";
|
| 361 |
+
}
|
| 362 |
+
|
| 363 |
+
previewPlayBtn.addEventListener("click", function () {
|
| 364 |
+
if (previewAudioEl.paused) {
|
| 365 |
+
previewAudioEl.play();
|
| 366 |
+
} else {
|
| 367 |
+
previewAudioEl.pause();
|
| 368 |
+
}
|
| 369 |
+
});
|
| 370 |
+
|
| 371 |
+
previewReplayBtn.addEventListener("click", function () {
|
| 372 |
+
previewAudioEl.currentTime = 0;
|
| 373 |
+
previewAudioEl.play();
|
| 374 |
+
});
|
| 375 |
+
|
| 376 |
+
previewAudioEl.addEventListener("play", function () {
|
| 377 |
+
previewIconPlay.style.display = "none";
|
| 378 |
+
previewIconPause.style.display = "inline";
|
| 379 |
+
});
|
| 380 |
+
|
| 381 |
+
previewAudioEl.addEventListener("pause", function () {
|
| 382 |
+
previewIconPlay.style.display = "inline";
|
| 383 |
+
previewIconPause.style.display = "none";
|
| 384 |
+
});
|
| 385 |
+
|
| 386 |
+
previewAudioEl.addEventListener("ended", function () {
|
| 387 |
+
previewIconPlay.style.display = "inline";
|
| 388 |
+
previewIconPause.style.display = "none";
|
| 389 |
+
});
|
| 390 |
+
|
| 391 |
+
previewAudioEl.addEventListener("loadedmetadata", function () {
|
| 392 |
+
previewSeek.max = previewAudioEl.duration || 0;
|
| 393 |
+
previewDurationEl.textContent = formatAudioTime(previewAudioEl.duration);
|
| 394 |
+
});
|
| 395 |
+
|
| 396 |
+
previewAudioEl.addEventListener("timeupdate", function () {
|
| 397 |
+
previewSeek.value = previewAudioEl.currentTime;
|
| 398 |
+
previewCurrentTimeEl.textContent = formatAudioTime(previewAudioEl.currentTime);
|
| 399 |
+
});
|
| 400 |
+
|
| 401 |
+
previewSeek.addEventListener("input", function () {
|
| 402 |
+
previewAudioEl.currentTime = Number(previewSeek.value);
|
| 403 |
+
});
|
| 404 |
+
|
| 405 |
+
previewBtn.addEventListener("click", async function () {
|
| 406 |
+
const responseText = responseTextInput.value.trim();
|
| 407 |
+
if (!responseText) {
|
| 408 |
+
showError("Type a reply before previewing.");
|
| 409 |
+
return;
|
| 410 |
+
}
|
| 411 |
+
|
| 412 |
+
clearBanner();
|
| 413 |
+
setButtonLoading(previewBtn, true, "Generating preview...");
|
| 414 |
+
|
| 415 |
+
const formData = new FormData();
|
| 416 |
+
formData.append("interaction_id", selectedInteractionId);
|
| 417 |
+
formData.append("response_text", responseText);
|
| 418 |
+
|
| 419 |
+
try {
|
| 420 |
+
const response = await fetch("/preview-response", { method: "POST", body: formData });
|
| 421 |
+
const data = await response.json();
|
| 422 |
+
|
| 423 |
+
if (!response.ok) {
|
| 424 |
+
showError(data.error || "Couldn't generate a preview.");
|
| 425 |
+
return;
|
| 426 |
+
}
|
| 427 |
+
|
| 428 |
+
previewTextBubble.innerHTML = '<span class="chat-label">Preview</span>' + escapeHtml(data.translated_response);
|
| 429 |
+
setPreviewAudioSource(data.audio_url);
|
| 430 |
+
previewBlock.style.display = "block";
|
| 431 |
+
lastPreviewText = responseText;
|
| 432 |
+
lastPreviewTranslation = data.translated_response;
|
| 433 |
+
} catch (error) {
|
| 434 |
+
showError("Couldn't reach the server. Check your connection and try again.");
|
| 435 |
+
} finally {
|
| 436 |
+
setButtonLoading(previewBtn, false, "Generating preview...");
|
| 437 |
+
}
|
| 438 |
+
});
|
| 439 |
+
|
| 440 |
submitResponseBtn.addEventListener("click", async function () {
|
| 441 |
clearBanner();
|
| 442 |
+
const responseText = responseTextInput.value.trim();
|
| 443 |
|
| 444 |
if (!responseText) {
|
| 445 |
showError("Type a reply before sending.");
|
|
|
|
| 452 |
formData.append("interaction_id", selectedInteractionId);
|
| 453 |
formData.append("response_text", responseText);
|
| 454 |
|
| 455 |
+
// If this exact text was just previewed, tell the backend to reuse
|
| 456 |
+
// that translation + audio instead of generating them again - same
|
| 457 |
+
// result, half the API cost. If the text was edited since the last
|
| 458 |
+
// preview (or there was no preview at all), this is skipped and the
|
| 459 |
+
// backend does the full work, same as before.
|
| 460 |
+
if (lastPreviewText === responseText && lastPreviewTranslation) {
|
| 461 |
+
formData.append("translated_response", lastPreviewTranslation);
|
| 462 |
+
formData.append("reuse_audio", "true");
|
| 463 |
+
}
|
| 464 |
+
|
| 465 |
try {
|
| 466 |
const response = await fetch("/provider-response", { method: "POST", body: formData });
|
| 467 |
const data = await response.json();
|
|
|
|
| 471 |
return;
|
| 472 |
}
|
| 473 |
|
| 474 |
+
activeItem = null;
|
| 475 |
+
completedLoaded = false;
|
| 476 |
showStep("list");
|
| 477 |
document.getElementById("detail-placeholder").style.display = "block";
|
| 478 |
document.getElementById("detail-content").style.display = "none";
|