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