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:
parent
23788d63c5
commit
7fdbcd51e6
8 changed files with 240 additions and 72 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue