feat: ChatGPT-style chat list — abbreviated rows with relative dates, pinned-first ordering, ⋮ menu (Rename/Pin/Export/Delete); clinical context always on; warn-only budget; mic live-transcript fallback; learning-hub page mode; composer autosize; include-context icon fix

This commit is contained in:
Daniel 2026-09-09 00:14:34 +02:00
parent 23788d63c5
commit 7fdbcd51e6
8 changed files with 240 additions and 72 deletions

View file

@ -74,7 +74,6 @@
<input type="file" id="assistant-attach-input" accept="image/png,image/jpeg,image/webp" multiple hidden>
<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-send" type="submit"><i class="fas fa-arrow-up"></i></button>
</div>
@ -88,13 +87,8 @@
<aside class="assistant-side">
<div class="card">
<div class="card-header"><h3><i class="fas fa-wand-magic-sparkles"></i> Image / Graph</h3></div>
<div class="card-header"><h3><i class="fas fa-wand-magic-sparkles"></i> Generated image</h3></div>
<div class="assistant-side-body">
<textarea id="assistant-image-prompt" rows="4" placeholder="Generate a teaching image, pathway, or infographic from the current answer..."></textarea>
<div class="assistant-image-buttons">
<button id="btn-assistant-image" class="btn-sm btn-primary" type="button"><i class="fas fa-image"></i> Generate image</button>
<button id="btn-assistant-image-clear" class="btn-sm btn-ghost" type="button"><i class="fas fa-rotate-left"></i> Clear image</button>
</div>
<div id="assistant-visual-output" class="assistant-visual-output"></div>
</div>
</div>

View file

@ -133,11 +133,11 @@
.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; max-height:520px; 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; }
.assistant-saved-chat { border:1px solid var(--g200); border-radius:10px; padding:8px 10px; background:white; display:flex; flex-direction:column; gap:3px; width:100%; text-align:left; cursor:pointer; color:inherit; font:inherit; }
.assistant-saved-chat:hover { background:var(--g100, #f3f4f6); }
.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); }
.assistant-saved-chat-title { font-size:12px; font-weight:700; color:var(--g800); line-height:1.35; }
.assistant-saved-chat-meta { font-size:11px; color:var(--g500); }
.assistant-saved-chat-title { font-size:13px; font-weight:500; color:var(--g800); line-height:1.35; display:block; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.assistant-saved-chat-meta { font-size:11px; color:var(--g500); display:block; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.assistant-save-actions { display:flex; gap:6px; flex-wrap:wrap; }
.assistant-source { border:1px solid var(--g200); border-radius:10px; padding:9px; background:white; font-size:12px; line-height:1.5; }
.assistant-source strong { color:var(--g800); }
@ -265,17 +265,32 @@ body.assistant-workspace .assistant-learning-view { min-height: 0; height: 100vh
.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 { 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:flex-end; 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-input { flex:1 1 auto; border:none; outline:none; resize:none; background:transparent; font-size:15px; line-height:1.5; min-height:44px; max-height:160px; padding:10px 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 { display:flex; align-items:center; justify-content:center; width:38px; height:38px; border-radius:50%; cursor:pointer; color:var(--g500); position:relative; }
.assistant-check i { 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%; }
/* Learning Hub: full-width page without the chat rail or sources column */
.assistant-layout.learning-mode .assistant-history,
.assistant-layout.learning-mode .assistant-side { display:none; }
.assistant-layout.learning-mode { grid-template-columns:minmax(0,1fr); }
.assistant-layout.learning-mode .assistant-main { grid-column:1; }
/* Saved-chat options menu */
.assistant-saved-chat-menu { position:absolute; top:6px; right:6px; width:26px; height:26px; border-radius:6px; display:flex; align-items:center; justify-content:center; color:var(--g500); opacity:0; transition:opacity .12s ease; pointer-events:none; }
.assistant-saved-chat:hover .assistant-saved-chat-menu, .assistant-saved-chat:focus-within .assistant-saved-chat-menu { opacity:1; pointer-events:auto; }
.assistant-saved-chat-menu:hover { background:var(--g100); color:var(--g800); }
.assistant-saved-chat-menu-pop, .assistant-chat-menu { position:absolute; right:8px; top:30px; z-index:20; background:white; border:1px solid var(--g200); border-radius:10px; box-shadow:0 10px 30px rgba(0,0,0,.16); padding:4px; display:grid; min-width:150px; }
.assistant-chat-menu button { display:flex; align-items:center; gap:8px; border:none; background:none; padding:8px 10px; font-size:13px; color:var(--g700); cursor:pointer; border-radius:7px; text-align:left; }
.assistant-chat-menu button:hover { background:var(--g100); }
.assistant-chat-menu button[data-chat-action="delete"] { color:var(--danger,#dc2626); }
.assistant-chat-menu button i { width:14px; text-align:center; }
.assistant-saved-chat-rename { width:100%; border:1.5px solid var(--purple); border-radius:6px; padding:4px 6px; font-size:12px; }

View file

@ -738,10 +738,6 @@ function _serverTranscribe(blob) {
headers: headers,
body: formData
}).then(function(r) { return r.json(); }).then(function(data) {
var elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
if (data.success && data.provider) {
showToast('Transcribed via ' + data.provider + ' (' + elapsed + 's)', 'info');
}
if (!data.success && blob.size > 0 && typeof saveAudioBackup === 'function') {
console.log('[AudioBackup] Transcription failed, saving backup...');
saveAudioBackup(blob, 'failed-transcription').then(function(id) {

View file

@ -128,3 +128,12 @@ export function fetchAssistantImageJobs() {
return fetch('/api/clinical-assistant/image/jobs?limit=24', { headers: authHeaders(), credentials: 'same-origin' })
.then(parseJsonWithStatus);
}
export function renameSavedAssistantChat(id, title) {
return fetch('/api/clinical-assistant/chats/' + encodeURIComponent(id), {
method: 'PATCH',
headers: authHeaders(Object.assign({ 'Content-Type': 'application/json' })),
credentials: 'same-origin',
body: JSON.stringify({ title: title })
}).then(parseJsonWithStatus);
}

View file

@ -23,7 +23,7 @@ import {
translateAssistantMessage,
assistantAttachmentLimits,
assistantAttachmentPayload
, fetchAssistantImageJobs} from './assistant/api.js';
, fetchAssistantImageJobs, renameSavedAssistantChat } from './assistant/api.js';
var initialized = false;
var messages = [];
var attachments = []; // Input-only images: ride the outgoing question, then persist with the sent message.
@ -124,8 +124,6 @@ import {
var clearBtn = document.getElementById('btn-assistant-clear');
var cancelBtn = document.getElementById('btn-assistant-cancel');
var exportBtn = document.getElementById('btn-assistant-export-pdf');
var imageBtn = document.getElementById('btn-assistant-image');
var imageClearBtn = document.getElementById('btn-assistant-image-clear');
var attachInput = document.getElementById('assistant-attach-input');
var input = document.getElementById('assistant-input');
var goBackBtn = document.getElementById('btn-assistant-goback');
@ -145,7 +143,10 @@ import {
if (goBackBtn) goBackBtn.addEventListener('click', goBackToMainMenu);
if (chatViewBtn) chatViewBtn.addEventListener('click', openChatView);
if (learningViewBtn) learningViewBtn.addEventListener('click', openLearningView);
if (input) input.addEventListener('input', updateConversationBudget);
if (input) {
input.addEventListener('input', updateConversationBudget);
input.addEventListener('input', resizeAssistantInput);
}
if (cancelBtn) cancelBtn.addEventListener('click', cancelAssistantSearch);
if (exportBtn) exportBtn.addEventListener('click', exportAnswerPdf);
var takehomeBtn = document.getElementById('btn-assistant-takehome');
@ -154,8 +155,6 @@ import {
if (micBtn) micBtn.addEventListener('click', toggleAssistantMic);
var voiceBtn = document.getElementById('btn-assistant-voice');
if (voiceBtn) voiceBtn.addEventListener('click', openConversationMode);
if (imageBtn) imageBtn.addEventListener('click', generateImage);
if (imageClearBtn) imageClearBtn.addEventListener('click', clearGeneratedImage);
if (attachInput) attachInput.addEventListener('change', onAttachFiles);
document.addEventListener('click', onAssistantDocumentClick);
document.addEventListener('keydown', onAssistantKeydown);
@ -201,7 +200,7 @@ import {
function onAsk(e) {
if (e) e.preventDefault();
var input = document.getElementById('assistant-input');
var includeContext = document.getElementById('assistant-include-context');
var text = input ? input.value : '';
if (assistantBusy) {
if (typeof showToast === 'function') showToast('Assistant is still finishing the current answer', 'error');
@ -249,7 +248,7 @@ import {
message: text,
idempotencyKey: crypto.randomUUID(),
history: history,
includeContext: !includeContext || includeContext.checked
includeContext: true // clinical retrieval is always on — it is the purpose of the app
};
// Images ride the outgoing clinical question for inference; once sent they persist with the saved chat.
if (attachments.length) payload.images = assistantAttachmentPayload(attachments);
@ -780,35 +779,6 @@ import {
mermaidReady = true;
}
function generateImage(promptOverride) {
var promptEl = document.getElementById('assistant-image-prompt');
var out = document.getElementById('assistant-visual-output');
var button = document.getElementById('btn-assistant-image');
var prompt = typeof promptOverride === 'string' ? promptOverride : (promptEl ? promptEl.value : '');
if (!prompt.trim() && lastAnswer) prompt = 'Create a pediatric teaching visual from this conversation.';
if (!prompt.trim() || button.disabled) return;
var owner;
try { owner = captureSharingOwner(); } catch (_) { return; }
var selection = generatedImageJobs;
button.disabled = true;
startAssistantImageJob(prompt, messages.map(function(m) { return { role: m.role, content: m.content }; })).then(function(data) {
assertSharingOwner(owner);
if (generatedImageJobs !== selection) return;
if (!data.success) throw new Error(data.error || 'Image generation failed');
generatedImageJobs = [{ jobId: data.jobId }];
lastGeneratedImageSrc = '';
exporter.invalidate();
out.replaceChildren();
renderImageJobs(out, generatedImageJobs, 'clinical_assistant', function(card, image) {
if (generatedImageJobs[0]?.jobId !== image.jobId) return;
lastGeneratedImageSrc = image.imageUrl;
card.insertAdjacentHTML('beforeend', imageStore.renderGeneratedImage(image.imageUrl, 'Generated teaching visual', image.downloadUrl));
exporter.invalidate();
});
}).catch(function(error) { if (!validSharingOwner(owner) || error.name === 'AbortError') return; if (typeof showToast === 'function') showToast(error.message, 'error'); })
.finally(function() { if (validSharingOwner(owner)) button.disabled = false; });
}
function onAssistantDocumentClick(e) {
var citation = e.target.closest('#assistant-messages .assistant-cite');
if (citation) {
@ -869,6 +839,21 @@ import {
renderAttachments();
return;
}
var chatMenuBtn = e.target.closest('[data-assistant-chat-menu]');
if (chatMenuBtn) {
e.preventDefault();
e.stopPropagation();
openChatMenu(chatMenuBtn);
return;
}
var chatAction = e.target.closest('[data-chat-action]');
if (chatAction) {
e.preventDefault();
e.stopPropagation();
handleChatAction(chatAction.getAttribute('data-chat-action'), chatAction.getAttribute('data-chat-id'));
return;
}
if (!e.target.closest('[data-assistant-chat-menu-pop]')) closeChatMenu();
var deleteBtn = e.target.closest('[data-assistant-delete-chat]');
if (deleteBtn) {
e.preventDefault();
@ -992,14 +977,25 @@ import {
assistantMic.recorder = null;
if (!recorder) return;
recorder.stop().then(function(blob) {
var live = (assistantMic.finalText + assistantMic.sessionFinals).trim();
var finish = function(text) {
var input = document.getElementById('assistant-input');
if (!input) return;
if (text) {
input.value = text;
updateConversationBudget();
resizeAssistantInput();
} else if (typeof showToast === 'function') {
showToast('No speech detected — try again', 'error');
}
};
if (typeof transcribeAudio === 'function' && blob && blob.size > 100) {
transcribeAudio(blob).then(function(data) {
if (data && data.success && data.text) {
var input = document.getElementById('assistant-input');
if (input) { input.value = data.text; updateConversationBudget(); }
}
}).catch(function() {});
}
// Server transcription wins; the live transcript is the fallback,
// never lose what the browser already heard.
finish(data && data.success && data.text ? data.text : live);
}).catch(function() { finish(live); });
} else finish(live);
}).catch(function() {});
}
@ -1082,6 +1078,7 @@ import {
if (!input) return;
input.value = text;
updateConversationBudget();
resizeAssistantInput();
if (status) status.textContent = 'Asking…';
var send = document.getElementById('btn-assistant-send');
if (send) send.click();
@ -1236,6 +1233,104 @@ import {
});
}
// ── Saved-chat options: Rename, Pin, Export, Delete (ChatGPT-style menu) ──
function pinnedChatIds() {
try {
var raw = localStorage.getItem('assistantPinnedChats');
var ids = raw ? JSON.parse(raw) : [];
return Array.isArray(ids) ? ids.map(String) : [];
} catch (e) { return []; }
}
function isChatPinned(id) {
return pinnedChatIds().indexOf(String(id)) !== -1;
}
function toggleChatPinned(id) {
var ids = pinnedChatIds();
var key = String(id);
var idx = ids.indexOf(key);
if (idx === -1) ids.push(key); else ids.splice(idx, 1);
try { localStorage.setItem('assistantPinnedChats', JSON.stringify(ids)); } catch (e) {}
loadSavedChats();
}
function renameSavedChat(id, newTitle) {
var title = String(newTitle || '').trim().slice(0, 160);
if (!title) return Promise.resolve();
return renameSavedAssistantChat(id, title).then(function (data) {
if (!data.success) throw new Error(data.error || 'Rename failed');
loadSavedChats();
}).catch(function (err) {
if (typeof showToast === 'function') showToast(err.message, 'error');
});
}
function exportSavedChat(id) {
fetchSavedAssistantChat(id).then(function (data) {
if (!data.success || !data.chat) throw new Error('Could not load that chat');
var payload = data.chat.payload || {};
exportAnswerPdf({
messages: payload.messages || [],
lastAnswer: payload.lastAnswer || '',
lastSources: payload.sources || [],
generatedImageJobs: [],
lastGeneratedImageSrc: payload.generatedImage || ''
});
}).catch(function (err) {
if (typeof showToast === 'function') showToast(err.message, 'error');
});
}
function openChatMenu(menuBtn) {
closeChatMenu();
var id = menuBtn.getAttribute('data-assistant-chat-menu');
var pop = document.createElement('div');
pop.className = 'assistant-chat-menu';
pop.setAttribute('data-assistant-chat-menu-pop', '');
var pinned = isChatPinned(id);
pop.innerHTML =
'<button type="button" data-chat-action="rename" data-chat-id="' + escapeAttr(id) + '"><i class="fas fa-pen"></i> Rename</button>' +
'<button type="button" data-chat-action="pin" data-chat-id="' + escapeAttr(id) + '"><i class="fas fa-thumbtack"></i> ' + (pinned ? 'Unpin' : 'Pin') + '</button>' +
'<button type="button" data-chat-action="export" data-chat-id="' + escapeAttr(id) + '"><i class="fas fa-file-pdf"></i> Export</button>' +
'<button type="button" data-chat-action="delete" data-chat-id="' + escapeAttr(id) + '"><i class="fas fa-trash"></i> Delete</button>';
menuBtn.closest('.assistant-saved-chat').appendChild(pop);
// Document-level delegation handles the actions; the row-click guard in the
// menu-button branch keeps the row from opening when the menu is used.
}
function closeChatMenu() {
document.querySelectorAll('[data-assistant-chat-menu-pop]').forEach(function (pop) { pop.remove(); });
}
function handleChatAction(action, id) {
closeChatMenu();
if (action === 'pin') { toggleChatPinned(id); return; }
if (action === 'delete') { deleteSavedChat(id); return; }
if (action === 'export') { exportSavedChat(id); return; }
if (action === 'rename') {
var rows = document.querySelectorAll('[data-assistant-load-chat]');
var row = null;
for (var i = 0; i < rows.length; i++) {
if (String(rows[i].getAttribute('data-assistant-load-chat')) === String(id)) { row = rows[i]; break; }
}
if (!row) return;
var titleEl = row.querySelector('.assistant-saved-chat-title');
var current = String(titleEl ? titleEl.textContent.replace(/^\ufeff/, '').trim() : '');
var input = document.createElement('input');
input.className = 'assistant-saved-chat-rename';
input.value = current;
input.maxLength = 160;
titleEl.replaceWith(input);
input.focus();
input.select();
var commit = function () { renameSavedChat(id, input.value); };
input.addEventListener('blur', commit);
input.addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); commit(); } });
}
}
// ── Patient take home: plain-language summary + copy/export/email ────
var takehomeBusy = false;
var takehomeText = '';
@ -1425,6 +1520,8 @@ import {
if (learning) learning.hidden = true;
if (chatBtn) chatBtn.classList.add('active');
if (learningBtn) learningBtn.classList.remove('active');
var layout = document.getElementById('assistant-layout');
if (layout) layout.classList.remove('learning-mode');
}
function openLearningView() {
@ -1438,6 +1535,9 @@ import {
learning.hidden = false;
if (chatBtn) chatBtn.classList.remove('active');
if (learningBtn) learningBtn.classList.add('active');
// Learning Hub is its own page: no saved chats, no image rail, no sources column.
var layout = document.getElementById('assistant-layout');
if (layout) layout.classList.add('learning-mode');
var load = learningViewHtml ? Promise.resolve(learningViewHtml) :
(typeof fetch === 'function' ? fetch('/components/learning.html').then(function(response) {
if (!response.ok) throw new Error('Learning Hub unavailable');
@ -1593,14 +1693,17 @@ import {
if (!el) return;
el.value = btn.getAttribute('data-assistant-example') || '';
updateConversationBudget();
resizeAssistantInput();
el.focus();
onAsk();
});
});
}
function exportAnswerPdf() {
exporter.exportAnswerPdf({
function exportAnswerPdf(customState) {
// Click bindings pass the event here; only a real state object is an override.
var state = customState && customState.messages ? customState : null;
exporter.exportAnswerPdf(state || {
messages: messages,
lastAnswer: lastAnswer,
lastSources: lastSources,
@ -1620,6 +1723,12 @@ import {
savedChatCache = chats || [];
var wrap = document.getElementById('assistant-saved-chats');
if (!wrap) return;
chats = savedChatCache.slice().sort(function (a, b) {
var pa = isChatPinned(a.id) ? 0 : 1;
var pb = isChatPinned(b.id) ? 0 : 1;
if (pa !== pb) return pa - pb;
return String(b.updated_at || b.created_at || '').localeCompare(String(a.updated_at || a.created_at || ''));
});
if (!chats.length) {
wrap.innerHTML = '<p class="assistant-muted">Chats save automatically as you go.</p>';
return;
@ -1627,10 +1736,12 @@ import {
// 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.
return '<button type="button" class="assistant-saved-chat" data-assistant-load-chat="' + escapeAttr(chat.id) + '" aria-label="Open saved chat">' +
'<span class="assistant-saved-chat-title">' + escapeHtml(chat.title || 'Saved chat') + '</span>' +
var title = chat.title || 'Saved chat';
var pinned = isChatPinned(chat.id);
return '<button type="button" class="assistant-saved-chat' + (pinned ? ' pinned' : '') + '" data-assistant-load-chat="' + escapeAttr(chat.id) + '" aria-label="Open saved chat" title="' + escapeAttr(title) + '">' +
'<span class="assistant-saved-chat-title">' + (pinned ? '<i class="fas fa-thumbtack"></i> ' : '') + escapeHtml(title) + '</span>' +
'<span class="assistant-saved-chat-meta">' + escapeHtml(formatSavedDate(chat.updated_at || chat.created_at)) + '</span>' +
'<span class="assistant-saved-chat-delete" role="button" tabindex="0" data-assistant-delete-chat="' + escapeAttr(chat.id) + '" aria-label="Delete saved chat" title="Delete chat"><i class="fas fa-trash"></i></span>' +
'<span class="assistant-saved-chat-menu" role="button" tabindex="0" data-assistant-chat-menu="' + escapeAttr(chat.id) + '" aria-label="Chat options" title="Options"><i class="fas fa-ellipsis-vertical"></i></span>' +
'</button>';
}).join('');
}
@ -1765,6 +1876,14 @@ import {
return Number.isInteger(limit) && limit >= 1000 && limit <= 1000000;
}
function resizeAssistantInput() {
var input = document.getElementById('assistant-input');
if (!input) return;
input.style.height = 'auto';
input.style.height = Math.min(input.scrollHeight, 160) + 'px';
}
function updateConversationBudget() {
var input = document.getElementById('assistant-input');
var used = conversationSize(input ? input.value : '');
@ -1806,7 +1925,18 @@ import {
}
function formatSavedDate(value) {
try { return new Date(value).toLocaleString(); } catch (e) { return ''; }
// ChatGPT-style relative grouping for the chat list.
var d;
try { d = new Date(value); if (isNaN(d.getTime())) return ''; } catch (e) { return ''; }
var now = new Date();
var startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
var startOfValue = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
var days = Math.round((startOfToday - startOfValue) / 86400000);
if (days <= 0) return 'Today';
if (days === 1) return 'Yesterday';
if (days < 7) return 'Previous 7 days';
if (days < 30) return 'Previous 30 days';
return d.toLocaleDateString();
}
function cssEscape(value) {

View file

@ -199,6 +199,23 @@ router.post('/clinical-assistant/chats', async function(req, res) {
}
});
router.patch('/clinical-assistant/chats/:id', async function(req, res) {
try {
var title = cleanSavedChatTitle(req.body.title);
if (!title) return res.status(400).json({ error: 'Title required', code: 'INVALID_TITLE' });
var result = await db.run(
'UPDATE clinical_assistant_chats SET title = $1, updated_at = NOW() WHERE id = $2 AND user_id = $3',
[cryptoUtil.encryptString(title), req.params.id, req.user.id]
);
if (!result.changes) return res.status(404).json({ error: 'Saved chat not found', code: 'CHAT_NOT_FOUND' });
logger.audit(req.user.id, 'clinical_assistant_chat_rename', 'Renamed clinical assistant chat', req, { category: 'clinical' });
res.json({ success: true, id: req.params.id, title: title });
} catch (e) {
if (!e.statusCode || e.statusCode >= 500) logger.error('PATCH /clinical-assistant/chats/:id', e.message);
res.status(e.statusCode || 500).json({ error: e.statusCode ? e.message : 'Request failed', code: e.code });
}
});
router.delete('/clinical-assistant/chats/:id', async function(req, res) {
try {
await db.run('DELETE FROM clinical_assistant_chats WHERE id = $1 AND user_id = $2', [req.params.id, req.user.id]);

View file

@ -93,15 +93,22 @@ test('saved-chat rows open on click; the hover trash deletes without opening', a
await c.loadSavedChats();
const row = app.document.querySelector('[data-assistant-load-chat="7"]');
assert.ok(row, 'row rendered as the click target');
assert.ok(row.querySelector('[data-assistant-delete-chat="7"]'), 'hover delete icon present');
assert.ok(row.querySelector('[data-assistant-chat-menu="7"]'), 'hover options menu present');
row.click();
await new Promise(r => setImmediate(r)); await new Promise(r => setImmediate(r));
assert.equal(String(c.currentChatId), '7', 'row click opens the chat');
const deleted = [];
app.context.deleteSavedAssistantChat = async id => { deleted.push(id); return { success: true }; };
row.querySelector('[data-assistant-delete-chat="7"]').click();
app.context.renameSavedAssistantChat = async () => ({ success: true });
row.querySelector('[data-assistant-chat-menu="7"]').click();
await new Promise(r => setImmediate(r));
assert.deepEqual(deleted, ['7'], 'delete icon deletes');
const pop = app.document.querySelector('[data-assistant-chat-menu-pop]');
assert.ok(pop, 'options menu opens');
const actions = [...pop.querySelectorAll('[data-chat-action]')].map(b => b.getAttribute('data-chat-action'));
assert.deepEqual(actions, ['rename', 'pin', 'export', 'delete'], 'Rename, Pin, Export, Delete');
pop.querySelector('[data-chat-action="delete"]').click();
await new Promise(r => setImmediate(r));
assert.deepEqual(deleted, ['7'], 'menu delete removes the chat without opening it');
});
test('regenerate on a non-latest answer refuses honestly without touching the chat', t => {

View file

@ -126,7 +126,7 @@ test('selected sidebar B survives A-done -> B-queued -> save/reopen/export, with
vm.runInContext(fs.readFileSync('public/js/clinicalAssistant.js','utf8').replace(/^import[\s\S]*?;\n/gm,''),ui.context);
const transcript=[{role:'assistant',content:'Original [3, 1].',sources:[{number:3,page:19}],imageJobs:[{jobId:id}]}];
ui.context.restoreSavedChat({messages:transcript,lastAnswer:'Original [3, 1].',generatedImage:src});
ui.context.generateImage('B request');await new Promise(r=>setImmediate(r));ui.context.performAutosave();
ui.context.startImageFromSelection('B request','');await new Promise(r=>setImmediate(r));await new Promise(r=>setImmediate(r));ui.context.performAutosave();
assert.equal(saved.generatedImage, undefined, 'an empty sidebar reference is omitted while a job is queued');assert.equal(saved.generatedImageJobs[0].jobId,b);assert.equal(saved.messages[0].imageJobs[0].jobId,id);
ui.context.restoreSavedChat(saved);await new Promise(r=>setImmediate(r));assert.equal(ui.context.lastGeneratedImageSrc,'');
vm.runInContext(fs.readFileSync('public/js/assistant/export.js','utf8').replace(/^import .*;\n/gm,'').replace(/^export /gm,''),ui.context);