1042 lines
45 KiB
JavaScript
1042 lines
45 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 } 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,
|
|
requestAssistantHandoff,
|
|
assistantAttachmentLimits,
|
|
assistantAttachmentPayload
|
|
} from './assistant/api.js';
|
|
var initialized = false;
|
|
var messages = [];
|
|
var attachments = []; // Input-only images: never persisted, never sent with handoff.
|
|
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 exporter = createAssistantExporter({ renderMarkdown: renderMarkdown, presentMessage: savedMessagePresentation, showToast: window.showToast });
|
|
var imageStore = createAssistantImageStore();
|
|
|
|
document.addEventListener('tabChanged', function (e) {
|
|
if (e.detail && e.detail.tab === 'assistant') initIfNeeded();
|
|
});
|
|
|
|
function initIfNeeded() {
|
|
if (initialized) return;
|
|
var root = document.getElementById('assistant-tab');
|
|
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 copyBtn = document.getElementById('btn-assistant-copy');
|
|
var saveBtn = document.getElementById('btn-assistant-save');
|
|
var saveConfirmBtn = document.getElementById('btn-assistant-save-confirm');
|
|
var saveCancelBtn = document.getElementById('btn-assistant-save-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');
|
|
|
|
if (form) form.addEventListener('submit', onAsk);
|
|
if (clearBtn) clearBtn.addEventListener('click', clearConversation);
|
|
document.getElementById('btn-assistant-download-chat').addEventListener('click', downloadTranscript);
|
|
document.getElementById('btn-assistant-handoff').addEventListener('click', requestHandoff);
|
|
document.getElementById('btn-assistant-copy-handoff').addEventListener('click', function() {
|
|
navigator.clipboard.writeText(document.getElementById('assistant-handoff-text').value).catch(function() {
|
|
if (typeof showToast === 'function') showToast('Could not copy; select the summary text instead.', 'error');
|
|
});
|
|
});
|
|
function closeHandoffModal() { setHandoff(''); }
|
|
var handoffModal = document.getElementById('assistant-handoff-modal');
|
|
if (handoffModal) {
|
|
document.getElementById('btn-assistant-handoff-close').addEventListener('click', closeHandoffModal);
|
|
document.getElementById('btn-assistant-handoff-done').addEventListener('click', closeHandoffModal);
|
|
handoffModal.addEventListener('click', function(e) { if (e.target === handoffModal) closeHandoffModal(); });
|
|
}
|
|
if (input) input.addEventListener('input', updateConversationBudget);
|
|
if (cancelBtn) cancelBtn.addEventListener('click', cancelAssistantSearch);
|
|
if (copyBtn) copyBtn.addEventListener('click', copyLastAnswer);
|
|
if (saveBtn) saveBtn.addEventListener('click', showSavePanel);
|
|
if (saveConfirmBtn) saveConfirmBtn.addEventListener('click', saveCurrentChat);
|
|
if (saveCancelBtn) saveCancelBtn.addEventListener('click', hideSavePanel);
|
|
if (exportBtn) exportBtn.addEventListener('click', exportAnswerPdf);
|
|
if (imageBtn) imageBtn.addEventListener('click', generateImage);
|
|
if (imageClearBtn) imageClearBtn.addEventListener('click', clearGeneratedImage);
|
|
if (attachInput) attachInput.addEventListener('change', onAttachFiles);
|
|
document.addEventListener('click', onAssistantDocumentClick);
|
|
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;
|
|
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. Save/download this chat, start a new chat, or explicitly request a handoff summary.', '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();
|
|
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;
|
|
var row = appendMessage('user', text);
|
|
if (row && loading && loading.parentNode) loading.parentNode.insertBefore(row, loading);
|
|
if (input) input.value = '';
|
|
// Attached images are input-only and clear on a successful send.
|
|
attachments = [];
|
|
renderAttachments();
|
|
exporter.invalidate();
|
|
updateConversationBudget();
|
|
};
|
|
|
|
var payload = {
|
|
message: text,
|
|
idempotencyKey: crypto.randomUUID(),
|
|
history: history,
|
|
includeContext: !includeContext || includeContext.checked
|
|
};
|
|
// Images ride the outgoing clinical question only, never handoff or saves.
|
|
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);
|
|
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);
|
|
var wrap = document.getElementById('assistant-messages');
|
|
if (wrap) wrap.scrollTop = wrap.scrollHeight;
|
|
messages.push({ role: 'assistant', content: content, sources: sources || [] });
|
|
updateConversationBudget();
|
|
}
|
|
|
|
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.innerHTML = role === 'assistant' ? renderAssistantBubbleHtml(content, sources, rawHtml, options) : escapeHtml(content);
|
|
if (role !== 'assistant' && options && options.notice) bubble.innerHTML += '<p role="note">' + escapeHtml(options.notice) + '</p>';
|
|
if (role === 'assistant' && suggestions && suggestions.length) bubble.appendChild(renderSuggestionButtons(suggestions));
|
|
renderEmbeddedBlocks(bubble);
|
|
}
|
|
|
|
function appendMessage(role, content, sources, suggestions, rawHtml) {
|
|
var row = appendMessageNode(role, content, sources, suggestions, rawHtml);
|
|
if (row) messages.push({ role: role, content: content, sources: role === 'assistant' ? (sources || []) : [] });
|
|
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 });
|
|
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);
|
|
}
|
|
|
|
function renderEmbeddedBlocks(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);
|
|
return; // The native anchor navigates to the matching source in the refreshed panel.
|
|
}
|
|
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 deleteBtn = e.target.closest('[data-assistant-delete-chat]');
|
|
if (deleteBtn) {
|
|
e.preventDefault();
|
|
deleteSavedChat(deleteBtn.getAttribute('data-assistant-delete-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();
|
|
}
|
|
}
|
|
|
|
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 && !window.confirm('Start a new chat? Save or download this conversation first if you want to keep it.')) return;
|
|
if (activeAssistantRequest) cancelAssistantSearch();
|
|
messages = [];
|
|
attachments = [];
|
|
renderAttachments();
|
|
lastAnswer = '';
|
|
lastSources = [];
|
|
lastGeneratedImageSrc = '';
|
|
exporter.invalidate();
|
|
var wrap = document.getElementById('assistant-messages');
|
|
if (wrap) {
|
|
wrap.innerHTML = renderEmptyState();
|
|
bindExampleButtons(wrap);
|
|
}
|
|
renderSources([]);
|
|
clearGeneratedImage();
|
|
setHandoff('');
|
|
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 copyLastAnswer() {
|
|
if (!lastAnswer) { if (typeof showToast === 'function') showToast('No answer to copy', 'error'); return; }
|
|
navigator.clipboard.writeText(lastAnswer).then(function () {
|
|
if (typeof showToast === 'function') showToast('Copied answer', 'success');
|
|
}).catch(function () {
|
|
if (typeof showToast === 'function') showToast('Copy failed', 'error');
|
|
});
|
|
}
|
|
|
|
function exportAnswerPdf() {
|
|
exporter.exportAnswerPdf({
|
|
messages: messages,
|
|
lastAnswer: lastAnswer,
|
|
lastSources: lastSources,
|
|
lastGeneratedImageSrc: lastGeneratedImageSrc,
|
|
generatedImageJobs: generatedImageJobs
|
|
});
|
|
}
|
|
|
|
function saveCurrentChat() {
|
|
if (assistantBusy) return;
|
|
if (!messages.length) { if (typeof showToast === 'function') showToast('No assistant chat to save yet', 'error'); return; }
|
|
var titleEl = document.getElementById('assistant-save-title');
|
|
var title = String(titleEl && titleEl.value || deriveChatTitle()).trim() || deriveChatTitle();
|
|
setBusy(true, 'Saving chat...');
|
|
// Sidebar image is session-only and is deliberately not saved with the chat.
|
|
return saveAssistantChat({
|
|
title: title,
|
|
messages: messages,
|
|
sources: lastSources,
|
|
lastAnswer: lastAnswer,
|
|
generatedImageJobs: generatedImageJobs
|
|
})
|
|
.then(function (data) {
|
|
setBusy(false, 'Ready');
|
|
if (!data.success) throw new Error(data.error || 'Save failed');
|
|
hideSavePanel();
|
|
if (typeof showToast === 'function') showToast('Saved chat', 'success');
|
|
loadSavedChats();
|
|
})
|
|
.catch(function (err) {
|
|
setBusy(false, 'Error', true);
|
|
if (typeof showToast === 'function') showToast(err.message, 'error');
|
|
});
|
|
}
|
|
|
|
function showSavePanel() {
|
|
if (assistantBusy) return;
|
|
if (!messages.length) { if (typeof showToast === 'function') showToast('No assistant chat to save yet', 'error'); return; }
|
|
var panel = document.getElementById('assistant-save-panel');
|
|
var titleEl = document.getElementById('assistant-save-title');
|
|
if (!panel || !titleEl) return saveCurrentChat();
|
|
titleEl.value = deriveChatTitle();
|
|
panel.hidden = false;
|
|
titleEl.focus();
|
|
titleEl.select();
|
|
}
|
|
|
|
function hideSavePanel() {
|
|
var panel = document.getElementById('assistant-save-panel');
|
|
if (panel) panel.hidden = true;
|
|
}
|
|
|
|
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">Saved chats appear here after you click Save chat.</p>';
|
|
return;
|
|
}
|
|
wrap.innerHTML = chats.map(function (chat) {
|
|
return '<div class="assistant-saved-chat">' +
|
|
'<div class="assistant-saved-chat-title">' + escapeHtml(chat.title || 'Saved chat') + '</div>' +
|
|
'<div class="assistant-saved-chat-meta">' + escapeHtml(formatSavedDate(chat.updated_at || chat.created_at)) + '</div>' +
|
|
'<div class="assistant-saved-chat-actions">' +
|
|
'<button type="button" class="btn-sm btn-ghost" data-assistant-load-chat="' + escapeAttr(chat.id) + '">Load</button>' +
|
|
'<button type="button" class="btn-sm btn-ghost" data-assistant-delete-chat="' + escapeAttr(chat.id) + '">Delete</button>' +
|
|
'</div></div>';
|
|
}).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');
|
|
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 deleteSavedChat(id) {
|
|
var btn = document.querySelector('[data-assistant-delete-chat="' + cssEscape(id) + '"]');
|
|
if (btn && btn.getAttribute('data-confirm-delete') !== 'true') {
|
|
btn.setAttribute('data-confirm-delete', 'true');
|
|
btn.textContent = 'Confirm delete';
|
|
setTimeout(function () {
|
|
if (btn.getAttribute('data-confirm-delete') === 'true') {
|
|
btn.removeAttribute('data-confirm-delete');
|
|
btn.textContent = 'Delete';
|
|
}
|
|
}, 5000);
|
|
return;
|
|
}
|
|
deleteSavedAssistantChat(id)
|
|
.then(function () { loadSavedChats(); if (typeof showToast === 'function') showToast('Deleted saved chat', 'success'); })
|
|
.catch(function () { if (typeof showToast === 'function') showToast('Delete failed', 'error'); });
|
|
}
|
|
|
|
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 || [] };
|
|
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) {
|
|
var display = savedMessagePresentation(m);
|
|
var row = appendMessageNode(m.role, display.answer, m.sources && m.sources.length ? m.sources : lastSources, null, false, display);
|
|
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();
|
|
setHandoff('');
|
|
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);
|
|
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, 80);
|
|
}
|
|
|
|
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 or explicitly request a handoff summary. A handoff also requires the existing history to fit the budget; your unsent draft is not included in a handoff.';
|
|
}
|
|
}
|
|
|
|
function downloadTranscript() {
|
|
var payload = { version: 2, title: deriveChatTitle(), messages: messages, sources: lastSources,
|
|
lastAnswer: lastAnswer, generatedImageJobs: generatedImageJobs, 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 setHandoff(summary) {
|
|
var text = String(summary || '');
|
|
document.getElementById('assistant-handoff-text').value = text; // Keep exact copy text.
|
|
var preview = document.getElementById('assistant-handoff-preview');
|
|
if (preview) {
|
|
preview.innerHTML = text ? renderMarkdown(text, []) : '';
|
|
if (text) renderEmbeddedBlocks(preview);
|
|
}
|
|
var modal = document.getElementById('assistant-handoff-modal');
|
|
if (modal) modal.classList.toggle('hidden', !text);
|
|
}
|
|
|
|
function requestHandoff() {
|
|
if (assistantBusy || !messages.length) return;
|
|
if (conversationChars !== null && conversationSize('') > conversationChars) {
|
|
updateConversationBudget();
|
|
if (typeof showToast === 'function') showToast('History exceeds the handoff budget. Save/download the full chat; nothing has been changed.', 'error');
|
|
return;
|
|
}
|
|
setBusy(true, 'Creating requested handoff...');
|
|
return requestAssistantHandoff(messages.map(function(m) { return { role: m.role, content: m.content }; }))
|
|
.then(function(data) {
|
|
if (!data.success) {
|
|
if (data.budget) conversationChars = validConversationLimit(data.budget.limit) ? data.budget.limit : null;
|
|
updateConversationBudget();
|
|
throw new Error(data.error || 'Handoff failed. Your conversation is unchanged.');
|
|
}
|
|
setHandoff(data.summary);
|
|
})
|
|
.catch(function(error) { if (typeof showToast === 'function') showToast(error.message, 'error'); })
|
|
.finally(function() { setBusy(false, 'Ready'); });
|
|
}
|
|
|
|
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;
|
|
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;
|
|
}
|
|
|
|
function sanitize(html) { return window.DOMPurify ? window.DOMPurify.sanitize(html, { ADD_ATTR: ['target'] }) : escapeHtml(html); }
|