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 @@ + + + +
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\./); +});