// ============================================================ // 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 = ' 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 || []); reportImageFailures(data.imageFailures); loadLibrary(); }) .catch(function (err) { status(err.message, 'bad'); }) .finally(function () { if (btn) { btn.disabled = false; btn.innerHTML = ' 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 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 = ' 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 || []); reportImageFailures(data.imageFailures); if (box) box.value = ''; loadLibrary(); }) .catch(function (err) { say(err.message, 'bad'); }) .finally(function () { if (btn) { btn.disabled = false; btn.innerHTML = ' 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 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); } }());