// ============================================================ // 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, linkCitationsInHtml, orderSourcesByCitation, renderAssistantMarkdown, 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...'); // 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 || []; progressSources(loading, streamSources.length); 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. // Stored as the server sent them. Ordering by first citation happens at // the render points below and nowhere else, so the text and the saved // sources keep their identity: `[7]` stays `[7]`, and source 7 stays 7. lastAnswer = finalData.answer || finalData.markdown || ''; lastSources = finalData.sources || finalData.citations || streamSources; var shown = displaySources(lastAnswer, lastSources); replaceLoadingMessage(loading, lastAnswer, shown, finalData.suggestions || []); attachImageJobs(loading, messages[messages.length - 1], finalData.imageJobs || []); renderSources(shown); // 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('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 = '

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); // The unfinished block is rendered as markdown too, re-parsed each frame. // It used to be shown as raw text until its blank line arrived, which is // the flash of asterisks and pipes the reader sees on every paragraph — // Open WebUI never shows it because it re-renders the whole message on // every token. The tail is one block, so re-parsing it costs nothing. // // A table gets its rows completed first (partialTableMarkdown): rows that // arrive next have no header of their own and cannot parse alone. A block // inside an unclosed code fence stays text: a half-written mermaid or // chart would be handed to its renderer on every frame and fail there. var growing = partialTableMarkdown(tail); var openFence = (tail.match(/```/g) || []).length % 2 === 1; // A header row with nothing under it yet is a table markdown-it would // happily draw — two lines of pipes and an empty body. Not yet. var headerOnly = !growing && TABLE_ROW.test(tail.trim().split('\n')[0] || ''); if (tail && !openFence && !headerOnly) { state.tail.className = 'assistant-stream-tail assistant-stream-partial'; state.tail.innerHTML = renderAssistantBubbleHtml(growing || tail, sources, false); renderEmbeddedBlocks(state.tail); state.tail.hidden = false; } else { state.tail.className = 'assistant-stream-tail 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; } // ── What is happening, as steps ──────────────────────────────────── // The wait used to be one line of text that changed. It is now a list the // reader can follow: each step is added when the server says it has begun // and ticked when it says it is over, so the list is a record of the real // work, not an animation on a timer. Nothing here delays the answer: the // stream is unchanged, this only draws what it already reports. var PROGRESS_STEPS = { // The server's status messages, mapped to what they mean for the list. 'Sources checked; preparing answer...': { done: ['analyze', 'search'] }, 'Generating answer...': { done: ['analyze', 'search'], active: ['write', 'Writing the answer'] } }; function progressStep(row, key, label, state) { var list = row && row.querySelector('.assistant-progress'); if (!list) return null; var item = list.querySelector('[data-step="' + key + '"]'); if (!item) { item = document.createElement('li'); item.className = 'assistant-progress-step'; item.dataset.step = key; item.innerHTML = ''; list.appendChild(item); } if (label) item.querySelector('.assistant-progress-label').textContent = label; if (state) { item.classList.toggle('is-active', state === 'active'); item.classList.toggle('is-done', state === 'done'); } return item; } function finishActiveSteps(row) { var list = row && row.querySelector('.assistant-progress'); if (!list) return; list.querySelectorAll('.assistant-progress-step.is-active').forEach(function (item) { item.classList.remove('is-active'); item.classList.add('is-done'); }); } // A status from the server. Known ones move the fixed steps; anything else // ("Looking at your image…", "Generating image…", "Completing answer...") // becomes a step of its own, and whatever was running is ticked. function updateLoadingMessage(row, message) { if (!row || !message) return; var known = PROGRESS_STEPS[message]; if (known) { (known.done || []).forEach(function (key) { progressStep(row, key, null, 'done'); }); if (known.active) { finishActiveSteps(row); progressStep(row, known.active[0], known.active[1], 'active'); } return; } finishActiveSteps(row); progressStep(row, message, message, 'active'); } // The search is over the moment the sources arrive; the count is the result. function progressSources(row, count) { progressStep(row, 'analyze', null, 'done'); progressStep(row, 'search', count > 0 ? 'Found ' + count + ' source' + (count === 1 ? '' : 's') : 'Searched the clinical library', 'done'); } 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'; // The steps are the whole message. A title line above them said the same // thing as the step in progress, with a second animation beside it. bubble.innerHTML = '
    '; row.appendChild(label); row.appendChild(bubble); // Retrieval runs before the stream opens, so the server cannot say when // reading the question ends and searching begins. Both steps start now; // the first is marked brief and the second queued, and the stylesheet // hands over from one to the other — no timer in here, so nothing to // cancel and nothing for the autosave debounce to share a clock with. // The real sources event ticks both. progressStep(row, 'analyze', 'Analyzing the question', 'active').classList.add('is-brief'); progressStep(row, 'search', 'Searching the clinical library', 'active').classList.add('is-queued'); 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 || [], { 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') { // html:true, as marked rendered before it. An answer may carry a // or a
    ; the single sanitisation boundary is what makes that safe. markdownRenderer = window.markdownit({ html: true, 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'; } // Sources as the reader should see them: ordered by first citation, with // `number` the display position and `sourceNumber` the identity. Used only // where something is rendered. What is stored — messages, lastSources, the // save payload — keeps the sources exactly as the server sent them, so a // saved chat is the same shape however many times it is opened and saved. function displaySources(text, sources) { if (!Array.isArray(sources) || !sources.length) return sources || []; return orderSourcesByCitation(text, sources, { markdownIt: getMarkdownRenderer() }).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); // data-source-number is the source's identity; the list is ordered by // first citation, so position no longer means anything. var number = Number(citation.getAttribute('data-source-number')); var source = bubble.assistantSources.filter(function (s) { return Number(s && (s.sourceNumber || s.number)) === number; })[0] || bubble.assistantSources[number - 1]; var shown = Number(citation.getAttribute('data-display-number')) || number; if (source) openSourceModal(source, shown); } 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 = ''; 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