fix: OWUI-style polish — fullscreen workspace replaces main menu, row-click saved chats (delete stays as hover), markdown-rendered patient handout, admin-only raw transcript, /api TTS URL, scrollable unclipped tables, action buttons hidden in exports, autosave-only copy

This commit is contained in:
Daniel 2026-09-08 22:41:08 +02:00
parent 2e0951995c
commit da86c7db79
17 changed files with 129 additions and 146 deletions

View file

@ -19,18 +19,9 @@
<div class="card">
<div class="card-header">
<h3><i class="fas fa-bookmark"></i> Saved Chats</h3>
<button id="btn-assistant-save" class="btn-sm btn-ghost" type="button"><i class="fas fa-floppy-disk"></i> Save</button>
</div>
<div id="assistant-saved-chats" class="assistant-saved-chats">
<p class="assistant-muted">Saved chats appear here after you click Save chat.</p>
</div>
<div id="assistant-save-panel" class="assistant-save-panel" hidden>
<label for="assistant-save-title">Save chat as</label>
<input id="assistant-save-title" type="text" maxlength="160" autocomplete="off">
<div class="assistant-save-actions">
<button id="btn-assistant-save-confirm" class="btn-sm btn-primary" type="button">Save</button>
<button id="btn-assistant-save-cancel" class="btn-sm btn-ghost" type="button">Cancel</button>
</div>
<p class="assistant-muted">Chats save automatically as you go.</p>
</div>
</div>
</aside>

View file

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

View file

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

View file

@ -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) {

View file

@ -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) {

View file

@ -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 = '<p class="assistant-muted">Saved chats appear here after you click Save chat.</p>';
wrap.innerHTML = '<p class="assistant-muted">Chats save automatically as you go.</p>';
return;
}
// Open WebUI behavior: the whole row opens the chat — no Load/Delete buttons.
wrap.innerHTML = chats.map(function (chat) {
return '<div class="assistant-saved-chat">' +
'<div class="assistant-saved-chat-title">' + escapeHtml(chat.title || 'Saved chat') + '</div>' +
'<div class="assistant-saved-chat-meta">' + escapeHtml(formatSavedDate(chat.updated_at || chat.created_at)) + '</div>' +
'<div class="assistant-saved-chat-actions">' +
'<button type="button" class="btn-sm btn-ghost" data-assistant-load-chat="' + escapeAttr(chat.id) + '">Load</button>' +
'<button type="button" class="btn-sm btn-ghost" data-assistant-delete-chat="' + escapeAttr(chat.id) + '">Delete</button>' +
'</div></div>';
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>' +
'<span class="assistant-saved-chat-meta">' + escapeHtml(formatSavedDate(chat.updated_at || chat.created_at)) + '</span>' +
'</button>';
}).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' }));

View file

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

View file

@ -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 => {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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));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='<style>.hidden{display:none!important}</style>'+fs.readFileSync('public/components/cms.html','utf8');

View file

@ -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, /<strong>Rest<\/strong>/, 'take-home renders through the shared markdown pipeline');
assert.match(modal.querySelector('.assistant-takehome-result').innerHTML, /<ul>/, 'bullets render too');
assert.match(modal.querySelector('.assistant-takehome-result').textContent, /Rest and drink fluids/);
assert.ok(modal.querySelector('[data-assistant-takehome-copy]'));
assert.ok(modal.querySelector('[data-assistant-takehome-export]'));
assert.ok(modal.querySelector('[data-assistant-takehome-send]'));