pediatric-ai-scribe-v3/public/js/clinicalAssistant.js
Daniel f3c3f47d99
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 49s
Forgejo Android APK / Build signed APK (push) Successful in 2m5s
Forgejo Docker Build / Build Docker image (push) Successful in 10s
Forgejo Docker Build / Deploy to the host (push) Failing after 2s
fix: voice mode reads the answer that just arrived, and reads what the page shows
Two bugs, one cause each.

It read the previous answer. setBusy(false) is what announces
assistant-answer-done, and it ran before lastAnswer was assigned — so every
listener was handed the answer before last. It now fires after the answer exists
both in that variable and on the page. A test asserts the order, because the
order is the whole bug.

And it read the markdown. The better answer than unpicking the markup is not to
have any: the rendered bubble is already the answer with its headings, emphasis
and tables resolved, so voice mode reads that. It cannot drift from what the
reader is looking at, and it needs no rules about what "##" sounds like. Read
from a clone, with the parts that are not the answer removed — the action
buttons, the sources list, the follow-up suggestions, code blocks and tables —
so the page itself is untouched. A bubble still thinking is never read.

speakableText() stays as the fallback for when the bubble cannot be found, since
raw markdown read aloud is worse than silence.

Separately: e2e/seed.js hashed with bcrypt directly, so seeded accounts did not
exercise the argon2id path production writes. It uses the app's own hasher now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 21:10:50 +02:00

2662 lines
124 KiB
JavaScript

// ============================================================
// CLINICAL ASSISTANT — evidence-grounded chat UI.
// Backend contract intentionally uses /api/clinical-assistant/* so the
// server can call native MCP directly without routing through mcpo.
// ============================================================
import { EMPTY_PROMPT_SETS } from './assistant/data.js';
import { escapeAttr, escapeHtml, renderAssistantMarkdown, renderCitationLinks, safeImageUrl, wrapTables } from './assistant/citations.js';
import { renderSourcesList } from './assistant/sources.js';
import { createAssistantExporter } from './assistant/export.js';
import { createAssistantImageStore } from './assistant/images.js';
import { renderImageJobs, imageJson } from './generatedImages.js';
import { captureSharingOwner, assertSharingOwner, validSharingOwner } from './assistant/sharing.js';
import {
deleteSavedAssistantChat,
fetchAssistantChat,
fetchAssistantExamples,
fetchAssistantStatus,
fetchSavedAssistantChat,
fetchSavedAssistantChats,
openAssistantStream,
startAssistantImageJob,
saveAssistantChat,
translateAssistantMessage,
assistantAttachmentLimits,
assistantAttachmentPayload
, fetchAssistantImageJob, fetchAssistantImageJobs, renameSavedAssistantChat } from './assistant/api.js';
var initialized = false;
var messages = [];
var attachments = []; // Input-only images: ride the outgoing question, then persist with the sent message.
var lastAnswer = '';
var lastSources = [];
var mermaidReady = false;
var dynamicExamples = [];
var lastGeneratedImageSrc = '';
var generatedImageJobs = [];
var markdownRenderer = null;
var assistantBusy = false;
var selectedChatModel = '';
var selectedImageModel = '';
var statusChoices = { allowedChatModels: [], allowedImageModels: [], chatModel: '', imageModel: '' };
var activeAssistantRequest = null;
var conversationChars = null;
var STREAM_MARKDOWN_LIMIT = 3500;
var AUTOSAVE_DELAY_MS = 800;
var currentChatId = null;
var autosaveTimer = null;
var autosaveErrorShown = false;
var translateProvider = 'libretranslate';
var learningViewHtml = null;
var lastNonAssistantTab = null;
var regenerateMode = false;
var exporter = createAssistantExporter({ renderMarkdown: renderMarkdown, presentMessage: savedMessagePresentation, showToast: window.showToast });
var imageStore = createAssistantImageStore();
var TRANSLATE_LANGUAGES = [['en', 'English'], ['es', 'Español'], ['fr', 'Français'], ['de', 'Deutsch'], ['it', 'Italiano'], ['pt', 'Português'], ['zh', '中文'], ['ar', 'العربية'], ['ru', 'Русский'], ['hi', 'हिन्दी'], ['nl', 'Nederlands'], ['pl', 'Polski'], ['tr', 'Türkçe'], ['uk', 'Українська'], ['fa', 'فارسی'], ['sw', 'Kiswahili']];
var savedChatCache = [];
var translateLanguagesAvailable = null; // { libretranslate: [...] }
var translateLanguagesLoading = false;
function refreshTranslateLanguages(pop) {
if (translateLanguagesLoading) return;
translateLanguagesLoading = true;
fetch('/api/clinical-assistant/translate/languages', { headers: getAuthHeaders(), credentials: 'same-origin' })
.then(function(r) { return r.json(); })
.then(function(data) {
if (data && data.success && data.languages) translateLanguagesAvailable = data.languages;
})
.catch(function() { /* keep the full static list on failure */ })
.finally(function() {
translateLanguagesLoading = false;
if (typeof document === 'undefined') return;
var open = document.querySelector('[data-assistant-translate-pop]');
if (open) renderTranslateLanguages(open, open.assistantMessageRow);
renderTakehomeLanguageOptions();
});
}
function availableTranslateLanguages() {
var available = translateLanguagesAvailable;
return TRANSLATE_LANGUAGES.filter(function(pair) {
if (!available) return true;
var codes = available[translateProvider] || available.libretranslate || [];
return codes.indexOf(pair[0]) !== -1;
});
}
function renderTranslateLanguages(pop, row) {
var list = pop.querySelector('.assistant-translate-langs');
if (!list) return;
var pairs = availableTranslateLanguages();
list.innerHTML = pairs.map(function(pair) {
return '<button type="button" data-assistant-translate-lang="' + escapeAttr(pair[0]) + '">' + escapeHtml(pair[1]) + '</button>';
}).join('');
if (!pairs.length) list.innerHTML = '<span class="assistant-translate-none">No languages available for this provider.</span>';
}
var SOURCE_EXCERPT_PREVIEW = 10000;
document.addEventListener('tabChanged', function (e) {
if (e.detail && e.detail.tab === 'assistant') {
initIfNeeded();
// Open WebUI-style: the assistant workspace replaces the main menu;
// saved chats become the left navigation and Go back restores the app.
document.body.classList.add('assistant-workspace');
}
if (e.detail && e.detail.tab && e.detail.tab !== 'assistant' && e.detail.tab !== 'learning') {
lastNonAssistantTab = e.detail.tab;
document.body.classList.remove('assistant-workspace');
}
});
window.addEventListener('account-boundary', function () {
cancelAutosave();
if (initialized) closeTranslatePicker();
closeSourceModal();
});
function initIfNeeded() {
if (initialized) return;
var root = document.getElementById('assistant-tab');
if (typeof window !== 'undefined' && window._userRole && window._userRole !== 'admin') {
var downloadBtn = document.getElementById('btn-assistant-download-chat');
if (downloadBtn) downloadBtn.remove();
}
if (!root || !root.querySelector('#assistant-form')) return;
initialized = true;
bindEvents();
loadModelSelection();
loadStatus();
loadExamples();
}
// Delegated document listeners must be registered once. initIfNeeded already
// guards against a second bind, but a stray bindEvents() would otherwise
// double-fire every click — activating a tab twice, sending twice, and so on.
var documentListenersBound = false;
function onceOnDocument(type, handler) {
if (documentListenersBound) return;
document.addEventListener(type, handler);
}
function bindEvents() {
var form = document.getElementById('assistant-form');
var clearBtn = document.getElementById('btn-assistant-clear');
var exportBtn = document.getElementById('btn-assistant-export-pdf');
var attachInput = document.getElementById('assistant-attach-input');
var input = document.getElementById('assistant-input');
var createImageBtn = document.getElementById('btn-assistant-create-image');
if (createImageBtn) createImageBtn.addEventListener('click', function() {
var layout = document.getElementById('assistant-layout');
if (layout) layout.classList.remove('mobile-chats-open');
openCreateImageDialog();
});
// The rail mirrors the app's real tab list rather than restating it, so a tab
// added, renamed or hidden in index.html shows up here with no extra work.
// Clinical work only. Account and content-management tabs stay in the app
// sidebar, where someone looking for them will go; they are not work
// surfaces to launch into beside Encounter HPI.
var NON_WORK_TABS = ['settings', 'admin', 'docs', 'faq', 'cms'];
var renderWorkspaceCards = function() {
var cards = document.getElementById('assistant-workspace-cards');
var links = document.getElementById('assistant-workspace-links');
if (!cards && !links) return;
if (cards) cards.innerHTML = '';
if (links) links.innerHTML = '';
Array.prototype.forEach.call(document.querySelectorAll('.tab-btn'), function(tab) {
var name = tab.getAttribute('data-tab');
if (!name || tab.classList.contains('hidden')) return;
var icon = tab.querySelector('i');
var label = tab.querySelector('span');
var body = '<i class="' + escapeAttr(icon ? icon.className : 'fas fa-circle') + '"></i>' +
'<span>' + escapeHtml(label ? label.textContent : name) + '</span>';
// The rail mirrors the whole menu; the cards are clinical work only, so
// Settings and friends stay reachable without becoming launch tiles.
if (links) {
var link = document.createElement('button');
link.type = 'button';
link.className = 'assistant-rail-link';
link.setAttribute('data-assistant-workspace-tab', name);
link.innerHTML = body;
links.appendChild(link);
}
if (cards && NON_WORK_TABS.indexOf(name) === -1) {
var card = document.createElement('button');
card.type = 'button';
card.className = 'assistant-workspace-card';
card.setAttribute('data-assistant-workspace-tab', name);
card.innerHTML = body;
cards.appendChild(card);
}
});
};
// In the assistant, Workspace shows the launcher; in the app you are already
// there. Either way the switch only changes which pill is highlighted.
var showWorkspaceLauncher = function(show) {
renderWorkspaceCards();
document.body.classList.toggle('assistant-mode-workspace', !!show);
document.querySelectorAll('[data-assistant-mode]').forEach(function(button) {
var on = (button.getAttribute('data-assistant-mode') === 'workspace') === !!show;
button.classList.toggle('active', on);
button.setAttribute('aria-selected', on ? 'true' : 'false');
});
};
window.assistantShowWorkspaceLauncher = showWorkspaceLauncher;
onceOnDocument('click', function(event) {
var card = event.target.closest && event.target.closest('[data-assistant-workspace-tab]');
if (!card) return;
document.body.classList.remove('assistant-workspace', 'assistant-mode-workspace');
if (typeof window.activateTab === 'function') window.activateTab(card.getAttribute('data-assistant-workspace-tab'));
});
// The + menu holds everything that acts on the conversation, so the top of
// the view can stay empty and both modes line up.
var plusBtn = document.getElementById('btn-assistant-plus');
var plusMenu = document.getElementById('assistant-plus-menu');
var closePlus = function() {
if (!plusMenu) return;
plusMenu.hidden = true;
if (plusBtn) plusBtn.setAttribute('aria-expanded', 'false');
};
if (plusBtn && plusMenu) {
plusBtn.addEventListener('click', function(event) {
event.stopPropagation();
var open = plusMenu.hidden;
plusMenu.hidden = !open;
plusBtn.setAttribute('aria-expanded', open ? 'true' : 'false');
});
// Choosing an item, clicking away or pressing Escape all dismiss it.
plusMenu.addEventListener('click', function() { closePlus(); });
onceOnDocument('click', function(event) {
if (!event.target.closest || !event.target.closest('.assistant-plus')) closePlus();
});
onceOnDocument('keydown', function(event) {
if (event.key === 'Escape') closePlus();
});
}
var sourcesBtn = document.getElementById('btn-assistant-sources');
if (sourcesBtn) sourcesBtn.addEventListener('click', function() {
var open = !document.body.classList.contains('assistant-sources-open');
document.body.classList.toggle('assistant-sources-open', open);
sourcesBtn.setAttribute('aria-expanded', open ? 'true' : 'false');
});
// A sheet with no way out is a trap: tapping away or pressing Escape closes it.
onceOnDocument('click', function(event) {
if (!document.body.classList.contains('assistant-sources-open')) return;
if (event.target.closest && (event.target.closest('.assistant-side') ||
event.target.closest('#btn-assistant-sources') || event.target.closest('.assistant-cite'))) return;
document.body.classList.remove('assistant-sources-open');
if (sourcesBtn) sourcesBtn.setAttribute('aria-expanded', 'false');
});
onceOnDocument('keydown', function(event) {
if (event.key !== 'Escape') return;
document.body.classList.remove('assistant-sources-open');
if (sourcesBtn) sourcesBtn.setAttribute('aria-expanded', 'false');
});
// Tapping a citation opens the sheet, so the number leads somewhere.
onceOnDocument('click', function(event) {
if (window.innerWidth > 640) return;
if (!(event.target.closest && event.target.closest('.assistant-cite'))) return;
document.body.classList.add('assistant-sources-open');
if (sourcesBtn) sourcesBtn.setAttribute('aria-expanded', 'true');
});
// The saved-chats list has no collapse control any more; drop the preference
// it left behind so nothing can restore a hidden list.
try { localStorage.removeItem('ped_assistant_chats_open'); } catch (e) {}
// Ctrl+Shift+O starts a new chat, as advertised next to the button.
onceOnDocument('keydown', function(event) {
if (!event.ctrlKey || !event.shiftKey || String(event.key).toLowerCase() !== 'o') return;
if (!document.body.classList.contains('assistant-workspace')) return;
event.preventDefault();
var button = document.getElementById('btn-assistant-clear');
if (button) button.click();
});
var closeDrawer = function() {
var layout = document.getElementById('assistant-layout');
if (layout) layout.classList.remove('mobile-chats-open');
};
var drawerCloseBtn = document.getElementById('btn-assistant-drawer-close');
if (drawerCloseBtn) drawerCloseBtn.addEventListener('click', closeDrawer);
var backdrop = document.getElementById('assistant-drawer-backdrop');
if (backdrop) backdrop.addEventListener('click', closeDrawer);
if (typeof window !== 'undefined' && window.innerWidth > 640) {
var startCollapsed = false;
try { startCollapsed = localStorage.getItem('ped_assistant_history_collapsed') === '1'; } catch (e) {}
var layoutForPref = document.getElementById('assistant-layout');
if (layoutForPref && startCollapsed) layoutForPref.classList.add('history-collapsed');
}
if (form) form.addEventListener('submit', onAsk);
var sendBtn = document.getElementById('btn-assistant-send');
if (sendBtn) sendBtn.addEventListener('click', function(e) {
// Open WebUI behavior: the send button becomes Stop while working.
if (assistantBusy) { e.preventDefault(); cancelAssistantSearch(); }
});
if (clearBtn) clearBtn.addEventListener('click', function(ev) {
var layout = document.getElementById('assistant-layout');
if (layout) layout.classList.remove('mobile-chats-open'); // back to the chat
clearConversation(ev);
});
document.getElementById('btn-assistant-download-chat').addEventListener('click', downloadTranscript);
if (input) {
input.addEventListener('input', updateConversationBudget);
input.addEventListener('input', resizeAssistantInput);
}
if (exportBtn) exportBtn.addEventListener('click', exportAnswerPdf);
var takehomeBtn = document.getElementById('btn-assistant-takehome');
if (takehomeBtn) takehomeBtn.addEventListener('click', openPatientTakehome);
var micBtn = document.getElementById('btn-assistant-mic');
if (micBtn) micBtn.addEventListener('click', toggleAssistantMic);
var voiceBtn = document.getElementById('btn-assistant-voice');
if (voiceBtn) voiceBtn.addEventListener('click', openConversationMode);
if (attachInput) attachInput.addEventListener('change', onAttachFiles);
document.addEventListener('click', onAssistantDocumentClick);
document.addEventListener('keydown', onAssistantKeydown);
if (input) input.addEventListener('keydown', function (e) {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') onAsk(e);
});
bindExampleButtons(document);
loadSavedChats();
documentListenersBound = true; // every delegated listener above is now registered
}
function loadStatus() {
return fetchAssistantStatus()
.then(function (data) {
conversationChars = data.success && validConversationLimit(data.conversationChars) ? data.conversationChars : null;
if (data.success && data.translateProvider === 'libretranslate') translateProvider = data.translateProvider;
if (data.success) applyCitationMode(data.showSources !== false);
if (data.success) {
statusChoices = {
allowedChatModels: data.allowedChatModels || [],
allowedImageModels: data.allowedImageModels || [],
chatModel: data.chatModel || '',
imageModel: data.imageModel || ''
};
bindModelSelects();
fillModelSelect(document.getElementById('assistant-chat-model-select'), statusChoices.allowedChatModels, statusChoices.chatModel, selectedChatModel, 'ped_assistant_chat_model', 'chat');
var chatSel = document.getElementById('assistant-chat-model-select');
selectedChatModel = chatSel ? chatSel.value : selectedChatModel;
var imageSel = document.getElementById('assistant-image-model-select');
if (imageSel) {
fillModelSelect(imageSel, statusChoices.allowedImageModels, statusChoices.imageModel, selectedImageModel, 'ped_assistant_image_model', 'image');
selectedImageModel = imageSel.value || selectedImageModel;
}
}
updateConversationBudget();
})
.catch(function (e) {
conversationChars = null;
updateConversationBudget(); // No guessed cap; the server remains authoritative.
});
}
function loadExamples() {
fetchAssistantExamples()
.then(function (data) {
if (!data.success || !Array.isArray(data.examples) || !data.examples.length) return;
dynamicExamples = data.examples;
var wrap = document.getElementById('assistant-messages');
if (wrap && wrap.querySelector('.assistant-empty')) {
wrap.innerHTML = renderEmptyState();
bindExampleButtons(wrap);
}
})
.catch(function () {});
}
function onAsk(e) {
if (e) e.preventDefault();
var input = document.getElementById('assistant-input');
var text = input ? input.value : '';
if (assistantBusy) {
if (typeof showToast === 'function') showToast('Assistant is still finishing the current answer', 'error');
return;
}
if (!text.trim()) {
if (typeof showToast === 'function') showToast('Enter a clinical question', 'error');
return;
}
if (conversationChars !== null && conversationSize(text) > conversationChars) {
updateConversationBudget();
if (typeof showToast === 'function') showToast('Conversation limit reached. This chat is saved automatically; start a new chat.', 'error');
return;
}
// Only text/roles go to inference; the full source maps/images stay in the transcript.
var history = messages.map(function(m) { return { role: m.role, content: m.content }; });
var request = createAssistantRequest();
request.regenerateMode = regenerateMode;
regenerateMode = false; // consumed by this request
activeAssistantRequest = request;
setBusy(true, 'Looking up sources...');
var loading = appendLoadingMessage('Looking up sources', 'Retrieving and synthesizing references...');
request.loading = loading;
request.accept = function() {
if (request.accepted || request.cancelled) return;
request.accepted = true;
if (!request.regenerateMode) {
var row = appendMessage('user', text, undefined, undefined, undefined, {
attachments: attachments.map(function(a) { return { dataBase64: a.dataBase64, mimeType: a.mimeType, name: a.name || '' }; })
});
if (row && loading && loading.parentNode) loading.parentNode.insertBefore(row, loading);
}
if (input) input.value = '';
// Attached images are input-only until sent, then persist with the message.
attachments = [];
renderAttachments();
exporter.invalidate();
scheduleAutosave();
updateConversationBudget();
};
var payload = {
message: text,
idempotencyKey: crypto.randomUUID(),
history: history,
includeContext: true // clinical retrieval is always on — it is the purpose of the app
};
if (selectedChatModel) payload.chatModel = selectedChatModel;
if (selectedImageModel) payload.imageModel = selectedImageModel;
// Images ride the outgoing clinical question for inference; once sent they persist with the saved chat.
if (attachments.length) payload.images = assistantAttachmentPayload(attachments);
return streamAssistantResponse(payload, loading, request)
.catch(function (err) {
if (request.cancelled) return;
setBusy(false, 'Error', true);
if (loading) loading.remove(); // Errors are not invented assistant turns.
if (err.budget) conversationChars = validConversationLimit(err.budget.limit) ? err.budget.limit : null;
updateConversationBudget();
if (typeof showToast === 'function') showToast(err.message, 'error');
})
.finally(function () {
if (activeAssistantRequest === request) activeAssistantRequest = null;
});
}
function createAssistantRequest() {
var controller = typeof AbortController === 'function' ? new AbortController() : null;
return {
cancelled: false,
signal: controller ? controller.signal : undefined,
abort: function () {
this.cancelled = true;
if (controller) controller.abort();
}
};
}
async function streamAssistantResponse(payload, loading, request) {
var response = await openAssistantStream(payload, { signal: request ? request.signal : undefined });
if (!response.ok || !response.body) {
var fallback = await response.json().catch(function () { return {}; });
throw Object.assign(new Error(fallback.error || ('Request failed (' + response.status + ')')), { code: fallback.code, budget: fallback.budget });
}
if (request && request.accept) request.accept();
var partial = '';
var streamSources = [];
var finalData = null;
var lastRender = 0;
var bubble = loading ? loading.querySelector('.assistant-bubble') : null;
var decoder = new TextDecoder();
var buffer = '';
function renderProvisional(force) {
var now = Date.now();
if (!force && now - lastRender < 180) return;
lastRender = now;
if (!bubble) return;
loading.classList.remove('assistant-loading-msg');
bubble.classList.remove('assistant-thinking');
bubble.assistantSources = streamSources;
bubble.innerHTML = partial ? renderStreamingAnswerHtml(partial, streamSources) : '<p class="assistant-muted">Generating answer...</p>';
renderEmbeddedBlocks(bubble);
var wrap = document.getElementById('assistant-messages');
if (wrap) wrap.scrollTop = wrap.scrollHeight;
}
function handleEvent(type, data) {
if (type === 'status') {
updateLoadingMessage(loading, data.message || 'Working...');
return;
}
if (type === 'sources') {
streamSources = data.sources || [];
renderSources(streamSources);
return;
}
if (type === 'token') {
partial += data.token || '';
renderProvisional(false);
return;
}
if (type === 'done') {
finalData = data || {};
return;
}
if (type === 'error') throw Object.assign(new Error(data.error || 'Assistant stream failed'), { code: data.code, budget: data.budget });
}
var reader = response.body.getReader();
while (true) {
if (request && request.cancelled) return;
var chunk = await reader.read();
if (chunk.done) break;
buffer += decoder.decode(chunk.value, { stream: true });
var parts = buffer.split('\n\n');
buffer = parts.pop() || '';
parts.forEach(function (part) {
var parsed = parseSseEvent(part);
if (parsed) handleEvent(parsed.type, parsed.data);
});
}
if (buffer.trim()) {
var tail = parseSseEvent(buffer);
if (tail) handleEvent(tail.type, tail.data);
}
if (!finalData) {
updateLoadingMessage(loading, 'Stream ended early. Retrying without streaming...');
finalData = await fetchAssistantFallback(payload, request);
}
if (request && request.cancelled) return;
lastAnswer = finalData.answer || finalData.markdown || '';
lastSources = finalData.sources || finalData.citations || streamSources;
replaceLoadingMessage(loading, lastAnswer, lastSources, finalData.suggestions || []);
attachImageJobs(loading, messages[messages.length - 1], finalData.imageJobs || []);
renderSources(lastSources);
// Last, not first. setBusy(false) announces assistant-answer-done, and
// firing it before lastAnswer was assigned meant every listener — voice
// mode among them — was handed the *previous* answer. It now fires once the
// answer exists both in that variable and on the page.
setBusy(false, 'Ready');
}
function renderStreamingAnswerHtml(text, sources) {
if (shouldUseLightweightStreamingRender(text)) {
return '<pre class="assistant-streaming-text">' + escapeHtml(text) + '</pre>';
}
return renderAssistantBubbleHtml(text, sources, false);
}
function shouldUseLightweightStreamingRender(text) {
text = String(text || '');
if (text.length > STREAM_MARKDOWN_LIMIT) return true;
var pipeRows = text.split('\n').filter(function (line) { return /^\s*\|.*\|\s*$/.test(line); }).length;
return pipeRows >= 8;
}
function parseSseEvent(block) {
var type = 'message';
var data = '';
String(block || '').split(/\r?\n/).forEach(function (line) {
if (line.indexOf('event:') === 0) type = line.substring(6).trim();
if (line.indexOf('data:') === 0) data += line.substring(5).trim();
});
if (!data) return null;
try { return { type: type, data: JSON.parse(data) }; }
catch (e) { return null; }
}
async function fetchAssistantFallback(payload, request) {
var data;
try {
data = await fetchAssistantChat(payload, { signal: request ? request.signal : undefined });
} catch (e) {
if (request && request.cancelled) throw e;
if (e && e.name === 'AbortError') throw new Error('Assistant request cancelled.');
throw e;
}
if (!data.success) throw new Error(data.error || ('Request failed (' + data._status + ')'));
return data;
}
function updateLoadingMessage(row, detail) {
if (!row) return;
var el = row.querySelector('.assistant-thinking-detail');
if (el) el.textContent = detail;
}
function appendLoadingMessage(title, detail) {
var wrap = document.getElementById('assistant-messages');
if (!wrap) return null;
var empty = wrap.querySelector('.assistant-empty');
if (empty) empty.remove();
var row = document.createElement('div');
row.className = 'assistant-msg assistant assistant-loading-msg';
var label = document.createElement('div');
label.className = 'assistant-msg-label';
label.textContent = 'Assistant';
var bubble = document.createElement('div');
bubble.className = 'assistant-bubble assistant-thinking';
bubble.innerHTML = '<div class="assistant-thinking-line"><span class="assistant-thinking-dot"></span><span class="assistant-thinking-dot"></span><span class="assistant-thinking-dot"></span><strong>' + escapeHtml(title || 'Working') + '</strong></div>' +
'<div class="assistant-thinking-detail">' + escapeHtml(detail || 'Preparing response...') + '</div>';
row.appendChild(label);
row.appendChild(bubble);
wrap.appendChild(row);
wrap.scrollTop = wrap.scrollHeight;
return row;
}
function replaceLoadingMessage(row, content, sources, suggestions, rawHtml) {
if (!row || !row.parentNode) {
appendMessage('assistant', content, sources, suggestions, rawHtml);
scheduleAutosave();
return;
}
var bubble = row.querySelector('.assistant-bubble');
if (!bubble) return;
row.classList.remove('assistant-loading-msg');
bubble.classList.remove('assistant-thinking');
fillMessageBubble(bubble, 'assistant', content, sources, suggestions, rawHtml);
if (!row.querySelector('.assistant-msg-actions')) {
var actions = document.createElement('div');
actions.className = 'assistant-msg-actions';
actions.innerHTML = '<button type="button" data-assistant-msg-copy title="Copy raw message"><i class="fas fa-copy"></i></button>' +
'<button type="button" data-assistant-msg-translate title="Translate"><i class="fas fa-language"></i></button>' +
'<button type="button" data-assistant-msg-regenerate title="Regenerate"><i class="fas fa-rotate-right"></i></button>';
row.appendChild(actions);
}
var wrap = document.getElementById('assistant-messages');
if (wrap) wrap.scrollTop = wrap.scrollHeight;
messages.push({ role: 'assistant', content: content, sources: sources || [] });
row.dataset.messageIndex = String(messages.length - 1);
updateConversationBudget();
scheduleAutosave();
}
function renderAssistantBubbleHtml(content, sources, rawHtml, options) {
return rawHtml ? sanitize(String(content || '')) : renderMarkdown(content, sources || [], options);
}
function fillMessageBubble(bubble, role, content, sources, suggestions, rawHtml, options) {
bubble.assistantSources = role === 'assistant' && Array.isArray(sources) ? sources : [];
bubble.assistantRawContent = String(content || '');
var renderOptions = Object.assign({}, options || {}, { notice: '' });
bubble.innerHTML = role === 'assistant' ? renderAssistantBubbleHtml(content, sources, rawHtml, renderOptions) : renderMarkdown(content, [], renderOptions);
if (options && options.notice) bubble.innerHTML += '<p role="note">' + escapeHtml(options.notice) + '</p>';
if (Array.isArray(options && options.attachments) && options.attachments.length) bubble.appendChild(renderMessageAttachments(options.attachments));
if (role === 'assistant' && suggestions && suggestions.length) bubble.appendChild(renderSuggestionButtons(suggestions));
renderEmbeddedBlocks(bubble);
}
function renderMessageAttachments(attachments) {
var wrap = document.createElement('div');
wrap.className = 'assistant-message-attachments';
(attachments || []).forEach(function(attachment, index) {
var item = document.createElement('figure');
item.className = 'assistant-message-attachment';
var img = document.createElement('img');
img.src = 'data:' + attachment.mimeType + ';base64,' + attachment.dataBase64;
img.alt = 'Attached image ' + (index + 1);
var caption = document.createElement('figcaption');
caption.textContent = attachment.name || ('Attachment ' + (index + 1));
item.appendChild(img);
item.appendChild(caption);
wrap.appendChild(item);
});
return wrap;
}
function appendMessage(role, content, sources, suggestions, rawHtml, extras) {
var row = appendMessageNode(role, content, sources, suggestions, rawHtml, extras);
if (row) {
messages.push({ role: role, content: content, sources: role === 'assistant' ? (sources || []) : [], ...(extras && Array.isArray(extras.attachments) && extras.attachments.length ? { attachments: extras.attachments } : {}) });
row.dataset.messageIndex = String(messages.length - 1);
}
return row;
}
function onAttachFiles(e) {
var input = e && e.target;
var files = Array.prototype.slice.call(input && input.files ? input.files : []);
files.forEach(addAttachmentFile);
if (input) input.value = ''; // Allow re-selecting the same file later.
}
function addAttachmentFile(file) {
var limits = assistantAttachmentLimits;
if (!file) return;
if (limits.mimeTypes.indexOf(file.type) === -1) {
if (typeof showToast === 'function') showToast('Only PNG, JPEG and WebP images can be attached; "' + String(file.name || 'attachment') + '" was ignored.', 'error');
return;
}
if (typeof file.size === 'number' && file.size > limits.maxImageBytes) {
if (typeof showToast === 'function') showToast('Each image is limited to 5 MiB; "' + String(file.name || 'attachment') + '" was ignored.', 'error');
return;
}
if (attachments.length >= limits.maxImages) {
if (typeof showToast === 'function') showToast('A maximum of 4 images can be attached to one question.', 'error');
return;
}
var totalBytes = attachments.reduce(function(sum, a) { return sum + (a.size || 0); }, 0);
if (typeof file.size === 'number' && totalBytes + file.size > limits.maxTotalBytes) {
if (typeof showToast === 'function') showToast('Attached images are limited to 10 MiB in total.', 'error');
return;
}
var reader = new FileReader();
reader.onload = function() {
var dataUrl = String(reader.result || '');
var comma = dataUrl.indexOf(',');
if (comma === -1 || dataUrl.indexOf(';base64,') === -1) {
if (typeof showToast === 'function') showToast('Could not read "' + String(file.name || 'attachment') + '".', 'error');
return;
}
attachments.push({ dataBase64: dataUrl.slice(comma + 1), mimeType: file.type, size: file.size, src: dataUrl, name: String(file.name || '') });
renderAttachments();
};
reader.onerror = function() {
if (typeof showToast === 'function') showToast('Could not read "' + String(file.name || 'attachment') + '".', 'error');
};
reader.readAsDataURL(file);
}
function renderAttachments() {
var wrap = document.getElementById('assistant-attachments');
if (!wrap) return;
wrap.innerHTML = '';
attachments.forEach(function(attachment, index) {
var item = document.createElement('div');
item.className = 'assistant-attachment';
var img = document.createElement('img');
img.src = attachment.src || ('data:' + attachment.mimeType + ';base64,' + attachment.dataBase64);
img.alt = 'Attached image ' + (index + 1);
var remove = document.createElement('button');
remove.type = 'button';
remove.className = 'assistant-attachment-remove';
remove.setAttribute('data-assistant-remove-attachment', String(index));
remove.setAttribute('aria-label', 'Remove attached image ' + (index + 1));
remove.textContent = '✕';
item.appendChild(img);
item.appendChild(remove);
wrap.appendChild(item);
});
wrap.hidden = !attachments.length;
}
function renderSuggestionButtons(suggestions) {
var wrap = document.createElement('div');
wrap.className = 'assistant-suggestion-buttons';
suggestions.forEach(function (s) {
var btn = document.createElement('button');
btn.type = 'button';
btn.textContent = s;
btn.addEventListener('click', function () {
var input = document.getElementById('assistant-input');
if (input) input.value = s;
onAsk(new Event('submit'));
});
wrap.appendChild(btn);
});
return wrap;
}
function renderMarkdown(md, sources, options) {
var opts = options || {};
try {
return renderAssistantMarkdown(md, sources || [], {
marked: window.marked,
markdownIt: getMarkdownRenderer(),
katex: window.katex,
sanitize: sanitize,
citationLabel: opts.citationLabel,
citationTargetPrefix: opts.citationTargetPrefix,
notice: opts.notice
});
} catch (e) {
console.warn('[clinical-assistant] markdown render failed:', e && e.message ? e.message : e);
return '<pre>' + escapeHtml(String(md || '')) + '</pre>' +
'<p role="note">Formatting unavailable; retained text is shown unchanged.</p>' +
(opts.notice ? '<p role="note">' + escapeHtml(opts.notice) + '</p>' : '');
}
}
function getMarkdownRenderer() {
if (markdownRenderer) return markdownRenderer;
if (typeof window.markdownit === 'function') {
markdownRenderer = window.markdownit({
html: false,
linkify: true,
typographer: true,
breaks: true
});
return markdownRenderer;
}
return null;
}
// Display-only admin switch: with sources off the server sends none and strips
// the now-orphaned [n] markers, so the panel would only ever show its empty
// state. The layout gives the column back to the chat instead of a blank rail.
// The prompt and the stored answer are unaffected either way.
var citationsOn = true;
function applyCitationMode(enabled) {
citationsOn = enabled !== false;
document.body.classList.toggle('assistant-no-citations', !citationsOn);
var side = document.querySelector('.assistant-side');
if (side) side.hidden = !citationsOn;
}
// On a phone the third column has no room, so sources open as a sheet. The
// button appears only when an answer actually has citations.
function syncSourcesButton(count) {
var button = document.getElementById('btn-assistant-sources');
if (!button) return;
button.hidden = !(citationsOn && count > 0);
var label = document.getElementById('assistant-sources-count');
if (label) label.textContent = count === 1 ? '1 source' : count + ' sources';
}
function renderSources(sources) {
var wrap = document.getElementById('assistant-sources');
if (!wrap) return;
wrap.innerHTML = renderSourcesList(sources);
syncSourcesButton(Array.isArray(sources) ? sources.length : 0);
}
// ── OWUI-style code blocks with a Copy button ───────────────────────
function wireCodeBlocks(root) {
if (!root || !root.querySelectorAll) return;
root.querySelectorAll('pre').forEach(function(pre) {
if (pre.getAttribute('data-assistant-code-wired') === 'true') return;
pre.setAttribute('data-assistant-code-wired', 'true');
var wrapper = document.createElement('div');
wrapper.className = 'assistant-codeblock';
var button = document.createElement('button');
button.type = 'button';
button.className = 'assistant-code-copy';
button.setAttribute('data-assistant-code-copy', '');
button.textContent = 'Copy';
pre.parentNode.insertBefore(wrapper, pre);
wrapper.appendChild(pre);
wrapper.appendChild(button);
button.addEventListener('click', function() {
var code = pre.querySelector('code') || pre;
copyText(code.textContent, 'Code copied', 'Copy failed');
});
});
}
function copyText(text, successMessage, failureMessage) {
var navigatorRef = typeof navigator !== 'undefined' ? navigator : null;
if (!navigatorRef || !navigatorRef.clipboard || typeof navigatorRef.clipboard.writeText !== 'function') {
if (typeof showToast === 'function') showToast(failureMessage, 'error');
return;
}
navigatorRef.clipboard.writeText(String(text)).then(function() {
if (typeof showToast === 'function') showToast(successMessage, 'success');
}).catch(function() {
if (typeof showToast === 'function') showToast(failureMessage, 'error');
});
}
function renderEmbeddedBlocks(root) {
wireCodeBlocks(root);
function mermaidSource(el) {
var raw = el.getAttribute('data-mermaid') || '';
try { return decodeURIComponent(raw); } catch (e) { return raw; }
}
root.querySelectorAll('[data-mermaid]').forEach(function (el) {
ensureMermaid().then(function () {
if (!window.mermaid) { el.textContent = mermaidSource(el); return; }
var id = 'assistant-mermaid-' + Math.random().toString(16).slice(2);
window.mermaid.render(id, mermaidSource(el))
.then(function (out) { el.innerHTML = out.svg || ''; })
.catch(function () { el.textContent = mermaidSource(el); });
});
});
root.querySelectorAll('canvas[data-chart]').forEach(function (canvas) {
if (!window.Chart) return;
try {
var cfg = JSON.parse(canvas.getAttribute('data-chart') || '{}');
new window.Chart(canvas.getContext('2d'), cfg);
} catch (e) {
canvas.replaceWith(document.createTextNode('Invalid chart JSON'));
}
});
}
function ensureMermaid() {
if (window.mermaid && mermaidReady) return Promise.resolve();
return new Promise(function (resolve) {
if (window.mermaid) { configureMermaid(); resolve(); return; }
var script = document.createElement('script');
script.src = '/vendor/mermaid.min.js';
script.onload = function () { configureMermaid(); resolve(); };
script.onerror = resolve;
document.head.appendChild(script);
});
}
function configureMermaid() {
if (!window.mermaid) return;
window.mermaid.initialize({ startOnLoad: false, securityLevel: 'strict', theme: 'default', flowchart: { useMaxWidth: true, htmlLabels: true } });
mermaidReady = true;
}
function onAssistantDocumentClick(e) {
var msgEdit = e.target.closest('[data-assistant-msg-edit]');
if (msgEdit) {
var editRow = msgEdit.closest('.assistant-msg');
var editBubble = editRow && editRow.querySelector('.assistant-bubble');
var input = document.getElementById('assistant-input');
if (editBubble && input) {
input.value = String(editBubble.assistantRawContent !== undefined ? editBubble.assistantRawContent : editBubble.textContent).trim();
updateConversationBudget();
resizeAssistantInput();
input.focus();
if (typeof input.scrollIntoView === 'function') input.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
return;
}
var citation = e.target.closest('#assistant-messages .assistant-cite');
if (citation) {
var bubble = citation.closest('.assistant-bubble');
if (bubble && Array.isArray(bubble.assistantSources)) {
renderSources(bubble.assistantSources);
var number = Number(citation.getAttribute('data-source-number'));
var source = bubble.assistantSources[number - 1];
if (source) openSourceModal(source, number);
}
return; // The native anchor navigates to the matching source in the refreshed panel.
}
var msgCopy = e.target.closest('[data-assistant-msg-copy]');
if (msgCopy) {
var msgRow = msgCopy.closest('.assistant-msg');
var msgBubble = msgRow && msgRow.querySelector('.assistant-bubble');
copyText(msgBubble && msgBubble.assistantRawContent !== undefined ? msgBubble.assistantRawContent : '', 'Message copied', 'Copy failed');
return;
}
var msgTranslate = e.target.closest('[data-assistant-msg-translate]');
if (msgTranslate) {
openTranslatePicker(msgTranslate.closest('.assistant-msg'));
return;
}
var translateLang = e.target.closest('[data-assistant-translate-lang]');
if (translateLang) {
var picker = translateLang.closest('[data-assistant-translate-pop]');
requestMessageTranslation(picker.assistantMessageRow, translateLang.getAttribute('data-assistant-translate-lang'), translateProvider);
closeTranslatePicker();
return;
}
var showOriginal = e.target.closest('[data-assistant-msg-show-original]');
if (showOriginal) {
var translatedBubble = showOriginal.closest('.assistant-bubble');
if (translatedBubble && translatedBubble.assistantOriginalHtml) {
var keptCards = detachImageCards(translatedBubble);
translatedBubble.innerHTML = translatedBubble.assistantOriginalHtml;
reattachImageCards(translatedBubble, keptCards);
renderEmbeddedBlocks(translatedBubble);
}
return;
}
var msgRegenerate = e.target.closest('[data-assistant-msg-regenerate]');
if (msgRegenerate) {
regenerateMessage(msgRegenerate.closest('.assistant-msg'));
return;
}
if (!e.target.closest('[data-assistant-translate-pop]')) closeTranslatePicker();
var removeAttachment = e.target.closest('[data-assistant-remove-attachment]');
if (removeAttachment) {
attachments.splice(Number(removeAttachment.getAttribute('data-assistant-remove-attachment')), 1);
renderAttachments();
return;
}
var chatMenuBtn = e.target.closest('[data-assistant-chat-menu]');
if (chatMenuBtn) {
e.preventDefault();
e.stopPropagation();
openChatMenu(chatMenuBtn);
return;
}
var chatAction = e.target.closest('[data-chat-action]');
if (chatAction) {
e.preventDefault();
e.stopPropagation();
handleChatAction(chatAction.getAttribute('data-chat-action'), chatAction.getAttribute('data-chat-id'));
return;
}
if (!e.target.closest('[data-assistant-chat-menu-pop]')) closeChatMenu();
var deleteBtn = e.target.closest('[data-assistant-delete-chat]');
if (deleteBtn) {
e.preventDefault();
e.stopPropagation();
deleteSavedChat(deleteBtn.getAttribute('data-assistant-delete-chat'));
return;
}
var loadBtn = e.target.closest('[data-assistant-load-chat]');
if (loadBtn) {
e.preventDefault();
loadSavedChat(loadBtn.getAttribute('data-assistant-load-chat'));
return;
}
var openBtn = e.target.closest('[data-assistant-open-image]');
if (openBtn) {
e.preventDefault();
imageStore.openImagePreview(openBtn.getAttribute('data-assistant-open-image'));
return;
}
var downloadBtn = e.target.closest('[data-assistant-download-image]');
if (downloadBtn) {
e.preventDefault();
imageStore.downloadImage(downloadBtn.getAttribute('data-assistant-download-image'));
return;
}
if (e.target.closest('.assistant-image-modal-close') || e.target.classList.contains('assistant-image-modal')) {
imageStore.closeImagePreview();
}
}
// ── OWUI-style source modal (title, page, 10k excerpt + Show all) ──
function openSourceModal(source, number) {
closeSourceModal();
var title = source.title || source.resource || 'Source';
var page = source.page || source.page_number || source.pageNumber;
var excerpt = String(source.excerpt || '');
var modal = document.createElement('div');
modal.className = 'modal assistant-source-modal';
modal.setAttribute('role', 'dialog');
modal.setAttribute('aria-modal', 'true');
modal.setAttribute('aria-labelledby', 'assistant-source-modal-title');
var body = '<div class="modal-content">' +
'<div class="modal-header"><h2 id="assistant-source-modal-title">Source ' + escapeHtml(number) + '</h2>' +
'<button type="button" class="modal-close" aria-label="Close source"><i class="fas fa-xmark"></i></button></div>' +
'<div class="modal-body">' +
'<div class="assistant-source-modal-meta">' + escapeHtml(title) + (page ? ' · page ' + escapeHtml(page) : '') + '</div>' +
'<div class="assistant-source-modal-excerpt" data-assistant-source-excerpt>' + escapeHtml(excerpt.slice(0, SOURCE_EXCERPT_PREVIEW)) + '</div>' +
(excerpt.length > SOURCE_EXCERPT_PREVIEW ? '<button type="button" class="btn-sm btn-ghost" data-assistant-source-show-all>Show all</button>' : '') +
'</div></div>';
modal.innerHTML = body;
modal.addEventListener('click', function(event) {
if (event.target === modal || event.target.closest('.assistant-source-modal .modal-close')) closeSourceModal();
});
var showAll = modal.querySelector('[data-assistant-source-show-all]');
if (showAll) {
showAll.addEventListener('click', function() {
modal.querySelector('[data-assistant-source-excerpt]').textContent = excerpt;
showAll.hidden = true;
});
}
document.body.appendChild(modal);
}
function closeSourceModal() {
var modal = document.querySelector('.assistant-source-modal');
if (modal) modal.remove();
}
function onAssistantKeydown(e) {
if (e.key === 'Escape') {
closeSourceModal();
closeTranslatePicker();
}
}
// ── Per-message translate with provider choice (local-first) ───────
// ── Voice: mic dictation in the composer + conversation (call) mode ──
var assistantMic = { recording: false, recorder: null, recognition: null, finalText: '', sessionFinals: '' };
function toggleAssistantMic() {
if (assistantMic.recording) stopAssistantMic();
else startAssistantMic();
}
function startAssistantMic() {
unlockAudioPlayback();
var input = document.getElementById('assistant-input');
assistantMic.finalText = String(input && input.value || '');
assistantMic.sessionFinals = '';
assistantMic.recorder = new AudioRecorder();
assistantMic.recording = true;
assistantMic.recorder.start().then(function() {
var btn = document.getElementById('btn-assistant-mic');
if (btn) { btn.classList.add('recording'); var icon = btn.querySelector('i'); if (icon) icon.className = 'fas fa-stop'; }
assistantMic.recognition = createSpeechRecognition();
if (assistantMic.recognition) {
assistantMic.recognition.onresult = function(e) {
var interim = '';
for (var i = e.resultIndex; i < e.results.length; i++) {
if (e.results[i].isFinal) assistantMic.sessionFinals += e.results[i][0].transcript + ' ';
else interim = e.results[i][0].transcript;
}
var input2 = document.getElementById('assistant-input');
if (input2) { input2.value = (assistantMic.finalText + assistantMic.sessionFinals).trim() + (interim ? ' ' + interim : ''); updateConversationBudget(); }
};
assistantMic.recognition.onend = function() { if (assistantMic.recording) { try { assistantMic.recognition.start(); } catch (e) {} } };
try { assistantMic.recognition.start(); } catch (e) {}
}
}).catch(function() {
assistantMic.recording = false;
if (typeof showToast === 'function') showToast('Microphone denied', 'error');
});
}
function stopAssistantMic() {
assistantMic.recording = false;
var btn = document.getElementById('btn-assistant-mic');
if (btn) { btn.classList.remove('recording'); var icon = btn.querySelector('i'); if (icon) icon.className = 'fas fa-microphone'; }
if (assistantMic.recognition) { try { assistantMic.recognition.stop(); } catch (e) {} assistantMic.recognition = null; }
var recorder = assistantMic.recorder;
assistantMic.recorder = null;
if (!recorder) return;
recorder.stop().then(function(blob) {
var live = (assistantMic.finalText + assistantMic.sessionFinals).trim();
var finish = function(text) {
var input = document.getElementById('assistant-input');
if (!input) return;
if (text) {
input.value = text;
updateConversationBudget();
resizeAssistantInput();
} else if (typeof showToast === 'function') {
showToast('No speech detected — try again', 'error');
}
};
// Same flow as the working dictation tab: send whatever was recorded,
// fall back to the live transcript, never drop what the browser heard.
if (!blob || blob.size === 0) { if (live) finish(live); return; }
if (typeof transcribeAudio === 'function') {
return transcribeAudio(blob).then(function(data) {
if (data && data.success && data.text) finish(data.text);
else if (data && data.noProvider) finish(live);
else finish(live);
}).catch(function() { finish(live); });
}
finish(live);
}).catch(function() {});
}
var conversationMode = { active: false, listening: false, recorder: null, recognition: null,
sessionFinals: '', speaking: null };
// Whatever is currently reading an answer aloud, stopped. Without a handle on
// the <audio> element there was nothing to stop: ending the call left the
// answer talking until the page was reloaded.
function conversationStopSpeaking() {
try { if (window.speechSynthesis) window.speechSynthesis.cancel(); } catch (e) {}
var playing = conversationMode.speaking;
conversationMode.speaking = null;
if (!playing) return;
try { playing.pause(); playing.src = ''; } catch (e) {}
if (playing.dataset && playing.dataset.objectUrl) {
try { URL.revokeObjectURL(playing.dataset.objectUrl); } catch (e) {}
}
}
function openConversationMode() {
if (conversationMode.active) return;
var view = document.getElementById('assistant-chat-view');
if (!view) return;
conversationMode.active = true;
var overlay = document.createElement('div');
overlay.className = 'assistant-voice-overlay';
overlay.id = 'assistant-voice-overlay';
overlay.innerHTML = '<div class="assistant-voice-card">' +
'<div class="assistant-voice-status" id="assistant-voice-status">Tap to speak</div>' +
'<button type="button" class="assistant-voice-mic" id="assistant-voice-mic"><i class="fas fa-microphone"></i></button>' +
'<div class="assistant-voice-actions"><button type="button" id="assistant-voice-end" class="btn-sm btn-ghost"><i class="fas fa-phone-slash"></i> End</button></div></div>';
view.appendChild(overlay);
overlay.querySelector('#assistant-voice-mic').addEventListener('click', conversationMicToggle);
overlay.querySelector('#assistant-voice-end').addEventListener('click', endConversationMode);
document.addEventListener('assistant-answer-done', conversationAnswerDone);
}
function conversationMicToggle() {
if (!conversationMode.active) return;
if (conversationMode.listening) conversationStopListening();
else conversationStartListening();
}
// iOS/Safari blocks programmatic audio playback until a user gesture
// unlocks the audio session. Unlock on every tap of a voice control.
var AUDIO_UNLOCK_WAV = 'data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQAAAAA=';
function unlockAudioPlayback() {
try {
var probe = new Audio(AUDIO_UNLOCK_WAV);
probe.muted = true;
var p = probe.play();
if (p && typeof p.catch === 'function') p.catch(function() {});
setTimeout(function() { try { probe.pause(); probe.src = ''; } catch (e) {} }, 250);
} catch (e) {}
}
function conversationStartListening() {
unlockAudioPlayback();
// Speaking over the last answer is how a voice assistant talks past you.
conversationStopSpeaking();
var status = document.getElementById('assistant-voice-status');
if (status) status.textContent = 'Listening…';
conversationMode.listening = true;
conversationMode.sessionFinals = '';
conversationMode.recorder = new AudioRecorder();
conversationMode.recorder.start().then(function() {
var mic = document.getElementById('assistant-voice-mic');
if (mic) mic.classList.add('recording');
conversationMode.recognition = createSpeechRecognition();
if (conversationMode.recognition) {
conversationMode.recognition.onresult = function(e) {
for (var i = e.resultIndex; i < e.results.length; i++) if (e.results[i].isFinal) conversationMode.sessionFinals += e.results[i][0].transcript + ' ';
};
conversationMode.recognition.onend = function() { if (conversationMode.listening) { try { conversationMode.recognition.start(); } catch (e) {} } };
try { conversationMode.recognition.start(); } catch (e) {}
}
}).catch(function() {
conversationMode.listening = false;
if (status) status.textContent = 'Microphone unavailable';
});
}
function conversationStopListening() {
conversationMode.listening = false;
var mic = document.getElementById('assistant-voice-mic');
if (mic) mic.classList.remove('recording');
if (conversationMode.recognition) { try { conversationMode.recognition.stop(); } catch (e) {} conversationMode.recognition = null; }
var recorder = conversationMode.recorder;
conversationMode.recorder = null;
if (!recorder) return;
recorder.stop().then(function(blob) {
var status = document.getElementById('assistant-voice-status');
var live = conversationMode.sessionFinals.trim();
var finish = function(text) {
if (text) { conversationSend(text); }
else if (status) status.textContent = 'Tap to speak';
};
if (typeof transcribeAudio === 'function' && blob && blob.size > 100) {
if (status) status.textContent = 'Transcribing…';
transcribeAudio(blob).then(function(data) { finish(data && data.success && data.text ? data.text : live); })
.catch(function() { finish(live); });
} else finish(live);
}).catch(function() {});
}
function conversationSend(text) {
if (!conversationMode.active) return;
var status = document.getElementById('assistant-voice-status');
var input = document.getElementById('assistant-input');
if (!input) return;
input.value = text;
updateConversationBudget();
resizeAssistantInput();
if (status) status.textContent = 'Asking…';
var send = document.getElementById('btn-assistant-send');
if (send) send.click();
}
function conversationAnswerDone(e) {
if (!conversationMode.active) return;
var status = document.getElementById('assistant-voice-status');
var detail = (e && e.detail) || {};
if (detail.isError) { if (status) status.textContent = 'Answer failed — tap to speak'; return; }
if (status) status.textContent = 'Speaking…';
speakAnswerVoice(renderedAnswerText() || String(detail.answer || ''));
}
// What the page shows, which is already the answer with its markup rendered
// away — headings are headings, bold is bold, a table is a table. Reading
// that is both simpler and more faithful than unpicking the markdown, and it
// cannot drift from what the reader is looking at.
//
// The markdown remains the fallback for the case where the bubble is not
// found, and is reduced first, because raw markdown read aloud is worse than
// silence.
function renderedAnswerText() {
var wrap = document.getElementById('assistant-messages');
if (!wrap) return '';
var rows = wrap.querySelectorAll('.assistant-msg.assistant');
var last = rows[rows.length - 1];
if (!last || last.classList.contains('assistant-loading-msg')) return '';
var bubble = last.querySelector('.assistant-bubble');
if (!bubble) return '';
// A clone, so removing the parts that are not the answer does not touch the
// page: the action buttons, the sources list and the follow-up suggestions
// are all inside the bubble and none of them is worth reading out.
var copy = bubble.cloneNode(true);
copy.querySelectorAll('.assistant-msg-actions, .assistant-sources, .assistant-suggestions, button, pre, table')
.forEach(function (node) { node.remove(); });
return String(copy.innerText || copy.textContent || '').replace(/\n{2,}/g, '\n').trim();
}
// What a person would read out, not what the model wrote. Passed raw, a
// browser voice reads "#" and "**" aloud or stumbles over them — the answer
// is markdown and the speaker is not a renderer.
function speakableText(markdown) {
return String(markdown || '')
.replace(/```[\s\S]*?```/g, ' ') // code blocks say nothing useful
.replace(/`([^`]+)`/g, '$1')
.replace(/!\[[^\]]*\]\([^)]*\)/g, ' ') // images
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') // links keep their words
.replace(/^\s{0,3}#{1,6}\s+/gm, '') // headings are just sentences
.replace(/^\s{0,3}>\s?/gm, '')
.replace(/^\s*[-*+]\s+/gm, '') // bullets
.replace(/^\s*\d+\.\s+/gm, '')
.replace(/^\s*\|.*\|\s*$/gm, ' ') // a table read aloud is noise
.replace(/^\s*[-:|\s]{3,}\s*$/gm, ' ')
.replace(/(\*\*|__)(.*?)\1/g, '$2')
.replace(/(\*|_)(.*?)\1/g, '$2')
.replace(/~~(.*?)~~/g, '$1')
.replace(/^\s*[-*_]{3,}\s*$/gm, ' ') // horizontal rules
.replace(/\[\^?\d+\]/g, ' ') // citation markers
.replace(/[ \t]+/g, ' ')
.replace(/ ([.,;:!?])/g, '$1') // the gap a removed marker leaves
.replace(/\n[ \t]*\n+/g, '\n')
.replace(/\n{2,}/g, '\n')
.trim();
}
function speakAnswerVoice(markdown) {
var text = speakableText(markdown);
var done = function(msg) {
var st = document.getElementById('assistant-voice-status');
if (st) st.textContent = msg || 'Tap to speak';
};
if (!text) { done(); return; }
// Device TTS first when the browser supports it; the model voice is the fallback.
if (typeof window !== 'undefined' && window.speechSynthesis && typeof SpeechSynthesisUtterance === 'function') {
try {
var utter = new SpeechSynthesisUtterance(text);
utter.onend = function() { conversationMode.speaking = null; done('Tap to speak'); };
utter.onerror = function() { conversationMode.speaking = null; done('Tap to speak'); };
window.speechSynthesis.cancel();
conversationMode.speaking = utter;
window.speechSynthesis.speak(utter);
return;
} catch (e) { /* fall through to the model voice */ }
}
fetch('/api/text-to-speech', {
method: 'POST',
headers: getAuthHeaders(Object.assign({ 'Content-Type': 'application/json' })),
body: JSON.stringify({ text: text })
}).then(function(r) { if (!r.ok) throw new Error('Voice reply unavailable'); return r.blob(); })
.then(function(blob) {
var url = URL.createObjectURL(blob);
var audio = new Audio(url);
audio.dataset.objectUrl = url;
// The reply arrives after the call may already have ended. Nothing
// should start talking into a closed overlay.
if (!conversationMode.active) { URL.revokeObjectURL(url); return; }
conversationMode.speaking = audio;
audio.onended = function() { conversationMode.speaking = null; URL.revokeObjectURL(url); done(); };
audio.onerror = function() { conversationMode.speaking = null; URL.revokeObjectURL(url); done('Voice reply unavailable — tap to speak'); };
audio.play().catch(function() { conversationMode.speaking = null; URL.revokeObjectURL(url); done('Voice reply unavailable — tap to speak'); });
}).catch(function() { done('Voice reply unavailable — tap to speak'); });
}
function endConversationMode() {
conversationMode.active = false;
conversationStopSpeaking();
conversationStopListening();
if (activeAssistantRequest) activeAssistantRequest.abort();
document.removeEventListener('assistant-answer-done', conversationAnswerDone);
var overlay = document.getElementById('assistant-voice-overlay');
if (overlay) overlay.remove();
}
// ── Create image: describe it, or base it on any saved chat (latest first) ──
function openCreateImageDialog() {
var existing = document.getElementById('assistant-create-image-modal');
if (existing) { existing.remove(); return; }
var modal = document.createElement('div');
modal.className = 'assistant-takehome-modal';
modal.id = 'assistant-create-image-modal';
var options = [{ id: '', title: 'No chat' }].concat((savedChatCache || []).map(function (chat) {
return { id: chat.id, title: chat.title || 'Saved chat' };
}));
modal.innerHTML = '<div class="modal-content">' +
'<div class="modal-header"><h2>Create image</h2>' +
'<button type="button" class="modal-close" data-create-image-close aria-label="Close"><i class="fas fa-xmark"></i></button></div>' +
'<div class="modal-body">' +
'<label for="create-image-description" class="assistant-create-image-label">Describe what you want</label>' +
'<textarea id="create-image-description" rows="3" placeholder="e.g. a colorful poster on asthma care at home" autocomplete="off"></textarea>' +
'<label for="create-image-chat" class="assistant-create-image-label">Base it on</label>' +
'<select id="create-image-chat">' +
options.map(function (opt) {
return '<option value="' + escapeAttr(opt.id) + '">' + escapeHtml(opt.title) + '</option>';
}).join('') +
'</select>' +
'<label for="assistant-image-model-select" class="assistant-create-image-label">Image model</label>' +
'<select id="assistant-image-model-select" class="assistant-model-control"></select>' +
'<div class="assistant-create-image-actions">' +
'<button type="button" class="btn-generate" id="btn-create-image-generate"><i class="fas fa-wand-magic-sparkles"></i> Generate</button>' +
'</div>' +
'<div id="create-image-progress" class="create-image-progress" hidden></div>' +
'<div class="assistant-create-image-label">Your images</div>' +
'<div id="create-image-history" class="create-image-history"><p class="assistant-muted">Loading your images…</p></div>' +
'</div></div>';
document.body.appendChild(modal);
fitSelectOptions(modal.querySelector('#create-image-chat'));
modal.addEventListener('click', function (event) {
if (event.target === modal || event.target.closest('[data-create-image-close]')) modal.remove();
});
modal.querySelector('#btn-create-image-generate').addEventListener('click', function () {
var description = document.getElementById('create-image-description');
var select = document.getElementById('create-image-chat');
var prompt = String(description && description.value || '').trim();
var chatId = select ? String(select.value || '') : '';
startImageFromSelection(prompt, chatId, {
onStatus: function (text) {
var progress = document.getElementById('create-image-progress');
if (progress) { progress.textContent = text; progress.hidden = !text; }
},
onDone: function (url) {
var progress = document.getElementById('create-image-progress');
if (progress) { progress.textContent = 'Done ✓'; progress.hidden = false; setTimeout(function() { progress.textContent = ''; progress.hidden = true; }, 1800); }
renderCreateImageHistory();
openImagePreview(url);
},
onError: function (msg) {
var progress = document.getElementById('create-image-progress');
if (progress) { progress.textContent = msg; progress.hidden = false; }
}
});
});
var select = modal.querySelector('#create-image-chat');
if (select) select.selectedIndex = 0;
bindModelSelects();
fillModelSelect(document.getElementById('assistant-image-model-select'), statusChoices.allowedImageModels, statusChoices.imageModel, selectedImageModel, 'ped_assistant_image_model', 'image');
renderCreateImageHistory();
}
function renderCreateImageHistory() {
var wrap = document.getElementById('create-image-history');
if (!wrap) return;
if (typeof fetchAssistantImageJobs === 'function') {
fetchAssistantImageJobs().then(function (data) {
if (!wrap.isConnected || !data.success || !Array.isArray(data.jobs)) return;
var jobs = data.jobs.slice(0, 200);
var done = jobs.filter(function (job) { return job.imageUrl; });
var running = jobs.filter(function (job) { return !job.imageUrl && job.status !== 'error' && job.status !== 'interrupted'; });
if (!done.length && !running.length) {
wrap.innerHTML = '<p class="assistant-muted">Your generated images will appear here.</p>';
return;
}
wrap.innerHTML = done.map(function (job) {
var dl = job.imageUrl + (job.imageUrl.indexOf('?') === -1 ? '?download=1' : '&download=1');
return '<div class="assistant-gallery-item-wrap">' +
'<button type="button" class="assistant-gallery-item" data-gallery-image="' + escapeAttr(job.imageUrl) + '" title="' + escapeAttr(new Date(job.createdAt || '').toLocaleString()) + '"><img src="' + escapeAttr(job.imageUrl) + '" data-image-thumb="256" alt="Generated image" loading="lazy"></button>' +
'<a class="assistant-gallery-download" href="' + escapeAttr(dl) + '" download title="Download this image"><i class="fas fa-download"></i></a>' +
'</div>';
}).join('') + running.map(function (job) {
return '<div class="assistant-gallery-item assistant-gallery-running" title="Generating…"><i class="fas fa-spinner fa-spin"></i></div>';
}).join('');
if (running.length) setTimeout(renderCreateImageHistory, 4000);
}).catch(function () {});
return;
}
// Fallback when the API helper is unavailable (older bundles/tests).
wrap.innerHTML = '<p class="assistant-muted">Your generated images will appear here.</p>';
}
function startImageFromSelection(prompt, chatId, hooks) {
hooks = hooks || {};
// "No chat" means the description alone — there is nothing to base it on.
var sourceMessages = chatId ? messages : [];
var load = chatId
? function () {
return fetchSavedAssistantChat(chatId).then(function (data) {
if (!data.success || !data.chat) throw new Error('Could not load that chat');
sourceMessages = (data.chat.payload && data.chat.payload.messages) || [];
});
}
: function () { return Promise.resolve(); };
load().then(function () {
var full = sourceMessages.map(function (m) { return { role: m.role, content: m.content }; });
if (!prompt && !full.length) {
if (typeof hooks.onError === 'function') hooks.onError('Describe the image you want first');
else if (typeof showToast === 'function') showToast('Describe the image you want first', 'error');
return;
}
var effective = prompt || 'Create a pediatric teaching visual from this conversation.';
var owner;
try { owner = captureSharingOwner(); } catch (_) { return; }
startAssistantImageJob(effective, full, selectedImageModel || undefined).then(function (data) {
assertSharingOwner(owner);
if (!data.success) throw new Error(data.error || 'Image generation failed');
generatedImageJobs = [{ jobId: data.jobId }];
lastGeneratedImageSrc = ''; // the queued job replaces any previous completed image
exporter.invalidate();
if (typeof hooks.onStatus === 'function') hooks.onStatus('Generating image…');
// The job runs server-side; closing the popup never cancels it.
var pollAttempts = 0;
var poll = function () {
// Keep polling until the job finishes; image models can take minutes.
fetchAssistantImageJob(data.jobId).then(function (job) {
if (!job || !job.success) { setTimeout(poll, 2500); return; }
if (job.imageUrl) {
lastGeneratedImageSrc = job.imageUrl;
exporter.invalidate();
try {
if (typeof hooks.onDone === 'function') hooks.onDone(job.imageUrl); else openImagePreview(job.imageUrl);
} catch (doneError) {
if (typeof hooks.onError === 'function') hooks.onError('Image ready: ' + String(doneError && doneError.message));
}
return;
}
if (job.status === 'error' || job.status === 'interrupted') {
if (typeof hooks.onError === 'function') hooks.onError(job.error || 'Image generation failed');
else if (typeof showToast === 'function') showToast(job.error || 'Image generation failed', 'error');
return;
}
setTimeout(poll, 2500);
}).catch(function (pollError) {
// A transient network blip should retry; a programming error must not
// masquerade as a slow image forever. This exact bug shipped: the
// status fetch was never imported, so every tick threw and the catch
// silently rescheduled, leaving "Generating image…" up permanently.
if (pollError instanceof ReferenceError || pollError instanceof TypeError) {
if (typeof hooks.onError === 'function') hooks.onError('Cannot check image status: ' + pollError.message);
console.error('[clinical-assistant] image polling is broken', pollError);
return;
}
if (++pollAttempts > 240) { // ~10 minutes of transient failures
if (typeof hooks.onError === 'function') hooks.onError('Lost contact while generating; the image may still finish — check the gallery.');
return;
}
setTimeout(poll, 2500);
});
};
setTimeout(poll, 2500);
}).catch(function (error) {
if (typeof hooks.onError === 'function') hooks.onError(error.message);
if (!validSharingOwner(owner) || error.name === 'AbortError') return;
if (typeof showToast === 'function') showToast(error.message, 'error');
});
}).catch(function (error) { if (typeof showToast === 'function') showToast(error.message, 'error'); });
}
function openImagePreview(src) {
if (!src) return;
var downloadUrl = src + (src.indexOf('?') === -1 ? '?download=1' : '&download=1');
var modal = document.createElement('div');
modal.className = 'assistant-takehome-modal';
modal.id = 'assistant-image-preview-modal';
modal.innerHTML = '<div class="modal-content"><div class="modal-header"><h2>Generated image</h2>' +
'<a class="btn-sm btn-ghost" href="' + escapeAttr(downloadUrl) + '" download title="Download this image"><i class="fas fa-download"></i> Download</a>' +
'<button type="button" class="modal-close" data-image-preview-close aria-label="Close"><i class="fas fa-xmark"></i></button></div>' +
'<div class="modal-body"><img src="' + escapeAttr(src) + '" alt="Generated image" style="width:100%;border-radius:12px;"></div></div>';
document.body.appendChild(modal);
modal.addEventListener('click', function (event) {
if (event.target === modal || event.target.closest('[data-image-preview-close]')) modal.remove();
});
}
// ── Per-account model selection (localStorage, admin-allowlist-limited) ──
function modelStorageKey(key) {
try {
if (window.AccountBoundary && typeof window.AccountBoundary.storageKey === 'function') return window.AccountBoundary.storageKey(key);
} catch (e) {}
return key + ':unscoped';
}
function loadModelSelection() {
try {
selectedChatModel = localStorage.getItem(modelStorageKey('ped_assistant_chat_model')) || '';
selectedImageModel = localStorage.getItem(modelStorageKey('ped_assistant_image_model')) || '';
} catch (e) { selectedChatModel = ''; selectedImageModel = ''; }
}
function saveModelSelection(kind, value) {
try {
var key = modelStorageKey(kind);
if (value) localStorage.setItem(key, value);
else localStorage.removeItem(key);
} catch (e) {}
}
function fillModelSelect(select, allowed, configured, saved, saveKind, kind) {
if (!select) return;
if (kind) select.setAttribute('data-model-select-kind', kind);
select.innerHTML = '';
var def = document.createElement('option');
def.value = '';
def.textContent = configured ? ('Default (' + configured + ')') : 'Default (admin model)';
select.appendChild(def);
(Array.isArray(allowed) ? allowed : []).forEach(function(id) {
if (!id || id === configured) return;
var opt = document.createElement('option');
opt.value = id;
opt.textContent = id;
select.appendChild(opt);
});
var stillAllowed = Array.prototype.some.call(select.options, function(o) { return o.value === saved; });
var chosen = stillAllowed ? saved : '';
select.value = chosen;
if (saveKind && chosen !== saved) saveModelSelection(saveKind, chosen);
// One model means no choice: hide the selector entirely.
var show = Array.isArray(allowed) && allowed.length > 1;
select.hidden = !show;
var pill = document.getElementById('assistant-model-pill');
if (pill) pill.hidden = !show;
if (select.id === 'assistant-chat-model-select') syncModelMenu();
}
// The composer shows the word "Model", not the model id, which can be as long
// as "openrouter-gemini-3.1-flash-image-preview". The list behind it is built
// from the select above, which stays the state holder, so choosing here goes
// through the same change event that persists every other selection.
function syncModelMenu() {
var select = document.getElementById('assistant-chat-model-select');
var menu = document.getElementById('assistant-model-menu');
var button = document.getElementById('btn-assistant-model');
if (!select || !menu || !button) return;
menu.innerHTML = '';
Array.prototype.forEach.call(select.options, function(option) {
var row = document.createElement('button');
row.type = 'button';
row.className = 'assistant-model-option';
row.setAttribute('role', 'option');
row.setAttribute('data-model-value', option.value);
row.setAttribute('aria-selected', option.value === select.value ? 'true' : 'false');
var tick = document.createElement('i');
tick.className = 'fas fa-check';
var text = document.createElement('span');
text.textContent = option.textContent;
row.appendChild(tick);
row.appendChild(text);
menu.appendChild(row);
});
var current = select.options[select.selectedIndex];
button.title = current ? 'Chat model: ' + current.textContent : 'Choose the chat model';
}
function closeModelMenu() {
var menu = document.getElementById('assistant-model-menu');
var button = document.getElementById('btn-assistant-model');
if (menu) menu.hidden = true;
if (button) button.setAttribute('aria-expanded', 'false');
}
function bindModelMenu() {
var docEl = typeof document !== 'undefined' ? document.documentElement : null;
if (docEl && docEl.dataset && docEl.dataset.modelMenuBound) return;
if (docEl && docEl.dataset) docEl.dataset.modelMenuBound = '1';
document.addEventListener('click', function(event) {
var target = event.target;
if (!target || !target.closest) return;
var option = target.closest('.assistant-model-option');
if (option) {
var select = document.getElementById('assistant-chat-model-select');
if (select) {
select.value = option.getAttribute('data-model-value');
// The delegated [data-model-select-kind] listener does the saving.
var changed = document.createEvent('Event');
changed.initEvent('change', true, false);
select.dispatchEvent(changed);
syncModelMenu();
}
closeModelMenu();
return;
}
if (target.closest('#btn-assistant-model')) {
var menu = document.getElementById('assistant-model-menu');
var button = document.getElementById('btn-assistant-model');
if (!menu || !button) return;
var opening = menu.hidden;
menu.hidden = !opening;
button.setAttribute('aria-expanded', opening ? 'true' : 'false');
return;
}
if (!target.closest('.assistant-model-pill')) closeModelMenu();
});
document.addEventListener('keydown', function(event) {
if (event.key === 'Escape') closeModelMenu();
});
}
function bindModelSelects() {
bindModelMenu();
// Delegated persistence: any chat/image model select saves immediately,
// even when the popup recreates its element.
var docEl = typeof document !== 'undefined' ? document.documentElement : null;
if (docEl && docEl.dataset && docEl.dataset.modelSelectsBound) return;
if (docEl && docEl.dataset) docEl.dataset.modelSelectsBound = '1';
document.addEventListener('change', function(e) {
var sel = e.target && e.target.closest ? e.target.closest('[data-model-select-kind]') : null;
if (!sel) return;
var kind = sel.getAttribute('data-model-select-kind');
var value = sel.value || '';
if (kind === 'chat') { selectedChatModel = value; saveModelSelection('ped_assistant_chat_model', value); }
if (kind === 'image') { selectedImageModel = value; saveModelSelection('ped_assistant_image_model', value); }
});
}
// ── Saved-chat options: Rename, Pin, Export, Delete (ChatGPT-style menu) ──
function pinnedChatIds() {
try {
var raw = localStorage.getItem('assistantPinnedChats');
var ids = raw ? JSON.parse(raw) : [];
return Array.isArray(ids) ? ids.map(String) : [];
} catch (e) { return []; }
}
function isChatPinned(id) {
return pinnedChatIds().indexOf(String(id)) !== -1;
}
function toggleChatPinned(id) {
var ids = pinnedChatIds();
var key = String(id);
var idx = ids.indexOf(key);
if (idx === -1) ids.push(key); else ids.splice(idx, 1);
try { localStorage.setItem('assistantPinnedChats', JSON.stringify(ids)); } catch (e) {}
loadSavedChats();
}
function renameSavedChat(id, newTitle) {
var title = String(newTitle || '').trim().slice(0, 160);
if (!title) return Promise.resolve();
return renameSavedAssistantChat(id, title).then(function (data) {
if (!data.success) throw new Error(data.error || 'Rename failed');
loadSavedChats();
}).catch(function (err) {
if (typeof showToast === 'function') showToast(err.message, 'error');
});
}
function exportSavedChat(id) {
fetchSavedAssistantChat(id).then(function (data) {
if (!data.success || !data.chat) throw new Error('Could not load that chat');
var payload = data.chat.payload || {};
exportAnswerPdf({
messages: payload.messages || [],
lastAnswer: payload.lastAnswer || '',
lastSources: payload.sources || [],
generatedImageJobs: [],
lastGeneratedImageSrc: payload.generatedImage || ''
});
}).catch(function (err) {
if (typeof showToast === 'function') showToast(err.message, 'error');
});
}
function openChatMenu(menuBtn) {
closeChatMenu();
var id = menuBtn.getAttribute('data-assistant-chat-menu');
var pop = document.createElement('div');
pop.className = 'assistant-chat-menu';
pop.setAttribute('data-assistant-chat-menu-pop', '');
var pinned = isChatPinned(id);
pop.innerHTML =
'<button type="button" data-chat-action="rename" data-chat-id="' + escapeAttr(id) + '"><i class="fas fa-pen"></i> Rename</button>' +
'<button type="button" data-chat-action="pin" data-chat-id="' + escapeAttr(id) + '"><i class="fas fa-thumbtack"></i> ' + (pinned ? 'Unpin' : 'Pin') + '</button>' +
'<button type="button" data-chat-action="export" data-chat-id="' + escapeAttr(id) + '"><i class="fas fa-file-pdf"></i> Export</button>' +
'<button type="button" data-chat-action="delete" data-chat-id="' + escapeAttr(id) + '"><i class="fas fa-trash"></i> Delete</button>';
menuBtn.closest('.assistant-saved-chat').appendChild(pop);
// Document-level delegation handles the actions; the row-click guard in the
// menu-button branch keeps the row from opening when the menu is used.
}
function closeChatMenu() {
document.querySelectorAll('[data-assistant-chat-menu-pop]').forEach(function (pop) { pop.remove(); });
}
function handleChatAction(action, id) {
closeChatMenu();
if (action === 'pin') { toggleChatPinned(id); return; }
if (action === 'delete') { deleteSavedChat(id); return; }
if (action === 'export') { exportSavedChat(id); return; }
if (action === 'rename') {
var rows = document.querySelectorAll('[data-assistant-load-chat]');
var row = null;
for (var i = 0; i < rows.length; i++) {
if (String(rows[i].getAttribute('data-assistant-load-chat')) === String(id)) { row = rows[i]; break; }
}
if (!row) return;
var titleEl = row.querySelector('.assistant-saved-chat-title');
var current = String(titleEl ? titleEl.textContent.replace(/^\ufeff/, '').trim() : '');
var input = document.createElement('input');
input.className = 'assistant-saved-chat-rename';
input.value = current;
input.maxLength = 160;
titleEl.replaceWith(input);
input.focus();
input.select();
var commit = function () { renameSavedChat(id, input.value); };
input.addEventListener('blur', commit);
input.addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); commit(); } });
}
}
// ── Patient take home: plain-language summary + copy/export/email ────
var takehomeBusy = false;
var takehomeText = ''; // canonical original markdown, never overwritten
var takehomeTranslatedHtml = ''; // translated HTML, '' when showing the original
var takehomeLang = '';
// The take home is translated as HTML for the same reason chat messages are:
// LibreTranslate's text mode destroys tables and emphasis markers.
function takehomeVisibleHtml() {
return takehomeTranslatedHtml
? wrapTables(sanitize(takehomeTranslatedHtml))
: renderMarkdown(takehomeText, [], {});
}
// Copy / Export / Email must hand over what the caregiver is actually reading.
function takehomeVisibleText() {
if (!takehomeTranslatedHtml) return takehomeText;
var holder = document.createElement('div');
holder.innerHTML = sanitize(takehomeTranslatedHtml);
return String(holder.textContent || '').replace(/\n{3,}/g, '\n\n').trim();
}
function openPatientTakehome() {
if (takehomeBusy) return;
if (!lastAnswer || !String(lastAnswer).trim()) {
if (typeof showToast === 'function') showToast('Ask a question first, then create a patient take home', 'error');
return;
}
closePatientTakehomeModal();
takehomeBusy = true;
takehomeText = '';
takehomeTranslatedHtml = '';
takehomeLang = '';
var modal = document.createElement('div');
modal.className = 'assistant-takehome-modal';
modal.id = 'assistant-takehome-modal';
modal.innerHTML = '<div class="modal-content">' +
'<div class="modal-header"><h2>Patient take home</h2>' +
'<button type="button" class="modal-close" data-assistant-takehome-close aria-label="Close"><i class="fas fa-xmark"></i></button></div>' +
'<div class="modal-body"><div class="assistant-takehome-loading"><i class="fas fa-spinner fa-spin"></i> Writing a plain-language summary…</div>' +
'<div class="assistant-takehome-result hidden"></div>' +
'<div class="assistant-takehome-actions hidden">' +
'<button type="button" class="btn-sm" data-assistant-takehome-copy><i class="fas fa-copy"></i> Copy</button>' +
'<button type="button" class="btn-sm" data-assistant-takehome-export><i class="fas fa-download"></i> Export</button>' +
'<label class="assistant-takehome-lang">' +
'<select id="assistant-takehome-lang" data-assistant-takehome-lang aria-label="Translate take home"></select></label>' +
'<div class="assistant-takehome-email"><input type="email" id="assistant-takehome-email" placeholder="Email address" autocomplete="off">' +
'<button type="button" class="btn-sm btn-primary" data-assistant-takehome-send>Send</button></div>' +
'</div></div></div>';
document.body.appendChild(modal);
modal.addEventListener('click', function(event) {
if (event.target === modal || event.target.closest('[data-assistant-takehome-close]')) closePatientTakehomeModal();
});
fetch('/api/clinical-assistant/patient-takehome', {
method: 'POST',
headers: getAuthHeaders(Object.assign({ 'Content-Type': 'application/json' })),
body: JSON.stringify({ answer: lastAnswer })
}).then(function(r) { return r.json(); }).then(function(data) {
if (!data.success) throw new Error(data.error || 'Take-home generation failed');
takehomeText = String(data.text || '').trim();
var loading = modal.querySelector('.assistant-takehome-loading');
var result = modal.querySelector('.assistant-takehome-result');
var actions = modal.querySelector('.assistant-takehome-actions');
if (loading) loading.remove();
if (result) {
// Same universal markdown renderer as chat bubbles; raw text stays
// canonical for Copy/Export/email.
result.innerHTML = takehomeVisibleHtml();
result.classList.remove('hidden');
}
if (actions) actions.classList.remove('hidden');
renderTakehomeLanguageOptions();
if (!translateLanguagesAvailable) refreshTranslateLanguages();
}).catch(function(err) {
var loading = modal.querySelector('.assistant-takehome-loading');
if (loading) { loading.innerHTML = '<i class="fas fa-triangle-exclamation"></i> ' + escapeHtml(err.message); }
}).finally(function() { takehomeBusy = false; });
}
function renderTakehomeLanguageOptions() {
var select = document.getElementById('assistant-takehome-lang');
if (!select) return;
select.innerHTML = '<option value="">Original</option>' +
availableTranslateLanguages().map(function(pair) {
return '<option value="' + escapeAttr(pair[0]) + '">' + escapeHtml(pair[1]) + '</option>';
}).join('');
select.value = takehomeLang;
}
function renderTakehomeBody() {
var result = document.querySelector('#assistant-takehome-modal .assistant-takehome-result');
if (result) result.innerHTML = takehomeVisibleHtml();
}
function translateTakehome(target) {
var select = document.getElementById('assistant-takehome-lang');
if (!takehomeText) return;
if (!target) { // back to the original
takehomeLang = '';
takehomeTranslatedHtml = '';
renderTakehomeBody();
return;
}
if (select) select.disabled = true;
translateAssistantMessage(simplifyHtmlForTranslation(renderMarkdown(takehomeText, [], {})), target, translateProvider, 'html')
.then(function(data) {
if (!data.success) throw new Error(data.error || 'Translation failed');
takehomeLang = target;
takehomeTranslatedHtml = String(data.translated || '');
renderTakehomeBody();
})
.catch(function(err) {
if (select) select.value = takehomeLang; // keep the select honest about what is shown
if (typeof showToast === 'function') showToast(err && err.message ? err.message : 'Translation failed', 'error');
})
.finally(function() { if (select) select.disabled = false; });
}
function exportTakehomePdf() {
var owner;
try { owner = captureSharingOwner(); } catch (_) { return; }
var doc;
try { doc = window.open('', '_blank'); } catch (_) { doc = null; }
if (!doc) {
if (validSharingOwner(owner) && typeof showToast === 'function') showToast('Allow popups to export the PDF', 'error');
return;
}
assertSharingOwner(owner);
var html = takehomeVisibleHtml();
doc.document.write('<!DOCTYPE html><html><head><meta charset="utf-8"><title>Patient Take Home</title>' +
'<style>body{font-family:Arial,sans-serif;color:#111827;line-height:1.6;margin:32px;max-width:720px}' +
'h1{color:#0f766e;font-size:24px;margin:0 0 4px;border-bottom:2px solid #0f766e;padding-bottom:8px}' +
'p{margin:8px 0}ul,ol{padding-left:22px}li{margin:4px 0}strong{font-weight:700}' +
'@media print{body{margin:18mm}}</style></head><body><h1>Patient Take Home</h1>' + html + '</body></html>');
doc.document.close();
var printed = function() { if (validSharingOwner(owner)) { try { doc.focus(); doc.print(); } catch (_) {} } };
setTimeout(printed, 250);
}
function closePatientTakehomeModal() {
var modal = document.getElementById('assistant-takehome-modal');
if (modal) modal.remove();
takehomeTranslatedHtml = '';
takehomeLang = '';
}
document.addEventListener('change', function(event) {
var langSelect = event.target.closest && event.target.closest('[data-assistant-takehome-lang]');
if (langSelect) translateTakehome(langSelect.value);
});
document.addEventListener('click', function(event) {
var galleryItem = event.target.closest('[data-gallery-image]');
if (galleryItem) { openImagePreview(galleryItem.getAttribute('data-gallery-image')); return; }
if (event.target.closest('[data-assistant-takehome-copy]')) {
copyText(takehomeVisibleText(), 'Take home copied', 'Copy failed');
return;
}
if (event.target.closest('[data-assistant-takehome-export]')) {
if (!takehomeVisibleText()) return;
exportTakehomePdf();
return;
}
if (event.target.closest('[data-assistant-takehome-send]')) {
var input = document.getElementById('assistant-takehome-email');
var to = input ? input.value.trim() : '';
if (!to) { if (typeof showToast === 'function') showToast('Enter an email address', 'error'); return; }
var btn = event.target.closest('[data-assistant-takehome-send]');
btn.disabled = true;
fetch('/api/clinical-assistant/patient-takehome/email', {
method: 'POST',
headers: getAuthHeaders(Object.assign({ 'Content-Type': 'application/json' })),
body: JSON.stringify({ to: to, text: takehomeVisibleText() })
}).then(function(r) { return r.json(); }).then(function(data) {
if (!data.success) throw new Error(data.error || 'Could not send the email');
if (typeof showToast === 'function') showToast('Take home sent to ' + to, 'success');
if (input) input.value = '';
}).catch(function(err) {
if (typeof showToast === 'function') showToast(err.message, 'error');
}).finally(function() { btn.disabled = false; });
return;
}
});
function openTranslatePicker(row) {
if (!row) return;
closeTranslatePicker();
var pop = document.createElement('div');
pop.className = 'assistant-translate-pop';
pop.setAttribute('data-assistant-translate-pop', '');
// Simple for users: pick a language. Translation is always the local
// LibreTranslate container, so patient text never leaves this network.
pop.innerHTML = '<div class="assistant-translate-langs">' +
TRANSLATE_LANGUAGES.map(function(pair) {
return '<button type="button" data-assistant-translate-lang="' + escapeAttr(pair[0]) + '">' + escapeHtml(pair[1]) + '</button>';
}).join('') +
'</div>';
pop.assistantMessageRow = row;
row.appendChild(pop);
if (!translateLanguagesAvailable) refreshTranslateLanguages(pop);
}
function closeTranslatePicker() {
document.querySelectorAll('[data-assistant-translate-pop]').forEach(function(pop) { pop.remove(); });
}
function detachImageCards(bubble) {
var cards = Array.prototype.slice.call(bubble.querySelectorAll('.assistant-image-card'));
cards.forEach(function(card) { card.remove(); });
return cards;
}
function reattachImageCards(bubble, cards) {
cards.forEach(function(card) { bubble.appendChild(card); });
}
// LibreTranslate mangles markdown syntax in text mode: table pipes come back as
// "←", the |---| delimiter row is translated as prose, and "**bold**" returns as
// "** bold**", which no longer renders. Its html mode leaves tags — and bare
// [n] markers — completely intact, so rendered HTML is what gets translated.
function simplifyHtmlForTranslation(html) {
if (typeof DOMParser === 'undefined') return String(html || '');
var doc = new DOMParser().parseFromString(String(html || ''), 'text/html');
// Rendered maths and UI chrome are not prose; flatten them to their text so
// the translator cannot rearrange markup it does not own.
doc.body.querySelectorAll('.katex, mjx-container, .assistant-code-copy, .assistant-table-actions, .assistant-msg-actions, .assistant-image-card, script, style')
.forEach(function(el) { el.replaceWith(doc.createTextNode(el.textContent || '')); });
doc.body.querySelectorAll('.assistant-table-scroll').forEach(function(el) {
var table = el.querySelector('table');
if (table) el.replaceWith(table); // re-wrapped after translation
});
return doc.body.innerHTML;
}
// Citation markers must survive the round trip so the translated answer keeps
// its clickable [n] chips.
function citationNumbersIn(text) {
var found = [];
String(text || '').replace(/\[((?:\d+\s*,\s*)*\d+)\]/g, function(match, cluster) {
cluster.split(',').forEach(function(n) {
var num = Number(n.trim());
if (Number.isInteger(num) && num > 0 && found.indexOf(num) === -1) found.push(num);
});
return match;
});
return found;
}
function requestMessageTranslation(row, target, provider) {
var bubble = row && row.querySelector('.assistant-bubble');
if (!bubble) return;
var raw = bubble.assistantRawContent !== undefined ? bubble.assistantRawContent : String(bubble.textContent || '').trim();
if (!String(raw).trim()) return;
// Image cards are live nodes whose status polling must survive; they are
// detached, kept out of the stored original HTML, and re-attached on every
// path out of here (success, failure and Show original).
var imageCards = detachImageCards(bubble);
if (!bubble.assistantOriginalHtml) bubble.assistantOriginalHtml = bubble.innerHTML;
var sources = Array.isArray(bubble.assistantSources) ? bubble.assistantSources : [];
var expected = citationNumbersIn(raw);
function showTranslation(translated) {
// Sanitize what the service returned, THEN turn the surviving [n] markers
// into the usual chips bound to this message's sources, then restore the
// scroll wrapper the simplification removed.
var html = wrapTables(renderCitationLinks(sanitize(String(translated || '')), sources, {}));
var lost = expected.filter(function(n) { return citationNumbersIn(translated).indexOf(n) === -1; });
if (lost.length) {
// Keep dropped evidence reachable rather than letting it disappear.
html += '<div class="assistant-translated-sources"><strong>' +
escapeHtml('Sources not carried into the translation') + '</strong>' +
renderCitationLinks(lost.map(function(n) { return '[' + n + ']'; }).join(' '), sources, {}) +
'</div>';
}
bubble.innerHTML = html +
'<button type="button" class="btn-sm btn-ghost" data-assistant-msg-show-original><i class="fas fa-undo"></i> Show original</button>';
reattachImageCards(bubble, imageCards); // same nodes — status polling continues
renderEmbeddedBlocks(bubble);
}
// [n] markers are left unlinked here (empty sources) so they cross as plain
// text and can be re-linked after translation.
var sourceHtml = simplifyHtmlForTranslation(renderMarkdown(raw, [], {}));
translateAssistantMessage(sourceHtml, target, provider, 'html')
.then(function(data) {
if (!data.success) throw new Error(data.error || 'Translation failed');
showTranslation(data.translated);
})
.catch(function() {
// Some LibreTranslate builds reject html mode. Plain text loses tables
// and emphasis, but an unformatted translation beats none.
return translateAssistantMessage(raw, target, provider, 'text')
.then(function(data) {
if (!data.success) throw new Error(data.error || 'Translation failed');
showTranslation(renderMarkdown(String(data.translated || ''), [], {}));
})
.catch(function(err) {
bubble.innerHTML = bubble.assistantOriginalHtml;
reattachImageCards(bubble, imageCards);
renderEmbeddedBlocks(bubble);
if (typeof showToast === 'function') showToast(err && err.message ? err.message : 'Translation failed', 'error');
});
});
}
// ── Regenerate: replay the preceding question (latest answer only) ──
function regenerateMessage(row) {
if (assistantBusy) {
if (typeof showToast === 'function') showToast('Assistant is still finishing the current answer', 'error');
return;
}
var index = Number(row && row.getAttribute('data-message-index'));
var message = Number.isInteger(index) ? messages[index] : null;
if (!message || message.role !== 'assistant') return;
var lastAssistantIndex = -1;
for (var i = messages.length - 1; i >= 0; i--) if (messages[i].role === 'assistant') { lastAssistantIndex = i; break; }
if (index !== lastAssistantIndex) {
if (typeof showToast === 'function') showToast('Only the latest answer can be regenerated in this chat.', 'error');
return;
}
var question = messages[index - 1];
if (!question || question.role !== 'user') {
if (typeof showToast === 'function') showToast('No question precedes this answer to regenerate.', 'error');
return;
}
messages = messages.slice(0, index - 1); // drop the answer AND its question; the question is replayed as the new message
if (row && row.parentNode) row.remove();
var input = document.getElementById('assistant-input');
if (input) input.value = question.content;
regenerateMode = true;
updateConversationBudget();
onAsk();
}
// ── Debounced autosave (800ms after each completed turn/change) ────
function scheduleAutosave() {
if (typeof setTimeout !== 'function' || !messages.length) return;
autosaveErrorShown = false; // a new change retries
if (autosaveTimer && typeof clearTimeout === 'function') clearTimeout(autosaveTimer);
autosaveTimer = setTimeout(performAutosave, AUTOSAVE_DELAY_MS);
}
function cancelAutosave() {
if (autosaveTimer && typeof clearTimeout === 'function') clearTimeout(autosaveTimer);
autosaveTimer = null;
}
function autosavePayload() {
return {
title: currentChatId ? undefined : deriveChatTitle(),
messages: messages,
sources: lastSources,
lastAnswer: lastAnswer,
generatedImageJobs: generatedImageJobs,
generatedImage: lastGeneratedImageSrc || undefined,
...(currentChatId ? { id: currentChatId } : {})
};
}
function performAutosave() {
autosaveTimer = null;
if (assistantBusy || !messages.length) return;
var saveState = document.getElementById('assistant-autosave-state');
if (saveState) { saveState.textContent = 'Saving…'; saveState.className = 'assistant-autosave-state saving'; }
return saveAssistantChat(autosavePayload())
.then(function(data) {
if (!data.success) throw new Error(data.error || 'Autosave failed');
if (data.id) currentChatId = data.id;
autosaveErrorShown = false;
var done = document.getElementById('assistant-autosave-state');
if (done) { done.textContent = 'Saved'; done.className = 'assistant-autosave-state saved'; }
loadSavedChats();
})
.catch(function(err) {
// Surface once per change; never block or retry-loop the chat flow.
var failed = document.getElementById('assistant-autosave-state');
if (failed) { failed.textContent = 'Save failed'; failed.className = 'assistant-autosave-state failed'; }
if (!autosaveErrorShown) {
autosaveErrorShown = true;
if (typeof showToast === 'function') showToast(err && err.message ? err.message : 'Autosave failed', 'error');
}
});
}
function clearGeneratedImage() {
lastGeneratedImageSrc = '';
generatedImageJobs = [];
imageStore.clear();
exporter.invalidate();
}
function attachImageJobs(row, message, jobs) {
if (!row || !jobs.length) return;
message.imageJobs = jobs.map(function(job) { return { jobId: job.jobId }; });
renderImageJobs(row.querySelector('.assistant-bubble') || row, message.imageJobs, 'clinical_assistant', function(card, data) {
card.insertAdjacentHTML('beforeend', imageStore.renderGeneratedImage(data.imageUrl, 'Generated teaching visual', data.downloadUrl));
exporter.invalidate();
});
}
function clearConversation(event) {
if (assistantBusy && !activeAssistantRequest) return;
// Chats autosave, so a new chat needs no confirmation.
performClearConversation();
}
function performClearConversation() {
if (activeAssistantRequest) cancelAssistantSearch();
cancelAutosave();
currentChatId = null;
autosaveErrorShown = false;
messages = [];
attachments = [];
renderAttachments();
lastAnswer = '';
lastSources = [];
lastGeneratedImageSrc = '';
exporter.invalidate();
var wrap = document.getElementById('assistant-messages');
if (wrap) {
wrap.innerHTML = renderEmptyState();
bindExampleButtons(wrap);
}
renderSources([]);
clearGeneratedImage();
updateConversationBudget();
loadSavedChats();
}
function cancelAssistantSearch() {
if (!activeAssistantRequest) return;
var request = activeAssistantRequest;
request.abort();
activeAssistantRequest = null;
setBusy(false, 'Ready');
if (request.loading) request.loading.remove();
}
function renderEmptyState() {
var examples = pickExamples();
return '<div class="assistant-empty"><i class="fas fa-book-medical"></i>' +
'<h3>Evidence-first pediatric assistant</h3>' +
'<p>Ask a real clinical question. Citations stay linked to the source cards on the right.</p>' +
'<div class="assistant-examples">' + examples.map(function (item) {
var source = item.sourceTitle ? (' title="Available from: ' + escapeAttr(item.sourceTitle + (item.page ? ', page ' + item.page : '')) + '"') : '';
return '<button type="button" data-assistant-example="' + escapeAttr(item.prompt) + '"' + source + '>' + escapeHtml(item.label) + '</button>';
}).join('') + '</div></div>';
}
function pickExamples() {
var examples = dynamicExamples.length ? dynamicExamples.slice() : EMPTY_PROMPT_SETS[Math.floor(Math.random() * EMPTY_PROMPT_SETS.length)].slice();
for (var i = examples.length - 1; i > 0; i--) {
var j = Math.floor(Math.random() * (i + 1));
var tmp = examples[i];
examples[i] = examples[j];
examples[j] = tmp;
}
return examples.slice(0, 3);
}
function bindExampleButtons(root) {
(root || document).querySelectorAll('[data-assistant-example]').forEach(function (btn) {
if (btn.getAttribute('data-assistant-bound') === 'true') return;
btn.setAttribute('data-assistant-bound', 'true');
btn.addEventListener('click', function () {
// ChatGPT behavior: one tap asks the example immediately — no separate send.
var el = document.getElementById('assistant-input');
if (!el) return;
el.value = btn.getAttribute('data-assistant-example') || '';
updateConversationBudget();
resizeAssistantInput();
el.focus();
onAsk();
});
});
}
function exportAnswerPdf(customState) {
// Click bindings pass the event here; only a real state object is an override.
var state = customState && customState.messages ? customState : null;
exporter.exportAnswerPdf(state || {
messages: messages,
lastAnswer: lastAnswer,
lastSources: lastSources,
lastGeneratedImageSrc: lastGeneratedImageSrc,
generatedImageJobs: generatedImageJobs
});
}
function loadSavedChats() {
fetchSavedAssistantChats()
.then(function (data) { if (data.success) renderSavedChats(data.chats || []); })
.catch(function () {});
}
// Open WebUI groups saved chats by recency rather than showing one long list.
// Pinned chats stay in their own group at the top.
var SAVED_CHAT_MONTHS = ['January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'];
function startOfDay(date) {
return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
}
function savedChatGroup(value, now) {
var when = new Date(value);
if (!value || isNaN(when.getTime())) return { key: 'undated', label: 'Undated', order: 900 };
var days = Math.floor((startOfDay(now) - startOfDay(when)) / 86400000);
if (days <= 0) return { key: 'today', label: 'Today', order: 0 };
if (days === 1) return { key: 'yesterday', label: 'Yesterday', order: 1 };
if (days <= 3) return { key: 'd3', label: 'Previous 3 days', order: 2 };
if (days <= 7) return { key: 'd7', label: 'Previous 7 days', order: 3 };
if (days <= 30) return { key: 'd30', label: 'Previous 30 days', order: 4 };
// Older than a month: by calendar month, with the year once it is not this one.
var label = SAVED_CHAT_MONTHS[when.getMonth()] +
(when.getFullYear() === now.getFullYear() ? '' : ' ' + when.getFullYear());
return {
key: 'm' + when.getFullYear() + '-' + when.getMonth(),
label: label,
// Newer months first, always after the relative groups.
order: 100 + (9999 - when.getFullYear()) * 12 + (11 - when.getMonth())
};
}
function groupSavedChats(chats, now) {
var groups = [];
var byKey = {};
chats.forEach(function (chat) {
var group = isChatPinned(chat.id)
? { key: 'pinned', label: 'Pinned', order: -1 }
: savedChatGroup(chat.updated_at || chat.created_at, now);
if (!byKey[group.key]) {
byKey[group.key] = { key: group.key, label: group.label, order: group.order, chats: [] };
groups.push(byKey[group.key]);
}
byKey[group.key].chats.push(chat);
});
return groups.sort(function (a, b) { return a.order - b.order; });
}
// Published for the shared search palette: the assistant owns its chats, so it
// exposes them rather than having the palette reach into its internals.
if (typeof window !== 'undefined') window.assistantSearchableChats = function() {
return (savedChatCache || []).map(function(chat) {
return {
id: chat.id,
title: chat.title || 'Saved chat',
icon: isChatPinned(chat.id) ? 'fas fa-thumbtack' : 'fas fa-message',
meta: formatSavedDate(chat.updated_at || chat.created_at)
};
});
};
if (typeof window !== 'undefined') window.assistantOpenChat = function(id) { loadSavedChat(id); };
function renderSavedChats(chats) {
savedChatCache = chats || [];
var wrap = document.getElementById('assistant-saved-chats');
if (!wrap) return;
chats = savedChatCache.slice().sort(function (a, b) {
var pa = isChatPinned(a.id) ? 0 : 1;
var pb = isChatPinned(b.id) ? 0 : 1;
if (pa !== pb) return pa - pb;
return String(b.updated_at || b.created_at || '').localeCompare(String(a.updated_at || a.created_at || ''));
});
if (!chats.length) {
wrap.innerHTML = '<p class="assistant-muted">Chats save automatically as you go.</p>';
return;
}
// Open WebUI behavior: the whole row opens the chat — no Load/Delete buttons.
function chatRow(chat) {
var title = chat.title || 'Saved chat';
var pinned = isChatPinned(chat.id);
return '<button type="button" class="assistant-saved-chat' + (pinned ? ' pinned' : '') + '" data-assistant-load-chat="' + escapeAttr(chat.id) + '" aria-label="Open saved chat" title="' + escapeAttr(title) + '">' +
'<span class="assistant-saved-chat-title">' + (pinned ? '<i class="fas fa-thumbtack"></i> ' : '') + escapeHtml(title) + '</span>' +
'<span class="assistant-saved-chat-meta">' + escapeHtml(formatSavedDate(chat.updated_at || chat.created_at)) + '</span>' +
'<span class="assistant-saved-chat-menu" role="button" tabindex="0" data-assistant-chat-menu="' + escapeAttr(chat.id) + '" aria-label="Chat options" title="Options"><i class="fas fa-ellipsis-vertical"></i></span>' +
'</button>';
}
wrap.innerHTML = groupSavedChats(chats, new Date()).map(function (group) {
return '<div class="assistant-saved-chat-group">' +
'<h4 class="assistant-saved-chat-heading">' + escapeHtml(group.label) + '</h4>' +
group.chats.map(chatRow).join('') +
'</div>';
}).join('');
}
function deleteSavedChat(id) {
if (assistantBusy) return;
return deleteSavedAssistantChat(id)
.then(function (data) {
if (!data.success) throw new Error(data.error || 'Delete failed');
if (String(currentChatId) === String(id)) performClearConversation();
loadSavedChats();
if (typeof showToast === 'function') showToast('Chat deleted', 'success');
})
.catch(function (err) {
if (typeof showToast === 'function') showToast(err.message, 'error');
});
}
// Opening a saved chat is not the assistant working, so it does not flip the
// send button into Stop and does not announce itself: the transcript
// appearing is the confirmation. It still takes the busy lock, so two rapid
// clicks cannot interleave two transcripts — but silently.
var loadingSavedChat = false;
function loadSavedChat(id) {
if (assistantBusy || loadingSavedChat) return;
var layout = document.getElementById('assistant-layout');
if (layout) layout.classList.remove('mobile-chats-open');
loadingSavedChat = true;
return fetchSavedAssistantChat(id)
.then(function (data) {
if (!data.success) throw new Error(data.error || 'Load failed');
currentChatId = data.chat ? data.chat.id : null;
restoreSavedChat(data.chat && data.chat.payload || {});
})
.catch(function (err) { if (typeof showToast === 'function') showToast(err.message, 'error'); })
.finally(function() { loadingSavedChat = false; });
}
function restoreSavedChat(payload) {
messages = Array.isArray(payload.messages) ? payload.messages.map(function (m) {
var message = { role: m.role === 'assistant' ? 'assistant' : 'user', content: String(m.content || ''), sources: Array.isArray(m.sources) ? m.sources : [], imageJobs: m.imageJobs || [], attachments: Array.isArray(m.attachments) ? m.attachments : [] };
if ((payload.version !== 2 || m.legacyClipped === true) && message.content.length === 12000 && !/[\r\n]/.test(message.content)) {
message.legacyClipped = true;
if (message.role === 'assistant' && isRetainedLegacyAnswer(message.content, m.retainedAnswer)) message.retainedAnswer = m.retainedAnswer;
}
return message;
}) : [];
lastSources = Array.isArray(payload.sources) ? payload.sources : [];
lastAnswer = String(payload.lastAnswer || lastAssistantMessage(messages) || '');
var finalMessage = messages[messages.length - 1];
if (finalMessage && finalMessage.role === 'assistant' && finalMessage.legacyClipped &&
(!finalMessage.sources.length || JSON.stringify(finalMessage.sources) === JSON.stringify(lastSources)) &&
isRetainedLegacyAnswer(finalMessage.content, lastAnswer)) finalMessage.retainedAnswer = lastAnswer;
generatedImageJobs = payload.generatedImageJobs || [];
// A selected job is authoritative, even for older saves containing a stale asset URL.
lastGeneratedImageSrc = String(payload.generatedImage || ''); // the saved completed image is authoritative
var wrap = document.getElementById('assistant-messages');
if (wrap) {
wrap.innerHTML = '';
messages.forEach(function (m, index) {
var display = savedMessagePresentation(m);
display.attachments = m.attachments;
var row = appendMessageNode(m.role, display.answer, m.sources && m.sources.length ? m.sources : lastSources, null, false, display);
row.dataset.messageIndex = String(index);
attachImageJobs(row, m, m.imageJobs);
});
wrap.scrollTop = wrap.scrollHeight;
}
renderSources(lastSources);
exporter.invalidate();
updateConversationBudget();
}
function isRetainedLegacyAnswer(content, retained) {
return typeof retained === 'string' && retained.length > content.length && retained.length <= 30000 &&
!/[\r\n]/.test(retained) && retained.startsWith(content);
}
function savedMessagePresentation(message) {
if (!message.legacyClipped) return { answer: message.content, notice: '' };
return {
answer: message.retainedAnswer || message.content,
notice: message.retainedAnswer ?
'Showing the retained lastAnswer that exactly extends this legacy clipped message; the stored transcript is unchanged.' +
(message.retainedAnswer.length === 30000 ? ' That retained answer may also have been clipped at 30,000 characters; absent content cannot be recovered.' : '') :
'This legacy message may have been clipped at 12,000 characters. Missing content cannot be recovered from the saved text.'
};
}
function appendMessageNode(role, content, sources, suggestions, rawHtml, options) {
var wrap = document.getElementById('assistant-messages');
if (!wrap) return;
var empty = wrap.querySelector('.assistant-empty');
if (empty) empty.remove();
var row = document.createElement('div');
row.className = 'assistant-msg ' + role;
var label = document.createElement('div');
label.className = 'assistant-msg-label';
label.textContent = role === 'user' ? 'You' : 'Assistant';
var bubble = document.createElement('div');
bubble.className = 'assistant-bubble';
row.appendChild(label);
row.appendChild(bubble);
wrap.appendChild(row);
fillMessageBubble(bubble, role, content, sources, suggestions, rawHtml, options);
var actions = document.createElement('div');
actions.className = 'assistant-msg-actions';
actions.innerHTML = '<button type="button" data-assistant-msg-copy title="Copy raw message"><i class="fas fa-copy"></i></button>' +
'<button type="button" data-assistant-msg-translate title="Translate"><i class="fas fa-language"></i></button>' +
(role === 'assistant'
? '<button type="button" data-assistant-msg-regenerate title="Regenerate"><i class="fas fa-rotate-right"></i></button>'
: '<button type="button" data-assistant-msg-edit title="Edit question"><i class="fas fa-pen"></i></button>');
row.appendChild(actions);
wrap.scrollTop = wrap.scrollHeight;
return row;
}
// A hard slice(0, 60) cut titles mid-word — "Rickets Radiographic Fea". The
// server already allows 160, so keep whole words and use that budget; every
// place that displays a title decides its own visible length from the width it
// actually has, rather than inheriting one arbitrary cut made at save time.
function deriveChatTitle() {
var first = messages.find(function (m) { return m.role === 'user' && m.content; });
return truncateOnWord(String(first && first.content || 'Clinical assistant chat').replace(/\s+/g, ' ').trim(), 160);
}
// A native <select> opens a list as wide as its longest option, so full chat
// titles pushed "Base it on" far past the dialog. Each label keeps as many
// whole words as fit the select's own width; the full title stays on hover.
var OPTION_CHROME = 44; // list padding + scrollbar, which the text cannot use
function fitSelectOptions(select) {
if (!select) return;
var style = window.getComputedStyle(select);
var room = select.clientWidth - OPTION_CHROME;
if (room <= 0) return; // not laid out (or no layout engine): leave labels alone
var ctx = fitSelectOptions.ctx || (fitSelectOptions.ctx = document.createElement('canvas').getContext('2d'));
if (!ctx) return;
ctx.font = style.fontWeight + ' ' + style.fontSize + ' ' + style.fontFamily;
Array.prototype.forEach.call(select.options, function (opt) {
var full = opt.getAttribute('data-full-title') || opt.textContent;
opt.setAttribute('data-full-title', full);
opt.title = full;
if (ctx.measureText(full).width <= room) { opt.textContent = full; return; }
var words = full.split(/\s+/), text = '';
for (var i = 0; i < words.length; i++) {
var next = text ? text + ' ' + words[i] : words[i];
if (ctx.measureText(next + '…').width > room) break;
text = next;
}
// One word wider than the box: fall back to cutting characters.
if (!text) { text = full; while (text.length > 1 && ctx.measureText(text + '…').width > room) text = text.slice(0, -1); }
opt.textContent = text.replace(/[\s.,;:!?-]+$/, '') + '…';
});
}
window.addEventListener('resize', function () { fitSelectOptions(document.getElementById('create-image-chat')); });
// Cuts at a word boundary, and only adds an ellipsis when something was
// actually dropped.
function truncateOnWord(text, limit) {
text = String(text || '').trim();
if (text.length <= limit) return text;
var cut = text.slice(0, limit);
var lastSpace = cut.lastIndexOf(' ');
// Fall back to the hard cut for a single very long token.
if (lastSpace > limit * 0.5) cut = cut.slice(0, lastSpace);
return cut.replace(/[\s.,;:!?-]+$/, '') + '…';
}
function conversationSize(question) {
return messages.reduce(function(total, message) { return total + message.content.length; }, String(question || '').length);
}
function validConversationLimit(limit) {
return Number.isInteger(limit) && limit >= 1000 && limit <= 1000000;
}
function resizeAssistantInput() {
var input = document.getElementById('assistant-input');
if (!input) return;
input.style.height = 'auto';
input.style.height = Math.min(input.scrollHeight, 160) + 'px';
}
function updateConversationBudget() {
var input = document.getElementById('assistant-input');
var used = conversationSize(input ? input.value : '');
var warning = document.getElementById('assistant-context-warning');
if (warning) {
warning.hidden = conversationChars === null || used * 10 < conversationChars * 9;
warning.textContent = (used > conversationChars ? 'Conversation limit exceeded. Sending is blocked.' :
used === conversationChars ? 'At the conversation limit. Any additional input will exceed it.' :
'Approaching the conversation limit (90% or more used).') +
' Nothing is truncated or automatically summarized. Save or download this chat, then choose New chat.';
}
}
function downloadTranscript() {
// Raw transcript JSON is an admin-only artifact.
if (typeof window !== 'undefined' && window._userRole && window._userRole !== 'admin') {
if (typeof showToast === 'function') showToast('Raw transcript download is for administrators only', 'error');
return;
}
var payload = { version: 2, title: deriveChatTitle(), messages: messages, sources: lastSources,
lastAnswer: lastAnswer, generatedImageJobs: generatedImageJobs, generatedImage: lastGeneratedImageSrc || undefined, savedAt: new Date().toISOString() };
var url = URL.createObjectURL(new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }));
var link = document.createElement('a');
link.href = url;
link.download = 'clinical-chat.json';
document.body.appendChild(link);
link.click();
link.remove();
setTimeout(function() { URL.revokeObjectURL(url); }, 60000);
}
function lastAssistantMessage(items) {
for (var i = items.length - 1; i >= 0; i--) if (items[i].role === 'assistant') return items[i].content;
return '';
}
function formatSavedDate(value) {
// ChatGPT-style relative grouping for the chat list.
var d;
try { d = new Date(value); if (isNaN(d.getTime())) return ''; } catch (e) { return ''; }
var now = new Date();
var startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
var startOfValue = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
var days = Math.round((startOfToday - startOfValue) / 86400000);
if (days <= 0) return 'Today';
if (days === 1) return 'Yesterday';
if (days < 7) return 'Previous 7 days';
if (days < 30) return 'Previous 30 days';
return d.toLocaleDateString();
}
function cssEscape(value) {
if (window.CSS && typeof window.CSS.escape === 'function') return window.CSS.escape(String(value || ''));
return String(value || '').replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
function setBusy(isBusy, text, isError) {
assistantBusy = !!isBusy;
if (!isBusy) {
// createEvent keeps this working in every environment (browser and test harnesses).
var doneEvent = document.createEvent('Event');
doneEvent.initEvent('assistant-answer-done', false, false);
doneEvent.detail = { isError: !!isError, answer: lastAnswer || '' };
document.dispatchEvent(doneEvent);
}
var status = document.getElementById('assistant-status');
var label = document.getElementById('assistant-status-text');
var send = document.getElementById('btn-assistant-send');
var input = document.getElementById('assistant-input');
var attachInput = document.getElementById('assistant-attach-input');
if (status) {
status.classList.toggle('busy', !!isBusy);
status.classList.toggle('error', !!isError);
}
if (label) label.textContent = text || (isBusy ? 'Working...' : 'Ready');
if (send) {
// The send button becomes a clickable STOP while the assistant works.
send.classList.toggle('busy', !!isBusy);
var icon = send.querySelector('i');
if (icon) icon.className = isBusy ? 'fas fa-stop' : 'fas fa-arrow-up';
send.setAttribute('aria-label', isBusy ? 'Stop generating' : 'Send');
}
if (input) input.disabled = !!isBusy;
if (attachInput) attachInput.disabled = !!isBusy;
}
var imageHookInstalled = false;
function sanitize(html) {
if (window.DOMPurify) {
// Defense in depth for the safe-image allowlist: after sanitization,
// drop any <img> whose src is not a data URI or our own origin.
if (!imageHookInstalled && typeof window.DOMPurify.addHook === 'function') {
imageHookInstalled = true;
window.DOMPurify.addHook('uponSanitizeElement', function(node, data) {
if (data.tagName === 'img') {
var src = node.getAttribute ? (node.getAttribute('src') || '') : '';
if (!safeImageUrl(src)) node.remove();
}
});
}
return window.DOMPurify.sanitize(html, { ADD_ATTR: ['target'] });
}
return escapeHtml(html);
}