diff --git a/public/components/assistant.html b/public/components/assistant.html index f6183744..b87a13b6 100644 --- a/public/components/assistant.html +++ b/public/components/assistant.html @@ -1,6 +1,6 @@
- +

AI Clinical Assistant

diff --git a/public/css/assistant.css b/public/css/assistant.css index b699cc5b..62ae3058 100644 --- a/public/css/assistant.css +++ b/public/css/assistant.css @@ -135,6 +135,10 @@ .assistant-image-modal-cancel { justify-self:center; border:0; border-radius:999px; background:white; color:var(--g800); font-weight:700; padding:9px 14px; box-shadow:var(--shadow); cursor:pointer; } .assistant-sources { padding:10px 12px; display:grid; gap:8px; flex:1 1 auto; min-height:0; overflow-y:auto; } .assistant-saved-chats { flex:1 1 auto; overflow-y:auto; min-height:0; padding:10px 12px; display:flex; flex-direction:column; gap:8px; } +/* Open WebUI-style recency headings above each run of chats. */ +.assistant-saved-chat-group { display:block; } +.assistant-saved-chat-group + .assistant-saved-chat-group { margin-top:10px; } +.assistant-saved-chat-heading { margin:0 0 2px; padding:0 10px; font-size:11px; font-weight:700; letter-spacing:.04em; text-transform:uppercase; color:var(--g400); } .assistant-saved-chat { border:1px solid transparent; border-radius:10px; padding:7px 10px; background:transparent; display:flex; flex-direction:column; gap:1px; width:100%; text-align:left; cursor:pointer; color:inherit; font:inherit; min-width:0; } .assistant-saved-chat:hover { background:var(--g100, #f3f4f6); border-color:transparent; } .assistant-saved-chat.active { border-color:var(--accent, #0f766e); } @@ -270,11 +274,16 @@ /* Full-screen Open WebUI workspace: the assistant replaces the app chrome */ body.assistant-workspace .assistant-layout { height: calc(100vh - 64px); min-height: 0; grid-template-rows: minmax(0, 1fr); overflow: hidden; } /* Desktop: the saved-chats rail collapses like ChatGPT's sidebar */ -.assistant-toggle-history { border:none; background:none; color:var(--g600); padding:6px 8px; border-radius:8px; cursor:pointer; } +.assistant-toggle-history { border:none; background:none; color:var(--g600); padding:6px 8px; border-radius:8px; cursor:pointer; line-height:1; } .assistant-toggle-history:hover { background:var(--g100); color:var(--g800); } +.assistant-toggle-history.is-collapsed { color:var(--purple); background:var(--purple-light); } +.assistant-layout { transition:grid-template-columns .18s ease; } +/* Open WebUI-style rail: the column collapses to zero and the chat takes the + space, animated so the change reads as a fold rather than a jump. */ .assistant-layout { transition:grid-template-columns .18s ease; } .assistant-layout.history-collapsed { grid-template-columns:0 minmax(0,1fr) 330px; } .assistant-layout.history-collapsed .assistant-history { display:none; } +@media (prefers-reduced-motion:reduce) { .assistant-layout { transition:none; } } body.assistant-workspace .assistant-layout > * { min-height: 0; } body.assistant-workspace .assistant-main, body.assistant-workspace .assistant-main #assistant-chat-view { min-height: 0; } diff --git a/public/js/clinicalAssistant.js b/public/js/clinicalAssistant.js index 3bf0cca1..62e27a2e 100644 --- a/public/js/clinicalAssistant.js +++ b/public/js/clinicalAssistant.js @@ -149,6 +149,18 @@ import { openCreateImageDialog(); }); var mobileMenuBtn = document.getElementById('btn-assistant-mobile-menu'); + // The button is the only affordance once the rail is gone, so it must always + // say which way it goes. + var syncHistoryToggle = function(collapsed) { + var btn = document.getElementById('btn-assistant-toggle-history'); + if (!btn) return; + var label = collapsed ? 'Show saved chats' : 'Hide saved chats'; + btn.setAttribute('aria-expanded', collapsed ? 'false' : 'true'); + btn.setAttribute('aria-label', label); + btn.setAttribute('title', label); + btn.classList.toggle('is-collapsed', !!collapsed); + }; + var closeDrawer = function() { var layout = document.getElementById('assistant-layout'); if (layout) layout.classList.remove('mobile-chats-open'); @@ -167,15 +179,15 @@ import { var layout = document.getElementById('assistant-layout'); if (!layout) return; var collapsed = layout.classList.toggle('history-collapsed'); + syncHistoryToggle(collapsed); try { localStorage.setItem('ped_assistant_history_collapsed', collapsed ? '1' : '0'); } catch (e) {} }); if (typeof window !== 'undefined' && window.innerWidth > 640) { - try { - if (localStorage.getItem('ped_assistant_history_collapsed') === '1') { - var layoutForPref = document.getElementById('assistant-layout'); - if (layoutForPref) layoutForPref.classList.add('history-collapsed'); - } - } catch (e) {} + var startCollapsed = false; + try { startCollapsed = localStorage.getItem('ped_assistant_history_collapsed') === '1'; } catch (e) {} + var layoutForPref = document.getElementById('assistant-layout'); + if (layoutForPref && startCollapsed) layoutForPref.classList.add('history-collapsed'); + syncHistoryToggle(startCollapsed); } if (form) form.addEventListener('submit', onAsk); @@ -2013,6 +2025,51 @@ import { loadImageGallery(); } + // Open WebUI groups saved chats by recency rather than showing one long list. + // Pinned chats stay in their own group at the top. + var SAVED_CHAT_MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December']; + + function startOfDay(date) { + return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime(); + } + + function savedChatGroup(value, now) { + var when = new Date(value); + if (!value || isNaN(when.getTime())) return { key: 'undated', label: 'Undated', order: 900 }; + var days = Math.floor((startOfDay(now) - startOfDay(when)) / 86400000); + if (days <= 0) return { key: 'today', label: 'Today', order: 0 }; + if (days === 1) return { key: 'yesterday', label: 'Yesterday', order: 1 }; + if (days <= 3) return { key: 'd3', label: 'Previous 3 days', order: 2 }; + if (days <= 7) return { key: 'd7', label: 'Previous 7 days', order: 3 }; + if (days <= 30) return { key: 'd30', label: 'Previous 30 days', order: 4 }; + // Older than a month: by calendar month, with the year once it is not this one. + var label = SAVED_CHAT_MONTHS[when.getMonth()] + + (when.getFullYear() === now.getFullYear() ? '' : ' ' + when.getFullYear()); + return { + key: 'm' + when.getFullYear() + '-' + when.getMonth(), + label: label, + // Newer months first, always after the relative groups. + order: 100 + (9999 - when.getFullYear()) * 12 + (11 - when.getMonth()) + }; + } + + function groupSavedChats(chats, now) { + var groups = []; + var byKey = {}; + chats.forEach(function (chat) { + var group = isChatPinned(chat.id) + ? { key: 'pinned', label: 'Pinned', order: -1 } + : savedChatGroup(chat.updated_at || chat.created_at, now); + if (!byKey[group.key]) { + byKey[group.key] = { key: group.key, label: group.label, order: group.order, chats: [] }; + groups.push(byKey[group.key]); + } + byKey[group.key].chats.push(chat); + }); + return groups.sort(function (a, b) { return a.order - b.order; }); + } + function renderSavedChats(chats) { savedChatCache = chats || []; var wrap = document.getElementById('assistant-saved-chats'); @@ -2028,8 +2085,7 @@ import { return; } // Open WebUI behavior: the whole row opens the chat — no Load/Delete buttons. - wrap.innerHTML = chats.map(function (chat) { - // Open WebUI behavior: the row opens the chat; a hover trash icon deletes it. + function chatRow(chat) { var title = chat.title || 'Saved chat'; var pinned = isChatPinned(chat.id); return ''; + } + wrap.innerHTML = groupSavedChats(chats, new Date()).map(function (group) { + return '
' + + '

' + escapeHtml(group.label) + '

' + + group.chats.map(chatRow).join('') + + '
'; }).join(''); } diff --git a/src/routes/clinicalAssistant.js b/src/routes/clinicalAssistant.js index 5965c33d..e02f143e 100644 --- a/src/routes/clinicalAssistant.js +++ b/src/routes/clinicalAssistant.js @@ -51,7 +51,10 @@ var { DEFAULT_BEHAVIOR } = require('../utils/clinicalPrompts'); router.use(authMiddleware); -var GREETING_RE = /^(hi|hello|hey|yo|good\s+(morning|afternoon|evening)|thanks|thank you|ok|okay|sup)[\s.!?]*$/i; +// Acknowledgements get the prompt for a real question instead of a retrieval and +// a paid generation. Deliberately NOT here: "yes"/"sure"/"no"/"more" — answers end +// with "would you like more detail?", so those must still reach the model. +var GREETING_RE = /^[\s.!?,]*(hi|hello|hey|yo|good\s+(morning|afternoon|evening)|thanks|thank you|thank u|ty|ok|okay|okey|k|kk|sup|nice|cool|great|perfect|excellent|awesome|got it|understood|noted|alright|all right|fine|good|very good|well done|bravo|окей|ок|хорошо|спасибо|отлично|vale|gracias|bien|genial|perfecto|merci|d'accord|super|danke|gut|prima|obrigado|obrigada|grazie|bene|ótimo|otimo)[\s.!?,]*$/iu; var MAX_SAVED_CHATS_PER_USER = 100; var MAX_SAVED_CHAT_TITLE = 160; var promptPool = createClinicalPromptPool({ diff --git a/test/assistant-image-intent.test.js b/test/assistant-image-intent.test.js index f39a6df6..e311fb34 100644 --- a/test/assistant-image-intent.test.js +++ b/test/assistant-image-intent.test.js @@ -84,3 +84,20 @@ test('the in-chat image is a clickable thumbnail, not a full-width picture', asy assert.match(rule, /max-height:240px/); assert.match(rule, /cursor:zoom-in/, 'the thumbnail invites the click'); }); + +test('acknowledgements ask for a clinical question instead of costing a generation', () => { + const fs = require('node:fs'); + const src = fs.readFileSync(path.join(__dirname, '..', 'src/routes/clinicalAssistant.js'), 'utf8'); + const line = src.split('\n').find(l => l.startsWith('var GREETING_RE')); + assert.ok(line, 'GREETING_RE located'); + const RE = new Function(line + '; return GREETING_RE;')(); + for (const ack of ['ok', 'Okay', 'Окей', 'ок', 'Nice', 'Perfect', 'cool', 'great', + 'thanks!', 'Thank you', 'got it', 'noted', 'bien', 'gracias', 'merci', 'danke', 'спасибо']) { + assert.equal(RE.test(ack), true, JSON.stringify(ack) + ' should get the "ask me something" reply'); + } + // Answers end with "would you like more detail?", so these must reach the model. + for (const real of ['yes', 'sure', 'no', 'more', 'yes please', 'tell me more', + 'amoxicillin dose', 'PVL', 'what is IVH', 'okay but what about grade 3']) { + assert.equal(RE.test(real), false, JSON.stringify(real) + ' must still be answered'); + } +}); diff --git a/test/assistant-saved-tables.test.js b/test/assistant-saved-tables.test.js index 3b0381c7..800b299a 100644 --- a/test/assistant-saved-tables.test.js +++ b/test/assistant-saved-tables.test.js @@ -427,3 +427,42 @@ test('durable jobs preserve legacy provenance, provisional/clicked turn sources app.window.dispatchEvent(new app.window.PopStateEvent('popstate')); assert.equal(modal.isConnected, false); assert.equal(JSON.stringify(c.messages), before, 'private preparation and display provenance leave canonical messages unchanged'); }); + +test('saved chats are grouped by recency the way Open WebUI groups them', () => { + const fs = require('node:fs'); + const path = require('node:path'); + const src = fs.readFileSync(path.join(__dirname, '..', 'public/js/clinicalAssistant.js'), 'utf8'); + const start = src.indexOf('var SAVED_CHAT_MONTHS'); + const end = src.indexOf(' function renderSavedChats(chats) {'); + assert.ok(start > 0 && end > start, 'grouping helpers located'); + const pinned = new Set(['pin-me']); + const scope = new Function('isChatPinned', src.slice(start, end) + '; return { savedChatGroup, groupSavedChats };')( + id => pinned.has(id)); + + const now = new Date('2026-09-09T12:00:00Z'); + const label = value => scope.savedChatGroup(value, now).label; + assert.equal(label('2026-09-09T08:00:00Z'), 'Today'); + assert.equal(label('2026-09-08T09:00:00Z'), 'Yesterday'); + assert.equal(label('2026-09-07T10:00:00Z'), 'Previous 3 days'); + assert.equal(label('2026-09-04T10:00:00Z'), 'Previous 7 days'); + assert.equal(label('2026-08-20T10:00:00Z'), 'Previous 30 days'); + assert.equal(label('2026-07-15T10:00:00Z'), 'July', 'older than a month falls back to the calendar month'); + assert.equal(label('2025-08-15T10:00:00Z'), 'August 2025', 'a previous year is named'); + assert.equal(label(null), 'Undated', 'a chat with no timestamp is not dropped'); + + const groups = scope.groupSavedChats([ + { id: 'a', updated_at: '2026-07-15T10:00:00Z' }, + { id: 'pin-me', updated_at: '2026-07-15T10:00:00Z' }, + { id: 'b', updated_at: '2026-09-09T09:00:00Z' }, + { id: 'c', updated_at: '2026-09-08T09:00:00Z' } + ], now); + assert.deepEqual(groups.map(g => g.label), ['Pinned', 'Today', 'Yesterday', 'July'], + 'pinned first, then newest to oldest'); + assert.deepEqual(groups[0].chats.map(c => c.id), ['pin-me']); + + const older = scope.groupSavedChats([ + { id: 'x', updated_at: '2025-12-02T10:00:00Z' }, + { id: 'y', updated_at: '2026-07-15T10:00:00Z' } + ], now); + assert.deepEqual(older.map(g => g.label), ['July', 'December 2025'], 'months run newest first'); +}); diff --git a/test/assistant-workspace-layout.test.js b/test/assistant-workspace-layout.test.js index e3dbcb44..5a9ebd39 100644 --- a/test/assistant-workspace-layout.test.js +++ b/test/assistant-workspace-layout.test.js @@ -115,3 +115,23 @@ test('mobile drawer rows carry no chat icons and keep the options menu', () => { assert.ok(/\.assistant-layout \{ display:flex; flex-direction:column; height:100dvh;/.test(css), 'mobile layout locks the viewport'); assert.ok(/\.assistant-side \{ display:none; \}/.test(css), 'no sources column under the chat on mobile'); }); + +test('the saved-chats rail toggle is visible and states which way it goes', () => { + const fs = require('node:fs'); + const path = require('node:path'); + const root = path.join(__dirname, '..'); + const html = fs.readFileSync(path.join(root, 'public/components/assistant.html'), 'utf8'); + const toggle = html.split('\n').find(line => line.includes('btn-assistant-toggle-history')); + assert.ok(toggle, 'the toggle exists'); + // fa-sidebar is Font Awesome PRO; on the Free 6.5.0 build this app loads it + // renders nothing, so the toggle was an invisible button. + assert.doesNotMatch(toggle, /fa-sidebar/, 'no Pro-only icon'); + assert.match(toggle, /fa-table-columns/, 'a Font Awesome Free panel icon'); + assert.match(toggle, /aria-expanded="true"/, 'exposes its state'); + assert.match(toggle, /aria-controls="assistant-history"/); + + const js = fs.readFileSync(path.join(root, 'public/js/clinicalAssistant.js'), 'utf8'); + assert.match(js, /syncHistoryToggle\(collapsed\)/, 'the toggle is synced when clicked'); + assert.match(js, /syncHistoryToggle\(startCollapsed\)/, 'and on restore from localStorage'); + assert.match(js, /Show saved chats/, 'the label flips so the collapsed state is escapable'); +});