feat: instructions can ask for the illustration; library scrolls and searches; Modify
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
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
This commit is contained in:
parent
79c329ceda
commit
8cca5a4796
4 changed files with 218 additions and 14 deletions
|
|
@ -68,7 +68,10 @@
|
|||
</label>
|
||||
<p style="margin:0;font-size:12px;color:var(--g500);">
|
||||
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 <strong>Instructions</strong> 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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -106,7 +109,7 @@
|
|||
|
||||
<div class="admin-row" style="align-items:flex-start;">
|
||||
<label for="mr-refinement" class="admin-row-label">Instructions <span style="color:var(--g400);font-weight:400;">(optional)</span></label>
|
||||
<textarea id="mr-refinement" class="admin-control" style="min-height:70px;resize:vertical;font-family:inherit;" placeholder="e.g. for FY1s, case-based, emphasise red flags"></textarea>
|
||||
<textarea id="mr-refinement" class="admin-control" style="min-height:70px;resize:vertical;font-family:inherit;" placeholder="e.g. for FY1s, case-based, emphasise red flags, include a diagram of the airway"></textarea>
|
||||
</div>
|
||||
|
||||
<div style="border-top:1px solid var(--g100);padding-top:12px;display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
|
||||
|
|
@ -121,10 +124,49 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modify — change something already generated rather than starting again.
|
||||
The markdown is what is stored, so "redo slide 4" is a text edit; the
|
||||
resource keeps its id, its downloads and its place in the library. -->
|
||||
<div class="card" style="margin-top:14px;">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-pen-to-square"></i> Modify</h3>
|
||||
<span style="font-size:12px;color:var(--g500);">Revise one you already have</span>
|
||||
</div>
|
||||
<div style="padding:16px;display:flex;flex-direction:column;gap:14px;">
|
||||
<div class="admin-row">
|
||||
<label for="mr-modify-target" class="admin-row-label">Resource</label>
|
||||
<select id="mr-modify-target" class="admin-control" style="max-width:420px;"></select>
|
||||
</div>
|
||||
|
||||
<div class="admin-row" style="align-items:flex-start;">
|
||||
<label for="mr-modify-instructions" class="admin-row-label">What to change</label>
|
||||
<div style="flex:1;display:flex;flex-direction:column;gap:4px;min-width:0;">
|
||||
<textarea id="mr-modify-instructions" class="admin-control" style="min-height:70px;resize:vertical;font-family:inherit;" placeholder="e.g. split slide 4 into two, drop the pharmacology, add a summary slide at the end"></textarea>
|
||||
<p style="margin:0;font-size:12px;color:var(--g500);">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="border-top:1px solid var(--g100);padding-top:12px;display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
|
||||
<button id="btn-mr-modify" class="btn-sm btn-primary" type="button"><i class="fas fa-pen-to-square"></i> Apply changes</button>
|
||||
<span id="mr-modify-status" role="status" style="font-size:12px;color:var(--g600);"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-top:14px;">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-book"></i> Library</h3>
|
||||
<button id="btn-mr-refresh" class="btn-sm btn-ghost" type="button"><i class="fas fa-rotate"></i> Refresh</button>
|
||||
</div>
|
||||
<div id="mr-list" style="padding:12px 16px;display:flex;flex-direction:column;gap:6px;"></div>
|
||||
<div style="padding:12px 16px 0;">
|
||||
<input id="mr-search" type="search" class="admin-control" placeholder="Search your resources by title or topic" autocomplete="off">
|
||||
</div>
|
||||
<!-- Bounded rather than unlimited: a long library otherwise pushes everything
|
||||
else off the page. Tall enough to show several at a glance, and it
|
||||
collapses to the content when there are only a few. -->
|
||||
<div id="mr-list" style="padding:12px 16px;display:flex;flex-direction:column;gap:6px;max-height:360px;overflow-y:auto;"></div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 = '<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'; }
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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 <a:bodyPr/> on every shape, which leaves the body with
|
||||
// no autofit even though the slide master has one. Rendered and counted: a
|
||||
|
|
|
|||
Loading…
Reference in a new issue