pediatric-ai-scribe-v3/public/js/myResources.js
Daniel b1e039d834
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 56s
Forgejo Docker Build / Root app tests (push) Successful in 45s
Forgejo Android APK / Build signed APK (push) Successful in 1m53s
Forgejo Docker Build / Build Docker image (push) Successful in 8s
Forgejo Docker Build / Deploy to the host (push) Failing after 2s
fix: a newly added model reaches every picker, including the user-facing ones
The first pass at this covered the two admin cards. It missed the pickers that
matter most: the per-tab model selectors in app.js, which every clinical tab
uses, and the My Resources model dropdown. Both were filled once at page load,
so a model added in Admin was still invisible where people actually choose one.

app.js's boot fetch is now a named loadModelList() that also runs on
models-changed; My Resources re-runs loadOptions(), which is the same call that
decides whether the model row is shown at all.

Both rebuilds keep a choice already made. These selects can be rebuilt while
someone is halfway through a form, and silently moving them off the model they
picked would be worse than not refreshing.

Verified against a mutation: removing the app.js listener fails the test that
says every picker listens.

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

801 lines
35 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;
// A model an administrator adds has to show up here too, without a reload.
// loadOptions also decides whether the model row is shown at all, so this is
// the same call rather than a partial refresh of the select.
document.addEventListener('models-changed', function () {
if (inited) loadOptions();
});
document.addEventListener('tabChanged', function (e) {
if (!e.detail || e.detail.tab !== 'myresources') return;
if (!inited) { init(); inited = true; }
// Reopening the tab is a fresh start, not a resumed one. The DOM survives
// the visit, so without this the previous run's illustration and status sit
// under an empty form as though they belonged to it.
else clearResults();
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');
// Refreshes whichever view is showing, rather than always the documents.
if (refresh) refresh.addEventListener('click', function () {
var panel = document.getElementById('mr-images-panel');
if (panel && !panel.hidden) loadImages(true); else loadLibrary();
});
var docsTab = document.getElementById('tab-mr-docs');
var imagesTab = document.getElementById('tab-mr-images');
if (docsTab) docsTab.addEventListener('click', function () { showLibraryTab('docs'); });
if (imagesTab) imagesTab.addEventListener('click', function () { showLibraryTab('images'); });
var moreImages = document.getElementById('btn-mr-images-more');
if (moreImages) moreImages.addEventListener('click', function () { loadImages(false); });
// 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) {
// Rebuilt, but a choice already made is kept: this also runs when an
// administrator adds a model, and moving someone off the model they
// picked mid-form would be worse than not refreshing at all.
var chosen = select.value;
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 (chosen && models.indexOf(chosen) !== -1) select.value = chosen;
}
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)';
}
// Everything the last run left on screen. The tab keeps its DOM between
// visits — app.js loads a component once and marks it data-loaded — so an
// illustration from a previous generation stayed visible under an empty form
// when the tab was reopened, looking like output for a topic nobody had
// typed. It cleared on a full page refresh and only then, which is why it
// read as a leak.
function clearResults() {
['mr-images', 'mr-searches', 'mr-image-failures'].forEach(function (id) {
var el = document.getElementById(id);
if (el) el.textContent = '';
});
status('');
}
function runGenerate() {
var topic = (document.getElementById('mr-topic') || {}).value || '';
if (!topic.trim()) { status('Enter a topic first.', 'bad'); return; }
clearResults();
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);
// A deck that fell back came out as plain slides. Silently handing
// someone the plainer artifact left them comparing two decks with no
// idea why one had layouts and the other did not — and asking again
// usually gets the designed one, which is only worth knowing if the
// fallback is visible.
if (data.deckFallback) {
status('Saved, but as plain slides: the model could not produce a slide ' +
'design twice (' + data.deckFallback + '). Generating again usually gets one.', 'bad');
}
showIllustrations(data.imageJobs || []);
reportImageFailures(data.imageFailures);
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 reportImageFailures(failures) {
if (!(failures || []).length || typeof showToast !== 'function') return;
// Fewer pictures than asked for, without a word, would look like the model
// ignoring the request rather than the queue refusing it.
showToast(failures.length === 1
? 'One illustration could not be started: ' + failures[0]
: failures.length + ' illustrations could not be started.', 'error');
}
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 image library ──────────────────────────────────────────────────────
// Every picture this account has generated, across all three features. Kept
// because a figure outlives the deck it was drawn for: the deck gets replaced,
// the diagram is still good.
//
// Thumbnails, not originals. The server stores a 256px preview beside each
// asset, so a grid of thirty costs a few kB each instead of thirty full-size
// renders — data-image-thumb is what asks hydrateImage for the small copy.
var imagesLoaded = false;
var nextImagesBefore = null;
function showLibraryTab(which) {
var docs = which !== 'images';
[['tab-mr-docs', docs], ['tab-mr-images', !docs]].forEach(function (pair) {
var tab = document.getElementById(pair[0]);
if (!tab) return;
// aria-selected is the whole state: the stylesheet keys off it, so the
// visible highlight and what a screen reader announces cannot disagree.
tab.setAttribute('aria-selected', pair[1] ? 'true' : 'false');
});
var list = document.getElementById('mr-list');
var panel = document.getElementById('mr-images-panel');
var search = document.getElementById('mr-docs-search');
if (list) list.hidden = !docs;
if (search) search.hidden = !docs;
if (panel) panel.hidden = docs;
// Fetched the first time the tab is opened, not on page load: most visits
// never open it, and it is a database read plus a thumbnail per tile.
if (!docs && !imagesLoaded) loadImages(true);
}
function loadImages(reset) {
var grid = document.getElementById('mr-images-grid');
var empty = document.getElementById('mr-images-empty');
var more = document.getElementById('btn-mr-images-more');
if (!grid) return;
if (reset) { grid.textContent = ''; nextImagesBefore = null; }
if (empty) { empty.hidden = false; empty.textContent = 'Loading…'; }
var url = '/api/generated-images?limit=60' +
(nextImagesBefore && !reset ? '&before=' + encodeURIComponent(nextImagesBefore) : '');
fetch(url, { headers: getAuthHeaders() })
.then(function (r) { return r.json(); })
.then(function (data) {
if (!data.success) throw new Error(data.error || 'Could not load your images');
imagesLoaded = true;
nextImagesBefore = data.nextBefore || null;
if (more) more.hidden = !nextImagesBefore;
if (empty) {
empty.hidden = Boolean(data.images.length) || Boolean(grid.children.length);
empty.textContent = 'No images yet. Tick "Add illustrations" when you generate, ' +
'or ask the Clinical Assistant for a diagram.';
}
import('/js/generatedImages.js').then(function (m) {
data.images.forEach(function (image) { grid.appendChild(imageTile(image, m)); });
}).catch(function () {
if (empty) { empty.hidden = false; empty.textContent = 'Images could not be displayed.'; }
});
})
.catch(function (err) {
if (empty) { empty.hidden = false; empty.textContent = err.message; }
});
}
// Built as elements, never innerHTML: the caption is a model-written prompt.
function imageTile(image, m) {
var tile = document.createElement('figure');
tile.className = 'img-tile';
// The picture is the control: clicking it opens the full size. A button
// rather than a div, so it is reachable and announced without inventing
// keyboard handling.
var frame = document.createElement('button');
frame.type = 'button';
frame.className = 'img-tile-frame loading';
frame.title = 'Open full size';
var img = document.createElement('img');
img.alt = image.prompt ? 'Generated illustration: ' + image.prompt.slice(0, 80) : 'Generated illustration';
img.setAttribute('data-image-thumb', '256');
img.loading = 'lazy';
frame.appendChild(img);
frame.addEventListener('click', function () { openImage(image, m); });
var badge = document.createElement('span');
badge.className = 'img-tile-badge';
badge.textContent = image.source;
frame.appendChild(badge);
m.hydrateImage(img, image.imageUrl)
.then(function () { frame.classList.remove('loading'); })
.catch(function () {
frame.classList.remove('loading');
img.remove();
var gone = document.createElement('span');
gone.style.cssText = 'font-size:11px;color:var(--g500);';
gone.textContent = 'Unavailable';
frame.appendChild(gone);
});
var body = document.createElement('div');
body.className = 'img-tile-body';
var prompt = document.createElement('figcaption');
prompt.className = 'img-tile-prompt';
prompt.textContent = image.prompt || 'No description recorded';
prompt.title = image.prompt || '';
var meta = document.createElement('div');
meta.className = 'img-tile-meta';
meta.textContent = new Date(image.createdAt).toLocaleDateString(undefined,
{ year: 'numeric', month: 'short', day: 'numeric' }) +
(image.bytes ? ' · ' + Math.max(1, Math.round(image.bytes / 1024)) + ' kB' : '');
var actions = document.createElement('div');
actions.className = 'img-tile-actions';
var download = document.createElement('button');
download.type = 'button';
download.className = 'img-tile-action';
download.textContent = 'Download';
download.addEventListener('click', function () { saveImage(image, m, download); });
var remove = document.createElement('button');
remove.type = 'button';
remove.className = 'img-tile-action danger';
remove.textContent = 'Delete';
remove.addEventListener('click', function () { deleteImage(image, tile, remove); });
actions.append(download, remove);
body.append(prompt, meta, actions);
tile.append(frame, body);
return tile;
}
// The full-size copy, fetched the same authenticated way as the tile. A plain
// link would not carry the session on a mobile client, and the asset is served
// no-store on purpose.
function openImage(image, m) {
var overlay = document.createElement('div');
overlay.className = 'img-lightbox';
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
overlay.setAttribute('aria-label', 'Generated illustration');
var full = document.createElement('img');
full.alt = image.prompt || 'Generated illustration';
var bar = document.createElement('div');
bar.className = 'img-lightbox-bar';
var caption = document.createElement('p');
caption.className = 'img-lightbox-caption';
caption.textContent = image.prompt || 'No description recorded';
var close = document.createElement('button');
close.type = 'button';
close.className = 'img-tile-action';
close.textContent = 'Close';
var save = document.createElement('button');
save.type = 'button';
save.className = 'img-tile-action';
save.textContent = 'Download';
save.addEventListener('click', function () { saveImage(image, m, save); });
bar.append(save, close);
overlay.append(full, caption, bar);
// Escape and a click on the backdrop both close it; a click on the picture
// or the buttons does not.
function dismiss() {
overlay.remove();
document.removeEventListener('keydown', onKey);
if (lastFocus && lastFocus.focus) lastFocus.focus();
}
function onKey(e) { if (e.key === 'Escape') dismiss(); }
var lastFocus = document.activeElement;
overlay.addEventListener('click', function (e) { if (e.target === overlay) dismiss(); });
close.addEventListener('click', dismiss);
document.addEventListener('keydown', onKey);
document.body.appendChild(overlay);
close.focus();
m.hydrateImage(full, image.imageUrl).catch(function () {
full.remove();
caption.textContent = 'That image could not be loaded.';
});
}
// Fetched, then saved from memory. A plain <a download href> would work in a
// browser carrying the session cookie and fail on a mobile client, whose
// session is a bearer token an <a> cannot send — and these assets are served
// no-store precisely so they are not sitting in a cache to be linked to.
function saveImage(image, m, button) {
var label = button.textContent;
button.disabled = true;
button.textContent = 'Saving…';
m.privateImageBlob(image.imageUrl)
.then(function (blob) {
var url = URL.createObjectURL(blob);
var link = document.createElement('a');
link.href = url;
link.download = 'illustration-' + image.id.slice(0, 8) + '.' +
({ 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp' })[image.mime] || 'png';
document.body.appendChild(link);
link.click();
link.remove();
// Revoked on the next frame: revoking immediately races the download in
// some browsers.
setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
})
.catch(function () {
if (typeof showToast === 'function') showToast('That image could not be downloaded.', 'error');
})
.finally(function () { button.disabled = false; button.textContent = label; });
}
function deleteImage(image, tile, button) {
// Asked, because it removes the picture from every deck that used it. The
// deck keeps working; the figure is simply no longer there.
if (!window.confirm('Delete this image? Any resource that used it will render without it.')) return;
button.disabled = true;
fetch('/api/generated-images/' + encodeURIComponent(image.id), {
method: 'DELETE', headers: getAuthHeaders()
})
.then(function (r) { return r.json(); })
.then(function (data) {
if (!data.success) throw new Error(data.error || 'Could not delete that image');
tile.remove();
var grid = document.getElementById('mr-images-grid');
var empty = document.getElementById('mr-images-empty');
if (grid && !grid.children.length && empty) {
empty.hidden = false;
empty.textContent = 'No images yet.';
}
})
.catch(function (err) {
button.disabled = false;
if (typeof showToast === 'function') showToast(err.message, 'error');
});
}
// 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'
: row.has_deck === false ? 'presentation, plain text' : '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'; }
clearResults();
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 || {};
// The model can return the document back unchanged. That is a failed
// modification, and saying "Applied" for it sent people off to download
// an identical file and conclude the feature was broken.
if (data.unchanged) {
say('The model returned it unchanged — nothing was modified. ' +
'Try naming the slide or section to change, and what to change about it.', 'bad');
} else {
// Whether it could see the slides is worth saying: it is the
// difference between "slide 4 looks crowded" being actionable and
// being guesswork, and it explains why this took longer.
say('Applied' +
(data.saw ? ', after looking at all ' + data.saw + ' slides' : '') +
(g.used ? ', using ' + g.count + ' library excerpt' + (g.count === 1 ? '' : 's') : '') +
'. Download it to see the result.', 'good');
}
reportSearches(data.searches);
showIllustrations(data.imageJobs || []);
reportImageFailures(data.imageFailures);
if (box && !data.unchanged) 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';
// The modified time, when there is one. Showing only the creation time meant
// a modification that did apply still looked like nothing had happened, so
// the timestamp was the thing people used to conclude modify was broken.
var created = new Date(row.created_at);
var updated = row.updated_at ? new Date(row.updated_at) : created;
var edited = updated - created > 2000;
var meta = document.createElement('div');
meta.style.cssText = 'font-size:11px;color:var(--g500);';
meta.textContent = (row.kind === 'article' ? 'Article' : 'Presentation') +
' · ' + (edited ? 'modified ' + updated.toLocaleString()
: created.toLocaleString()) +
(row.grounded_count ? ' · ' + row.grounded_count + ' library excerpts' : ' · not grounded') +
// Only worth saying when it is the weaker kind. A deck is the normal case.
(row.kind !== 'article' && row.has_deck === false ? ' · plain text, no slide layout' : '');
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);
}
}());