// ============================================================
// CLINICAL ASSISTANT — evidence-grounded chat UI.
// Backend contract intentionally uses /api/clinical-assistant/* so the
// server can call native MCP directly without routing through mcpo.
// ============================================================
import { EMPTY_PROMPT_SETS } from './assistant/data.js';
import { escapeAttr, escapeHtml, renderAssistantMarkdown, renderCitationLinks, safeImageUrl, wrapTables } from './assistant/citations.js';
import { renderSourcesList } from './assistant/sources.js';
import { createAssistantExporter } from './assistant/export.js';
import { createAssistantImageStore } from './assistant/images.js';
import { renderImageJobs, imageJson } from './generatedImages.js';
import { captureSharingOwner, assertSharingOwner, validSharingOwner } from './assistant/sharing.js';
import {
deleteSavedAssistantChat,
fetchAssistantChat,
fetchAssistantExamples,
fetchAssistantStatus,
fetchSavedAssistantChat,
fetchSavedAssistantChats,
openAssistantStream,
startAssistantImageJob,
saveAssistantChat,
translateAssistantMessage,
assistantAttachmentLimits,
assistantAttachmentPayload
, fetchAssistantImageJob, fetchAssistantImageJobs, renameSavedAssistantChat } from './assistant/api.js';
var initialized = false;
var messages = [];
var attachments = []; // Input-only images: ride the outgoing question, then persist with the sent message.
var lastAnswer = '';
var lastSources = [];
var mermaidReady = false;
var dynamicExamples = [];
var lastGeneratedImageSrc = '';
var generatedImageJobs = [];
var markdownRenderer = null;
var assistantBusy = false;
var selectedChatModel = '';
var selectedImageModel = '';
var statusChoices = { allowedChatModels: [], allowedImageModels: [], chatModel: '', imageModel: '' };
var activeAssistantRequest = null;
var conversationChars = null;
var 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 '';
}).join('');
if (!pairs.length) list.innerHTML = 'No languages available for this provider.';
}
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 = '' +
'' + escapeHtml(label ? label.textContent : name) + '';
// 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...');
var loading = appendLoadingMessage('Looking up sources', 'Retrieving and synthesizing references...');
request.loading = loading;
request.accept = function() {
if (request.accepted || request.cancelled) return;
request.accepted = true;
if (!request.regenerateMode) {
var row = appendMessage('user', text, undefined, undefined, undefined, {
attachments: attachments.map(function(a) { return { dataBase64: a.dataBase64, mimeType: a.mimeType, name: a.name || '' }; })
});
if (row && loading && loading.parentNode) loading.parentNode.insertBefore(row, loading);
}
if (input) input.value = '';
// Attached images are input-only until sent, then persist with the message.
attachments = [];
renderAttachments();
exporter.invalidate();
scheduleAutosave();
updateConversationBudget();
};
var payload = {
message: text,
idempotencyKey: crypto.randomUUID(),
history: history,
includeContext: true // clinical retrieval is always on — it is the purpose of the app
};
if (selectedChatModel) payload.chatModel = selectedChatModel;
if (selectedImageModel) payload.imageModel = selectedImageModel;
// Images ride the outgoing clinical question for inference; once sent they persist with the saved chat.
if (attachments.length) payload.images = assistantAttachmentPayload(attachments);
return streamAssistantResponse(payload, loading, request)
.catch(function (err) {
if (request.cancelled) return;
setBusy(false, 'Error', true);
if (loading) loading.remove(); // Errors are not invented assistant turns.
if (err.budget) conversationChars = validConversationLimit(err.budget.limit) ? err.budget.limit : null;
updateConversationBudget();
if (typeof showToast === 'function') showToast(err.message, 'error');
})
.finally(function () {
if (activeAssistantRequest === request) activeAssistantRequest = null;
});
}
function createAssistantRequest() {
var controller = typeof AbortController === 'function' ? new AbortController() : null;
return {
cancelled: false,
signal: controller ? controller.signal : undefined,
abort: function () {
this.cancelled = true;
if (controller) controller.abort();
}
};
}
async function streamAssistantResponse(payload, loading, request) {
var response = await openAssistantStream(payload, { signal: request ? request.signal : undefined });
if (!response.ok || !response.body) {
var fallback = await response.json().catch(function () { return {}; });
throw Object.assign(new Error(fallback.error || ('Request failed (' + response.status + ')')), { code: fallback.code, budget: fallback.budget });
}
if (request && request.accept) request.accept();
var partial = '';
var streamSources = [];
var finalData = null;
var lastRender = 0;
var bubble = loading ? loading.querySelector('.assistant-bubble') : null;
var decoder = new TextDecoder();
var buffer = '';
function renderProvisional(force) {
var now = Date.now();
if (!force && now - lastRender < 180) return;
lastRender = now;
if (!bubble) return;
loading.classList.remove('assistant-loading-msg');
bubble.classList.remove('assistant-thinking');
bubble.assistantSources = streamSources;
renderStreamingInto(bubble, partial, streamSources);
var wrap = document.getElementById('assistant-messages');
if (wrap) wrap.scrollTop = wrap.scrollHeight;
}
function handleEvent(type, data) {
if (type === 'status') {
updateLoadingMessage(loading, data.message || 'Working...');
return;
}
if (type === 'sources') {
streamSources = data.sources || [];
renderSources(streamSources);
return;
}
if (type === 'token') {
partial += data.token || '';
renderProvisional(false);
return;
}
if (type === 'done') {
finalData = data || {};
return;
}
if (type === 'error') throw Object.assign(new Error(data.error || 'Assistant stream failed'), { code: data.code, budget: data.budget });
}
var reader = response.body.getReader();
while (true) {
if (request && request.cancelled) return;
var chunk = await reader.read();
if (chunk.done) break;
buffer += decoder.decode(chunk.value, { stream: true });
var parts = buffer.split('\n\n');
buffer = parts.pop() || '';
parts.forEach(function (part) {
var parsed = parseSseEvent(part);
if (parsed) handleEvent(parsed.type, parsed.data);
});
}
if (buffer.trim()) {
var tail = parseSseEvent(buffer);
if (tail) handleEvent(tail.type, tail.data);
}
if (!finalData) {
updateLoadingMessage(loading, 'Stream ended early. Retrying without streaming...');
finalData = await fetchAssistantFallback(payload, request);
}
if (request && request.cancelled) return;
lastAnswer = finalData.answer || finalData.markdown || '';
lastSources = finalData.sources || finalData.citations || streamSources;
replaceLoadingMessage(loading, lastAnswer, lastSources, finalData.suggestions || []);
attachImageJobs(loading, messages[messages.length - 1], finalData.imageJobs || []);
renderSources(lastSources);
// Last, not first. setBusy(false) announces assistant-answer-done, and
// firing it before lastAnswer was assigned meant every listener — voice
// mode among them — was handed the *previous* answer. It now fires once the
// answer exists both in that variable and on the page.
setBusy(false, 'Ready');
}
// ---- 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
— 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('p'),
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;
}
function renderStreamingInto(bubble, text, sources) {
if (!text) {
bubble.assistantStreamState = null;
bubble.innerHTML = '
Generating answer...
';
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);
state.tail.textContent = tail;
state.tail.hidden = !tail;
}
/**
* 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 = '
';
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 = '' +
'' +
'';
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 += '
' + escapeHtml(options.notice) + '
';
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 '
' + escapeHtml(String(md || '')) + '
' +
'
Formatting unavailable; retained text is shown unchanged.
' +
(opts.notice ? '
' + escapeHtml(opts.notice) + '
' : '');
}
}
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 = '