pediatric-ai-scribe-v3/public/js/myResources.js
Daniel 055a86bbb1
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 55s
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Android APK / Build signed APK (push) Successful in 1m59s
Forgejo Docker Build / Build Docker image (push) Successful in 18s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
feat: My Resources says what it is, offers its sources in one place, and Modify gets them too
The screen had been trimmed to controls with no explanation of what it was for;
"Private to you" said who could see it, not what it did. It now opens with a
sentence that says what you get and how you get it out, and the header reads
"Only you can see these".

The four choices — clinical library, PubMed, web, illustration — are one "Draw
on" group instead of four separate rows, with the library ticked by default.
Each option hides itself when an administrator has not enabled it, so nothing
appears that a person could tick and then be refused.

Modify offers exactly the same choices. It had none, so "add what the 2024 trial
showed" was answered from the model's memory rather than by looking anything up.
Generate and Modify now go through one gatherSources(), so they cannot drift
into offering different things or searching them differently.

Writing "include a diagram of the airway" in the instructions now switches the
illustration option on and says why, rather than the request being dropped in
silence. Switching it off by hand sticks — the hint then reminds instead of
fighting — and when no image model is configured it says so rather than
pretending. Both the generate and modify boxes behave this way.

Two things found by testing this rather than assuming it.

PubMed ANDs every mapped term, so one unrecognised word takes the query to zero.
"febrile seizures" returns six results and "febrile seizures in under-fives"
returns none; "the anatomy of croup: subglottic narrowing and the steeple sign"
returned none until it was narrowed to "anatomy croup", which returns six. A
query that finds nothing is now retried against progressively shorter versions
of itself, longest first, and the response says which query actually worked so
the screen cannot report one that found nothing. Those extra calls tripped
NCBI's three-a-second limit and produced a 429, so retries are spaced and the
first attempt waits for nothing. Separately, the searches run on the topic while
the library retrieval also gets the instruction: retrieval is semantic and
benefits from the context, but a keyword engine handed a whole sentence returns
nothing.

And when a search was asked for and came back empty, the prompt now says not to
invent a citation, a PMID or a URL to fill the gap. Without that the model
supplies them from memory, and a fabricated PMID looks exactly like a real one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 18:54:17 +02:00

472 lines
20 KiB
JavaScript

// ============================================================
// MY RESOURCES
// A person's own generated teaching material.
//
// Separate from the Learning Hub on purpose: that is moderator-owned content
// published into categories for everyone, this is private and needs no role
// beyond being signed in. The server enforces that independently — every query
// there filters on the owner — so this only has to be an honest interface to it.
// ============================================================
(function () {
var inited = false;
document.addEventListener('tabChanged', function (e) {
if (!e.detail || e.detail.tab !== 'myresources') return;
if (!inited) { init(); inited = true; }
loadLibrary();
});
// What an administrator has switched on. Read once, used by both option
// groups and by the instruction check.
var available = { images: false, web: false, pubmed: false };
// Someone who writes "add a diagram of the airway" in the instructions has
// said what they want as plainly as ticking the box would. Left alone, the
// request is simply dropped and the resource comes back without a picture
// and without a word about why. These are the ways people actually ask.
var IMAGE_REQUEST = /\b(image|images|diagram|diagrams|illustration|illustrations|illustrate|illustrated|figure|figures|picture|pictures|drawing|drawings|chart|charts|graphic|graphics|infographic|visual|visuals|schematic)\b/i;
function looksLikeImageRequest(text) {
return IMAGE_REQUEST.test(String(text || ''));
}
// Turning the option on is the honest response: it does what was asked, it is
// visible, and it can be turned straight back off. Silently ignoring the
// sentence is not. Once it has been switched off by hand, it stays off —
// re-ticking it on every keystroke would be a fight.
function wireImageIntent(textareaId, checkboxId, hintId) {
var box = document.getElementById(textareaId);
var check = document.getElementById(checkboxId);
var hint = document.getElementById(hintId);
if (!box || !check || !hint) return;
var overruled = false;
check.addEventListener('change', function () {
if (!check.checked) overruled = true;
if (check.checked) hint.hidden = true;
});
box.addEventListener('input', function () {
var asked = looksLikeImageRequest(box.value);
if (!asked) { hint.hidden = true; return; }
if (!available.images) {
hint.textContent = 'Your instructions mention a figure, but no image model is configured, so none can be made.';
hint.hidden = false;
return;
}
if (check.checked || overruled) {
hint.hidden = overruled ? false : true;
if (overruled) hint.textContent = 'Your instructions ask for a figure. Tick the illustration option above to get one.';
return;
}
check.checked = true;
hint.textContent = 'Illustration switched on, because your instructions ask for a figure. Untick it if you would rather not.';
hint.hidden = false;
});
}
function init() {
var kind = document.getElementById('mr-kind');
if (kind) kind.addEventListener('change', syncFormatFields);
syncFormatFields();
var generate = document.getElementById('btn-mr-generate');
if (generate) generate.addEventListener('click', runGenerate);
var refresh = document.getElementById('btn-mr-refresh');
if (refresh) refresh.addEventListener('click', loadLibrary);
// One delegated handler: rows are rebuilt on every refresh, so binding per
// row would leak listeners and miss anything added later.
var list = document.getElementById('mr-list');
if (list) list.addEventListener('click', onRowClick);
// Filtering is local: the whole library is already in hand, so searching it
// is instant and costs no request.
var search = document.getElementById('mr-search');
if (search) search.addEventListener('input', renderLibrary);
var modify = document.getElementById('btn-mr-modify');
if (modify) modify.addEventListener('click', runModify);
wireImageIntent('mr-refinement', 'mr-with-images', 'mr-image-hint');
wireImageIntent('mr-modify-instructions', 'mr-modify-images', 'mr-modify-image-hint');
loadOptions();
}
// What an administrator has approved. The model row stays hidden unless there
// is a genuine choice to make — one approved model is not a decision anyone
// should be asked to take.
function loadOptions() {
fetch('/api/my-resources/options', { headers: getAuthHeaders() })
.then(function (r) { return r.json(); })
.then(function (data) {
if (!data || !data.success) return;
var select = document.getElementById('mr-model');
var modelRow = document.getElementById('mr-model-row');
var models = data.models || [];
if (select) {
select.textContent = '';
models.forEach(function (id) {
var option = document.createElement('option');
option.value = id;
option.textContent = id;
if (id === data.defaultModel) option.selected = true;
select.appendChild(option);
});
}
if (modelRow) modelRow.hidden = models.length < 2;
// Hidden entirely unless an administrator enabled it, so an option
// never appears that someone could tick and then be refused. Both
// groups are driven from the same answer: Generate and Modify offer
// the same choices, so they must offer the same ones.
available = {
images: Boolean(data.imagesAvailable),
web: Boolean(data.webSearchAvailable),
pubmed: Boolean(data.pubmedAvailable)
};
[['mr-images-row', 'images'], ['mr-web-row', 'web'], ['mr-pubmed-row', 'pubmed'],
['mr-modify-images-row', 'images'], ['mr-modify-web-row', 'web'],
['mr-modify-pubmed-row', 'pubmed']].forEach(function (pair) {
var row = document.getElementById(pair[0]);
if (row) row.hidden = !available[pair[1]];
});
})
.catch(function () { /* the defaults still work without this */ });
}
function syncFormatFields() {
var isArticle = (document.getElementById('mr-kind') || {}).value === 'article';
var slides = document.getElementById('mr-slide-count');
var words = document.getElementById('mr-word-wrap');
if (slides && slides.parentElement) slides.parentElement.hidden = isArticle;
if (words) words.hidden = !isArticle;
}
function status(text, tone) {
var el = document.getElementById('mr-status');
if (!el) return;
el.textContent = text || '';
el.style.color = tone === 'bad' ? 'var(--red)' : tone === 'good' ? 'var(--green)' : 'var(--g600)';
}
function runGenerate() {
var topic = (document.getElementById('mr-topic') || {}).value || '';
if (!topic.trim()) { status('Enter a topic first.', 'bad'); return; }
var btn = document.getElementById('btn-mr-generate');
if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Generating...'; }
status('Searching the library and writing. This takes a moment.');
var corpusBox = document.getElementById('mr-use-corpus');
fetch('/api/my-resources/generate', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
topic: topic.trim(),
kind: (document.getElementById('mr-kind') || {}).value || 'presentation',
slideCount: (document.getElementById('mr-slide-count') || {}).value,
wordCount: (document.getElementById('mr-word-count') || {}).value,
refinement: (document.getElementById('mr-refinement') || {}).value || '',
useCorpus: corpusBox && corpusBox.checked === false ? 'false' : 'true',
model: (document.getElementById('mr-model') || {}).value || '',
withImages: (document.getElementById('mr-with-images') || {}).checked ? 'true' : 'false',
withWebSearch: (document.getElementById('mr-web-search') || {}).checked ? 'true' : 'false',
withPubmed: (document.getElementById('mr-pubmed') || {}).checked ? 'true' : 'false'
})
})
.then(function (r) { return r.json(); })
.then(function (data) {
if (!data.success) throw new Error(data.error || 'Generation failed');
// Say what it was written from. Ungrounded material presented as
// grounded is the failure worth preventing.
var g = data.grounding || {};
status(g.used
? 'Saved. Written from ' + g.count + ' library excerpt' + (g.count === 1 ? '' : 's') + '.'
: 'Saved. Not grounded' + (g.reason ? ' — ' + g.reason : '') + '; written from the model alone.',
g.used ? 'good' : null);
reportSearches(data.searches);
showIllustrations(data.imageJobs || []);
loadLibrary();
})
.catch(function (err) { status(err.message, 'bad'); })
.finally(function () {
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fas fa-wand-magic-sparkles"></i> Generate'; }
});
}
// Say what was searched for. A query that left the network is worth showing
// plainly rather than leaving someone to wonder whether it happened.
function reportSearches(searches) {
(searches || []).forEach(function (s) {
if (typeof showToast !== 'function') return;
var where = s.tool === 'pubmed_search' ? 'PubMed' : 'the web';
showToast(s.count
? 'Searched ' + where + ' for "' + s.query + '" \u2014 ' + s.count + ' used.'
: 'Nothing found on ' + where + ' for "' + s.query + '".', 'info');
});
}
function ticked(id) {
var el = document.getElementById(id);
return Boolean(el && el.checked);
}
// The shared poller, so an image made here behaves exactly like one made in
// the assistant: same status line, same durable job, same asset endpoint. It
// lives in an ES module and this file is a classic script, hence the dynamic
// import — which also means a failure to load it cannot break generation.
function showIllustrations(jobs) {
var box = document.getElementById('mr-images');
if (!box) return;
box.innerHTML = '';
if (!jobs.length) return;
import('/js/generatedImages.js').then(function (m) {
m.renderImageJobs(box, jobs, 'my_resources', function (card, data) {
var img = document.createElement('img');
img.alt = 'Generated teaching illustration';
img.style.maxWidth = '100%';
card.append(img);
m.hydrateImage(img, data.imageUrl).catch(function () { img.alt = 'Private image unavailable'; });
});
}).catch(function () {
if (typeof showToast === 'function') showToast('An illustration was generated but could not be displayed.', 'info');
});
}
// The library as last fetched. Held so searching and the Modify picker both
// work from one copy rather than each asking the server again.
var library = [];
function loadLibrary() {
var list = document.getElementById('mr-list');
if (!list) return;
fetch('/api/my-resources', { headers: getAuthHeaders() })
.then(function (r) { return r.json(); })
.then(function (data) {
library = (data && data.resources) || [];
renderLibrary();
syncModifyTargets();
})
.catch(function () {
library = [];
list.textContent = '';
var failed = document.createElement('p');
failed.style.cssText = 'margin:0;font-size:13px;color:var(--red);';
failed.textContent = 'Could not load your resources.';
list.appendChild(failed);
syncModifyTargets();
});
}
function matches(row, needle) {
if (!needle) return true;
return (String(row.title || '') + ' ' + String(row.topic || '')).toLowerCase().indexOf(needle) !== -1;
}
function renderLibrary() {
var list = document.getElementById('mr-list');
if (!list) return;
var search = document.getElementById('mr-search');
var needle = String((search && search.value) || '').trim().toLowerCase();
var rows = library.filter(function (row) { return matches(row, needle); });
list.textContent = '';
if (!rows.length) {
var empty = document.createElement('p');
empty.style.cssText = 'margin:0;font-size:13px;color:var(--g400);';
// Two different situations, and saying "nothing yet" to someone whose
// search simply missed would be wrong.
empty.textContent = library.length
? 'Nothing matches "' + needle + '".'
: 'Nothing yet. Generate something above and it will appear here.';
list.appendChild(empty);
return;
}
rows.forEach(function (row) { list.appendChild(renderRow(row)); });
}
// The Modify picker is the same library, so it is rebuilt from it — and the
// selection survives a refresh, because losing it mid-sentence is maddening.
function syncModifyTargets() {
var select = document.getElementById('mr-modify-target');
if (!select) return;
var previous = select.value;
select.textContent = '';
if (!library.length) {
var none = document.createElement('option');
none.value = '';
none.textContent = 'Nothing to modify yet';
select.appendChild(none);
select.disabled = true;
return;
}
select.disabled = false;
library.forEach(function (row) {
var option = document.createElement('option');
option.value = String(row.id);
option.textContent = (row.title || 'Untitled') +
' \u2014 ' + (row.kind === 'article' ? 'article' : 'presentation');
select.appendChild(option);
});
if (previous && library.some(function (row) { return String(row.id) === previous; })) {
select.value = previous;
}
}
function runModify() {
var select = document.getElementById('mr-modify-target');
var box = document.getElementById('mr-modify-instructions');
var status = document.getElementById('mr-modify-status');
var btn = document.getElementById('btn-mr-modify');
function say(message, tone) {
if (!status) return;
status.textContent = message || '';
status.style.color = tone === 'bad' ? 'var(--red)' : tone === 'good' ? 'var(--green)' : 'var(--g600)';
}
var id = select && select.value;
var instructions = String((box && box.value) || '').trim();
if (!id) return say('Nothing to modify yet.', 'bad');
if (!instructions) return say('Say what to change.', 'bad');
if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Applying'; }
say('Rewriting…');
fetch('/api/my-resources/' + encodeURIComponent(id) + '/refine', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
instructions: instructions,
useCorpus: ticked('mr-modify-corpus') ? 'true' : 'false',
withPubmed: ticked('mr-modify-pubmed') ? 'true' : 'false',
withWebSearch: ticked('mr-modify-web-search') ? 'true' : 'false',
withImages: ticked('mr-modify-images') ? 'true' : 'false',
model: (document.getElementById('mr-model') || {}).value || ''
})
})
.then(function (r) { return r.json(); })
.then(function (data) {
if (!data.success) throw new Error(data.error || 'Could not apply the changes');
// Same reporting as generating: what it was written from, what was
// searched, and any figure that came back.
var g = data.grounding || {};
say('Applied' + (g.used ? ', using ' + g.count + ' library excerpt' + (g.count === 1 ? '' : 's') : '') +
'. Download it to see the result.', 'good');
reportSearches(data.searches);
showIllustrations(data.imageJobs || []);
if (box) box.value = '';
loadLibrary();
})
.catch(function (err) { say(err.message, 'bad'); })
.finally(function () {
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fas fa-pen-to-square"></i> Apply changes'; }
});
}
// Built as elements rather than innerHTML: a title comes from a model, and
// this is the one place it reaches the page.
function renderRow(row) {
var wrap = document.createElement('div');
wrap.className = 'saved-enc-item';
wrap.style.cssText = 'padding:8px 12px;display:flex;align-items:center;gap:10px;flex-wrap:wrap;';
var body = document.createElement('div');
body.style.flex = '1';
body.style.minWidth = '180px';
var title = document.createElement('div');
title.style.cssText = 'font-weight:600;font-size:13px;';
title.textContent = row.title || 'Untitled';
var meta = document.createElement('div');
meta.style.cssText = 'font-size:11px;color:var(--g500);';
meta.textContent = (row.kind === 'article' ? 'Article' : 'Presentation') +
' · ' + new Date(row.created_at).toLocaleString() +
(row.grounded_count ? ' · ' + row.grounded_count + ' library excerpts' : ' · not grounded');
body.appendChild(title);
body.appendChild(meta);
wrap.appendChild(body);
// An article has no slides, so offering PowerPoint would produce a deck of
// paragraphs. A presentation as Word is fine — prose absorbs slide content
// without overflowing anything.
var formats = row.kind === 'article' ? ['docx', 'pdf'] : ['pptx', 'docx', 'pdf'];
formats.forEach(function (format) {
var btn = document.createElement('button');
btn.className = 'btn-sm btn-ghost';
btn.type = 'button';
btn.dataset.download = String(row.id);
btn.dataset.format = format;
btn.textContent = format.toUpperCase();
btn.title = 'Download as ' + format.toUpperCase();
wrap.appendChild(btn);
});
var del = document.createElement('button');
del.className = 'btn-sm btn-ghost';
del.type = 'button';
del.dataset.remove = String(row.id);
del.style.color = 'var(--red)';
del.title = 'Delete';
var icon = document.createElement('i');
icon.className = 'fas fa-trash';
del.appendChild(icon);
wrap.appendChild(del);
return wrap;
}
function onRowClick(event) {
var download = event.target.closest && event.target.closest('[data-download]');
if (download) return downloadResource(download.dataset.download, download.dataset.format, download);
var remove = event.target.closest && event.target.closest('[data-remove]');
if (remove) {
showConfirm('Delete this resource? This cannot be undone.', function () {
fetch('/api/my-resources/' + encodeURIComponent(remove.dataset.remove), {
method: 'DELETE', headers: getAuthHeaders()
})
.then(function (r) { return r.json(); })
.then(function (d) {
if (!d.success) throw new Error(d.error || 'Could not delete');
loadLibrary();
})
.catch(function (err) { showToast(err.message, 'error'); });
}, { danger: true, confirmText: 'Delete' });
}
}
// Fetched rather than linked, because the download needs the auth header and
// an <a href> cannot carry one.
function downloadResource(id, format, btn) {
var original = btn.textContent;
btn.disabled = true;
btn.textContent = '...';
fetch('/api/my-resources/' + encodeURIComponent(id) + '/export?format=' + encodeURIComponent(format), {
headers: getAuthHeaders()
})
.then(function (r) {
if (!r.ok) return r.json().then(function (d) { throw new Error(d.error || 'Download failed'); });
var name = 'resource.' + format;
var disposition = r.headers.get('Content-Disposition') || '';
var match = disposition.match(/filename="([^"]+)"/);
if (match) name = match[1];
return r.blob().then(function (blob) { saveBlob(blob, name); });
})
.catch(function (err) { showToast(err.message, 'error'); })
.finally(function () { btn.disabled = false; btn.textContent = original; });
}
function saveBlob(blob, name) {
var url = URL.createObjectURL(blob);
var link = document.createElement('a');
link.href = url;
link.download = name;
document.body.appendChild(link);
link.click();
link.remove();
setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
}
}());