From bb218750d929776b3c715b5ccf61b83707d54194 Mon Sep 17 00:00:00 2001 From: Daniel Date: Tue, 8 Sep 2026 23:40:53 +0200 Subject: [PATCH] feat: Create image tool at rail top (describe or pick a chat), Images gallery of past creations, ChatGPT-style composer icons (headset/mic/attach/send), example tap asks immediately --- public/components/assistant.html | 23 +++-- public/css/assistant.css | 28 +++++ public/js/assistant/api.js | 5 + public/js/clinicalAssistant.js | 131 ++++++++++++++++++++++-- src/routes/clinicalAssistant.js | 18 ++++ test/assistant-workspace-layout.test.js | 7 +- 6 files changed, 196 insertions(+), 16 deletions(-) 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 @@
-

AI Clinical Assistant

@@ -12,6 +11,8 @@
+
@@ -38,7 +47,6 @@ - @@ -57,17 +65,18 @@
- +

Loading conversation limit; history and draft are counted in UTF-16 code units. The server validates each request.

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'); });