pediatric-ai-scribe-v3/public/js/clinicalAssistant.js
Daniel 0e1e74882e feat: sources are numbered in the order the answer cites them
Borrowed from the quiz app's AI Mode, where validating citations and
ordering them fall out of the same pass: it collects the sources an
answer actually used into an insertion-ordered map, so the list comes
back in first-citation order for free.

Ours listed sources in retrieval order — an order the reader never sees
and has no way to follow. An answer whose first citation was [7] opened
a list that began at [1], so matching a marker to a source meant
hunting. Reference lists in published writing are numbered by first
appearance for exactly this reason.

Cited sources now come first, renumbered by first appearance, and the
markers in the text are rewritten to match. Anything retrieved and not
cited keeps its place after them, labelled "not cited" — the panel is
also a view of what the search returned, which is worth keeping, but it
should not sit among the numbers the answer used.

The marker itself now shows its number instead of the word "src". Every
citation read identically, so the only way to tell one from another was
to hover it — which made the numbered list beneath useless to match
against. The export has shown numbers since the day "src" was
introduced, with no recorded reason for the difference.

Renumbering happens once the whole answer is known, never while
streaming: the order is the order of first citation, so a citation that
has not arrived yet cannot take its place, and numbers would shuffle
under the reader mid-sentence. The text is rewritten in a single pass —
number by number would turn 2 into 1 and then that 1 into whatever 1
maps to.

An invented citation reserves no position and is left exactly as it was.
It is still not turned into a link, and citation_audit still records it;
what matters here is that it cannot push a real source down the list.

Accuracy was already held: a marker with no matching source never
becomes a link. This changes what a reader can do with the ones that are
real.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 03:26:17 +02:00

2908 lines
135 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, orderSourcesByCitation, 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 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);
}
// Per device, not per account: a keyboard preference belongs to the keyboard.
// Someone who wants Enter to send at a desk usually does not want it on a
// phone, where Enter is how you get a second line in a note.
var ENTER_KEY = 'ped_assistant_enter_sends';
function enterSends() {
try {
var saved = localStorage.getItem(ENTER_KEY);
if (saved === '1') return true;
if (saved === '0') return false;
} catch (e) { /* private window, blocked storage: fall through to the default */ }
// Unset: send on a device with a real keyboard, newline on a touch one.
try { return !window.matchMedia('(hover: none) and (pointer: coarse)').matches; }
catch (e) { return true; }
}
function setEnterSends(on) {
try { localStorage.setItem(ENTER_KEY, on ? '1' : '0'); } catch (e) {}
}
// Say which key sends, where someone is already looking when they wonder.
function applyEnterHint() {
var input = document.getElementById('assistant-input');
if (!input) return;
input.title = enterSends()
? 'Enter sends · Shift+Enter for a new line'
: 'Enter for a new line · Ctrl+Enter sends';
}
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);
// Enter: send, or newline. Two rules never change, whatever the preference —
// Shift+Enter is always a newline, and Ctrl/Cmd+Enter always sends. Those
// are the muscle memory people arrive with, and a setting that broke either
// would be worse than no setting.
//
// Composition matters: an IME (Chinese, Japanese, Korean, and predictive
// keyboards on Android) uses Enter to accept a candidate word. Sending on
// that would cut a sentence in half mid-word, so a keystroke during
// composition is never a send.
if (input) input.addEventListener('keydown', function (e) {
if (e.key !== 'Enter') return;
if (e.isComposing || e.keyCode === 229) return;
if ((e.ctrlKey || e.metaKey)) { e.preventDefault(); onAsk(e); return; }
if (e.shiftKey || e.altKey) return; // newline, always
if (!enterSends()) return; // newline, by preference
e.preventDefault();
onAsk(e);
});
var enterToggle = document.getElementById('assistant-enter-sends');
if (enterToggle) {
enterToggle.checked = enterSends();
enterToggle.addEventListener('change', function () {
setEnterSends(enterToggle.checked);
applyEnterHint();
});
}
applyEnterHint();
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...');
// Retrieval finishes before the stream opens — deliberately, so a bad
// request still returns an error rather than a stream — which means this
// line is the only thing the reader has during the slowest part of the
// wait. It should name what is happening, not describe the software.
var loading = appendLoadingMessage('Searching the clinical library', 'Looking for sources that answer this…');
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 streamStatus = '';
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;
renderStreamingInto(bubble, partial, streamSources);
// The status used to vanish the instant the first token landed, which is
// the moment it becomes most useful: the answer is arriving *and* the
// assistant is still working — drawing a figure, completing a cut-off
// reply. It now sits above the partial answer until the answer is done.
showStreamStatus(bubble, streamStatus);
var wrap = document.getElementById('assistant-messages');
if (wrap) wrap.scrollTop = wrap.scrollHeight;
}
function handleEvent(type, data) {
if (type === 'status') {
streamStatus = data.message || 'Working...';
updateLoadingMessage(loading, streamStatus);
if (bubble) showStreamStatus(bubble, streamStatus);
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;
// Renumber here, where the whole answer is finally known. It cannot be
// done while streaming: the order is the order of first citation, and a
// citation that has not arrived yet cannot take its place — numbers would
// shuffle under the reader mid-sentence.
var ordered = orderSourcesByCitation(
finalData.answer || finalData.markdown || '',
finalData.sources || finalData.citations || streamSources);
lastAnswer = ordered.text;
lastSources = ordered.sources;
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');
}
// ---- streaming render -----------------------------------------------------
//
// Markdown only makes sense once a block is finished. Half a table is a row of
// pipes; half a fence is a stray ```. Re-parsing the whole partial answer every
// frame therefore flashed between a broken parse and the real thing, and the
// old answer to that was to give up on markdown entirely past a size or a
// pipe-row count and show raw text in a <pre> — the black monospace block.
//
// Instead: split the text at the last finished block, render everything before
// it as markdown once and *append* it, and keep only the unfinished tail as
// plain text. Settled content is never re-parsed and never re-rendered, so a
// diagram or chart that has already drawn is not thrown away on the next
// token, and the tail is the only thing that changes each frame.
//
// Streaming is a preview. The 'done' handler still renders the whole answer
// from scratch through fillMessageBubble, so anything this shows mid-stream —
// a loose list briefly split in two, a reference link not yet defined — is
// settled correctly by the final pass. That is what makes appending safe.
function beginStreamingRender(bubble) {
bubble.innerHTML = '';
var state = {
settled: document.createElement('div'),
tail: document.createElement('div'),
consumed: 0
};
state.settled.className = 'assistant-stream-settled';
state.tail.className = 'assistant-streaming-text';
bubble.appendChild(state.settled);
bubble.appendChild(state.tail);
bubble.assistantStreamState = state;
return state;
}
/**
* A shimmering line above the partial answer saying what is still happening.
*
* Its own node at the top of the bubble rather than part of the streamed
* content, so it can be replaced on every status without disturbing a word of
* the answer, and removed at the end without leaving a gap. The final render
* rebuilds the bubble, which takes it with it.
*/
function showStreamStatus(bubble, message) {
var strip = bubble.querySelector('.assistant-stream-status');
if (!message) { if (strip) strip.remove(); return; }
if (!strip) {
strip = document.createElement('div');
strip.className = 'assistant-stream-status';
strip.setAttribute('role', 'status');
bubble.insertBefore(strip, bubble.firstChild);
}
strip.textContent = message;
}
function renderStreamingInto(bubble, text, sources) {
if (!text) {
bubble.assistantStreamState = null;
bubble.innerHTML = '<p class="assistant-muted">Generating answer...</p>';
return;
}
var state = bubble.assistantStreamState || beginStreamingRender(bubble);
var cut = settledMarkdownLength(text, state.consumed);
if (cut > state.consumed) {
var holder = document.createElement('div');
holder.innerHTML = renderAssistantBubbleHtml(text.slice(state.consumed, cut), sources, false);
state.consumed = cut;
while (holder.firstChild) state.settled.appendChild(holder.firstChild);
// Over the whole settled subtree, not just the new nodes: a newly appended
// node may itself be the diagram, which querySelectorAll would skip. Blocks
// already drawn are marked and skipped inside renderEmbeddedBlocks.
renderEmbeddedBlocks(state.settled);
}
var tail = text.slice(state.consumed);
var growing = partialTableMarkdown(tail);
if (growing) {
// A table is the one block worth rendering before it is finished, and
// the one the blank-line rule serves worst: a table contains no blank
// lines, so the whole of it stays raw until the line *after* it arrives,
// and then snaps into place. That is the markdown flash, on exactly the
// content where it is most obvious.
//
// Re-rendered each frame rather than appended: the rows that arrive next
// have no header of their own, so they cannot be parsed as a separate
// chunk. The tail is small, so re-parsing it is cheap.
state.tail.className = 'assistant-stream-partial';
state.tail.innerHTML = renderAssistantBubbleHtml(growing, sources, false);
renderEmbeddedBlocks(state.tail);
state.tail.hidden = false;
} else {
state.tail.className = 'assistant-streaming-text';
state.tail.textContent = tail;
state.tail.hidden = !tail;
}
}
var TABLE_ROW = /^\s*\|.*\|\s*$/;
var TABLE_RULE = /^\s*\|[\s:|-]*-{2,}[\s:|-]*\|\s*$/;
/**
* The part of an unfinished tail that is already a readable table, or ''.
*
* A markdown table is parseable from the moment it has a header row, the
* |---| rule beneath it, and one body row — every complete row after that
* only adds to it. So the answer is the tail up to its last *complete* row: a
* half-typed row is left out and appears a frame later, which is what makes
* the table grow a row at a time instead of arriving all at once.
*/
function partialTableMarkdown(tail) {
var lines = String(tail).split('\n');
var rule = -1;
for (var i = 1; i < lines.length; i++) {
if (TABLE_RULE.test(lines[i]) && TABLE_ROW.test(lines[i - 1])) { rule = i; break; }
}
if (rule === -1) return ''; // no header and rule yet
var last = -1;
for (var j = rule + 1; j < lines.length; j++) {
if (TABLE_ROW.test(lines[j])) last = j;
}
if (last === -1) return ''; // a rule but no body row: nothing to show yet
return lines.slice(0, last + 1).join('\n');
}
/**
* How much of `text` is finished markdown, as a length.
*
* A blank line outside a code fence ends a block. Two exceptions, both of
* them blank lines a block owns rather than is separated by: the gap between
* the items of a loose list, where cutting renders one list as two each
* restarting at 1, and a blank line within an indented code block. When
* nothing qualifies — a single long paragraph, a fence opened on the first
* line — this returns `from` and everything stays in the tail, which is both
* the old behaviour and correct.
*/
function settledMarkdownLength(text, from) {
var fence = null;
var offset = 0;
var best = from;
var lines = String(text).split('\n');
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
var opener = line.match(/^\s{0,3}(```+|~~~+)/);
if (fence) {
if (opener && opener[1][0] === fence[0] && opener[1].length >= fence.length) fence = null;
} else if (opener) {
fence = opener[1];
} else if (!line.trim()) {
// Past the blank line, so the settled chunk ends with the break and the
// tail starts on real content.
var cut = offset + line.length + 1;
if (cut > from && !continuesBlock(lines, i)) best = cut;
}
offset += line.length + 1; // the \n that split() removed
}
return best;
}
/**
* Whether the blank line at `i` sits inside one block rather than between two.
*
* It takes both sides to tell: a list item *after* the blank only means a list
* is being split if there was already one before it. Looking forward alone
* rejects the perfectly good boundary between a sentence and the list it
* introduces, which is most of what this assistant writes.
*/
function continuesBlock(lines, i) {
var ITEM = /^(\s{4,}|\s*([-*+]|\d+[.)])\s)/;
var next = '';
for (var f = i + 1; f < lines.length; f++) { if (lines[f].trim()) { next = lines[f]; break; } }
if (!next || !ITEM.test(next)) return false;
var prev = '';
for (var b = i - 1; b >= 0; b--) { if (lines[b].trim()) { prev = lines[b]; break; } }
return ITEM.test(prev);
}
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) {
// The final render replaces everything, so the streaming state's nodes are
// now detached; leaving it set would have a later frame append into nothing.
bubble.assistantStreamState = null;
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');
});
}
// Mermaid diagrams and Chart.js canvases, drawn once each.
//
// Both are expensive and both are claimed: mermaid.render is async and
// replaces the element's contents, and a second Chart on the same canvas
// throws "Canvas is already in use". Streaming calls this again every time a
// block settles, so each element is marked when it is taken and skipped
// afterwards — otherwise a diagram near the top of a long answer would be torn
// down and redrawn on every block that followed it.
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]:not([data-drawn])').forEach(function (el) {
// Marked before the await, not after: two calls a frame apart would both
// get past an await-side check and race to render the same element.
el.setAttribute('data-drawn', '1');
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]:not([data-drawn])').forEach(function (canvas) {
if (!window.Chart) return; // unmarked, so it draws if Chart.js arrives later
canvas.setAttribute('data-drawn', '1');
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);
}