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