Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Android APK / Build signed APK (push) Successful in 2m9s
Forgejo Docker Build / Build Docker image (push) Successful in 11s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
The one feature here that sends text outside the building, so the defaults are the careful ones: disabled unless an administrator turns it on, opt-in per generation even then, and the option is hidden entirely rather than shown as something a user can tick and be refused. Only the search query leaves. Library excerpts, the generated resource and anything about the user never do. Both screens say so plainly, because a topic typed while drafting clinical material can carry clinical detail and the provider keeps its own logs. Four providers behind one shape, so swapping changes nothing downstream: Tavily, Serper over Google, Brave, and SearXNG — the only one where the query does not reach a commercial third party at all, which is why it is worth supporting even though it needs somewhere to run. The tool description says when NOT to search, because a model handed a search tool will reach for it constantly: not for settled clinical knowledge, which is what the indexed library is for, and one search per resource. That last one is enforced in the route with toolChoice: 'none' on the continuation rather than trusted to the model. A failed search never fails a generation — same contract as corpus retrieval. The resource is written without it and the response says what was searched for and what came back, so a query that left the network is visible rather than silent. The API key is masked on read and preserved when the field is left blank, the handling the OIDC client secret already gets, so changing provider cannot silently wipe a working key. Verified on the running instance: with nothing configured, webSearchAvailable is false, and a request asking for it anyway is ignored rather than honoured. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
269 lines
11 KiB
JavaScript
269 lines
11 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);
|
|
|
|
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;
|
|
})
|
|
.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'
|
|
})
|
|
})
|
|
.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.
|
|
if (data.webSearch && typeof showToast === 'function') {
|
|
showToast(data.webSearch.count
|
|
? 'Searched the web for "' + data.webSearch.query + '" — ' + data.webSearch.count + ' results used.'
|
|
: 'Web search found nothing for "' + data.webSearch.query + '".', 'info');
|
|
}
|
|
if ((data.imageJobs || []).length && typeof showToast === 'function') {
|
|
showToast('An illustration is being generated; it will appear in your image history.', 'info');
|
|
}
|
|
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'; }
|
|
});
|
|
}
|
|
|
|
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) {
|
|
list.textContent = '';
|
|
var rows = (data && data.resources) || [];
|
|
if (!rows.length) {
|
|
var empty = document.createElement('p');
|
|
empty.style.cssText = 'margin:0;font-size:13px;color:var(--g400);';
|
|
empty.textContent = 'Nothing yet. Generate something above and it will appear here.';
|
|
list.appendChild(empty);
|
|
return;
|
|
}
|
|
rows.forEach(function (row) { list.appendChild(renderRow(row)); });
|
|
})
|
|
.catch(function () {
|
|
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);
|
|
});
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
}());
|