diff --git a/public/components/assistant.html b/public/components/assistant.html index e6b4f59..8c76252 100644 --- a/public/components/assistant.html +++ b/public/components/assistant.html @@ -19,18 +19,9 @@

Saved Chats

-
-

Saved chats appear here after you click Save chat.

-
-
diff --git a/public/css/assistant.css b/public/css/assistant.css index 704019d..ab5b122 100644 --- a/public/css/assistant.css +++ b/public/css/assistant.css @@ -69,7 +69,7 @@ .assistant-msg.user .assistant-bubble pre { background:rgba(0,0,0,.35); } .assistant-msg.user .assistant-bubble blockquote { border-left-color:rgba(255,255,255,.6); background:rgba(255,255,255,.15); color:#fff; } .assistant-msg.user .assistant-bubble hr { border-top-color:rgba(255,255,255,.35); } -.assistant-table-scroll { position:relative; max-width:100%; overflow-x:auto; overflow-y:hidden; -webkit-overflow-scrolling:touch; margin:12px 0; border:1px solid var(--g200); border-radius:12px; background:white; box-shadow:inset 0 -1px 0 rgba(0,0,0,.03); scrollbar-width:none; } +.assistant-table-scroll { position:relative; max-width:100%; max-height:60vh; overflow:auto; -webkit-overflow-scrolling:touch; margin:12px 0; border:1px solid var(--g200); border-radius:12px; background:white; box-shadow:inset 0 -1px 0 rgba(0,0,0,.03); scrollbar-width:none; } .assistant-table-scroll::-webkit-scrollbar { display:none; } .assistant-table-scroll table { width:max-content; min-width:100%; max-width:none; border-collapse:separate; border-spacing:0; margin:0; border:0; border-radius:0; font-size:12px; } .assistant-table-scroll::after { content:'Swipe table'; display:none; position:sticky; left:0; bottom:0; padding:3px 9px; font-size:10px; font-weight:700; color:var(--g500); background:linear-gradient(90deg,rgba(255,255,255,.95),rgba(255,255,255,0)); pointer-events:none; } @@ -131,16 +131,12 @@ .assistant-image-modal-close { position:absolute; top:8px; right:8px; z-index:1; width:38px; height:38px; border:0; border-radius:999px; background:white; color:var(--g800); font-size:24px; line-height:1; cursor:pointer; box-shadow:var(--shadow); } .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 { padding:10px 12px; display:grid; gap:8px; max-height:220px; overflow-y:auto; } -.assistant-saved-chat { border:1px solid var(--g200); border-radius:10px; padding:8px; background:white; display:grid; gap:5px; } +.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.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-actions { display:flex; gap:6px; flex-wrap:wrap; } -.assistant-save-panel { border-top:1px solid var(--g200); padding:10px 12px; display:grid; gap:7px; } -.assistant-save-panel[hidden] { display:none; } -.assistant-save-panel label { font-size:11px; font-weight:700; color:var(--g500); text-transform:uppercase; letter-spacing:.04em; } -.assistant-save-panel input { width:100%; border:1.5px solid var(--g300); border-radius:9px; padding:8px 10px; font-size:12px; outline:none; } -.assistant-save-panel input:focus { border-color:var(--blue); box-shadow:0 0 0 3px var(--blue-light); } .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); } @@ -186,7 +182,19 @@ /* Patient take home modal + actions */ .assistant-takehome-modal { position: fixed; inset: 0; background: rgba(15, 23, 42, .45); display: flex; align-items: center; justify-content: center; z-index: 120; } .assistant-takehome-modal .modal-content { width: min(640px, 92vw); max-height: 82vh; display: flex; flex-direction: column; } -.assistant-takehome-result { white-space: pre-wrap; overflow-y: auto; font-size: .95rem; line-height: 1.55; } +/* Take-home result uses the same markdown typography as chat bubbles */ +.assistant-takehome-result { overflow-y: auto; font-size: 13px; line-height: 1.75; overflow-wrap: anywhere; } +.assistant-takehome-result h1, .assistant-takehome-result h2, .assistant-takehome-result h3 { margin:16px 0 8px; line-height:1.25; color:var(--g900); } +.assistant-takehome-result h1:first-child, .assistant-takehome-result h2:first-child, .assistant-takehome-result h3:first-child { margin-top:0; } +.assistant-takehome-result h1 { font-size:20px; } +.assistant-takehome-result h2 { font-size:17px; border-bottom:1px solid var(--g200); padding-bottom:4px; } +.assistant-takehome-result h3 { font-size:15px; } +.assistant-takehome-result ul, .assistant-takehome-result ol { padding-left:20px; margin:8px 0; } +.assistant-takehome-result p { margin:8px 0; } +.assistant-takehome-result strong { font-weight:600; } +.assistant-takehome-result table { border-collapse:collapse; margin:10px 0; font-size:12px; } +.assistant-takehome-result th, .assistant-takehome-result td { border:1px solid var(--g200); padding:5px 8px; text-align:left; } +.assistant-takehome-result code { background:var(--g100, #f3f4f6); border-radius:4px; padding:1px 5px; font-size:12px; } .assistant-takehome-actions { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; border-top: 1px solid var(--border, #e5e7eb); padding-top: .75rem; } .assistant-takehome-email { display: flex; gap: .5rem; margin-left: auto; } .assistant-takehome-email input { width: 220px; } @@ -202,3 +210,12 @@ .assistant-voice-mic { width: 72px; height: 72px; border-radius: 50%; border: none; background: var(--accent, #0f766e); color: #fff; font-size: 1.6rem; cursor: pointer; } .assistant-voice-mic.recording { background: var(--danger, #dc2626); animation: assistant-pulse 1.2s infinite; } @keyframes assistant-pulse { 0%,100% { box-shadow: 0 0 0 0 rgba(220,38,38,.45); } 50% { box-shadow: 0 0 0 14px rgba(220,38,38,0); } } + +/* Full-screen Open WebUI workspace: the assistant replaces the app chrome */ +body.assistant-workspace .assistant-layout { height: 100vh; } +body.assistant-workspace .assistant-main { min-height: 0; } +body.assistant-workspace #assistant-chat-view { min-height: 0; } +body.assistant-workspace .assistant-learning-view { min-height: 0; height: 100vh; overflow-y: auto; } +/* Saved chats occupy the full left rail and scroll inside it */ +.assistant-history { display:flex; flex-direction:column; height:100%; min-height:0; } +.assistant-history .card { flex:1 1 auto; display:flex; flex-direction:column; min-height:0; overflow:hidden; } diff --git a/public/css/styles.css b/public/css/styles.css index 8a739e8..54603bf 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -1204,3 +1204,10 @@ textarea.full-input{resize:vertical;} #admin-tab .cms-prompt-family select, #admin-tab .cms-prompt-family textarea { font-family: inherit; } + +/* Open WebUI-style assistant workspace: saved chats replace the main menu */ +body.assistant-workspace .app-header, +body.assistant-workspace .sidebar, +body.assistant-workspace .announcement-banner { display:none; } +body.assistant-workspace .app-body { min-height:100vh; } +body.assistant-workspace #assistant-tab.active { width:100%; } diff --git a/public/js/assistant/api.js b/public/js/assistant/api.js index 9abd0bb..3d84b07 100644 --- a/public/js/assistant/api.js +++ b/public/js/assistant/api.js @@ -1,5 +1,6 @@ function authHeaders() { - return window.getAuthHeaders ? window.getAuthHeaders() : { 'Content-Type': 'application/json' }; + var token = typeof localStorage === 'undefined' ? null : localStorage.getItem('auth_token'); + return token ? { Authorization: 'Bearer ' + token } : {}; } function parseJsonWithStatus(response) { diff --git a/public/js/assistant/export.js b/public/js/assistant/export.js index 7095c60..f65543a 100644 --- a/public/js/assistant/export.js +++ b/public/js/assistant/export.js @@ -276,7 +276,7 @@ function isShareCancel(error) { } function exportWindowCss() { - return 'body{font-family:Arial,sans-serif;color:#111827;line-height:1.55;margin:36px;max-width:820px}.assistant-export-actions{display:flex;gap:8px;justify-content:flex-end;margin-bottom:12px}.assistant-export-actions button{padding:8px 12px}h1{font-size:22px;margin:0 0 4px}h2{font-size:17px;margin-top:26px;border-bottom:1px solid #e5e7eb;padding-bottom:4px;break-after:avoid}h3{font-size:14px;margin:18px 0 8px;break-after:avoid}.meta,.question{font-size:12px;color:#6b7280;margin-bottom:12px}.answer{font-size:13px}.export-section{margin-top:20px;break-before:auto}.full-answer{page-break-before:auto}.export-image img{max-width:100%;border:1px solid #e5e7eb;border-radius:10px}.refs{font-size:12px;padding-left:20px;margin-top:8px;break-inside:auto}.refs li{margin:6px 0;break-inside:avoid}.assistant-cite{display:inline-flex;align-items:center;justify-content:center;min-width:16px;height:16px;padding:0 5px;margin:0 1px;border-radius:999px;background:#f3e8ff;color:#7c3aed;border:1px solid rgba(124,58,237,.22);font-size:9px;font-weight:800;line-height:16px;text-decoration:none;text-transform:uppercase;letter-spacing:.03em;vertical-align:baseline;white-space:nowrap;-webkit-print-color-adjust:exact;print-color-adjust:exact}.answer table{width:100%;border-collapse:collapse;table-layout:auto;margin:12px 0 18px;border:1px solid #e5e7eb;page-break-inside:auto}.answer th,.answer td{padding:7px 8px;border:1px solid #e5e7eb;text-align:left;vertical-align:top;word-break:normal;overflow-wrap:break-word}.answer th{background:#f9fafb;font-weight:700;-webkit-print-color-adjust:exact;print-color-adjust:exact}.answer th:last-child,.answer td:last-child{width:1%;white-space:nowrap}.answer tr{break-inside:avoid;page-break-inside:avoid}.answer thead{display:table-header-group}.answer tbody{display:table-row-group}.answer p{margin:8px 0}.answer ul,.answer ol{padding-left:20px}.answer li{margin:4px 0}@media print{.assistant-export-actions{display:none}body{margin:24mm}.export-section{break-inside:auto}.refs{break-before:avoid}}'; + return 'body{font-family:Arial,sans-serif;color:#111827;line-height:1.55;margin:36px;max-width:820px}.assistant-table-actions,.assistant-msg-actions,.assistant-code-copy,.assistant-translate-pop,.assistant-takehome-actions,.assistant-takehome-modal,.assistant-voice-overlay{display:none!important}.assistant-export-actions{display:flex;gap:8px;justify-content:flex-end;margin-bottom:12px}.assistant-export-actions button{padding:8px 12px}h1{font-size:22px;margin:0 0 4px}h2{font-size:17px;margin-top:26px;border-bottom:1px solid #e5e7eb;padding-bottom:4px;break-after:avoid}h3{font-size:14px;margin:18px 0 8px;break-after:avoid}.meta,.question{font-size:12px;color:#6b7280;margin-bottom:12px}.answer{font-size:13px}.export-section{margin-top:20px;break-before:auto}.full-answer{page-break-before:auto}.export-image img{max-width:100%;border:1px solid #e5e7eb;border-radius:10px}.refs{font-size:12px;padding-left:20px;margin-top:8px;break-inside:auto}.refs li{margin:6px 0;break-inside:avoid}.assistant-cite{display:inline-flex;align-items:center;justify-content:center;min-width:16px;height:16px;padding:0 5px;margin:0 1px;border-radius:999px;background:#f3e8ff;color:#7c3aed;border:1px solid rgba(124,58,237,.22);font-size:9px;font-weight:800;line-height:16px;text-decoration:none;text-transform:uppercase;letter-spacing:.03em;vertical-align:baseline;white-space:nowrap;-webkit-print-color-adjust:exact;print-color-adjust:exact}.answer table{width:100%;border-collapse:collapse;table-layout:auto;margin:12px 0 18px;border:1px solid #e5e7eb;page-break-inside:auto}.answer th,.answer td{padding:7px 8px;border:1px solid #e5e7eb;text-align:left;vertical-align:top;word-break:normal;overflow-wrap:break-word}.answer th{background:#f9fafb;font-weight:700;-webkit-print-color-adjust:exact;print-color-adjust:exact}.answer th:last-child,.answer td:last-child{width:1%;white-space:nowrap}.answer tr{break-inside:avoid;page-break-inside:avoid}.answer thead{display:table-header-group}.answer tbody{display:table-row-group}.answer p{margin:8px 0}.answer ul,.answer ol{padding-left:20px}.answer li{margin:4px 0}@media print{.assistant-export-actions{display:none}body{margin:24mm}.export-section{break-inside:auto}.refs{break-before:avoid}}'; } function inlineExportCss() { @@ -284,7 +284,7 @@ function inlineExportCss() { } function exportTableScrollCss() { - return '.answer [align=right],#assistant-export-modal .answer [align=right]{text-align:right}.answer [align=center],#assistant-export-modal .answer [align=center]{text-align:center}.assistant-table-scroll{max-width:100%;overflow-x:auto;overflow-y:hidden;-webkit-overflow-scrolling:touch;margin:12px 0 18px;border:1px solid #e5e7eb;border-radius:10px;background:white}.answer .assistant-table-scroll table,#assistant-export-modal .answer .assistant-table-scroll table{width:max-content;min-width:100%;max-width:none;margin:0;border:0;border-radius:0}.answer .assistant-table-scroll th,.answer .assistant-table-scroll td,#assistant-export-modal .answer .assistant-table-scroll th,#assistant-export-modal .answer .assistant-table-scroll td{min-width:120px;overflow-wrap:normal;word-break:normal}.assistant-table-scroll::after{content:"Swipe table";display:block;position:sticky;left:0;bottom:0;padding:3px 9px;font-size:10px;font-weight:700;color:#6b7280;background:linear-gradient(90deg,rgba(255,255,255,.95),rgba(255,255,255,0));pointer-events:none}@media print{.assistant-table-scroll{overflow:visible;border:0}.assistant-table-scroll::after{display:none}.answer .assistant-table-scroll table,#assistant-export-modal .answer .assistant-table-scroll table{width:100%;max-width:100%}}'; + return '.answer [align=right],#assistant-export-modal .answer [align=right]{text-align:right}.answer [align=center],#assistant-export-modal .answer [align=center]{text-align:center}.assistant-table-actions,.assistant-msg-actions,.assistant-code-copy,.assistant-translate-pop,.assistant-takehome-actions,.assistant-takehome-modal,.assistant-voice-overlay{display:none!important}.assistant-table-scroll{max-width:100%;overflow-x:auto;overflow-y:hidden;-webkit-overflow-scrolling:touch;margin:12px 0 18px;border:1px solid #e5e7eb;border-radius:10px;background:white}.answer .assistant-table-scroll table,#assistant-export-modal .answer .assistant-table-scroll table{width:max-content;min-width:100%;max-width:none;margin:0;border:0;border-radius:0}.answer .assistant-table-scroll th,.answer .assistant-table-scroll td,#assistant-export-modal .answer .assistant-table-scroll th,#assistant-export-modal .answer .assistant-table-scroll td{min-width:120px;overflow-wrap:normal;word-break:normal}.assistant-table-scroll::after{content:"Swipe table";display:block;position:sticky;left:0;bottom:0;padding:3px 9px;font-size:10px;font-weight:700;color:#6b7280;background:linear-gradient(90deg,rgba(255,255,255,.95),rgba(255,255,255,0));pointer-events:none}@media print{.assistant-table-scroll{overflow:visible;border:0}.assistant-table-scroll::after{display:none}.answer .assistant-table-scroll table,#assistant-export-modal .answer .assistant-table-scroll table{width:100%;max-width:100%}}'; } function collectExportItems(messages, lastAnswer, lastSources, presentMessage) { diff --git a/public/js/clinicalAssistant.js b/public/js/clinicalAssistant.js index 8b2fcdc..e6ade01 100644 --- a/public/js/clinicalAssistant.js +++ b/public/js/clinicalAssistant.js @@ -64,6 +64,7 @@ import { .catch(function() { /* keep the full static list on failure */ }) .finally(function() { translateLanguagesLoading = false; + if (typeof document === 'undefined') return; var open = document.querySelector('[data-assistant-translate-pop]'); if (open) renderTranslateLanguages(open, open.assistantMessageRow); }); @@ -86,8 +87,16 @@ import { var SOURCE_EXCERPT_PREVIEW = 10000; document.addEventListener('tabChanged', function (e) { - if (e.detail && e.detail.tab === 'assistant') initIfNeeded(); - if (e.detail && e.detail.tab && e.detail.tab !== 'assistant' && e.detail.tab !== 'learning') lastNonAssistantTab = e.detail.tab; + if (e.detail && e.detail.tab === 'assistant') { + initIfNeeded(); + // Open WebUI-style: the assistant workspace replaces the main menu; + // saved chats become the left navigation and Go back restores the app. + document.body.classList.add('assistant-workspace'); + } + if (e.detail && e.detail.tab && e.detail.tab !== 'assistant' && e.detail.tab !== 'learning') { + lastNonAssistantTab = e.detail.tab; + document.body.classList.remove('assistant-workspace'); + } }); window.addEventListener('account-boundary', function () { cancelAutosave(); @@ -98,6 +107,10 @@ import { function initIfNeeded() { if (initialized) return; var root = document.getElementById('assistant-tab'); + if (typeof window !== 'undefined' && window._userRole && window._userRole !== 'admin') { + var downloadBtn = document.getElementById('btn-assistant-download-chat'); + if (downloadBtn) downloadBtn.remove(); + } if (!root || !root.querySelector('#assistant-form')) return; initialized = true; bindEvents(); @@ -109,9 +122,6 @@ import { var form = document.getElementById('assistant-form'); var clearBtn = document.getElementById('btn-assistant-clear'); var cancelBtn = document.getElementById('btn-assistant-cancel'); - var saveBtn = document.getElementById('btn-assistant-save'); - var saveConfirmBtn = document.getElementById('btn-assistant-save-confirm'); - var saveCancelBtn = document.getElementById('btn-assistant-save-cancel'); var exportBtn = document.getElementById('btn-assistant-export-pdf'); var imageBtn = document.getElementById('btn-assistant-image'); var imageClearBtn = document.getElementById('btn-assistant-image-clear'); @@ -129,9 +139,6 @@ import { if (learningViewBtn) learningViewBtn.addEventListener('click', openLearningView); if (input) input.addEventListener('input', updateConversationBudget); if (cancelBtn) cancelBtn.addEventListener('click', cancelAssistantSearch); - if (saveBtn) saveBtn.addEventListener('click', showSavePanel); - if (saveConfirmBtn) saveConfirmBtn.addEventListener('click', saveCurrentChat); - if (saveCancelBtn) saveCancelBtn.addEventListener('click', hideSavePanel); if (exportBtn) exportBtn.addEventListener('click', exportAnswerPdf); var takehomeBtn = document.getElementById('btn-assistant-takehome'); if (takehomeBtn) takehomeBtn.addEventListener('click', openPatientTakehome); @@ -199,7 +206,7 @@ import { if (conversationChars !== null && conversationSize(text) > conversationChars) { updateConversationBudget(); - if (typeof showToast === 'function') showToast('Conversation limit reached. Save or download this chat, then start a new chat.', 'error'); + if (typeof showToast === 'function') showToast('Conversation limit reached. This chat is saved automatically; start a new chat.', 'error'); return; } @@ -860,12 +867,6 @@ import { loadSavedChat(loadBtn.getAttribute('data-assistant-load-chat')); return; } - var deleteBtn = e.target.closest('[data-assistant-delete-chat]'); - if (deleteBtn) { - e.preventDefault(); - deleteSavedChat(deleteBtn.getAttribute('data-assistant-delete-chat')); - return; - } var openBtn = e.target.closest('[data-assistant-open-image]'); if (openBtn) { e.preventDefault(); @@ -1086,7 +1087,7 @@ import { if (st) st.textContent = msg || 'Tap to speak'; }; if (!text) { done(); return; } - fetch('/text-to-speech', { + fetch('/api/text-to-speech', { method: 'POST', headers: getAuthHeaders(Object.assign({ 'Content-Type': 'application/json' })), body: JSON.stringify({ text: text }) @@ -1151,7 +1152,12 @@ import { var result = modal.querySelector('.assistant-takehome-result'); var actions = modal.querySelector('.assistant-takehome-actions'); if (loading) loading.remove(); - if (result) { result.textContent = takehomeText; result.classList.remove('hidden'); } + if (result) { + // Same universal markdown renderer as chat bubbles; raw text stays + // canonical for Copy/Export/email. + result.innerHTML = renderMarkdown(takehomeText, [], {}); + result.classList.remove('hidden'); + } if (actions) actions.classList.remove('hidden'); }).catch(function(err) { var loading = modal.querySelector('.assistant-takehome-loading'); @@ -1274,6 +1280,7 @@ import { // ── Workspace navigation: Go back, chat / learning view switch ───── function goBackToMainMenu() { + document.body.classList.remove('assistant-workspace'); if (typeof window.activateTab === 'function') { window.activateTab(lastNonAssistantTab || 'encounter'); return; @@ -1384,12 +1391,12 @@ import { if (event && messages.length) { // Use the site's standard inline dialog instead of the native confirm(). if (typeof showConfirm === 'function') { - showConfirm('Start a new chat? Save or download this conversation first if you want to keep it.', function() { + showConfirm('Start a new chat? Your current chat is already saved automatically.', function() { performClearConversation(); }); return; } - if (!window.confirm('Start a new chat? Save or download this conversation first if you want to keep it.')) return; + if (!window.confirm('Start a new chat? Your current chat is already saved automatically.')) return; } performClearConversation(); } @@ -1473,54 +1480,6 @@ import { }); } - function saveCurrentChat() { - if (assistantBusy) return; - if (!messages.length) { if (typeof showToast === 'function') showToast('No assistant chat to save yet', 'error'); return; } - var titleEl = document.getElementById('assistant-save-title'); - var title = String(titleEl && titleEl.value || deriveChatTitle()).trim() || deriveChatTitle(); - setBusy(true, 'Saving chat...'); - // The generated sidebar image persists with the chat; a manual save also updates the autosaved row. - cancelAutosave(); - return saveAssistantChat({ - title: title, - messages: messages, - sources: lastSources, - lastAnswer: lastAnswer, - generatedImageJobs: generatedImageJobs, - generatedImage: lastGeneratedImageSrc || undefined, - ...(currentChatId ? { id: currentChatId } : {}) - }) - .then(function (data) { - setBusy(false, 'Ready'); - if (!data.success) throw new Error(data.error || 'Save failed'); - if (data.id) currentChatId = data.id; - hideSavePanel(); - if (typeof showToast === 'function') showToast('Saved chat', 'success'); - loadSavedChats(); - }) - .catch(function (err) { - setBusy(false, 'Error', true); - if (typeof showToast === 'function') showToast(err.message, 'error'); - }); - } - - function showSavePanel() { - if (assistantBusy) return; - if (!messages.length) { if (typeof showToast === 'function') showToast('No assistant chat to save yet', 'error'); return; } - var panel = document.getElementById('assistant-save-panel'); - var titleEl = document.getElementById('assistant-save-title'); - if (!panel || !titleEl) return saveCurrentChat(); - titleEl.value = deriveChatTitle(); - panel.hidden = false; - titleEl.focus(); - titleEl.select(); - } - - function hideSavePanel() { - var panel = document.getElementById('assistant-save-panel'); - if (panel) panel.hidden = true; - } - function loadSavedChats() { fetchSavedAssistantChats() .then(function (data) { if (data.success) renderSavedChats(data.chats || []); }) @@ -1531,17 +1490,15 @@ import { var wrap = document.getElementById('assistant-saved-chats'); if (!wrap) return; if (!chats.length) { - wrap.innerHTML = '

Saved chats appear here after you click Save chat.

'; + wrap.innerHTML = '

Chats save automatically as you go.

'; return; } + // Open WebUI behavior: the whole row opens the chat — no Load/Delete buttons. wrap.innerHTML = chats.map(function (chat) { - return '
' + - '
' + escapeHtml(chat.title || 'Saved chat') + '
' + - '
' + escapeHtml(formatSavedDate(chat.updated_at || chat.created_at)) + '
' + - '
' + - '' + - '' + - '
'; + return ''; }).join(''); } @@ -1559,24 +1516,6 @@ import { .finally(function() { setBusy(false, 'Ready'); }); } - function deleteSavedChat(id) { - var btn = document.querySelector('[data-assistant-delete-chat="' + cssEscape(id) + '"]'); - if (btn && btn.getAttribute('data-confirm-delete') !== 'true') { - btn.setAttribute('data-confirm-delete', 'true'); - btn.textContent = 'Confirm delete'; - setTimeout(function () { - if (btn.getAttribute('data-confirm-delete') === 'true') { - btn.removeAttribute('data-confirm-delete'); - btn.textContent = 'Delete'; - } - }, 5000); - return; - } - deleteSavedAssistantChat(id) - .then(function () { loadSavedChats(); if (typeof showToast === 'function') showToast('Deleted saved chat', 'success'); }) - .catch(function () { if (typeof showToast === 'function') showToast('Delete failed', 'error'); }); - } - function restoreSavedChat(payload) { messages = Array.isArray(payload.messages) ? payload.messages.map(function (m) { var message = { role: m.role === 'assistant' ? 'assistant' : 'user', content: String(m.content || ''), sources: Array.isArray(m.sources) ? m.sources : [], imageJobs: m.imageJobs || [], attachments: Array.isArray(m.attachments) ? m.attachments : [] }; @@ -1695,6 +1634,11 @@ import { } function downloadTranscript() { + // Raw transcript JSON is an admin-only artifact. + if (typeof window !== 'undefined' && window._userRole && window._userRole !== 'admin') { + if (typeof showToast === 'function') showToast('Raw transcript download is for administrators only', 'error'); + return; + } var payload = { version: 2, title: deriveChatTitle(), messages: messages, sources: lastSources, lastAnswer: lastAnswer, generatedImageJobs: generatedImageJobs, generatedImage: lastGeneratedImageSrc || undefined, savedAt: new Date().toISOString() }; var url = URL.createObjectURL(new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' })); diff --git a/test/assistant-attachment-roundtrip.test.js b/test/assistant-attachment-roundtrip.test.js index 614c33c..a03b689 100644 --- a/test/assistant-attachment-roundtrip.test.js +++ b/test/assistant-attachment-roundtrip.test.js @@ -155,7 +155,8 @@ test('client restores attachments as thumbnails and saves them plus the generate context.appendMessage('assistant', 'Answer [1].', [{ number: 1, title: 'S', page: 2 }]); context.lastAnswer = 'Answer [1].'; context.lastGeneratedImageSrc = asset; - return context.saveCurrentChat().then(() => { + context.scheduleAutosave(); + return context.performAutosave().then(() => { assert.deepEqual(saves[0].messages[0].attachments, [png]); assert.equal(saves[0].generatedImage, asset); context.restoreSavedChat(saves[0]); diff --git a/test/assistant-autosave.test.js b/test/assistant-autosave.test.js index bbfa248..5d8e78a 100644 --- a/test/assistant-autosave.test.js +++ b/test/assistant-autosave.test.js @@ -125,9 +125,11 @@ test('loading a saved chat binds autosave updates to that chat id', async t => { assert.equal(app.saves[0].id, 7); }); -test('new chat clears the autosave identity and pending timer; manual save adopts the custom title and id', async t => { +test('new chat clears the autosave identity and pending timer; autosave derives the title and keeps updating one chat', async t => { const app = ui(t); const c = app.context; + assert.equal(app.document.getElementById('btn-assistant-save'), null, 'manual Save control is gone — chats autosave'); + assert.equal(app.document.getElementById('assistant-save-panel'), null); c.currentChatId = 5; c.appendMessage('user', 'Question'); c.scheduleAutosave(); @@ -138,15 +140,16 @@ test('new chat clears the autosave identity and pending timer; manual save adopt c.appendMessage('user', 'Fresh question'); c.appendMessage('assistant', 'Fresh answer.', []); c.lastAnswer = 'Fresh answer.'; - app.document.getElementById('assistant-save-title').value = 'Manual title'; - await c.saveCurrentChat(); + c.scheduleAutosave(); + await app.timers.flush(); assert.equal(app.saves.length, 1); - assert.equal(app.saves[0].title, 'Manual title'); + assert.equal(app.saves[0].title, 'Fresh question', 'title derives from the first user message'); assert.equal(app.saves[0].id, undefined); assert.equal(c.currentChatId, 1); - await c.saveCurrentChat(); - assert.equal(app.saves[1].id, 1, 'manual re-save updates the autosaved chat'); - assert.equal(app.saves[1].title, 'Manual title'); + c.scheduleAutosave(); + await app.timers.flush(); + assert.equal(app.saves[1].id, 1, 'later autosaves update the same chat'); + assert.equal(app.saves[1].title, undefined, 'updates keep the existing title (only the first save sets it)'); }); test('oversized conversations are sent whole and never client-truncated', async t => { diff --git a/test/assistant-image-attachments.test.js b/test/assistant-image-attachments.test.js index 4f93f8e..f7810b1 100644 --- a/test/assistant-image-attachments.test.js +++ b/test/assistant-image-attachments.test.js @@ -368,7 +368,7 @@ test('UI sends images only with the question, clears them on success, persists t assert.doesNotMatch(label.textContent, /image/i); // Pending attachments stay input-only until the question is sent. - await ui.context.saveCurrentChat(); + ui.context.performAutosave(); assert.equal(ui.calls.save[0].images, undefined); assert.equal(JSON.stringify(ui.calls.save[0]).includes('dataBase64'), false); @@ -380,7 +380,7 @@ test('UI sends images only with the question, clears them on success, persists t assert.equal(ui.context.messages.length, 3, 'question and answer appended'); // The sent question now persists its attachments with the saved chat. - await ui.context.saveCurrentChat(); + ui.context.performAutosave(); assert.ok(JSON.stringify(ui.calls.save.at(-1)).includes('dataBase64')); const savedUser = ui.calls.save.at(-1).messages.filter(m => m.role === 'user').at(-1); assert.deepEqual(JSON.parse(JSON.stringify(savedUser.attachments)), [{ dataBase64: expected[0].dataBase64, mimeType: 'image/png', name: 'photo.png' }]); diff --git a/test/assistant-saved-tables.test.js b/test/assistant-saved-tables.test.js index 9c1f293..8d92568 100644 --- a/test/assistant-saved-tables.test.js +++ b/test/assistant-saved-tables.test.js @@ -57,7 +57,7 @@ test('raw v2 actual save/load and SSE/fallback/append/export share all table cel assert.equal(bubble(app).querySelectorAll('td')[1].getAttribute('align'), 'right'); assert.equal(app.window.getComputedStyle(bubble(app).querySelectorAll('td')[1]).textAlign, 'right'); assert.equal(bubble(app).querySelector('.assistant-table-scroll').getAttribute('tabindex'), '0'); - await c.saveCurrentChat(); + await c.performAutosave(); assert.equal(app.saves.at(-1).version, 2); assert.equal(app.saves.at(-1).messages[0].content, table); assert.equal(app.saves.at(-1).lastAnswer, table); @@ -82,7 +82,7 @@ test('legacy collapsed complete tables recover for display only, including multi assert.equal(bubble(app).querySelectorAll('table').length, 2); assert.deepEqual(rows(bubble(app)).map(row => row[0]), ['Alpha', 'Beta', 'Gamma', 'Delta']); assert.equal(app.context.messages[1].content, raw); - await app.context.saveCurrentChat(); + app.context.performAutosave(); assert.equal(app.saves[0].messages[1].content, raw); app.context.exportAnswerPdf(); assert.equal(app.document.querySelectorAll('#assistant-export-modal table').length, 2); @@ -143,7 +143,7 @@ test('legacy exact-limit prefix may display only retained lastAnswer, never manu assert.equal(rows(bubble(app)).length, 452); assert.match(bubble(app).textContent, /retained.*lastAnswer/i); assert.equal(app.context.messages[1].content, clipped); - await app.context.saveCurrentChat(); + app.context.performAutosave(); assert.equal(app.saves[0].messages[1].content, clipped); assert.equal(app.saves[0].lastAnswer, full); app.context.exportAnswerPdf(); @@ -222,7 +222,7 @@ test('legacy loss provenance and retained raw extension survive v2 re-save and f app.context.appendMessage('user', 'Follow-up'); app.context.appendMessage('assistant', 'New answer', sources); app.context.lastAnswer = 'New answer'; - await app.context.saveCurrentChat(); + app.context.performAutosave(); const saved = app.saves[0]; assert.equal(saved.version, 2); assert.equal(saved.messages[1].content, clipped); @@ -284,7 +284,7 @@ test('clicking reused citation numbers selects the original turn source without citations[1].click(); assert.match(app.document.querySelector('#assistant-source-1').textContent, /Second turn reference.*page 23/is); citations[0].click(); - await app.context.saveCurrentChat(); + app.context.performAutosave(); assert.deepEqual(app.saves[0].messages[1].sources, first); assert.deepEqual(app.saves[0].messages[3].sources, second); assert.deepEqual(app.saves[0].sources, second, 'viewing an older source does not replace the latest retrieval map'); @@ -342,7 +342,7 @@ test('comparison angles and compact URLs preserve every source-column link, expo reopen(app, content); assert.equal(rows(bubble(app)).length, 2); assert.deepEqual([...bubble(app).querySelectorAll('.assistant-cite')].map(a => a.dataset.sourceNumber), ['1', '2']); - await app.context.saveCurrentChat(); + app.context.performAutosave(); assert.equal(app.saves.at(-1).messages[1].content, content); app.context.exportAnswerPdf(); const modal = app.document.querySelector('#assistant-export-modal'); @@ -406,7 +406,7 @@ test('durable jobs preserve legacy provenance, provisional/clicked turn sources await tick(); assert.equal(current.querySelector('table'), lastTable); assert.equal(lastTable.outerHTML, lastHtml); bubble(app).querySelector('.assistant-cite').click(); assert.match(app.document.querySelector('#assistant-source-2').textContent, /Synthetic B.*19/is); - await c.saveCurrentChat(); const saved = app.saves.at(-1); + await c.performAutosave(); const saved = app.saves.at(-1); assert.equal(saved.messages[0].content, raw); assert.equal(saved.messages[0].retainedAnswer, retained); assert.equal(saved.messages[0].legacyClipped, true); assert.deepEqual(saved.messages[0].sources, sources); assert.deepEqual(saved.sources, secondSources); assert.deepEqual(saved.messages[0].imageJobs, [{ jobId: id }]); assert.equal(saved.messages.at(-1).content, second); diff --git a/test/assistant-voice.test.js b/test/assistant-voice.test.js index 157da11..cd8cb82 100644 --- a/test/assistant-voice.test.js +++ b/test/assistant-voice.test.js @@ -37,7 +37,7 @@ function ui(t, options = {}) { this.play = () => { played.push(1); return Promise.resolve(); }; } const apiFetch = async (url, options) => { - if (url === '/text-to-speech') { ttsCalls.push(JSON.parse(options.body)); return new Response(audioBlob, { status: 200 }); } + if (url === '/api/text-to-speech') { ttsCalls.push(JSON.parse(options.body)); return new Response(audioBlob, { status: 200 }); } if (url === '/clinical-assistant/translate/languages') return new Response(JSON.stringify({ success: true, languages: { libretranslate: ['en'] } })); return new Response(JSON.stringify({ success: true, chats: [] })); }; diff --git a/test/assistant-workspace-layout.test.js b/test/assistant-workspace-layout.test.js index 9c30b24..e982e32 100644 --- a/test/assistant-workspace-layout.test.js +++ b/test/assistant-workspace-layout.test.js @@ -64,6 +64,18 @@ test('assistant area is an OWUI-style three-column workspace with a slim go-back assert.ok(examples.length >= 3, 'the generated example questions stay on the empty chat screen'); }); +test('opening the assistant replaces the main menu with the saved chats; Go back restores the app', async t => { + const app = workspace(t); + const c = app.context; + app.document.dispatchEvent(new app.window.CustomEvent('tabChanged', { detail: { tab: 'assistant' } })); + assert.ok(app.document.body.classList.contains('assistant-workspace'), 'fullscreen workspace class applied'); + app.document.dispatchEvent(new app.window.CustomEvent('tabChanged', { detail: { tab: 'encounter' } })); + assert.ok(!app.document.body.classList.contains('assistant-workspace'), 'leaving the assistant restores the app chrome'); + app.document.body.classList.add('assistant-workspace'); + app.document.getElementById('btn-assistant-goback').click(); + assert.ok(!app.document.body.classList.contains('assistant-workspace'), 'Go back removes the workspace class'); +}); + test('learning hub opens inside the assistant center column and keeps the right tools column visible', async t => { const app = workspace(t); const c = app.context; diff --git a/test/clinical-conversation.test.js b/test/clinical-conversation.test.js index a4056ca..c4ff3f2 100644 --- a/test/clinical-conversation.test.js +++ b/test/clinical-conversation.test.js @@ -216,7 +216,7 @@ test('actual UI sends complete prior history exactly once and saves the sidebar assert.equal(ui.context.messages.length, 22); assert.equal(ui.context.messages[20].content, 'New question'); assert.equal(ui.document.getElementById('assistant-input').value, ''); - await ui.context.saveCurrentChat(); + ui.context.performAutosave(); assert.equal(ui.calls.save[0].generatedImage, png); assert.equal(ui.calls.save[0].messages.length, 22); assert.equal(ui.context.messages.length, 22); diff --git a/test/clinical-release-integration.test.js b/test/clinical-release-integration.test.js index 1753e84..5b49252 100644 --- a/test/clinical-release-integration.test.js +++ b/test/clinical-release-integration.test.js @@ -116,8 +116,8 @@ test('native admin and assistant modules retain budget, table/source identity an document.getElementById('assistant-form').dispatchEvent(new window.Event('submit', { cancelable: true })); await tick(); assertSafe(document.querySelector('.assistant-msg.assistant .assistant-bubble'), purified); - document.getElementById('btn-assistant-save-confirm').click(); - await tick(); + await new Promise(resolve => setTimeout(resolve, 900)); // autosave debounce after the completed turn + assert.ok(saved, 'autosave persisted the completed turn'); assert.equal(saved.messages[1].content, markdown); assert.equal(saved.sources[0].excerpt, sources[0].excerpt); document.getElementById('btn-assistant-clear').click(); diff --git a/test/frontend-prompt-env.test.js b/test/frontend-prompt-env.test.js index fa7ef9e..6293abe 100644 --- a/test/frontend-prompt-env.test.js +++ b/test/frontend-prompt-env.test.js @@ -214,7 +214,7 @@ test('native conversation UI counts UTF-16 history + draft, warns at exactly 90% assert.equal(input.value.length, 102); assert.equal(ui.document.querySelectorAll('.assistant-msg').length, 2); assert.match(warning.textContent, /Sending is blocked/); - for (const id of ['btn-assistant-save', 'btn-assistant-download-chat', 'btn-assistant-export-pdf']) assert.equal(ui.document.getElementById(id).disabled, false); + for (const id of ['btn-assistant-download-chat', 'btn-assistant-export-pdf']) assert.equal(ui.document.getElementById(id).disabled, false); enter(ui, 'x'.repeat(101)); await ask(ui); const request = ui.calls.find(c => c.url.endsWith('/chat/stream')); assert.deepEqual(request.body.history, history); @@ -222,16 +222,21 @@ test('native conversation UI counts UTF-16 history + draft, warns at exactly 90% assert.equal(input.value, ''); }); -test('over-cap saved history remains viewable/saveable/exportable', async t => { +test('over-cap saved history remains viewable/autosavable/exportable', async t => { const history = [{ role: 'user', content: 'x'.repeat(1001) }, { role: 'assistant', content: 'Full saved answer' }]; const ui = await browser(t, 'assistant', (url, options) => { if (url === '/api/clinical-assistant/chats' && !options.method) return json({ success: true, chats: [{ id: 1 }] }); if (url === '/api/clinical-assistant/chats/1') return json({ success: true, chat: { payload: { version: 2, messages: history } } }); + if (url === '/api/clinical-assistant/chat/stream') { + return new Response('event: done\ndata: ' + JSON.stringify({ success: true, answer: 'Follow-up answer', sources: [] }) + '\n\n', { status: 200 }); + } }); await loadChat(ui); enter(ui, 'Unsent draft'); - ui.document.getElementById('btn-assistant-save-confirm').click(); await tick(); - assert.deepEqual(ui.calls.find(c => c.url.endsWith('/chats') && c.options.method === 'POST').body.messages.map(({ role, content }) => ({ role, content })), history); - assert.equal(ui.document.getElementById('assistant-input').value, 'Unsent draft'); + // Over-cap chats cannot gain new turns (Sending is blocked), so nothing re-saves. + ui.document.getElementById('assistant-input').value = 'Follow-up'; + await ask(ui); + assert.equal(ui.document.getElementById('assistant-input').value, 'Follow-up', 'the draft is preserved over an over-cap chat'); + assert.equal(ui.calls.filter(c => c.url.endsWith('/chats') && c.options.method === 'POST').length, 0, 'no autosave without a completed turn'); ui.window.matchMedia = () => ({ matches: true }); ui.document.getElementById('btn-assistant-export-pdf').click(); assert.match(ui.document.getElementById('assistant-export-modal').textContent, /Full saved answer/); diff --git a/test/generated-images-ui.test.js b/test/generated-images-ui.test.js index 66af34d..002268a 100644 --- a/test/generated-images-ui.test.js +++ b/test/generated-images-ui.test.js @@ -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));await ui.context.saveCurrentChat(); + ui.context.generateImage('B request');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); @@ -140,7 +140,7 @@ test('selected sidebar B survives A-done -> B-queued -> save/reopen/export, with assert.equal(ui.context.lastGeneratedImageSrc,'/api/generated-images/'+b); const exported=await ui.context.preparePrivateExport(state(),exportOwner);assert.equal(exported.lastGeneratedImageSrc,'data-for:/api/generated-images/'+b); assert.equal(exported.messages[0].images[0],'data-for:'+src);assert.equal(exported.messages[0].content,transcript[0].content); - await ui.context.saveCurrentChat();assert.equal(saved.generatedImage,'/api/generated-images/'+b,'the completed sidebar image persists with the chat');assert.equal(saved.generatedImageJobs[0].jobId,b);ui.dom.window.close(); + ui.context.performAutosave();assert.equal(saved.generatedImage,'/api/generated-images/'+b,'the completed sidebar image persists with the chat');assert.equal(saved.generatedImageJobs[0].jobId,b);ui.dom.window.close(); }); test('actual CMS presentation image controls are visible and insertion/save/reopen preserve Markdown; preserved refinement never resets editor', async () => { const ui=client();ui.dom.window.document.body.innerHTML=''+fs.readFileSync('public/components/cms.html','utf8'); diff --git a/test/patient-takehome.test.js b/test/patient-takehome.test.js index 7acb25d..c518e59 100644 --- a/test/patient-takehome.test.js +++ b/test/patient-takehome.test.js @@ -139,7 +139,7 @@ function client(t, options = {}) { const apiFetch = async (url, options) => { calls.push({ url, options }); if (url === '/api/clinical-assistant/patient-takehome') { - return new Response(JSON.stringify({ success: true, text: options.takehomeText || 'Patient take home sheet.' })); + return new Response(JSON.stringify({ success: true, text: options.takehomeText || '**Rest** and drink fluids.\n\n- Give medicine as directed' })); } if (url === '/api/clinical-assistant/patient-takehome/email') { if (options.failEmail) return new Response(JSON.stringify({ error: 'Email is not configured on this server yet' }), { status: 503 }); @@ -177,7 +177,9 @@ test('Patient take home button opens the modal, generates, and offers Copy/Expor await new Promise(r => setImmediate(r)); await new Promise(r => setImmediate(r)); const modal = app.document.getElementById('assistant-takehome-modal'); assert.ok(modal, 'modal opened'); - assert.equal(modal.querySelector('.assistant-takehome-result').textContent, 'Patient take home sheet.'); + assert.match(modal.querySelector('.assistant-takehome-result').innerHTML, /Rest<\/strong>/, 'take-home renders through the shared markdown pipeline'); + assert.match(modal.querySelector('.assistant-takehome-result').innerHTML, /