stabilize assistant and add diagrams

This commit is contained in:
Daniel 2026-05-08 00:23:00 +02:00
parent 525671ad2e
commit 12a6022c7d
11 changed files with 4120 additions and 248 deletions

6
.gitignore vendored
View file

@ -39,3 +39,9 @@ e2e/playwright-report/
.codex
.firecrawl/
# Refactored test stack stays local for now
.env.refactored
.env.refactored.example
docker-compose.refactored.yml
refactored-ped-ai/

View file

@ -0,0 +1,24 @@
/**
* Mermaid Diagrams per-user clinical pathway / algorithm diagrams.
* Source is plain Mermaid text; rendered to SVG client-side. Source
* encrypted at rest like personal_notes so a row dump stays useless
* without the app key.
*/
exports.up = (pgm) => {
pgm.createTable('mermaid_diagrams', {
id: { type: 'serial', primaryKey: true },
user_id: { type: 'integer', notNull: true, references: 'users(id)', onDelete: 'CASCADE' },
title: { type: 'text', notNull: true },
source: { type: 'text', notNull: true, default: '' },
notes: { type: 'text', notNull: true, default: '' },
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('NOW()') },
updated_at: { type: 'timestamptz', notNull: true, default: pgm.func('NOW()') },
});
pgm.createIndex('mermaid_diagrams', 'user_id');
pgm.createIndex('mermaid_diagrams', ['user_id', 'updated_at']);
};
exports.down = (pgm) => {
pgm.dropTable('mermaid_diagrams');
};

View file

@ -0,0 +1,235 @@
<div class="module-header">
<h2><i class="fas fa-diagram-project" style="color:#0ea5e9;"></i> Diagrams</h2>
</div>
<div class="diagrams-layout" id="diagrams-layout">
<aside class="diagrams-sidebar">
<div class="diagrams-sidebar-head">
<button id="btn-diagram-new" class="btn-primary diagrams-new-btn" type="button">
<i class="fas fa-plus"></i> New diagram
</button>
<div class="diagrams-search">
<i class="fas fa-search"></i>
<input type="text" id="diagram-search" placeholder="Search" autocomplete="off">
</div>
</div>
<div id="diagrams-list" class="diagrams-list">
<div class="diagrams-empty">Loading…</div>
</div>
</aside>
<section id="diagram-editor" class="diagram-editor">
<div class="diagram-toolbar">
<input type="text" id="diagram-title" class="diagram-title-input" placeholder="Diagram title" autocomplete="off">
<span id="diagram-status" class="diagram-status"></span>
<div class="diagram-toolbar-actions">
<button id="btn-diagram-export-svg" class="btn-sm" type="button" title="Export SVG">
<i class="fas fa-download"></i> SVG
</button>
<button id="btn-diagram-export-png" class="btn-sm" type="button" title="Export PNG">
<i class="fas fa-download"></i> PNG
</button>
<button id="btn-diagram-delete" class="btn-sm btn-diagram-delete" type="button" title="Delete diagram"
style="background:var(--red-light);color:var(--red);border:1px solid var(--red);">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
<div class="diagram-panes">
<div class="diagram-source-pane">
<textarea id="diagram-source" class="diagram-source" spellcheck="false"
placeholder="graph TD&#10; A[Start] --> B{Decision?}&#10; B -->|Yes| C[Action]&#10; B -->|No| D[End]"></textarea>
</div>
<div class="diagram-preview-pane">
<div id="diagram-preview" class="diagram-preview"></div>
<div id="diagram-error" class="diagram-error hidden"></div>
</div>
</div>
</section>
</div>
<style>
.diagrams-layout {
display: grid;
grid-template-columns: 280px minmax(0, 1fr);
gap: 16px;
height: calc(100vh - 200px);
min-height: 480px;
}
.diagrams-sidebar {
display: flex;
flex-direction: column;
background: var(--g50, #f8fafb);
border: 1px solid var(--g100, #e5ebef);
border-radius: 8px;
overflow: hidden;
}
.diagrams-sidebar-head {
padding: 10px;
display: grid;
gap: 8px;
border-bottom: 1px solid var(--g100, #e5ebef);
background: white;
}
.diagrams-new-btn { width: 100%; }
.diagrams-search {
position: relative;
}
.diagrams-search i {
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
color: var(--g400, #94a3b8);
font-size: 12px;
}
.diagrams-search input {
width: 100%;
padding: 7px 10px 7px 28px;
border: 1px solid var(--g200, #d5dfe6);
border-radius: 6px;
font-size: 13px;
}
.diagrams-list {
flex: 1;
overflow-y: auto;
padding: 6px;
}
.diagrams-empty {
padding: 18px 12px;
color: var(--g400, #94a3b8);
font-size: 13px;
text-align: center;
}
.diagram-row {
display: grid;
gap: 2px;
padding: 9px 10px;
border-radius: 6px;
cursor: pointer;
border: 1px solid transparent;
}
.diagram-row:hover {
background: white;
border-color: var(--g200, #d5dfe6);
}
.diagram-row.active {
background: var(--blue-light, #e0f2fe);
border-color: var(--blue, #0ea5e9);
}
.diagram-row strong {
font-size: 13.5px;
color: var(--g800, #1e293b);
}
.diagram-row span {
font-size: 11px;
color: var(--g400, #94a3b8);
}
.diagram-editor {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
background: white;
border: 1px solid var(--g100, #e5ebef);
border-radius: 8px;
overflow: hidden;
}
.diagram-toolbar {
display: flex;
align-items: center;
gap: 10px;
padding: 10px;
border-bottom: 1px solid var(--g100, #e5ebef);
background: var(--g50, #f8fafb);
}
.diagram-title-input {
flex: 1;
min-width: 0;
padding: 7px 10px;
border: 1px solid var(--g200, #d5dfe6);
border-radius: 6px;
font-size: 14px;
font-weight: 600;
background: white;
}
.diagram-status {
font-size: 11.5px;
color: var(--g400, #94a3b8);
white-space: nowrap;
}
.diagram-status.saving { color: var(--amber, #d97706); }
.diagram-status.saved { color: var(--green, #16a34a); }
.diagram-status.error { color: var(--red, #dc2626); }
.diagram-toolbar-actions {
display: flex;
gap: 6px;
}
.diagram-panes {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
min-height: 0;
}
.diagram-source-pane {
border-right: 1px solid var(--g100, #e5ebef);
display: flex;
}
.diagram-source {
flex: 1;
width: 100%;
height: 100%;
border: 0;
padding: 12px;
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
font-size: 13px;
line-height: 1.55;
resize: none;
outline: none;
background: white;
color: var(--g800, #1e293b);
}
.diagram-preview-pane {
position: relative;
overflow: auto;
padding: 18px;
background: var(--g50, #f8fafb);
}
.diagram-preview {
display: flex;
justify-content: center;
align-items: flex-start;
}
.diagram-preview svg {
max-width: 100%;
height: auto;
}
.diagram-error {
margin-top: 10px;
padding: 10px 12px;
background: var(--red-light, #fef2f2);
border: 1px solid var(--red, #dc2626);
color: var(--red, #dc2626);
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
font-size: 12px;
border-radius: 6px;
white-space: pre-wrap;
}
@media (max-width: 900px) {
.diagrams-layout {
grid-template-columns: 1fr;
height: auto;
}
.diagrams-sidebar {
max-height: 280px;
}
.diagram-panes {
grid-template-columns: 1fr;
grid-template-rows: 320px minmax(280px, 1fr);
}
.diagram-source-pane {
border-right: 0;
border-bottom: 1px solid var(--g100, #e5ebef);
}
}
</style>

View file

@ -7,7 +7,6 @@ export function renderAssistantMarkdown(md, sources, options) {
codeBlocks.push({ lang: (lang || '').toLowerCase(), code: code });
return '\n@@CODEBLOCK_' + idx + '@@\n';
});
text = stripSourcesSection(text);
text = renderLatexText(text, opts.katex);
text = normalizeAdjacentCitationClusters(text, sources || []);
@ -84,12 +83,6 @@ function formatCitationCluster(nums) {
return '[' + nums.join(', ') + ']';
}
export function stripSourcesSection(text) {
return String(text || '')
.replace(/\s*(?:-{3,}\s*)?(?:#{1,6}\s*)?(?:Sources|References)\s*:?\s*[\s\S]*$/i, '')
.trim();
}
export function normalizeMarkdownText(text) {
return stripOrphanMarkdownMarkers(String(text || '')
.replace(/\r\n/g, '\n')

View file

@ -4,7 +4,7 @@
// server can call native MCP directly without routing through mcpo.
// ============================================================
import { EMPTY_PROMPT_SETS } from './assistant/data.js';
import { escapeAttr, escapeHtml, renderAssistantMarkdown, stripSourcesSection } from './assistant/citations.js';
import { escapeAttr, escapeHtml, renderAssistantMarkdown } from './assistant/citations.js';
(function () {
'use strict';
@ -123,7 +123,7 @@ import { escapeAttr, escapeHtml, renderAssistantMarkdown, stripSourcesSection }
setBusy(true, 'Looking up sources...');
var loading = appendLoadingMessage('Looking up sources', 'Retrieving and synthesizing references...');
streamAssistantResponse({
fetchAssistantResponse({
message: text,
history: messages.slice(-8),
includeContext: !includeContext || includeContext.checked
@ -135,92 +135,17 @@ import { escapeAttr, escapeHtml, renderAssistantMarkdown, stripSourcesSection }
});
}
async function streamAssistantResponse(payload, loading) {
var response = await fetch('/api/clinical-assistant/chat/stream', {
method: 'POST',
headers: getAuthHeaders(),
credentials: 'same-origin',
body: JSON.stringify(payload)
});
if (!response.ok || !response.body) {
var fallback = await response.json().catch(function () { return {}; });
throw new Error(fallback.error || ('Request failed (' + response.status + ')'));
}
var partial = '';
var streamSources = [];
var streamSuggestions = [];
var lastRender = 0;
var bubble = loading ? loading.querySelector('.assistant-bubble') : null;
var decoder = new TextDecoder();
var buffer = '';
var doneData = null;
function renderPartial(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.innerHTML = partial ? renderMarkdown(partial, streamSources) : '<p class="assistant-muted">Generating answer...</p>';
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 || '';
renderPartial(false);
return;
}
if (type === 'done') {
doneData = data || {};
streamSuggestions = doneData.suggestions || [];
return;
}
if (type === 'error') throw new Error(data.error || 'Assistant stream failed');
}
var reader = response.body.getReader();
while (true) {
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 (!doneData) {
updateLoadingMessage(loading, 'Stream ended early. Retrying without streaming...');
doneData = await fetchAssistantFallback(payload);
}
async function fetchAssistantResponse(payload, loading) {
updateLoadingMessage(loading, 'Looking up sources...');
var data = await fetchAssistantFallback(payload);
setBusy(false, 'Ready');
var answer = (doneData && (doneData.answer || doneData.markdown)) || partial;
lastAnswer = answer || '';
lastSources = (doneData && (doneData.sources || doneData.citations)) || streamSources;
replaceLoadingMessage(loading, lastAnswer, lastSources, streamSuggestions);
lastAnswer = data.answer || '';
lastSources = data.sources || [];
replaceLoadingMessage(loading, lastAnswer, lastSources, data.suggestions || []);
renderSources(lastSources);
if (doneData && doneData.model) {
if (data.model) {
var label = document.getElementById('assistant-model-label');
if (label) label.textContent = 'Chat: ' + doneData.model;
if (label) label.textContent = 'Chat: ' + data.model;
}
}
@ -236,18 +161,6 @@ import { escapeAttr, escapeHtml, renderAssistantMarkdown, stripSourcesSection }
return data;
}
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; }
}
function updateLoadingMessage(row, detail) {
if (!row) return;
var el = row.querySelector('.assistant-thinking-detail');
@ -284,7 +197,7 @@ import { escapeAttr, escapeHtml, renderAssistantMarkdown, stripSourcesSection }
if (!bubble) return;
row.classList.remove('assistant-loading-msg');
bubble.classList.remove('assistant-thinking');
bubble.innerHTML = rawHtml ? sanitize(String(content || '')) : renderMarkdown(content, sources || []);
bubble.innerHTML = renderAssistantBubbleHtml(content, sources || [], rawHtml);
if (suggestions && suggestions.length) bubble.appendChild(renderSuggestionButtons(suggestions));
renderEmbeddedBlocks(bubble);
var wrap = document.getElementById('assistant-messages');
@ -292,6 +205,16 @@ import { escapeAttr, escapeHtml, renderAssistantMarkdown, stripSourcesSection }
messages.push({ role: 'assistant', content: content, sources: sources || [] });
}
function renderAssistantBubbleHtml(content, sources, rawHtml) {
if (rawHtml) return sanitize(String(content || ''));
try {
return renderMarkdown(content, sources || []);
} catch (e) {
console.warn('[clinical-assistant] markdown render failed:', e && e.message ? e.message : e);
return '<p>' + escapeHtml(String(content || '')).replace(/\n/g, '<br>') + '</p>';
}
}
function appendMessage(role, content, sources, suggestions, rawHtml) {
var wrap = document.getElementById('assistant-messages');
if (!wrap) return;
@ -647,7 +570,7 @@ import { escapeAttr, escapeHtml, renderAssistantMarkdown, stripSourcesSection }
var sources = Array.isArray(item.sources) ? item.sources : [];
var heading = item.heading || deriveExportHeading(item.question, idx);
var summary = item.summary || '';
var answer = stripSourcesSection(item.answer || '');
var answer = item.answer || '';
var citedNumbers = extractCitedSourceNumbers([summary, answer].filter(Boolean).join('\n\n'));
var refs = renderExportRefs(sources, idx + 1, citedNumbers);
return '<section class="export-section">' +
@ -688,13 +611,13 @@ import { escapeAttr, escapeHtml, renderAssistantMarkdown, stripSourcesSection }
question: pendingQuestion || 'Clinical question',
heading: deriveExportHeading(pendingQuestion, items.length),
summary: '',
answer: stripSourcesSection(m.content),
answer: m.content,
sources: Array.isArray(m.sources) && m.sources.length ? m.sources : lastSources
});
pendingQuestion = '';
});
if (!items.length && lastAnswer) {
items.push({ question: 'Clinical question', heading: 'Clinical Answer', summary: '', answer: stripSourcesSection(lastAnswer), sources: lastSources });
items.push({ question: 'Clinical question', heading: 'Clinical Answer', summary: '', answer: lastAnswer, sources: lastSources });
}
return items;
}

397
public/js/diagrams.js Normal file
View file

@ -0,0 +1,397 @@
// ============================================================
// MERMAID DIAGRAMS — per-user clinical pathway editor.
// Source on the left, live SVG preview on the right. CRUD against
// /api/diagrams. Autosaves on quiet typing like Personal Notes.
// ============================================================
(function () {
'use strict';
var initialized = false;
var mermaidReady = false;
var library = [];
var currentId = null;
var currentTitle = '';
var currentSource = '';
var currentNotes = '';
var saveTimer = null;
var renderTimer = null;
var renderToken = 0;
document.addEventListener('tabChanged', function (e) {
if (e.detail && e.detail.tab === 'diagrams') initIfNeeded();
});
function initIfNeeded() {
if (initialized) {
reloadLibrary();
return;
}
var tabRoot = document.getElementById('diagrams-tab');
if (!tabRoot || !tabRoot.querySelector('#diagrams-list')) {
// Component HTML not yet injected — try again on the next tab activation.
return;
}
initialized = true;
bindEvents();
ensureMermaid().then(reloadLibrary);
}
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 = function () {
showError('Failed to load Mermaid. Refresh and try again.');
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 bindEvents() {
var newBtn = document.getElementById('btn-diagram-new');
var search = document.getElementById('diagram-search');
var titleEl = document.getElementById('diagram-title');
var sourceEl = document.getElementById('diagram-source');
var deleteBtn = document.getElementById('btn-diagram-delete');
var svgBtn = document.getElementById('btn-diagram-export-svg');
var pngBtn = document.getElementById('btn-diagram-export-png');
if (newBtn) newBtn.addEventListener('click', onNew);
if (search) search.addEventListener('input', renderList);
if (titleEl) titleEl.addEventListener('input', onTitleInput);
if (sourceEl) sourceEl.addEventListener('input', onSourceInput);
if (deleteBtn) deleteBtn.addEventListener('click', onDelete);
if (svgBtn) svgBtn.addEventListener('click', exportSvg);
if (pngBtn) pngBtn.addEventListener('click', exportPng);
}
// ── Library ────────────────────────────────────────────────
function reloadLibrary() {
return fetch('/api/diagrams', { credentials: 'include', headers: window.getAuthHeaders ? window.getAuthHeaders() : {} })
.then(function (r) { return r.json(); })
.then(function (data) {
library = (data && data.diagrams) || [];
renderList();
if (currentId == null && library.length) {
loadDiagram(library[0].id);
} else if (!library.length) {
showEmptyEditor();
}
})
.catch(function () {
var listEl = document.getElementById('diagrams-list');
if (listEl) listEl.innerHTML = '<div class="diagrams-empty">Failed to load.</div>';
});
}
function renderList() {
var listEl = document.getElementById('diagrams-list');
if (!listEl) return;
var searchEl = document.getElementById('diagram-search');
var q = searchEl ? String(searchEl.value || '').toLowerCase() : '';
var rows = library.filter(function (d) {
if (!q) return true;
return String(d.title || '').toLowerCase().indexOf(q) !== -1;
});
if (!rows.length) {
listEl.innerHTML = '<div class="diagrams-empty">' + (library.length ? 'No matches.' : 'No diagrams yet.') + '</div>';
return;
}
listEl.innerHTML = rows.map(function (d) {
return '<div class="diagram-row' + (d.id === currentId ? ' active' : '') + '" data-id="' + d.id + '">'
+ '<strong>' + escapeHtml(d.title || 'Untitled') + '</strong>'
+ '<span>' + formatDate(d.updated_at) + '</span>'
+ '</div>';
}).join('');
Array.prototype.forEach.call(listEl.querySelectorAll('.diagram-row'), function (row) {
row.addEventListener('click', function () {
loadDiagram(parseInt(row.getAttribute('data-id'), 10));
});
});
}
function showEmptyEditor() {
currentId = null;
setFields('', '', '');
setStatus('');
var preview = document.getElementById('diagram-preview');
if (preview) preview.innerHTML = '';
hideError();
}
// ── Diagram load / save ────────────────────────────────────
function loadDiagram(id) {
flushSaveIfPending();
fetch('/api/diagrams/' + id, { credentials: 'include', headers: window.getAuthHeaders ? window.getAuthHeaders() : {} })
.then(function (r) { return r.json(); })
.then(function (data) {
if (!data || !data.diagram) return;
currentId = data.diagram.id;
setFields(data.diagram.title || '', data.diagram.source || '', data.diagram.notes || '');
setStatus('');
renderList();
scheduleRender(0);
})
.catch(function () { setStatus('Failed to load', 'error'); });
}
function setFields(title, source, notes) {
currentTitle = title;
currentSource = source;
currentNotes = notes;
var titleEl = document.getElementById('diagram-title');
var sourceEl = document.getElementById('diagram-source');
if (titleEl) titleEl.value = title;
if (sourceEl) sourceEl.value = source;
}
function onNew() {
flushSaveIfPending();
var title = 'New diagram';
var starter = 'graph TD\n A[Start] --> B{Decision?}\n B -->|Yes| C[Action]\n B -->|No| D[End]';
fetch('/api/diagrams', {
method: 'POST',
credentials: 'include',
headers: window.getAuthHeaders ? window.getAuthHeaders() : { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: title, source: starter, notes: '' }),
})
.then(function (r) { return r.json(); })
.then(function (data) {
if (data && data.id) {
return reloadLibrary().then(function () { loadDiagram(data.id); });
}
})
.catch(function () {
if (window.showToast) window.showToast('Could not create diagram', 'error');
});
}
function onTitleInput(e) {
currentTitle = e.target.value;
scheduleSave();
var row = document.querySelector('.diagram-row.active strong');
if (row) row.textContent = currentTitle || 'Untitled';
}
function onSourceInput(e) {
currentSource = e.target.value;
scheduleSave();
scheduleRender(220);
}
function scheduleSave() {
if (currentId == null) return;
setStatus('Saving…', 'saving');
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(saveNow, 700);
}
function flushSaveIfPending() {
if (saveTimer) {
clearTimeout(saveTimer);
saveTimer = null;
saveNow();
}
}
function saveNow() {
saveTimer = null;
if (currentId == null) return;
var id = currentId;
var payload = JSON.stringify({ title: currentTitle, source: currentSource, notes: currentNotes });
fetch('/api/diagrams/' + id, {
method: 'PUT',
credentials: 'include',
headers: window.getAuthHeaders ? window.getAuthHeaders() : { 'Content-Type': 'application/json' },
body: payload,
})
.then(function (r) {
if (!r.ok) throw new Error('save failed');
setStatus('Saved', 'saved');
var item = library.find(function (d) { return d.id === id; });
if (item) {
item.title = currentTitle;
item.source = currentSource;
item.updated_at = new Date().toISOString();
}
})
.catch(function () { setStatus('Save failed', 'error'); });
}
function onDelete() {
if (currentId == null) return;
var id = currentId;
var confirmFn = window.showConfirm || function (msg) { return Promise.resolve(window.confirm(msg)); };
Promise.resolve(confirmFn('Delete this diagram?')).then(function (ok) {
if (!ok) return;
fetch('/api/diagrams/' + id, {
method: 'DELETE',
credentials: 'include',
headers: window.getAuthHeaders ? window.getAuthHeaders() : {},
}).then(function () {
currentId = null;
return reloadLibrary();
});
});
}
// ── Render ─────────────────────────────────────────────────
function scheduleRender(delay) {
if (renderTimer) clearTimeout(renderTimer);
renderTimer = setTimeout(renderNow, delay || 0);
}
function renderNow() {
renderTimer = null;
var preview = document.getElementById('diagram-preview');
if (!preview) return;
var src = String(currentSource || '').trim();
if (!src) {
preview.innerHTML = '';
hideError();
return;
}
if (!window.mermaid || !mermaidReady) {
ensureMermaid().then(renderNow);
return;
}
var token = ++renderToken;
var id = 'mermaid-render-' + token;
Promise.resolve()
.then(function () { return window.mermaid.render(id, src); })
.then(function (result) {
if (token !== renderToken) return;
preview.innerHTML = result.svg;
hideError();
})
.catch(function (err) {
if (token !== renderToken) return;
var msg = err && err.message ? err.message : String(err);
showError(msg);
});
}
function showError(msg) {
var el = document.getElementById('diagram-error');
if (!el) return;
el.textContent = msg;
el.classList.remove('hidden');
}
function hideError() {
var el = document.getElementById('diagram-error');
if (el) el.classList.add('hidden');
}
// ── Export ─────────────────────────────────────────────────
function exportSvg() {
var preview = document.getElementById('diagram-preview');
var svg = preview && preview.querySelector('svg');
if (!svg) {
if (window.showToast) window.showToast('Render a diagram first', 'info');
return;
}
var serializer = new XMLSerializer();
var raw = serializer.serializeToString(svg);
if (!/^<\?xml/.test(raw)) raw = '<?xml version="1.0" encoding="UTF-8"?>\n' + raw;
var blob = new Blob([raw], { type: 'image/svg+xml' });
triggerDownload(blob, fileBase() + '.svg');
}
function exportPng() {
var preview = document.getElementById('diagram-preview');
var svg = preview && preview.querySelector('svg');
if (!svg) {
if (window.showToast) window.showToast('Render a diagram first', 'info');
return;
}
var serializer = new XMLSerializer();
var raw = serializer.serializeToString(svg);
var encoded = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(raw);
var img = new Image();
img.onload = function () {
var rect = svg.getBoundingClientRect();
var scale = 2;
var w = Math.max(1, Math.round(rect.width * scale));
var h = Math.max(1, Math.round(rect.height * scale));
var canvas = document.createElement('canvas');
canvas.width = w; canvas.height = h;
var ctx = canvas.getContext('2d');
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, w, h);
ctx.drawImage(img, 0, 0, w, h);
canvas.toBlob(function (blob) {
if (blob) triggerDownload(blob, fileBase() + '.png');
}, 'image/png');
};
img.onerror = function () {
if (window.showToast) window.showToast('PNG export failed', 'error');
};
img.src = encoded;
}
function triggerDownload(blob, name) {
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = name;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
}
function fileBase() {
var t = String(currentTitle || 'diagram').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
return t || 'diagram';
}
// ── Helpers ────────────────────────────────────────────────
function setStatus(text, kind) {
var el = document.getElementById('diagram-status');
if (!el) return;
el.textContent = text || '';
el.className = 'diagram-status' + (kind ? ' ' + kind : '');
}
function escapeHtml(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
}
function formatDate(iso) {
if (!iso) return '';
try {
var d = new Date(iso);
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + ' ' + d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
} catch (e) { return ''; }
}
// Save in-flight on tab switch / page hide so the user never loses keystrokes.
window.addEventListener('beforeunload', flushSaveIfPending);
document.addEventListener('visibilitychange', function () {
if (document.visibilityState === 'hidden') flushSaveIfPending();
});
})();

3298
public/vendor/mermaid.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -10,7 +10,7 @@ var axios = require('axios');
var router = express.Router();
var db = require('../db/database');
var { authMiddleware } = require('../middleware/auth');
var { callAI, callAIStream } = require('../utils/ai');
var { callAI } = require('../utils/ai');
var { gatewayUrl } = require('../utils/errors');
var logger = require('../utils/logger');
var cryptoUtil = require('../utils/crypto');
@ -242,7 +242,7 @@ router.post('/clinical-assistant/chat', async function(req, res) {
});
var answer = stripModelSourcesSection(String(ai.content || '').trim());
if (shouldRegenerateTruncatedAnswer(answer, ai.finishReason)) {
console.warn('[clinical-assistant] non-stream answer looked truncated; regenerating', { finishReason: ai.finishReason, chars: answer.length });
console.warn('[clinical-assistant] answer looked truncated; regenerating', { finishReason: ai.finishReason, chars: answer.length });
var completed = await callAI(prepared.messages, {
model: prepared.chatModel || undefined,
temperature: 0.15,
@ -273,72 +273,6 @@ router.post('/clinical-assistant/chat', async function(req, res) {
}
});
router.post('/clinical-assistant/chat/stream', async function(req, res) {
var started = Date.now();
var streamOpen = false;
function sendEvent(type, data) {
if (!streamOpen) return;
res.write('event: ' + type + '\n');
res.write('data: ' + JSON.stringify(data || {}) + '\n\n');
}
try {
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
res.setHeader('Cache-Control', 'no-cache, no-transform');
res.setHeader('Connection', 'keep-alive');
if (typeof res.flushHeaders === 'function') res.flushHeaders();
streamOpen = true;
sendEvent('status', { message: 'Looking up sources...' });
var prepared = await prepareAssistantChat(req.body);
if (prepared.direct) {
sendEvent('done', Object.assign({ duration: Date.now() - started }, prepared.direct));
return res.end();
}
sendEvent('sources', { sources: sanitizeSourcesForClient(prepared.sources), search: prepared.search });
sendEvent('status', { message: 'Generating answer...' });
var ai = await callAIStream(prepared.messages, {
model: prepared.chatModel || undefined,
temperature: 0.15,
maxTokens: 2600
}, function(delta) {
sendEvent('token', { token: delta });
});
var answer = stripModelSourcesSection(String(ai.content || '').trim());
if (shouldRegenerateTruncatedAnswer(answer, ai.finishReason)) {
console.warn('[clinical-assistant] stream answer looked truncated; regenerating', { finishReason: ai.finishReason, chars: answer.length });
sendEvent('status', { message: 'Completing answer...' });
var completed = await callAI(prepared.messages, {
model: prepared.chatModel || undefined,
temperature: 0.15,
maxTokens: 5000
});
answer = stripModelSourcesSection(String(completed.content || '').trim()) || answer;
ai.model = completed.model || ai.model;
ai.provider = completed.provider || ai.provider;
ai.finishReason = completed.finishReason || ai.finishReason;
}
logger.audit(req.user.id, 'clinical_assistant_query', 'Clinical assistant streaming query', req, {
category: 'clinical', model: ai.model || prepared.chatModel, duration: Date.now() - started
});
sendEvent('done', {
success: true,
answer: answer,
sources: sanitizeSourcesForClient(prepared.sources),
model: ai.model || prepared.chatModel || null,
provider: ai.provider || null,
duration: Date.now() - started,
search: prepared.search
});
res.end();
} catch (e) {
console.error('[clinical-assistant stream]', e.message, e.stack || '');
if (!streamOpen) return res.status(500).json({ error: assistantErrorMessage(e) });
sendEvent('error', { error: assistantErrorMessage(e) });
res.end();
}
});
router.post('/clinical-assistant/image', async function(req, res) {
try {
var prompt = String(req.body.prompt || '').trim();
@ -865,7 +799,7 @@ function formatSourcesForPrompt(sources) {
}
function buildSystemPrompt(behavior) {
return behavior + '\n\nRules:\n- Answer only the user question; do not dump unrelated textbook content.\n- For short typo-like, misspelled, or partial terms, use the retrieved sources to infer the closest medical concept when there is a plausible match. Begin with "Assuming you meant ..." and answer that concept. Do not ask for clarification unless the retrieved sources do not indicate any plausible concept.\n- Use the exact source numbers from the retrieved sources; do not renumber citations for order or style.\n- Cite factual claims immediately with numbered citations like [1] or [1, 3].\n- Every clinical recommendation, dose, threshold, lab value, statistic, comparison, contraindication, red flag, and table row must include its own supporting citation.\n- Do not leave a paragraph, bullet, or table row with multiple factual claims supported only by an uncited heading.\n- If a claim is not directly supported by retrieved sources, omit it or say the available sources are insufficient.\n- If retrieved sources mention the medication/intervention only for other diseases, explicitly say the available sources do not support it for the user\'s requested disease.\n- Do not cite a source number that is not provided.\n- If sources disagree or are insufficient, say so.\n- Keep the main answer concise and clinically useful.\n- Use clear markdown with headings, bullets, and tables when useful.\n- When using a table, output a valid GitHub-flavored markdown table with pipe characters and a separator row. Never output tab-separated tables.\n- Put any summary sentence in a separate paragraph after the table, not as a table row.\n- Do not add a final Sources or References section; the UI displays all retrieved sources separately.\n- Do not add generic disclaimers about clinician judgment.';
return behavior + '\n\nRules:\n- Answer only the user question; do not dump unrelated textbook content.\n- For recognizable medical terms, abbreviations, diseases, and acronyms, answer directly without prefacing with "Assuming you meant".\n- For genuinely misspelled or partial terms, use the retrieved sources to infer the closest medical concept when there is a plausible match, then answer directly. Ask for clarification only when the retrieved sources do not indicate any plausible concept.\n- Use the exact source numbers from the retrieved sources; do not renumber citations for order or style.\n- Cite factual claims immediately with numbered citations like [1] or [1, 3].\n- Every clinical recommendation, dose, threshold, lab value, statistic, comparison, contraindication, red flag, and table row must include its own supporting citation.\n- Do not leave a paragraph, bullet, or table row with multiple factual claims supported only by an uncited heading.\n- If a claim is not directly supported by retrieved sources, omit it or say the available sources are insufficient.\n- If retrieved sources mention the medication/intervention only for other diseases, explicitly say the available sources do not support it for the user\'s requested disease.\n- Do not cite a source number that is not provided.\n- If sources disagree or are insufficient, say so.\n- Keep the main answer concise and clinically useful.\n- Use clear markdown with headings, bullets, and tables when useful.\n- When using a table, output a valid GitHub-flavored markdown table with pipe characters and a separator row. Never output tab-separated tables.\n- Put any summary sentence in a separate paragraph after the table, not as a table row.\n- Do not add a final Sources or References section; the UI displays all retrieved sources separately.\n- Do not add generic disclaimers about clinician judgment.';
}
function buildUserPrompt(question, context, history, searchQuery) {
@ -876,9 +810,19 @@ function buildUserPrompt(question, context, history, searchQuery) {
}
function stripModelSourcesSection(answer) {
return String(answer || '')
return cleanDanglingSourceLeadIn(String(answer || '')
.replace(/\n\s*(---\s*)?(#{1,3}\s*)?(Sources|References)\s*\n[\s\S]*$/i, '')
.replace(/\n\s*>?\s*(⚠️\s*)?Clinical decision support[^\n]*$/gim, '')
.trim());
}
function cleanDanglingSourceLeadIn(answer) {
answer = String(answer || '').trim();
return answer
.replace(/([.!?])\s+(?:however,?\s*)?(?:but\s*)?(?:the\s*)?available(?:\s+(?:sources?|retrieved\s+sources?|evidence|material))?\s*$/i, '$1')
.replace(/([.!?])\s+(?:however,?\s*)?(?:but\s*)?(?:based\s+on\s+)?(?:the\s*)?available\s*$/i, '$1')
.replace(/(?:^|\n\n)(?:however,?\s*)?(?:but\s*)?(?:the\s*)?available(?:\s+(?:sources?|retrieved\s+sources?|evidence|material))?\s*$/i, '')
.replace(/(?:^|\n\n)(?:however,?\s*)?(?:but\s*)?(?:based\s+on\s+)?(?:the\s*)?available\s*$/i, '')
.trim();
}
@ -886,11 +830,11 @@ function shouldRegenerateTruncatedAnswer(answer, finishReason) {
answer = String(answer || '').trim();
if (!answer) return false;
if (finishReason === 'length') return true;
if (/\b(the|a|an|and|or|but|with|without|for|to|of|in|on|as|by|from|because|therefore|however|some|many|most|few|several|additional|other|further|including|such)$/i.test(answer)) return true;
if (/[,:;\-–—(]$/.test(answer)) return true;
if (/\b(the|a|an|and|or|but|with|without|for|to|of|in|on|as|by|from|because|therefore|however|some|many|most|few|several|additional|other|further|including|such|available)$/i.test(answer)) return true;
if (/[,:;\-(]$/.test(answer)) return true;
var lines = answer.split(/\n+/).map(function(line) { return line.trim(); }).filter(Boolean);
var last = lines.length ? lines[lines.length - 1] : answer;
if (last.length > 24 && /[A-Za-z]$/.test(last) && !/[.!?\])"']$/.test(last)) return true;
if (last.length > 24 && /[A-Za-z]$/.test(last) && !/[.!?\])"']$/.test(last)) return true;
return false;
}

114
src/routes/diagrams.js Normal file
View file

@ -0,0 +1,114 @@
// ============================================================
// MERMAID DIAGRAMS ROUTES — per-user clinical pathway diagrams.
// Pure CRUD, auth-gated. Title/source/notes encrypted at rest
// using the same crypto helper as personal_notes.
// ============================================================
var express = require('express');
var router = express.Router();
var db = require('../db/database');
var { authMiddleware } = require('../middleware/auth');
var logger = require('../utils/logger');
var cryptoUtil = require('../utils/crypto');
router.use(authMiddleware);
var MAX_TITLE = 200;
var MAX_SOURCE = 50000;
var MAX_NOTES = 10000;
var MAX_DIAGRAMS_PER_USER = 200;
function decryptRow(row) {
if (!row) return row;
try { row.title = cryptoUtil.decryptString(row.title); } catch (e) {}
try { row.source = cryptoUtil.decryptString(row.source); } catch (e) {}
try { row.notes = cryptoUtil.decryptString(row.notes); } catch (e) {}
return row;
}
function clip(s, n) {
return String(s == null ? '' : s).trim().substring(0, n);
}
router.get('/diagrams', async function (req, res) {
try {
var rows = await db.all(
'SELECT id, title, source, notes, created_at, updated_at FROM mermaid_diagrams WHERE user_id = $1 ORDER BY updated_at DESC',
[req.user.id]
);
rows.forEach(decryptRow);
res.json({ success: true, diagrams: rows });
} catch (err) {
console.error('[diagrams] list failed:', err.message);
res.status(500).json({ error: 'Request failed' });
}
});
router.get('/diagrams/:id', async function (req, res) {
try {
var row = await db.get(
'SELECT id, title, source, notes, created_at, updated_at FROM mermaid_diagrams WHERE id = $1 AND user_id = $2',
[req.params.id, req.user.id]
);
if (!row) return res.status(404).json({ error: 'Diagram not found' });
decryptRow(row);
res.json({ success: true, diagram: row });
} catch (err) {
res.status(500).json({ error: 'Request failed' });
}
});
router.post('/diagrams', async function (req, res) {
try {
var title = clip(req.body.title, MAX_TITLE) || 'Untitled diagram';
var source = clip(req.body.source, MAX_SOURCE);
var notes = clip(req.body.notes, MAX_NOTES);
var count = await db.get('SELECT COUNT(*) as cnt FROM mermaid_diagrams WHERE user_id = $1', [req.user.id]);
if (count && Number(count.cnt) >= MAX_DIAGRAMS_PER_USER) {
return res.status(400).json({ error: 'Maximum ' + MAX_DIAGRAMS_PER_USER + ' diagrams per user' });
}
var result = await db.run(
'INSERT INTO mermaid_diagrams (user_id, title, source, notes) VALUES ($1, $2, $3, $4) RETURNING id',
[req.user.id, cryptoUtil.encryptString(title), cryptoUtil.encryptString(source), cryptoUtil.encryptString(notes)]
);
logger.audit(req.user.id, 'diagram_create', 'Created mermaid diagram', req, { category: 'clinical' });
res.json({ success: true, id: result.lastInsertRowid });
} catch (err) {
console.error('[diagrams] create failed:', err.message);
res.status(500).json({ error: 'Request failed' });
}
});
router.put('/diagrams/:id', async function (req, res) {
try {
var existing = await db.get('SELECT id FROM mermaid_diagrams WHERE id = $1 AND user_id = $2', [req.params.id, req.user.id]);
if (!existing) return res.status(404).json({ error: 'Diagram not found' });
var title = clip(req.body.title, MAX_TITLE) || 'Untitled diagram';
var source = clip(req.body.source, MAX_SOURCE);
var notes = clip(req.body.notes, MAX_NOTES);
await db.run(
'UPDATE mermaid_diagrams SET title = $1, source = $2, notes = $3, updated_at = NOW() WHERE id = $4 AND user_id = $5',
[cryptoUtil.encryptString(title), cryptoUtil.encryptString(source), cryptoUtil.encryptString(notes), req.params.id, req.user.id]
);
res.json({ success: true });
} catch (err) {
console.error('[diagrams] update failed:', err.message);
res.status(500).json({ error: 'Request failed' });
}
});
router.delete('/diagrams/:id', async function (req, res) {
try {
await db.run('DELETE FROM mermaid_diagrams WHERE id = $1 AND user_id = $2', [req.params.id, req.user.id]);
logger.audit(req.user.id, 'diagram_delete', 'Deleted mermaid diagram', req, { category: 'clinical' });
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: 'Request failed' });
}
});
module.exports = router;

View file

@ -417,52 +417,6 @@ async function assertModelAllowed(requestedModel, options) {
}
}
async function callAIStream(messages, options, onToken) {
options = options || {};
var requestedModel = options.model;
var model = requestedModel || DEFAULT_MODEL;
var temperature = options.temperature || 0.3;
var maxTokens = options.maxTokens || 4000;
var startTime = Date.now();
await assertModelAllowed(requestedModel, options);
var client = null;
var provider = null;
if (activeProvider === 'litellm' && litellmClient) {
client = litellmClient;
provider = 'litellm';
} else if (activeProvider === 'openrouter' && openrouter) {
client = openrouter;
provider = 'openrouter';
} else if (activeProvider === 'azure' && azureClient) {
client = azureClient;
provider = 'azure';
model = process.env.AZURE_DEPLOYMENT_NAME || model;
}
if (!client) throw new Error('Streaming is only configured for OpenAI-compatible providers');
var content = '';
var finishReason = null;
var stream = await client.chat.completions.create({
model: model,
messages: messages,
temperature: temperature,
max_tokens: maxTokens,
stream: true
});
for await (var part of stream) {
var choice = part && part.choices && part.choices[0] ? part.choices[0] : null;
if (choice && choice.finish_reason) finishReason = choice.finish_reason;
var delta = choice && choice.delta ? choice.delta.content : '';
if (!delta) continue;
content += delta;
if (typeof onToken === 'function') onToken(delta);
}
var duration = Date.now() - startTime;
logger.apiCall(null, provider + '/' + model, { model: model, duration: duration, statusCode: 200 });
return { success: true, content: content, model: model, provider: provider, duration: duration, finishReason: finishReason };
}
// ============================================================
// MAIN CALL AI FUNCTION — Routes to correct provider
// ============================================================
@ -683,4 +637,4 @@ async function discoverModels() {
return discovered;
}
module.exports = { callAI, callAIStream, whisperClient, activeProvider, discoverModels, vertexClient, litellmClient };
module.exports = { callAI, whisperClient, activeProvider, discoverModels, vertexClient, litellmClient };

View file

@ -83,18 +83,11 @@ test('escapes source titles inside citation link titles', async () => {
assert.doesNotMatch(html, /<script>/);
});
test('strips model-generated Sources and References sections', async () => {
test('does not strip sentences that mention available sources', async () => {
const { renderAssistantMarkdown } = await loadCitationModule();
const html = renderAssistantMarkdown('Answer body [1].\n\n## Sources\n[1] duplicate source', sources);
assert.match(html, /Answer body/);
assert.doesNotMatch(html, /duplicate source/);
});
test('strips colon-style References sections', async () => {
const { renderAssistantMarkdown } = await loadCitationModule();
const html = renderAssistantMarkdown('Answer body [1].\n\n--- References:\n[1] duplicate source', sources);
assert.match(html, /Answer body/);
assert.doesNotMatch(html, /duplicate source/);
const html = renderAssistantMarkdown('Pneumonia can cause parapneumonic effusion [1]. However, the available sources do not specifically identify adenovirus as a typical cause [2]. In summary, evidence is insufficient [3].', sources);
assert.match(html, /available sources do not specifically identify adenovirus/);
assert.match(html, /In summary, evidence is insufficient/);
});
test('normalizes inline markdown headings before rendering', async () => {
@ -110,15 +103,6 @@ test('normalizes old saved assistant answer formatting', async () => {
assert.match(html, /<h3>Bronchiolitis Management<\/h3>/);
assert.match(html, /<li>Mainstay: Supportive care/);
assert.match(html, /Key Differences/);
assert.doesNotMatch(html, /duplicate/);
});
test('strips inline model References after a cited sentence', async () => {
const { renderAssistantMarkdown } = await loadCitationModule();
const html = renderAssistantMarkdown('Infants <2 years often do not respond [2, 3]. --- References: [2] Book p.1 [3] Other p.2', sources);
assert.match(html, /Infants &lt;2 years/);
assert.doesNotMatch(html, /References/);
assert.doesNotMatch(html, /Book p\.1/);
});
test('repairs smashed admission bullet list after citations', async () => {