diff --git a/package-lock.json b/package-lock.json
index 767e38e4..ccb53681 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -27,6 +27,7 @@
"express-rate-limit": "^7.4.0",
"helmet": "^8.0.0",
"jsonwebtoken": "^9.0.2",
+ "jszip": "^3.10.2",
"katex": "^0.18.7",
"mammoth": "^1.8.0",
"markdown-it": "^14.1.1",
@@ -4941,9 +4942,9 @@
"license": "MIT"
},
"node_modules/jszip": {
- "version": "3.10.1",
- "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
- "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
+ "version": "3.10.2",
+ "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.2.tgz",
+ "integrity": "sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
diff --git a/package.json b/package.json
index 7a5024df..f3d49adb 100644
--- a/package.json
+++ b/package.json
@@ -35,6 +35,7 @@
"express-rate-limit": "^7.4.0",
"helmet": "^8.0.0",
"jsonwebtoken": "^9.0.2",
+ "jszip": "^3.10.2",
"katex": "^0.18.7",
"mammoth": "^1.8.0",
"markdown-it": "^14.1.1",
diff --git a/public/components/my-resources.html b/public/components/my-resources.html
index 1a681af2..f28677d4 100644
--- a/public/components/my-resources.html
+++ b/public/components/my-resources.html
@@ -48,6 +48,31 @@
+
Instructions (optional)
diff --git a/public/js/myResources.js b/public/js/myResources.js
index 08d4803f..dd88a25e 100644
--- a/public/js/myResources.js
+++ b/public/js/myResources.js
@@ -32,6 +32,36 @@
// 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;
+ })
+ .catch(function () { /* the defaults still work without this */ });
}
function syncFormatFields() {
@@ -67,7 +97,9 @@
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'
+ useCorpus: corpusBox && corpusBox.checked === false ? 'false' : 'true',
+ model: (document.getElementById('mr-model') || {}).value || '',
+ withImages: (document.getElementById('mr-with-images') || {}).checked ? 'true' : 'false'
})
})
.then(function (r) { return r.json(); })
@@ -80,6 +112,9 @@
? '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);
+ 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'); })
@@ -139,7 +174,11 @@
body.appendChild(meta);
wrap.appendChild(body);
- ['pptx', 'docx', 'pdf'].forEach(function (format) {
+ // 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';
diff --git a/src/routes/myResources.js b/src/routes/myResources.js
index b3e1825c..fa0ea9f0 100644
--- a/src/routes/myResources.js
+++ b/src/routes/myResources.js
@@ -20,6 +20,7 @@ var db = require('../db/database');
var { authMiddleware } = require('../middleware/auth');
var { callAI } = require('../utils/ai');
var learningRetrieval = require('../utils/learningRetrieval');
+var imageTool = require('../utils/imageTool');
var documentExport = require('../utils/documentExport');
// Scoped to this router's own prefix. Mounted on /api, a bare
@@ -38,6 +39,26 @@ function clampInt(value, min, max, fallback) {
// Presentation and article are the two shapes markdown renders well into, and
// the only two the exporter knows. Anything else is rejected rather than
// guessed at.
+// The models a user may choose from are the ones an admin already curates for
+// the assistant. A second allow-list would be another thing to keep in step,
+// and would let this feature reach a model the institution never approved.
+async function allowedModels() {
+ var configured = String(await db.getSetting('clinical_assistant.chat_model', '') || '').trim();
+ var allowed = String(await db.getSetting('clinical_assistant.allowed_models', '') || '')
+ .split(',').map(function (m) { return m.trim(); }).filter(Boolean);
+ if (configured && allowed.indexOf(configured) === -1) allowed.unshift(configured);
+ return { configured: configured, allowed: allowed };
+}
+
+// Anything not on the list falls back to the configured default rather than
+// being refused: a stale option in a browser tab should not cost someone their
+// generation.
+async function resolveModel(requested) {
+ var models = await allowedModels();
+ var wanted = String(requested || '').trim();
+ return wanted && models.allowed.indexOf(wanted) !== -1 ? wanted : (models.configured || undefined);
+}
+
function normalizeKind(kind) {
return String(kind) === 'article' ? 'article' : 'presentation';
}
@@ -79,6 +100,23 @@ function firstHeading(markdown, fallback) {
return (m ? m[1] : fallback || 'Untitled').trim().slice(0, MAX_TITLE);
}
+// What this user may choose. Driven entirely by admin settings, so the screen
+// shows a single fixed model until an admin allows more — exactly like chat.
+router.get('/my-resources/options', async function (req, res) {
+ try {
+ var models = await allowedModels();
+ res.json({
+ success: true,
+ models: models.allowed,
+ defaultModel: models.configured,
+ imagesAvailable: Boolean(await db.getSetting('clinical_assistant.image_model', ''))
+ });
+ } catch (err) {
+ console.error('[my-resources] options:', err.message);
+ res.status(500).json({ error: 'Could not load options' });
+ }
+});
+
// ── Generate ────────────────────────────────────────────────
router.post('/my-resources/generate', async function (req, res) {
try {
@@ -105,9 +143,29 @@ router.post('/my-resources/generate', async function (req, res) {
wordCount: clampInt(req.body.wordCount, 200, 3000, 800)
});
- var ai = await callAI([{ role: 'user', content: prompt }], {
- model: req.body.model || undefined, temperature: 0.3
- });
+ // Illustration is opt-in per generation. The tool is only offered when the
+ // author ticked the box, because a model handed a drawing tool will find a
+ // reason to use it, and most teaching material does not want one.
+ var wantsImages = String(req.body.withImages) === 'true' || req.body.withImages === true;
+ var imageModel = wantsImages ? String(await db.getSetting('clinical_assistant.image_model', '') || '') : '';
+ var model = await resolveModel(req.body.model);
+ var messages = [{ role: 'user', content: prompt }];
+ var options = { model: model, temperature: 0.3 };
+
+ var ai = await callAI(messages, wantsImages && imageModel
+ ? Object.assign({}, options, { tools: imageTool.tools })
+ : options);
+
+ // The same dispatcher the assistant uses, so an image generated here is
+ // owned, queued and rendered exactly as one generated there.
+ if (wantsImages && imageModel) {
+ ai = await imageTool.dispatch(ai, {
+ owner: req.user.id, workflow: 'my_resources', body: req.body,
+ imageContext: topic, imageModel: imageModel,
+ messages: messages, options: options, callAI: callAI
+ });
+ }
+
var markdown = String((ai && ai.content) || '').trim();
if (!markdown) return res.status(502).json({ error: 'The model returned nothing. Try again.' });
@@ -122,6 +180,7 @@ router.post('/my-resources/generate', async function (req, res) {
resource: row,
markdown: markdown,
grounding: { used: Boolean(corpus.context), count: corpus.sources.length, reason: corpus.reason || null },
+ imageJobs: ai.imageJobs || [],
model: ai && ai.model
});
} catch (err) {
@@ -197,7 +256,7 @@ router.post('/my-resources/:id/refine', async function (req, res) {
'Keep the same overall structure unless the instruction asks otherwise, and keep any ' +
'References section at the end.\n\nINSTRUCTION: ' + instructions +
'\n\nMARKDOWN:\n"""\n' + existing.markdown + '\n"""' }],
- { model: req.body.model || undefined, temperature: 0.2 });
+ { model: await resolveModel(req.body.model), temperature: 0.2 });
var revised = String((ai && ai.content) || '').trim();
if (!revised) return res.status(502).json({ error: 'The model returned nothing. Try again.' });
@@ -226,6 +285,12 @@ router.get('/my-resources/:id/export', async function (req, res) {
);
if (!row) return res.status(404).json({ error: 'Not found' });
+ // The UI does not offer it, but the route is the boundary that matters:
+ // rendering an article as slides produces a deck of paragraphs.
+ if (row.kind === 'article' && format === 'pptx') {
+ return res.status(400).json({ error: 'An article has no slides. Download it as Word or PDF.' });
+ }
+
var bytes = await documentExport.render(row.markdown, row.kind, format);
res.setHeader('Content-Type', documentExport.mimeFor(format));
res.setHeader('Content-Disposition',
diff --git a/src/utils/documentExport.js b/src/utils/documentExport.js
index 8d766b1c..2ac50f4d 100644
--- a/src/utils/documentExport.js
+++ b/src/utils/documentExport.js
@@ -16,6 +16,7 @@ var fsp = require('fs/promises');
var os = require('os');
var pathMod = require('path');
var { execFile } = require('child_process');
+var JSZip = require('jszip');
var REFERENCE_DECK = pathMod.join(__dirname, '..', '..', 'assets', 'learning', 'slides-reference.pptx');
var GOTENBERG = process.env.GOTENBERG_URL || 'http://gotenberg:3000';
@@ -29,6 +30,42 @@ var FORMATS = {
function isSupported(format) { return Object.hasOwn(FORMATS, String(format)); }
function mimeFor(format) { return (FORMATS[format] || {}).mime; }
+/**
+ * Let a slide shrink its own 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 — so a slide with too much on it
+ * is simply cut off mid-sentence, and the remaining bullets are not rendered at
+ * all. Verified by rendering one: three of eight bullets survived.
+ *
+ *
with no scale asks the renderer to work out the reduction
+ * itself, which means a slide that already fits is untouched. A fixed
+ * fontScale would shrink every slide whether it needed it or not.
+ *
+ * This is a floor, not a substitute for slides that are the right length — the
+ * prompt still asks for one idea per slide. It stops a long one becoming
+ * unreadable rather than making overcrowding acceptable.
+ */
+async function fitSlideText(bytes) {
+ try {
+ var zip = await JSZip.loadAsync(bytes);
+ var slides = Object.keys(zip.files).filter(function (name) {
+ return /^ppt\/slides\/slide\d+\.xml$/.test(name);
+ });
+ if (!slides.length) return bytes;
+ for (var i = 0; i < slides.length; i++) {
+ var xml = await zip.file(slides[i]).async('string');
+ if (xml.indexOf('normAutofit') !== -1) continue;
+ zip.file(slides[i], xml.replace(/
/g, '
'));
+ }
+ return await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
+ } catch (e) {
+ // A deck that renders imperfectly beats no deck at all.
+ console.warn('[export] could not apply slide autofit:', e.message);
+ return bytes;
+ }
+}
+
function runPandoc(args, cwd) {
return new Promise(function (resolve, reject) {
execFile('pandoc', args, { cwd: cwd, timeout: 60000, maxBuffer: 1024 * 1024 },
@@ -69,12 +106,14 @@ async function render(markdown, kind, format) {
if (format === 'pptx') otherArgs.splice(1, 0, '--reference-doc=' + REFERENCE_DECK);
await runPandoc(otherArgs, workdir);
}
- return await fsp.readFile(pathMod.join(workdir, 'doc.' + format));
+ var built = await fsp.readFile(pathMod.join(workdir, 'doc.' + format));
+ return format === 'pptx' ? await fitSlideText(built) : built;
}
// PDF: hand the office file to Gotenberg. Its LibreOffice keeps the deck's
// layout, which is why this is not rendered from the markdown directly.
var bytes = await fsp.readFile(pathMod.join(workdir, 'doc.' + office));
+ if (office === 'pptx') bytes = await fitSlideText(bytes);
var form = new FormData();
form.append('files', new File([bytes], 'doc.' + office, { type: FORMATS[office].mime }));
var response = await fetch(GOTENBERG + '/forms/libreoffice/convert', {
diff --git a/test/my-resources.test.js b/test/my-resources.test.js
index dfb45166..28acf111 100644
--- a/test/my-resources.test.js
+++ b/test/my-resources.test.js
@@ -96,9 +96,9 @@ test('the screen is reachable by anyone signed in, and states that it is private
assert.match(component, /Nobody else sees these/);
});
-test('a row offers all three formats, and the download carries its auth', () => {
+test('a row offers the right formats, and the download carries its auth', () => {
const js = read('public/js/myResources.js');
- assert.match(js, /\['pptx', 'docx', 'pdf'\]\.forEach/);
+ assert.match(js, /formats\.forEach\(function \(format\)/);
// An
cannot carry the Authorization header, so the file is fetched
// and saved from a blob instead of linked.
assert.match(js, /headers: getAuthHeaders\(\)/);
@@ -109,3 +109,68 @@ test('a row offers all three formats, and the download carries its auth', () =>
assert.match(js, /title\.textContent = row\.title \|\| 'Untitled';/);
assert.doesNotMatch(js, /innerHTML\s*=\s*[^'"]*row\./, 'never interpolated into innerHTML');
});
+
+test('users pick from the models an admin already approved, and nothing else', () => {
+ const route = read('src/routes/myResources.js');
+ // One allow-list, the one chat already uses. A second would be another thing
+ // to keep in step, and would let this reach a model nobody approved.
+ assert.match(route, /db\.getSetting\('clinical_assistant\.allowed_models', ''\)/);
+ assert.match(route, /db\.getSetting\('clinical_assistant\.chat_model', ''\)/);
+ // A stale option in an open browser tab must not cost someone their
+ // generation, so an unknown model falls back rather than being refused.
+ assert.match(route, /return wanted && models\.allowed\.indexOf\(wanted\) !== -1 \? wanted : \(models\.configured \|\| undefined\);/);
+ // Refining goes through the same resolution, not req.body.model directly.
+ assert.doesNotMatch(route, /model: req\.body\.model \|\| undefined/);
+
+ // And the screen only asks when there is a real choice to make.
+ const js = read('public/js/myResources.js');
+ assert.match(js, /if \(modelRow\) modelRow\.hidden = models\.length < 2;/);
+});
+
+test('illustration is opt-in, and reuses the assistant’s image tool', () => {
+ const route = read('src/routes/myResources.js');
+ // A model handed a drawing tool will find a reason to use it, so the tool is
+ // only offered when the author asked for one.
+ assert.match(route, /var wantsImages = String\(req\.body\.withImages\) === 'true'/);
+ assert.match(route, /wantsImages && imageModel\s*\n?\s*\? Object\.assign\(\{\}, options, \{ tools: imageTool\.tools \}\)/);
+ // The same dispatcher the assistant uses, so an image made here is owned,
+ // queued and rendered identically to one made there.
+ assert.match(route, /imageTool\.dispatch\(ai, \{/);
+ assert.match(route, /workflow: 'my_resources'/, 'but attributed to this feature');
+ assert.match(route, /imageJobs: ai\.imageJobs \|\| \[\]/, 'and reported back');
+
+ // The row is hidden entirely when no image model is configured.
+ assert.match(read('public/js/myResources.js'), /if \(imagesRow\) imagesRow\.hidden = !data\.imagesAvailable;/);
+ assert.match(read('public/components/my-resources.html'), /Off by default: a model handed a drawing tool/);
+});
+
+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
+ // slide with eight bullets showed three and cut the third mid-sentence, and
+ // the remaining five were not on the slide at all.
+ const exporter = read('src/utils/documentExport.js');
+ assert.match(exporter, /async function fitSlideText\(bytes\)/);
+ assert.match(exporter, / <\/a:bodyPr>/);
+ // No fontScale: the renderer works out the reduction, so a slide that already
+ // fits is left alone. A fixed scale would shrink every slide regardless.
+ assert.doesNotMatch(exporter, /normAutofit fontScale/);
+ // Running it on a deck that already has autofit must not double-inject.
+ assert.match(exporter, /if \(xml\.indexOf\('normAutofit'\) !== -1\) continue;/);
+ // And it applies to the PDF path too, which renders from the pptx.
+ assert.match(exporter, /if \(office === 'pptx'\) bytes = await fitSlideText\(bytes\);/);
+ // A deck that renders imperfectly beats no deck at all.
+ assert.match(exporter, /could not apply slide autofit/);
+ assert.ok(JSON.parse(read('package.json')).dependencies.jszip, 'jszip is declared, not borrowed');
+});
+
+test('an article is never offered as slides', () => {
+ const js = read('public/js/myResources.js');
+ const route = read('src/routes/myResources.js');
+ // A deck of paragraphs is not a presentation. Word and PDF are fine for
+ // either; PowerPoint only makes sense for something written as slides.
+ assert.match(js, /row\.kind === 'article' \? \['docx', 'pdf'\] : \['pptx', 'docx', 'pdf'\]/);
+ // The route is the boundary that matters, not the button.
+ assert.match(route, /if \(row\.kind === 'article' && format === 'pptx'\)/);
+ assert.match(route, /An article has no slides\. Download it as Word or PDF\./);
+});