1720 lines
78 KiB
JavaScript
1720 lines
78 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, safeImageUrl } 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
|
|
} 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 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 translateLanguagesAvailable = null; // { libretranslate: [...], deepl: [...] }
|
|
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);
|
|
});
|
|
}
|
|
|
|
function renderTranslateLanguages(pop, row) {
|
|
var list = pop.querySelector('.assistant-translate-langs');
|
|
if (!list) return;
|
|
var available = translateLanguagesAvailable;
|
|
var pairs = TRANSLATE_LANGUAGES.filter(function(pair) {
|
|
if (!available) return true;
|
|
var codes = available[translateProvider] || available.libretranslate || [];
|
|
return codes.indexOf(pair[0]) !== -1;
|
|
});
|
|
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();
|
|
loadStatus();
|
|
loadExamples();
|
|
}
|
|
|
|
function bindEvents() {
|
|
var form = document.getElementById('assistant-form');
|
|
var clearBtn = document.getElementById('btn-assistant-clear');
|
|
var cancelBtn = document.getElementById('btn-assistant-cancel');
|
|
var exportBtn = document.getElementById('btn-assistant-export-pdf');
|
|
var imageBtn = document.getElementById('btn-assistant-image');
|
|
var imageClearBtn = document.getElementById('btn-assistant-image-clear');
|
|
var attachInput = document.getElementById('assistant-attach-input');
|
|
var input = document.getElementById('assistant-input');
|
|
var goBackBtn = document.getElementById('btn-assistant-goback');
|
|
var chatViewBtn = document.getElementById('btn-assistant-chat-view');
|
|
var learningViewBtn = document.getElementById('btn-assistant-learning-view');
|
|
|
|
if (form) form.addEventListener('submit', onAsk);
|
|
if (clearBtn) clearBtn.addEventListener('click', clearConversation);
|
|
document.getElementById('btn-assistant-download-chat').addEventListener('click', downloadTranscript);
|
|
if (goBackBtn) goBackBtn.addEventListener('click', goBackToMainMenu);
|
|
if (chatViewBtn) chatViewBtn.addEventListener('click', openChatView);
|
|
if (learningViewBtn) learningViewBtn.addEventListener('click', openLearningView);
|
|
if (input) input.addEventListener('input', updateConversationBudget);
|
|
if (cancelBtn) cancelBtn.addEventListener('click', cancelAssistantSearch);
|
|
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 (imageBtn) imageBtn.addEventListener('click', generateImage);
|
|
if (imageClearBtn) imageClearBtn.addEventListener('click', clearGeneratedImage);
|
|
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();
|
|
}
|
|
|
|
function loadStatus() {
|
|
return fetchAssistantStatus()
|
|
.then(function (data) {
|
|
var label = document.getElementById('assistant-model-label');
|
|
if (label && data.success) {
|
|
label.textContent = data.chatModel ? ('Chat: ' + data.chatModel) : 'Admin model';
|
|
}
|
|
conversationChars = data.success && validConversationLimit(data.conversationChars) ? data.conversationChars : null;
|
|
if (data.success && ['libretranslate', 'deepl'].includes(data.translateProvider)) translateProvider = data.translateProvider;
|
|
updateConversationBudget();
|
|
})
|
|
.catch(function () {
|
|
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 includeContext = document.getElementById('assistant-include-context');
|
|
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: !includeContext || includeContext.checked
|
|
};
|
|
// 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);
|
|
if (finalData.model) {
|
|
var label = document.getElementById('assistant-model-label');
|
|
if (label) label.textContent = 'Chat: ' + finalData.model;
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
function renderSources(sources) {
|
|
var wrap = document.getElementById('assistant-sources');
|
|
if (!wrap) return;
|
|
wrap.innerHTML = renderSourcesList(sources);
|
|
}
|
|
|
|
// ── OWUI-style table actions (Copy raw / Export CSV) ────────────────
|
|
function wireTableActions(root) {
|
|
if (!root || !root.querySelectorAll) return;
|
|
root.querySelectorAll('.assistant-table-scroll').forEach(function(wrapper) {
|
|
if (wrapper.getAttribute('data-assistant-table-wired') === 'true') return;
|
|
wrapper.setAttribute('data-assistant-table-wired', 'true');
|
|
wrapper.addEventListener('click', function(event) {
|
|
if (event.target.closest('[data-assistant-table-copy]')) {
|
|
var markdown = tableToMarkdown(wrapper.querySelector('table'));
|
|
copyText(markdown, 'Table copied', 'Copy failed');
|
|
return;
|
|
}
|
|
if (event.target.closest('[data-assistant-table-csv]')) {
|
|
exportTableCsv(wrapper.querySelector('table'));
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function tableToMarkdown(table) {
|
|
if (!table) return '';
|
|
var lines = [];
|
|
var rows = table.querySelectorAll('tr');
|
|
rows.forEach(function(row, rowIndex) {
|
|
var cells = row.querySelectorAll('th, td');
|
|
if (rowIndex === 1 && row.parentNode && row.parentNode.tagName === 'THEAD') return; // markdown tables carry one header row
|
|
if (rowIndex === 0) {
|
|
lines.push('| ' + Array.from(cells).map(cellToText).join(' | ') + ' |');
|
|
lines.push('| ' + Array.from(cells).map(function(cell) {
|
|
var align = cell.getAttribute('align') || (cell.style && cell.style.textAlign) || '';
|
|
if (align === 'center') return ':---:';
|
|
if (align === 'right') return '---:';
|
|
return '---';
|
|
}).join(' | ') + ' |');
|
|
return;
|
|
}
|
|
lines.push('| ' + Array.from(cells).map(cellToText).join(' | ') + ' |');
|
|
});
|
|
return lines.join('\n');
|
|
}
|
|
|
|
function cellToText(cell) {
|
|
return String(cell.textContent || '').replace(/\\/g, '\\\\').replace(/\|/g, '\\|').replace(/\r?\n/g, ' ').trim();
|
|
}
|
|
|
|
function exportTableCsv(table) {
|
|
if (!table) return;
|
|
var lines = [];
|
|
table.querySelectorAll('tr').forEach(function(row) {
|
|
lines.push(Array.from(row.querySelectorAll('th, td')).map(function(cell) {
|
|
var text = String(cell.textContent || '').trim();
|
|
return /[,"\r\n]/.test(text) ? '"' + text.replace(/"/g, '""') + '"' : text;
|
|
}).join(','));
|
|
});
|
|
var csv = '\uFEFF' + lines.join('\r\n');
|
|
try {
|
|
var blob = new Blob([csv], { type: 'text/csv' });
|
|
var objectUrl = URL.createObjectURL(blob);
|
|
var link = document.createElement('a');
|
|
link.href = objectUrl;
|
|
link.download = 'table.csv';
|
|
link.rel = 'noopener';
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
link.remove();
|
|
setTimeout(function() { URL.revokeObjectURL(objectUrl); }, 60000);
|
|
} catch (e) {
|
|
if (typeof showToast === 'function') showToast('CSV export failed', 'error');
|
|
}
|
|
}
|
|
|
|
// ── 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) {
|
|
wireTableActions(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 generateImage(promptOverride) {
|
|
var promptEl = document.getElementById('assistant-image-prompt');
|
|
var out = document.getElementById('assistant-visual-output');
|
|
var button = document.getElementById('btn-assistant-image');
|
|
var prompt = typeof promptOverride === 'string' ? promptOverride : (promptEl ? promptEl.value : '');
|
|
if (!prompt.trim() && lastAnswer) prompt = 'Create a pediatric teaching visual from this conversation.';
|
|
if (!prompt.trim() || button.disabled) return;
|
|
var owner;
|
|
try { owner = captureSharingOwner(); } catch (_) { return; }
|
|
var selection = generatedImageJobs;
|
|
button.disabled = true;
|
|
startAssistantImageJob(prompt, messages.map(function(m) { return { role: m.role, content: m.content }; })).then(function(data) {
|
|
assertSharingOwner(owner);
|
|
if (generatedImageJobs !== selection) return;
|
|
if (!data.success) throw new Error(data.error || 'Image generation failed');
|
|
generatedImageJobs = [{ jobId: data.jobId }];
|
|
lastGeneratedImageSrc = '';
|
|
exporter.invalidate();
|
|
out.replaceChildren();
|
|
renderImageJobs(out, generatedImageJobs, 'clinical_assistant', function(card, image) {
|
|
if (generatedImageJobs[0]?.jobId !== image.jobId) return;
|
|
lastGeneratedImageSrc = image.imageUrl;
|
|
card.insertAdjacentHTML('beforeend', imageStore.renderGeneratedImage(image.imageUrl, 'Generated teaching visual', image.downloadUrl));
|
|
exporter.invalidate();
|
|
});
|
|
}).catch(function(error) { if (!validSharingOwner(owner) || error.name === 'AbortError') return; if (typeof showToast === 'function') showToast(error.message, 'error'); })
|
|
.finally(function() { if (validSharingOwner(owner)) button.disabled = false; });
|
|
}
|
|
|
|
function onAssistantDocumentClick(e) {
|
|
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]');
|
|
var providerButton = picker && picker.querySelector('[data-assistant-translate-provider].active');
|
|
requestMessageTranslation(picker.assistantMessageRow, translateLang.getAttribute('data-assistant-translate-lang'), providerButton ? providerButton.getAttribute('data-assistant-translate-provider') : translateProvider);
|
|
closeTranslatePicker();
|
|
return;
|
|
}
|
|
var translateProviderBtn = e.target.closest('[data-assistant-translate-provider]');
|
|
if (translateProviderBtn) {
|
|
var pop = translateProviderBtn.closest('[data-assistant-translate-pop]');
|
|
pop.querySelectorAll('[data-assistant-translate-provider]').forEach(function(b) { b.classList.remove('active'); });
|
|
translateProviderBtn.classList.add('active');
|
|
return;
|
|
}
|
|
var showOriginal = e.target.closest('[data-assistant-msg-show-original]');
|
|
if (showOriginal) {
|
|
var translatedBubble = showOriginal.closest('.assistant-bubble');
|
|
if (translatedBubble && translatedBubble.assistantOriginalHtml) {
|
|
translatedBubble.innerHTML = translatedBubble.assistantOriginalHtml;
|
|
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 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() {
|
|
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) {
|
|
if (typeof transcribeAudio === 'function' && blob && blob.size > 100) {
|
|
transcribeAudio(blob).then(function(data) {
|
|
if (data && data.success && data.text) {
|
|
var input = document.getElementById('assistant-input');
|
|
if (input) { input.value = data.text; updateConversationBudget(); }
|
|
}
|
|
}).catch(function() {});
|
|
}
|
|
}).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();
|
|
}
|
|
|
|
function conversationStartListening() {
|
|
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();
|
|
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; }
|
|
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();
|
|
}
|
|
|
|
// ── Patient take home: plain-language summary + copy/export/email ────
|
|
var takehomeBusy = false;
|
|
var takehomeText = '';
|
|
|
|
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 = '';
|
|
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>' +
|
|
'<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 = renderMarkdown(takehomeText, [], {});
|
|
result.classList.remove('hidden');
|
|
}
|
|
if (actions) actions.classList.remove('hidden');
|
|
}).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 closePatientTakehomeModal() {
|
|
var modal = document.getElementById('assistant-takehome-modal');
|
|
if (modal) modal.remove();
|
|
}
|
|
|
|
document.addEventListener('click', function(event) {
|
|
if (event.target.closest('[data-assistant-takehome-copy]')) {
|
|
copyText(takehomeText, 'Take home copied', 'Copy failed');
|
|
return;
|
|
}
|
|
if (event.target.closest('[data-assistant-takehome-export]')) {
|
|
if (!takehomeText) return;
|
|
var blob = new Blob([takehomeText], { type: 'text/plain;charset=utf-8' });
|
|
var url = URL.createObjectURL(blob);
|
|
var a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = 'patient-take-home.txt';
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
a.remove();
|
|
setTimeout(function() { URL.revokeObjectURL(url); }, 15000);
|
|
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: takehomeText })
|
|
}).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', '');
|
|
pop.innerHTML = '<div class="assistant-translate-providers">' +
|
|
'<button type="button" data-assistant-translate-provider="libretranslate" class="' + (translateProvider === 'libretranslate' ? 'active' : '') + '">Local</button>' +
|
|
'<button type="button" data-assistant-translate-provider="deepl" class="' + (translateProvider === 'deepl' ? 'active' : '') + '">DeepL</button>' +
|
|
'</div>' +
|
|
'<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 requestMessageTranslation(row, target, provider) {
|
|
var bubble = row && row.querySelector('.assistant-bubble');
|
|
var content = bubble && bubble.assistantRawContent !== undefined ? bubble.assistantRawContent : '';
|
|
if (!bubble || !String(content).trim()) return;
|
|
if (!bubble.assistantOriginalHtml) bubble.assistantOriginalHtml = bubble.innerHTML;
|
|
translateAssistantMessage(content, target, provider)
|
|
.then(function(data) {
|
|
if (!data.success) throw new Error(data.error || 'Translation failed');
|
|
bubble.innerHTML = '<p class="assistant-translated-text">' + escapeHtml(data.translated) + '</p>' +
|
|
'<button type="button" class="btn-sm btn-ghost" data-assistant-msg-show-original><i class="fas fa-undo"></i> Show original</button>';
|
|
})
|
|
.catch(function(err) {
|
|
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();
|
|
}
|
|
|
|
// ── Workspace navigation: Go back, chat / learning view switch ─────
|
|
function goBackToMainMenu() {
|
|
document.body.classList.remove('assistant-workspace');
|
|
if (typeof window.activateTab === 'function') {
|
|
window.activateTab(lastNonAssistantTab || 'encounter');
|
|
return;
|
|
}
|
|
if (typeof document !== 'undefined' && document.dispatchEvent) document.dispatchEvent(new window.CustomEvent('tabChanged', { detail: { tab: 'encounter' } }));
|
|
}
|
|
|
|
function openChatView() {
|
|
var chat = document.getElementById('assistant-chat-view');
|
|
var learning = document.getElementById('assistant-learning-view');
|
|
var chatBtn = document.getElementById('btn-assistant-chat-view');
|
|
var learningBtn = document.getElementById('btn-assistant-learning-view');
|
|
if (chat) chat.hidden = false;
|
|
if (learning) learning.hidden = true;
|
|
if (chatBtn) chatBtn.classList.add('active');
|
|
if (learningBtn) learningBtn.classList.remove('active');
|
|
}
|
|
|
|
function openLearningView() {
|
|
var chat = document.getElementById('assistant-chat-view');
|
|
var learning = document.getElementById('assistant-learning-view');
|
|
var root = document.getElementById('assistant-learning-root');
|
|
var chatBtn = document.getElementById('btn-assistant-chat-view');
|
|
var learningBtn = document.getElementById('btn-assistant-learning-view');
|
|
if (!learning || !root) return;
|
|
if (chat) chat.hidden = true;
|
|
learning.hidden = false;
|
|
if (chatBtn) chatBtn.classList.remove('active');
|
|
if (learningBtn) learningBtn.classList.add('active');
|
|
var load = learningViewHtml ? Promise.resolve(learningViewHtml) :
|
|
(typeof fetch === 'function' ? fetch('/components/learning.html').then(function(response) {
|
|
if (!response.ok) throw new Error('Learning Hub unavailable');
|
|
return response.text();
|
|
}).then(function(html) { learningViewHtml = html; return html; }) : Promise.reject(new Error('Learning Hub unavailable')));
|
|
load.then(function(html) {
|
|
if (!root.hasChildNodes()) root.innerHTML = html;
|
|
document.dispatchEvent(new window.CustomEvent('tabChanged', { detail: { tab: 'learning' } }));
|
|
}).catch(function(err) {
|
|
openChatView();
|
|
if (typeof showToast === 'function') showToast(err.message || 'Learning Hub unavailable', 'error');
|
|
});
|
|
}
|
|
|
|
// ── 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;
|
|
return saveAssistantChat(autosavePayload())
|
|
.then(function(data) {
|
|
if (!data.success) throw new Error(data.error || 'Autosave failed');
|
|
if (data.id) currentChatId = data.id;
|
|
autosaveErrorShown = false;
|
|
loadSavedChats();
|
|
})
|
|
.catch(function(err) {
|
|
// Surface once per change; never block or retry-loop the chat flow.
|
|
if (!autosaveErrorShown) {
|
|
autosaveErrorShown = true;
|
|
if (typeof showToast === 'function') showToast(err && err.message ? err.message : 'Autosave failed', 'error');
|
|
}
|
|
});
|
|
}
|
|
|
|
function clearGeneratedImage() {
|
|
lastGeneratedImageSrc = '';
|
|
generatedImageJobs = [];
|
|
imageStore.clear();
|
|
var out = document.getElementById('assistant-visual-output');
|
|
if (out) out.innerHTML = '';
|
|
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;
|
|
if (event && messages.length) {
|
|
// Use the site's standard inline dialog instead of the native confirm().
|
|
if (typeof showConfirm === 'function') {
|
|
showConfirm('Start a new chat? Your current chat is already saved automatically.', function() {
|
|
performClearConversation();
|
|
});
|
|
return;
|
|
}
|
|
if (!window.confirm('Start a new chat? Your current chat is already saved automatically.')) return;
|
|
}
|
|
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 () {
|
|
var el = document.getElementById('assistant-input');
|
|
if (el) {
|
|
el.value = btn.getAttribute('data-assistant-example') || '';
|
|
updateConversationBudget();
|
|
el.focus();
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function exportAnswerPdf() {
|
|
exporter.exportAnswerPdf({
|
|
messages: messages,
|
|
lastAnswer: lastAnswer,
|
|
lastSources: lastSources,
|
|
lastGeneratedImageSrc: lastGeneratedImageSrc,
|
|
generatedImageJobs: generatedImageJobs
|
|
});
|
|
}
|
|
|
|
function loadSavedChats() {
|
|
fetchSavedAssistantChats()
|
|
.then(function (data) { if (data.success) renderSavedChats(data.chats || []); })
|
|
.catch(function () {});
|
|
}
|
|
|
|
function renderSavedChats(chats) {
|
|
var wrap = document.getElementById('assistant-saved-chats');
|
|
if (!wrap) return;
|
|
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.
|
|
wrap.innerHTML = chats.map(function (chat) {
|
|
return '<button type="button" class="assistant-saved-chat" data-assistant-load-chat="' + escapeAttr(chat.id) + '" aria-label="Open saved chat">' +
|
|
'<span class="assistant-saved-chat-title">' + escapeHtml(chat.title || 'Saved chat') + '</span>' +
|
|
'<span class="assistant-saved-chat-meta">' + escapeHtml(formatSavedDate(chat.updated_at || chat.created_at)) + '</span>' +
|
|
'</button>';
|
|
}).join('');
|
|
}
|
|
|
|
function loadSavedChat(id) {
|
|
if (assistantBusy) return;
|
|
setBusy(true, 'Loading chat...');
|
|
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 || {});
|
|
if (typeof showToast === 'function') showToast('Loaded saved chat', 'success');
|
|
})
|
|
.catch(function (err) { if (typeof showToast === 'function') showToast(err.message, 'error'); })
|
|
.finally(function() { setBusy(false, 'Ready'); });
|
|
}
|
|
|
|
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 = generatedImageJobs.length ? '' : String(payload.generatedImage || '');
|
|
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);
|
|
var out = document.getElementById('assistant-visual-output');
|
|
if (out) {
|
|
out.innerHTML = lastGeneratedImageSrc ? imageStore.renderGeneratedImage(lastGeneratedImageSrc, 'Generated clinical visual') : '';
|
|
if (generatedImageJobs.length) renderImageJobs(out, generatedImageJobs, 'clinical_assistant', function(card, data) {
|
|
if (generatedImageJobs[0]?.jobId !== data.jobId) return;
|
|
lastGeneratedImageSrc = data.imageUrl;
|
|
card.insertAdjacentHTML('beforeend', imageStore.renderGeneratedImage(data.imageUrl, 'Generated teaching visual', data.downloadUrl));
|
|
exporter.invalidate();
|
|
});
|
|
}
|
|
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>' : '');
|
|
row.appendChild(actions);
|
|
wrap.scrollTop = wrap.scrollHeight;
|
|
return row;
|
|
}
|
|
|
|
function deriveChatTitle() {
|
|
var first = messages.find(function (m) { return m.role === 'user' && m.content; });
|
|
return String(first && first.content || 'Clinical assistant chat').replace(/\s+/g, ' ').trim().slice(0, 60);
|
|
}
|
|
|
|
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 updateConversationBudget() {
|
|
var input = document.getElementById('assistant-input');
|
|
var used = conversationSize(input ? input.value : '');
|
|
var label = document.getElementById('assistant-context-budget');
|
|
if (label) label.textContent = used.toLocaleString() + (conversationChars === null ?
|
|
' conversation characters (UTF-16 code units). Limit unavailable; the server must validate each request.' :
|
|
' / ' + conversationChars.toLocaleString() + ' conversation characters (UTF-16 code units).');
|
|
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) {
|
|
try { return new Date(value).toLocaleString(); } catch (e) { return ''; }
|
|
}
|
|
|
|
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 cancel = document.getElementById('btn-assistant-cancel');
|
|
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) {
|
|
send.disabled = !!isBusy;
|
|
send.innerHTML = isBusy ? '<i class="fas fa-spinner fa-spin"></i> Searching' : '<i class="fas fa-paper-plane"></i> Ask';
|
|
}
|
|
if (cancel) {
|
|
var canCancel = !!isBusy && !!activeAssistantRequest;
|
|
if (canCancel) cancel.removeAttribute('hidden');
|
|
else cancel.setAttribute('hidden', '');
|
|
cancel.disabled = !canCancel;
|
|
cancel.style.display = canCancel ? 'inline-flex' : 'none';
|
|
}
|
|
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);
|
|
}
|