Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 50s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Successful in 2m4s
Forgejo Docker Build / Build Docker image (push) Successful in 12s
Forgejo Docker Build / Deploy to the host (push) Failing after 2s
Three things to My Resources. Illustration was entirely the model's call: someone who wanted a figure of something particular had no way to say so, because the instructions steered the prose and nothing else. The illustration guidance now tells the model to follow the author's instructions when they ask for a figure or name what it should show, and to treat that as the decision already made. Verified live: "include a diagram showing the age distribution and the simple-versus-complex distinction" produced exactly that, both halves in one figure. Exactly one image per generation is a real limit, not a wording choice — the shared imageTool dispatcher rejects more than one tool call per request, and it is used by the assistant and Learning Hub too. So the prompt says to draw the single most useful one if several are asked for, and the screen says the same. The library was an unbounded list that pushed everything below it off the page. It is now a 360px scrolling box with a search over title and topic, filtered locally because the rows are already in hand. "Nothing yet" and "nothing matches" are different messages, because telling someone whose search missed that they have never generated anything is wrong. Measured in a real render: 360px visible of 642px of content, and searching narrows 10 rows to 3. Modify is new UI over the refine endpoint, which existed with no way to reach it. Pick a resource, say what to change, and it is rewritten in place keeping its id, its downloads and its References section. The picker is built from the same library array, so it cannot drift, and a selection survives the refresh that follows a generation. Verified live: "add a Key Takeaways slide before References" inserted exactly that and left the other four slides alone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
395 lines
16 KiB
JavaScript
395 lines
16 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();
|
|
});
|
|
|
|
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);
|
|
|
|
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;
|
|
var imagesRow = document.getElementById('mr-images-row');
|
|
if (imagesRow) imagesRow.hidden = !data.imagesAvailable;
|
|
// Hidden entirely unless an administrator enabled it, so the option
|
|
// never appears as something a user could turn on and be refused.
|
|
var webRow = document.getElementById('mr-web-row');
|
|
if (webRow) webRow.hidden = !data.webSearchAvailable;
|
|
var pubmedRow = document.getElementById('mr-pubmed-row');
|
|
if (pubmedRow) pubmedRow.hidden = !data.pubmedAvailable;
|
|
})
|
|
.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);
|
|
// Say what was searched for. A query that left the network is worth
|
|
// showing plainly rather than leaving someone to wonder.
|
|
(data.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 + '" — ' + s.count + ' used.'
|
|
: 'Nothing found on ' + where + ' for "' + s.query + '".', 'info');
|
|
});
|
|
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'; }
|
|
});
|
|
}
|
|
|
|
// 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,
|
|
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');
|
|
say('Applied. The library entry is updated; download it to see the result.', 'good');
|
|
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);
|
|
}
|
|
}());
|