diff --git a/public/components/my-resources.html b/public/components/my-resources.html index e654af52..7c06edb2 100644 --- a/public/components/my-resources.html +++ b/public/components/my-resources.html @@ -68,7 +68,10 @@

Off by default: a model handed a drawing tool will find a reason to use it, and - most teaching material does not want one. Images appear in your image history. + most teaching material does not want one. With this on you can also say what to + draw in Instructions below — “include a diagram of the + airway” — instead of leaving the choice to the model. One illustration + per generation; it appears under the Generate button.

@@ -106,7 +109,7 @@
- +
@@ -121,10 +124,49 @@
+ +
+
+

Modify

+ Revise one you already have +
+
+
+ + +
+ +
+ +
+ +

+ Rewrites the resource in place, keeping its structure and any References section + unless you ask otherwise. The previous version is replaced, so copy anything you + want to keep first. +

+
+
+ +
+ + +
+
+
+

Library

-
+
+ +
+ +
diff --git a/public/js/myResources.js b/public/js/myResources.js index ef2f7b39..bfabfe15 100644 --- a/public/js/myResources.js +++ b/public/js/myResources.js @@ -33,6 +33,14 @@ 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(); } @@ -160,29 +168,122 @@ }); } + // 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) { - 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)); }); + 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, + 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 = ' Apply changes'; } }); } diff --git a/src/routes/myResources.js b/src/routes/myResources.js index 9c6e8e2b..491e7a64 100644 --- a/src/routes/myResources.js +++ b/src/routes/myResources.js @@ -108,7 +108,16 @@ function buildPrompt(opts) { 'description of the single most useful figure. It must be schematic or anatomical teaching ' + 'artwork, never a depiction of a real patient. The "output only Pandoc markdown" rule below ' + 'is about the written resource; the tool call is not a violation of it. Do not write an ' + - 'image tag or a URL into the markdown \u2014 the image is attached separately.\n' + 'image tag or a URL into the markdown \u2014 the image is attached separately.\n' + + // Otherwise the decision is the model's alone, and an author who wants a + // figure of something specific has no way to say so. Instructions are + // free text, so this is what makes "illustrate the airway anatomy" or + // "definitely include a diagram" actually reach the illustration choice + // instead of only steering the prose. + 'If the author\'s additional instructions below ask for illustration, or name what the ' + + 'figure should show, follow them: treat that as the decision already made and compose the ' + + 'image description from what they asked for. Exactly one image is produced per ' + + 'generation, so if they ask for several, draw the single most useful one.\n' : ''; var shape = kind === 'presentation' diff --git a/test/my-resources.test.js b/test/my-resources.test.js index 8b7d46a7..35f5cc41 100644 --- a/test/my-resources.test.js +++ b/test/my-resources.test.js @@ -179,6 +179,58 @@ test('illustration is opt-in, and reuses the assistant’s image tool', () => { assert.match(read('public/components/my-resources.html'), /Off by default: a model handed a drawing tool/); }); +test('the author can ask for the illustration, not only leave it to the model', () => { + const route = read('src/routes/myResources.js'); + // Without this the decision is the model's alone, and someone who wants a + // figure of something particular has no way to say so — the instructions + // steer the prose and nothing else. + assert.match(route, /If the author\\'s additional instructions below ask for illustration/); + assert.match(route, /image description from what they asked for/); + // And it says what it cannot do, rather than quietly drawing one of three. + assert.match(route, /Exactly one image is produced per/); + // The instructions really do come after this paragraph in the prompt, so + // "below" is accurate rather than a guess. + const prompt = route.slice(route.indexOf('function buildPrompt')); + assert.ok(prompt.indexOf('illustration +') < prompt.indexOf('Additional instructions'), + 'illustration guidance precedes the instructions it refers to'); + // Said once on screen too, including the limit. + const html = read('public/components/my-resources.html'); + assert.match(html, /include a diagram of the/); + assert.match(html, /One illustration\s*\n?\s*per generation/); +}); + +test('the library is bounded, searchable, and drives the modify picker', () => { + const html = read('public/components/my-resources.html'); + // Unbounded, a long library pushes everything else off the page. + assert.match(html, /id="mr-list"[^>]*max-height:360px;overflow-y:auto;/); + assert.match(html, /id="mr-search"/); + + const js = read('public/js/myResources.js'); + // Filtering is local — the rows are already in hand, so it costs no request. + assert.match(js, /search\.addEventListener\('input', renderLibrary\)/); + assert.match(js, /var rows = library\.filter/); + assert.match(js, /String\(row\.title \|\| ''\) \+ ' ' \+ String\(row\.topic \|\| ''\)/, 'title and topic both searched'); + // "Nothing yet" and "nothing matches" are different situations. + assert.match(js, /library\.length\s*\n?\s*\? 'Nothing matches/); +}); + +test('modify revises something already generated, in place', () => { + const js = read('public/js/myResources.js'); + // The endpoint existed with no way to reach it: the markdown is what is + // stored precisely so that "redo slide 4" is a text edit. + assert.match(js, /\/refine'/); + assert.match(js, /instructions: instructions/); + // The picker is the library, so it cannot drift from it, and a selection + // survives the refresh that follows a generation. + assert.match(js, /function syncModifyTargets\(\)/); + assert.match(js, /var previous = select\.value;/); + assert.match(js, /if \(previous && library\.some/); + // Refusals are local rather than a wasted round trip. + assert.match(js, /if \(!instructions\) return say\('Say what to change\.', 'bad'\);/); + // And the screen says the old version is gone, because it is. + assert.match(read('public/components/my-resources.html'), /The previous version is replaced/); +}); + test('a slide shrinks its text rather than spilling off the bottom', () => { // pandoc writes a bare on every shape, which leaves the body with // no autofit even though the slide master has one. Rendered and counted: a