Some checks failed
Build & release Android APK / Build signed APK (push) Has been cancelled
Build TWA APK / build-apk (push) Has been cancelled
Build & Push Docker Image / Build linux/amd64 (push) Has been cancelled
Build & Push Docker Image / Build linux/arm64 (push) Has been cancelled
Build & Push Docker Image / Merge manifests (push) Has been cancelled
316 lines
14 KiB
JavaScript
316 lines
14 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.
|
|
// ============================================================
|
|
(function () {
|
|
'use strict';
|
|
|
|
var initialized = false;
|
|
var messages = [];
|
|
var lastAnswer = '';
|
|
var lastSources = [];
|
|
var mermaidReady = false;
|
|
|
|
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();
|
|
}
|
|
|
|
function bindEvents() {
|
|
var form = document.getElementById('assistant-form');
|
|
var clearBtn = document.getElementById('btn-assistant-clear');
|
|
var copyBtn = document.getElementById('btn-assistant-copy');
|
|
var imageBtn = document.getElementById('btn-assistant-image');
|
|
var input = document.getElementById('assistant-input');
|
|
|
|
if (form) form.addEventListener('submit', onAsk);
|
|
if (clearBtn) clearBtn.addEventListener('click', clearConversation);
|
|
if (copyBtn) copyBtn.addEventListener('click', copyLastAnswer);
|
|
if (imageBtn) imageBtn.addEventListener('click', generateImage);
|
|
if (input) input.addEventListener('keydown', function (e) {
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') onAsk(e);
|
|
});
|
|
|
|
document.querySelectorAll('[data-assistant-example]').forEach(function (btn) {
|
|
btn.addEventListener('click', function () {
|
|
var el = document.getElementById('assistant-input');
|
|
if (el) {
|
|
el.value = btn.getAttribute('data-assistant-example') || '';
|
|
el.focus();
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function loadStatus() {
|
|
fetch('/api/clinical-assistant/status', { headers: getAuthHeaders(), credentials: 'same-origin' })
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (data) {
|
|
var label = document.getElementById('assistant-model-label');
|
|
if (label && data.success) {
|
|
label.textContent = data.chatModel ? ('Chat: ' + data.chatModel) : 'Admin model';
|
|
}
|
|
})
|
|
.catch(function () {
|
|
// Endpoint may not exist until backend slice lands; keep UI usable.
|
|
});
|
|
}
|
|
|
|
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.trim() : '';
|
|
if (!text) {
|
|
if (typeof showToast === 'function') showToast('Enter a clinical question', 'error');
|
|
return;
|
|
}
|
|
|
|
appendMessage('user', text);
|
|
if (input) input.value = '';
|
|
setBusy(true, 'Searching indexed resources...');
|
|
|
|
fetch('/api/clinical-assistant/chat', {
|
|
method: 'POST',
|
|
headers: getAuthHeaders(),
|
|
credentials: 'same-origin',
|
|
body: JSON.stringify({
|
|
message: text,
|
|
history: messages.slice(-8),
|
|
includeContext: !includeContext || includeContext.checked
|
|
})
|
|
})
|
|
.then(function (r) { return r.json().then(function (data) { data._status = r.status; return data; }); })
|
|
.then(function (data) {
|
|
setBusy(false, 'Ready');
|
|
if (!data.success) throw new Error(data.error || ('Request failed (' + data._status + ')'));
|
|
var answer = data.answer || data.markdown || '';
|
|
lastAnswer = answer;
|
|
lastSources = data.sources || data.citations || [];
|
|
appendMessage('assistant', answer, lastSources, data.suggestions || []);
|
|
renderSources(lastSources);
|
|
if (data.model) {
|
|
var label = document.getElementById('assistant-model-label');
|
|
if (label) label.textContent = 'Chat: ' + data.model;
|
|
}
|
|
})
|
|
.catch(function (err) {
|
|
setBusy(false, 'Error', true);
|
|
appendMessage('assistant', 'I could not complete the assistant request. ' + err.message + '\n\nIf this is the first run, the backend `/api/clinical-assistant/chat` route still needs to be wired to direct MCP search.');
|
|
if (typeof showToast === 'function') showToast(err.message, 'error');
|
|
});
|
|
}
|
|
|
|
function appendMessage(role, content, sources, suggestions) {
|
|
var wrap = document.getElementById('assistant-messages');
|
|
if (!wrap) return;
|
|
var empty = wrap.querySelector('.assistant-empty');
|
|
if (empty) empty.remove();
|
|
|
|
messages.push({ role: role, content: content });
|
|
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';
|
|
bubble.innerHTML = role === 'assistant' ? renderMarkdown(content, sources || []) : escapeHtml(content);
|
|
if (role === 'assistant' && suggestions && suggestions.length) {
|
|
bubble.appendChild(renderSuggestionButtons(suggestions));
|
|
}
|
|
row.appendChild(label);
|
|
row.appendChild(bubble);
|
|
wrap.appendChild(row);
|
|
renderEmbeddedBlocks(bubble);
|
|
wrap.scrollTop = wrap.scrollHeight;
|
|
}
|
|
|
|
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) {
|
|
var text = String(md || '');
|
|
var codeBlocks = [];
|
|
text = text.replace(/```(\w+)?\n([\s\S]*?)```/g, function (_, lang, code) {
|
|
var idx = codeBlocks.length;
|
|
codeBlocks.push({ lang: (lang || '').toLowerCase(), code: code });
|
|
return '\n@@CODEBLOCK_' + idx + '@@\n';
|
|
});
|
|
var html = escapeHtml(text)
|
|
.replace(/^### (.*)$/gm, '<h3>$1</h3>')
|
|
.replace(/^## (.*)$/gm, '<h2>$1</h2>')
|
|
.replace(/^# (.*)$/gm, '<h1>$1</h1>')
|
|
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
|
|
.replace(/\*(.*?)\*/g, '<em>$1</em>')
|
|
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
|
.replace(/\[(\d+)\]/g, function (_, n) {
|
|
var source = sources[Number(n) - 1];
|
|
var title = source ? source.title || source.resource || 'Source' : 'Source';
|
|
return '<a class="assistant-cite" href="#assistant-source-' + n + '" title="' + escapeHtml(title) + '">[' + n + ']</a>';
|
|
});
|
|
html = html.split(/\n{2,}/).map(function (block) {
|
|
if (/^\s*<(h\d|ul|ol|pre|div)/.test(block)) return block;
|
|
var lines = block.split('\n');
|
|
if (lines.every(function (l) { return /^\s*[-*] /.test(l) || !l.trim(); })) {
|
|
return '<ul>' + lines.filter(Boolean).map(function (l) { return '<li>' + l.replace(/^\s*[-*] /, '') + '</li>'; }).join('') + '</ul>';
|
|
}
|
|
if (lines.every(function (l) { return /^\s*\d+\. /.test(l) || !l.trim(); })) {
|
|
return '<ol>' + lines.filter(Boolean).map(function (l) { return '<li>' + l.replace(/^\s*\d+\. /, '') + '</li>'; }).join('') + '</ol>';
|
|
}
|
|
return '<p>' + block.replace(/\n/g, '<br>') + '</p>';
|
|
}).join('');
|
|
html = html.replace(/@@CODEBLOCK_(\d+)@@/g, function (_, idx) {
|
|
var block = codeBlocks[Number(idx)] || { lang: '', code: '' };
|
|
if (block.lang === 'mermaid') return '<div class="assistant-mermaid" data-mermaid="' + escapeAttr(block.code) + '">Rendering graph...</div>';
|
|
if (block.lang === 'chart' || block.lang === 'chartjs') return '<canvas class="assistant-chart" data-chart="' + escapeAttr(block.code) + '"></canvas>';
|
|
return '<pre><code>' + escapeHtml(block.code) + '</code></pre>';
|
|
});
|
|
return sanitize(html);
|
|
}
|
|
|
|
function renderSources(sources) {
|
|
var wrap = document.getElementById('assistant-sources');
|
|
if (!wrap) return;
|
|
if (!sources || sources.length === 0) {
|
|
wrap.innerHTML = '<p class="assistant-muted">No citations returned.</p>';
|
|
return;
|
|
}
|
|
wrap.innerHTML = sources.map(function (s, idx) {
|
|
var n = idx + 1;
|
|
var page = s.page || s.page_number || s.pageNumber;
|
|
var meta = [];
|
|
if (page) meta.push('page ' + page);
|
|
if (s.doc_type || s.type) meta.push(s.doc_type || s.type);
|
|
if (s.score != null) meta.push('score ' + Number(s.score).toFixed(3));
|
|
return '<div class="assistant-source" id="assistant-source-' + n + '">' +
|
|
'<strong>[' + n + '] ' + escapeHtml(s.title || s.resource || 'Untitled source') + '</strong>' +
|
|
'<div class="assistant-source-meta">' + escapeHtml(meta.join(' · ') || 'indexed source') + '</div>' +
|
|
(s.excerpt ? '<div style="margin-top:6px;color:var(--g600);">' + escapeHtml(s.excerpt).slice(0, 420) + '</div>' : '') +
|
|
'</div>';
|
|
}).join('');
|
|
}
|
|
|
|
function renderEmbeddedBlocks(root) {
|
|
root.querySelectorAll('[data-mermaid]').forEach(function (el) {
|
|
ensureMermaid().then(function () {
|
|
if (!window.mermaid) { el.textContent = el.getAttribute('data-mermaid'); return; }
|
|
var id = 'assistant-mermaid-' + Math.random().toString(16).slice(2);
|
|
window.mermaid.render(id, el.getAttribute('data-mermaid') || '')
|
|
.then(function (out) { el.innerHTML = out.svg || ''; })
|
|
.catch(function () { el.textContent = el.getAttribute('data-mermaid') || ''; });
|
|
});
|
|
});
|
|
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() {
|
|
var promptEl = document.getElementById('assistant-image-prompt');
|
|
var out = document.getElementById('assistant-visual-output');
|
|
var prompt = promptEl ? promptEl.value.trim() : '';
|
|
if (!prompt && lastAnswer) prompt = 'Create a concise pediatric clinical teaching visual from this answer:\n\n' + lastAnswer.slice(0, 3000);
|
|
if (!prompt) { if (typeof showToast === 'function') showToast('Enter an image prompt or ask a question first', 'error'); return; }
|
|
if (out) out.innerHTML = '<p class="assistant-muted"><i class="fas fa-spinner fa-spin"></i> Generating image...</p>';
|
|
fetch('/api/clinical-assistant/image', {
|
|
method: 'POST', headers: getAuthHeaders(), credentials: 'same-origin',
|
|
body: JSON.stringify({ prompt: prompt })
|
|
})
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (data) {
|
|
if (!data.success) throw new Error(data.error || 'Image generation failed');
|
|
var src = data.imageUrl || data.url || (data.base64 ? ('data:image/png;base64,' + data.base64) : '');
|
|
if (!src) throw new Error('No image returned');
|
|
if (out) out.innerHTML = '<img src="' + escapeAttr(src) + '" alt="Generated clinical visual"><a class="btn-sm btn-ghost" href="' + escapeAttr(src) + '" target="_blank" rel="noopener"><i class="fas fa-up-right-from-square"></i> Open</a>';
|
|
})
|
|
.catch(function (err) {
|
|
if (out) out.innerHTML = '<p class="assistant-muted">' + escapeHtml(err.message) + '</p>';
|
|
if (typeof showToast === 'function') showToast(err.message, 'error');
|
|
});
|
|
}
|
|
|
|
function clearConversation() {
|
|
messages = [];
|
|
lastAnswer = '';
|
|
lastSources = [];
|
|
var wrap = document.getElementById('assistant-messages');
|
|
if (wrap) wrap.innerHTML = '<div class="assistant-empty"><i class="fas fa-book-medical"></i><h3>Evidence-first pediatric assistant</h3><p>Ask a focused clinical question over indexed resources.</p></div>';
|
|
renderSources([]);
|
|
}
|
|
|
|
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 setBusy(isBusy, text, isError) {
|
|
var status = document.getElementById('assistant-status');
|
|
var label = document.getElementById('assistant-status-text');
|
|
var send = document.getElementById('btn-assistant-send');
|
|
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';
|
|
}
|
|
}
|
|
|
|
function escapeHtml(s) { return String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }
|
|
function escapeAttr(s) { return escapeHtml(s).replace(/'/g, '''); }
|
|
function sanitize(html) { return window.DOMPurify ? window.DOMPurify.sanitize(html) : html; }
|
|
})();
|