diff --git a/public/components/assistant.html b/public/components/assistant.html
index ec8f0f2b..61f1894f 100644
--- a/public/components/assistant.html
+++ b/public/components/assistant.html
@@ -1,6 +1,5 @@
@@ -38,7 +47,6 @@
-
@@ -57,17 +65,18 @@
diff --git a/public/css/assistant.css b/public/css/assistant.css
index d3ba9504..bdc1be65 100644
--- a/public/css/assistant.css
+++ b/public/css/assistant.css
@@ -251,3 +251,31 @@ body.assistant-workspace .assistant-learning-view { min-height: 0; height: 100vh
.assistant-saved-chat-delete { position:absolute; top:6px; right:8px; display:flex; align-items:center; justify-content:center; width:24px; height:24px; border-radius:6px; color:var(--g500, #6b7280); opacity:0; transition:opacity .12s ease; pointer-events:none; }
.assistant-saved-chat:hover .assistant-saved-chat-delete, .assistant-saved-chat:focus-within .assistant-saved-chat-delete { opacity:1; pointer-events:auto; }
.assistant-saved-chat-delete:hover { color:var(--danger, #dc2626); background:rgba(220,38,38,.08); }
+
+/* Create image entry + Images gallery (ChatGPT-style left rail) */
+.assistant-create-image { display:flex; align-items:center; gap:8px; width:100%; border:none; border-radius:12px; padding:10px 12px; background:linear-gradient(90deg,var(--purple,#7c3aed),var(--purple-light,#a78bfa)); color:#fff; font-weight:700; font-size:13px; cursor:pointer; }
+.assistant-create-image:hover { filter:brightness(1.06); }
+.assistant-goback-rail { margin-bottom:2px; }
+.assistant-gallery-card { flex:0 1 auto; }
+.assistant-image-gallery { display:grid; grid-template-columns:repeat(auto-fill,minmax(56px,1fr)); gap:6px; padding:10px 12px; max-height:220px; overflow-y:auto; }
+.assistant-gallery-item { border:none; background:none; padding:0; border-radius:8px; overflow:hidden; cursor:pointer; border:1px solid var(--g200); }
+.assistant-gallery-item img { width:100%; height:56px; object-fit:cover; display:block; }
+.assistant-create-image-label { display:block; font-size:12px; font-weight:700; color:var(--g500); margin:10px 0 4px; }
+#create-image-description, #create-image-chat { width:100%; border:1.5px solid var(--g300); border-radius:10px; padding:8px 10px; font-size:13px; }
+.assistant-create-image-actions { display:flex; justify-content:flex-end; margin-top:12px; }
+
+/* ChatGPT-style pill composer */
+.assistant-composer { border:1px solid var(--g200); border-radius:28px; box-shadow:0 8px 30px rgba(0,0,0,.08); padding:6px 8px 6px 18px; display:flex; align-items:center; gap:8px; }
+.assistant-composer > label { display:none; }
+#assistant-input { flex:1 1 auto; border:none; outline:none; resize:none; background:transparent; font-size:15px; line-height:1.5; min-height:24px; max-height:140px; padding:6px 0; }
+.assistant-composer-footer { display:flex; align-items:center; gap:2px; flex:0 0 auto; }
+.assistant-attach, .assistant-voice-btn, .assistant-mic { width:38px; height:38px; border:none; background:none; border-radius:50%; display:flex; align-items:center; justify-content:center; color:var(--g600); cursor:pointer; font-size:16px; }
+.assistant-attach:hover, .assistant-voice-btn:hover, .assistant-mic:hover { background:var(--g100); }
+.assistant-mic.recording { background:var(--danger,#dc2626); color:#fff; }
+.assistant-check { display:flex; align-items:center; justify-content:center; width:38px; height:38px; border-radius:50%; cursor:pointer; color:var(--g500); font-size:0; position:relative; }
+.assistant-check::before { content:'\\f002'; font-family:'Font Awesome 6 Free'; font-weight:900; font-size:13px; }
+.assistant-check input { position:absolute; inset:0; opacity:0; cursor:pointer; }
+.assistant-check:has(input:checked) { color:var(--purple); background:var(--purple-light,#faf5ff); }
+.btn-send { width:40px; height:40px; border:none; border-radius:50%; background:var(--accent,#0f766e); color:#fff; display:flex; align-items:center; justify-content:center; cursor:pointer; font-size:16px; }
+.btn-send:disabled { opacity:.5; cursor:not-allowed; }
+.assistant-composer .assistant-muted { flex-basis:100%; }
diff --git a/public/js/assistant/api.js b/public/js/assistant/api.js
index 3d84b077..76396fa9 100644
--- a/public/js/assistant/api.js
+++ b/public/js/assistant/api.js
@@ -123,3 +123,8 @@ export function assistantAttachmentPayload(attachments) {
return { dataBase64: attachment.dataBase64, mimeType: attachment.mimeType };
});
}
+
+export function fetchAssistantImageJobs() {
+ return fetch('/api/clinical-assistant/image/jobs?limit=24', { headers: authHeaders(), credentials: 'same-origin' })
+ .then(parseJsonWithStatus);
+}
diff --git a/public/js/clinicalAssistant.js b/public/js/clinicalAssistant.js
index 724ee530..f5d37fae 100644
--- a/public/js/clinicalAssistant.js
+++ b/public/js/clinicalAssistant.js
@@ -23,7 +23,7 @@ import {
translateAssistantMessage,
assistantAttachmentLimits,
assistantAttachmentPayload
-} from './assistant/api.js';
+, fetchAssistantImageJobs} from './assistant/api.js';
var initialized = false;
var messages = [];
var attachments = []; // Input-only images: ride the outgoing question, then persist with the sent message.
@@ -50,6 +50,7 @@ import {
var imageStore = createAssistantImageStore();
var TRANSLATE_LANGUAGES = [['en', 'English'], ['es', 'Español'], ['fr', 'Français'], ['de', 'Deutsch'], ['it', 'Italiano'], ['pt', 'Português'], ['zh', '中文'], ['ar', 'العربية'], ['ru', 'Русский'], ['hi', 'हिन्दी'], ['nl', 'Nederlands'], ['pl', 'Polski'], ['tr', 'Türkçe'], ['uk', 'Українська'], ['fa', 'فارسی'], ['sw', 'Kiswahili']];
+ var savedChatCache = [];
var translateLanguagesAvailable = null; // { libretranslate: [...], deepl: [...] }
var translateLanguagesLoading = false;
@@ -128,6 +129,8 @@ import {
var attachInput = document.getElementById('assistant-attach-input');
var input = document.getElementById('assistant-input');
var goBackBtn = document.getElementById('btn-assistant-goback');
+ var createImageBtn = document.getElementById('btn-assistant-create-image');
+ if (createImageBtn) createImageBtn.addEventListener('click', openCreateImageDialog);
var mobileMenuBtn = document.getElementById('btn-assistant-mobile-menu');
if (mobileMenuBtn) mobileMenuBtn.addEventListener('click', function(ev) {
ev.stopPropagation();
@@ -1122,6 +1125,117 @@ import {
if (overlay) overlay.remove();
}
+
+ // ── Create image: describe it, or base it on any saved chat (latest first) ──
+ function openCreateImageDialog() {
+ var existing = document.getElementById('assistant-create-image-modal');
+ if (existing) { existing.remove(); return; }
+ var modal = document.createElement('div');
+ modal.className = 'assistant-takehome-modal';
+ modal.id = 'assistant-create-image-modal';
+ var options = [{ id: '', title: 'This chat' }].concat((savedChatCache || []).map(function (chat) {
+ return { id: chat.id, title: chat.title || 'Saved chat' };
+ }));
+ modal.innerHTML = '' +
+ '' +
+ '
' +
+ '
' +
+ '
' +
+ '
' +
+ '
' +
+ '
' +
+ '' +
+ '
' +
+ '
';
+ document.body.appendChild(modal);
+ modal.addEventListener('click', function (event) {
+ if (event.target === modal || event.target.closest('[data-create-image-close]')) modal.remove();
+ });
+ modal.querySelector('#btn-create-image-generate').addEventListener('click', function () {
+ var description = document.getElementById('create-image-description');
+ var select = document.getElementById('create-image-chat');
+ var prompt = String(description && description.value || '').trim();
+ var chatId = select ? String(select.value || '') : '';
+ modal.remove();
+ startImageFromSelection(prompt, chatId);
+ });
+ var select = modal.querySelector('#create-image-chat');
+ if (select) select.selectedIndex = 0;
+ }
+
+ function startImageFromSelection(prompt, chatId) {
+ var sourceMessages = messages;
+ var load = chatId
+ ? function () {
+ return fetchSavedAssistantChat(chatId).then(function (data) {
+ if (!data.success || !data.chat) throw new Error('Could not load that chat');
+ sourceMessages = (data.chat.payload && data.chat.payload.messages) || [];
+ });
+ }
+ : function () { return Promise.resolve(); };
+ load().then(function () {
+ var full = sourceMessages.map(function (m) { return { role: m.role, content: m.content }; });
+ var effective = prompt || 'Create a pediatric teaching visual from this conversation.';
+ var owner;
+ try { owner = captureSharingOwner(); } catch (_) { return; }
+ startAssistantImageJob(effective, full).then(function (data) {
+ assertSharingOwner(owner);
+ if (!data.success) throw new Error(data.error || 'Image generation failed');
+ generatedImageJobs = [{ jobId: data.jobId }];
+ lastGeneratedImageSrc = '';
+ exporter.invalidate();
+ var out = document.getElementById('assistant-visual-output');
+ if (out) {
+ out.replaceChildren();
+ renderImageJobs(out, generatedImageJobs, 'clinical_assistant', function (card, image) {
+ if (generatedImageJobs[0] && generatedImageJobs[0].jobId !== image.jobId) return;
+ lastGeneratedImageSrc = image.imageUrl;
+ card.insertAdjacentHTML('beforeend', imageStore.renderGeneratedImage(image.imageUrl, 'Generated teaching visual', image.downloadUrl));
+ exporter.invalidate();
+ });
+ }
+ loadImageGallery();
+ if (typeof showToast === 'function') showToast('Image generation started — it appears in the Image / Graph panel', 'success');
+ }).catch(function (error) { if (!validSharingOwner(owner) || error.name === 'AbortError') return; if (typeof showToast === 'function') showToast(error.message, 'error'); });
+ }).catch(function (error) { if (typeof showToast === 'function') showToast(error.message, 'error'); });
+ }
+
+ // ── Images gallery: everything this user has created, newest first ──
+ function loadImageGallery() {
+ var wrap = document.getElementById('assistant-image-gallery');
+ if (!wrap || typeof fetchAssistantImageJobs !== 'function') return;
+ fetchAssistantImageJobs().then(function (data) {
+ if (!data.success || !Array.isArray(data.jobs)) return;
+ var done = data.jobs.filter(function (job) { return job.imageUrl; });
+ if (!done.length) {
+ wrap.innerHTML = 'Generated images appear here.
';
+ return;
+ }
+ wrap.innerHTML = done.slice(0, 24).map(function (job) {
+ return '';
+ }).join('');
+ }).catch(function () {});
+ }
+
+ function openImagePreview(src) {
+ if (!src) return;
+ var modal = document.createElement('div');
+ modal.className = 'assistant-takehome-modal';
+ modal.id = 'assistant-image-preview-modal';
+ modal.innerHTML = '';
+ document.body.appendChild(modal);
+ modal.addEventListener('click', function (event) {
+ if (event.target === modal || event.target.closest('[data-image-preview-close]')) modal.remove();
+ });
+ }
+
// ── Patient take home: plain-language summary + copy/export/email ────
var takehomeBusy = false;
var takehomeText = '';
@@ -1183,6 +1297,8 @@ import {
}
document.addEventListener('click', function(event) {
+ var galleryItem = event.target.closest('[data-gallery-image]');
+ if (galleryItem) { openImagePreview(galleryItem.getAttribute('data-gallery-image')); return; }
if (event.target.closest('[data-assistant-takehome-copy]')) {
copyText(takehomeText, 'Take home copied', 'Copy failed');
return;
@@ -1472,12 +1588,13 @@ import {
if (btn.getAttribute('data-assistant-bound') === 'true') return;
btn.setAttribute('data-assistant-bound', 'true');
btn.addEventListener('click', function () {
+ // ChatGPT behavior: one tap asks the example immediately — no separate send.
var el = document.getElementById('assistant-input');
- if (el) {
- el.value = btn.getAttribute('data-assistant-example') || '';
- updateConversationBudget();
- el.focus();
- }
+ if (!el) return;
+ el.value = btn.getAttribute('data-assistant-example') || '';
+ updateConversationBudget();
+ el.focus();
+ onAsk();
});
});
}
@@ -1496,9 +1613,11 @@ import {
fetchSavedAssistantChats()
.then(function (data) { if (data.success) renderSavedChats(data.chats || []); })
.catch(function () {});
+ loadImageGallery();
}
function renderSavedChats(chats) {
+ savedChatCache = chats || [];
var wrap = document.getElementById('assistant-saved-chats');
if (!wrap) return;
if (!chats.length) {
diff --git a/src/routes/clinicalAssistant.js b/src/routes/clinicalAssistant.js
index f10aa607..7c89634a 100644
--- a/src/routes/clinicalAssistant.js
+++ b/src/routes/clinicalAssistant.js
@@ -416,6 +416,24 @@ async function submitImage(req, res, synchronous) {
}
router.post('/clinical-assistant/image', (req, res) => submitImage(req, res, true));
router.post('/clinical-assistant/image/jobs', (req, res) => submitImage(req, res, false));
+router.get('/clinical-assistant/image/jobs', async function(req, res) {
+ try {
+ var limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 24, 1), 50);
+ var rows = await db.all(
+ "SELECT id, stage, model, created_at FROM generated_image_jobs WHERE owner_id=$1 AND workflow='clinical_assistant' ORDER BY created_at DESC LIMIT $2",
+ [req.user.id, limit]);
+ res.json({ success: true, jobs: rows.map(function (row) {
+ var done = row.stage === 'done';
+ return { jobId: row.id, status: row.stage, createdAt: row.created_at, model: row.model,
+ imageUrl: done ? '/api/generated-images/' + row.id : null,
+ downloadUrl: done ? '/api/generated-images/' + row.id + '?download=1' : null };
+ }) });
+ } catch (e) {
+ logger.error('GET /clinical-assistant/image/jobs', e.message);
+ res.status(503).json({ error: 'Image service unavailable' });
+ }
+});
+
router.get('/clinical-assistant/image/jobs/:id', async function(req, res) {
try { res.json(await generatedImages.service().get(req.params.id, req.user.id, 'clinical_assistant')); }
catch (e) { res.status(e.statusCode || 503).json({ error: e.statusCode ? e.message : 'Image service unavailable' }); }
diff --git a/test/assistant-workspace-layout.test.js b/test/assistant-workspace-layout.test.js
index e982e32a..b640bcd8 100644
--- a/test/assistant-workspace-layout.test.js
+++ b/test/assistant-workspace-layout.test.js
@@ -49,17 +49,18 @@ test('assistant area is an OWUI-style three-column workspace with a slim go-back
const left = app.document.querySelector('.assistant-history');
assert.ok(left.querySelector('#assistant-saved-chats'), 'saved chats live in the left rail');
assert.equal(left.querySelector('#btn-assistant-clear') !== null, true, 'New chat sits at the top of the rail');
- assert.equal(left.firstElementChild.id, 'btn-assistant-clear', 'New chat is the first rail element');
+ assert.equal(left.firstElementChild.id, 'btn-assistant-goback', 'Go back is the first rail element (ChatGPT-style), then Create image, then New chat');
const right = app.document.querySelector('.assistant-side');
assert.ok(right.querySelector('#assistant-visual-output'), 'image controls in the right column');
assert.ok(right.querySelector('#assistant-sources'), 'sources panel in the right column');
assert.equal(right.querySelector('#assistant-saved-chats'), null, 'saved chats moved out of the right column');
const topbar = app.document.querySelector('.assistant-topbar');
assert.ok(topbar);
- assert.ok(topbar.querySelector('#btn-assistant-goback'), 'Go back control present');
+ assert.ok(app.document.querySelector('.assistant-history #btn-assistant-goback'), 'Go back lives at the top of the left rail (ChatGPT-style sidebar)');
+ assert.ok(app.document.querySelector('.assistant-history #btn-assistant-create-image'), 'Create image entry sits at the rail top');
const actions = app.document.querySelector('.assistant-toolbar-actions');
const buttons = [...actions.querySelectorAll('button')].map(b => b.id);
- assert.deepEqual(buttons, ['btn-assistant-export-pdf', 'btn-assistant-download-chat', 'btn-assistant-takehome', 'btn-assistant-voice'], 'top bar keeps Export PDF, Download transcript, Patient take home and Voice');
+ assert.deepEqual(buttons, ['btn-assistant-export-pdf', 'btn-assistant-download-chat', 'btn-assistant-takehome'], 'top bar keeps Export PDF, Download transcript and Patient take home');
const examples = app.document.querySelectorAll('.assistant-empty .assistant-examples button, [data-assistant-example]');
assert.ok(examples.length >= 3, 'the generated example questions stay on the empty chat screen');
});