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

This commit is contained in:
Daniel 2026-09-08 23:40:53 +02:00
parent 68385c42f1
commit bb218750d9
6 changed files with 196 additions and 16 deletions

View file

@ -1,6 +1,5 @@
<div class="assistant-topbar">
<button id="btn-assistant-mobile-menu" class="btn-sm btn-ghost assistant-mobile-menu" type="button" title="Saved chats"><i class="fas fa-bars"></i></button>
<button id="btn-assistant-goback" class="btn-sm btn-ghost" type="button" title="Back to the main menu"><i class="fas fa-arrow-left"></i> Go back</button>
<div class="assistant-topbar-title">
<h2><i class="fas fa-brain" style="color:var(--purple);"></i> AI Clinical Assistant</h2>
</div>
@ -12,6 +11,8 @@
<div class="assistant-layout">
<aside class="assistant-history">
<button id="btn-assistant-goback" class="btn-sm btn-ghost assistant-goback-rail" type="button" title="Back to the main menu"><i class="fas fa-arrow-left"></i> Go back</button>
<button id="btn-assistant-create-image" class="assistant-create-image" type="button"><i class="fas fa-wand-magic-sparkles"></i> Create image</button>
<button id="btn-assistant-clear" class="assistant-new-chat" type="button"><i class="fas fa-plus"></i> New chat</button>
<nav class="assistant-view-switch" aria-label="Assistant views">
<button id="btn-assistant-chat-view" class="active" type="button"><i class="fas fa-comments"></i> Chat</button>
@ -25,6 +26,14 @@
<p class="assistant-muted">Chats save automatically as you go.</p>
</div>
</div>
<div class="card assistant-gallery-card">
<div class="card-header">
<h3><i class="fas fa-images"></i> Images</h3>
</div>
<div id="assistant-image-gallery" class="assistant-image-gallery">
<p class="assistant-muted">Generated images appear here.</p>
</div>
</div>
</aside>
<section class="assistant-main card">
@ -38,7 +47,6 @@
<button id="btn-assistant-export-pdf" class="btn-sm btn-ghost" type="button"><i class="fas fa-file-pdf"></i> Export PDF</button>
<button id="btn-assistant-download-chat" class="btn-sm btn-ghost" type="button">Download transcript</button>
<button id="btn-assistant-takehome" class="btn-sm btn-ghost" type="button"><i class="fas fa-heart"></i> Patient take home</button>
<button id="btn-assistant-voice" class="btn-sm btn-ghost" type="button"><i class="fas fa-microphone"></i> Voice</button>
</div>
</div>
@ -57,17 +65,18 @@
<form id="assistant-form" class="assistant-composer">
<label for="assistant-input">Clinical question</label>
<textarea id="assistant-input" rows="3" placeholder="Ask a focused clinical question..." autocomplete="off" aria-describedby="assistant-context-budget assistant-context-warning"></textarea>
<textarea id="assistant-input" rows="1" placeholder="Ask a focused clinical question..." autocomplete="off" aria-describedby="assistant-context-budget assistant-context-warning"></textarea>
<p id="assistant-context-budget" class="assistant-muted" aria-live="polite">Loading conversation limit; history and draft are counted in UTF-16 code units. The server validates each request.</p>
<div id="assistant-context-warning" role="alert" hidden></div>
<div id="assistant-attachments" class="assistant-attachments" aria-label="Attached images" hidden></div>
<div class="assistant-composer-footer">
<label class="assistant-attach" for="assistant-attach-input" title="Attach up to 4 images (PNG, JPEG, WebP)"><i class="fas fa-paperclip"></i> Attach images</label>
<label class="assistant-attach" for="assistant-attach-input" title="Attach up to 4 images (PNG, JPEG, WebP)"><i class="fas fa-paperclip"></i></label>
<input type="file" id="assistant-attach-input" accept="image/png,image/jpeg,image/webp" multiple hidden>
<label class="assistant-check"><input type="checkbox" id="assistant-include-context" checked> retrieve broader context</label>
<button id="btn-assistant-mic" class="btn-sm btn-ghost assistant-mic" type="button" title="Dictate your question"><i class="fas fa-microphone"></i></button>
<button id="btn-assistant-voice" class="assistant-voice-btn" type="button" title="Voice conversation"><i class="fas fa-headset"></i></button>
<button id="btn-assistant-mic" class="assistant-mic" type="button" title="Dictate your question"><i class="fas fa-microphone"></i></button>
<label class="assistant-check" title="Include retrieved clinical context"><input type="checkbox" id="assistant-include-context" checked></label>
<button id="btn-assistant-cancel" class="btn-sm btn-ghost" type="button" hidden><i class="fas fa-stop"></i> Cancel search</button>
<button id="btn-assistant-send" class="btn-generate" type="submit"><i class="fas fa-paper-plane"></i> Ask</button>
<button id="btn-assistant-send" class="btn-send" type="submit"><i class="fas fa-arrow-up"></i></button>
</div>
</form>
</div>

View file

@ -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%; }

View file

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

View file

@ -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 = '<div class="modal-content">' +
'<div class="modal-header"><h2>Create image</h2>' +
'<button type="button" class="modal-close" data-create-image-close aria-label="Close"><i class="fas fa-xmark"></i></button></div>' +
'<div class="modal-body">' +
'<label for="create-image-description" class="assistant-create-image-label">Describe what you want</label>' +
'<textarea id="create-image-description" rows="3" placeholder="e.g. a colorful poster on asthma care at home" autocomplete="off"></textarea>' +
'<label for="create-image-chat" class="assistant-create-image-label">Base it on</label>' +
'<select id="create-image-chat">' +
options.map(function (opt) {
return '<option value="' + escapeAttr(opt.id) + '">' + escapeHtml(opt.title) + '</option>';
}).join('') +
'</select>' +
'<div class="assistant-create-image-actions">' +
'<button type="button" class="btn-generate" id="btn-create-image-generate"><i class="fas fa-wand-magic-sparkles"></i> Generate</button>' +
'</div>' +
'</div></div>';
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 = '<p class="assistant-muted">Generated images appear here.</p>';
return;
}
wrap.innerHTML = done.slice(0, 24).map(function (job) {
return '<button type="button" class="assistant-gallery-item" data-gallery-image="' + escapeAttr(job.imageUrl) + '" title="' + escapeAttr(new Date(job.createdAt || '').toLocaleString()) + '"><img src="' + escapeAttr(job.imageUrl) + '" alt="Generated image" loading="lazy"></button>';
}).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 = '<div class="modal-content"><div class="modal-header"><h2>Generated image</h2>' +
'<button type="button" class="modal-close" data-image-preview-close aria-label="Close"><i class="fas fa-xmark"></i></button></div>' +
'<div class="modal-body"><img src="' + escapeAttr(src) + '" alt="Generated image" style="width:100%;border-radius:12px;"></div></div>';
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) {

View file

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

View file

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