diff --git a/public/components/admin.html b/public/components/admin.html index 99ef15d4..8a24174f 100644 --- a/public/components/admin.html +++ b/public/components/admin.html @@ -330,8 +330,7 @@
diff --git a/public/css/assistant.css b/public/css/assistant.css index 2d8b3bfc..7f630b8a 100644 --- a/public/css/assistant.css +++ b/public/css/assistant.css @@ -46,7 +46,8 @@ .assistant-translate-providers button.active { background:var(--purple); color:white; border-color:var(--purple); } .assistant-translate-pop [data-assistant-translate-lang] { border:0; background:transparent; text-align:left; padding:4px 6px; font-size:12px; color:var(--g700); border-radius:6px; cursor:pointer; } .assistant-translate-pop [data-assistant-translate-lang]:hover { background:var(--g50); } -.assistant-translated-text { white-space:pre-wrap; margin:0; } +.assistant-translated-sources { margin-top:10px; padding-top:8px; border-top:1px solid var(--g200); font-size:12px; color:var(--g500); } +.assistant-translated-sources strong { display:block; font-weight:600; margin-bottom:4px; } .assistant-message-attachments { display:flex; flex-wrap:wrap; gap:8px; margin-top:10px; } .assistant-message-attachment { margin:0; display:grid; gap:3px; justify-items:center; } .assistant-message-attachment img { width:64px; height:64px; object-fit:cover; border-radius:8px; border:1px solid var(--g200); background:var(--g50); display:block; } @@ -245,6 +246,9 @@ .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-lang { display: flex; align-items: center; } +.assistant-takehome-lang select { font-size: 12px; padding: 4px 6px; border: 1px solid var(--g200); border-radius: 6px; background: white; color: var(--g900); max-width: 150px; } +.assistant-takehome-lang select:disabled { opacity: .6; cursor: progress; } .assistant-takehome-email { display: flex; gap: .5rem; margin-left: auto; } .assistant-takehome-email input { width: 220px; } .assistant-takehome-loading { padding: 1.25rem 0; color: var(--muted, #6b7280); } diff --git a/public/js/admin/clinicalAssistant.js b/public/js/admin/clinicalAssistant.js index ff8280be..f04c84e4 100644 --- a/public/js/admin/clinicalAssistant.js +++ b/public/js/admin/clinicalAssistant.js @@ -1,3 +1,17 @@ +function assistantBudgetMeta(budget, savedOverride) { + var limit = budget && Number.isInteger(budget.limit) ? budget.limit.toLocaleString() : null; + if (savedOverride) { + return 'Saved override in use. Clear this field to fall back to CLINICAL_ASSISTANT_CONVERSATION_CHARS' + + (limit ? ' (' + limit + ' UTF-16 code units).' : '.'); + } + if (!limit) return 'UTF-16 code units. Leave empty to use CLINICAL_ASSISTANT_CONVERSATION_CHARS.'; + // conversationBudget() reports 'default' when the env var is unset, so naming + // the env var alone would be wrong in the most common deployment. + return budget.source === 'environment' + ? 'Currently ' + limit + ' UTF-16 code units from CLINICAL_ASSISTANT_CONVERSATION_CHARS. Set a value here to override it.' + : 'Currently the built-in default of ' + limit + ' UTF-16 code units (CLINICAL_ASSISTANT_CONVERSATION_CHARS is unset). Set a value here to override it.'; +} + export function initClinicalAssistantAdmin(adminEscapeHtml) { let configState = 'idle'; const IMAGE_MODEL_FALLBACKS = ['openai-gpt-image-1', 'openai-gpt-image-1-mini', 'openai-gpt-image-1.5', 'openai-dall-e-3']; @@ -135,16 +149,18 @@ export function initClinicalAssistantAdmin(adminEscapeHtml) { renderAssistantImageModels([], window._assistantImageModelValue); setValue('assistant-search-limit', cfg['clinical_assistant.search_limit'] || '8'); setValue('assistant-context-chars', cfg['clinical_assistant.context_chars'] || '1400'); - setValue('assistant-translate-provider', ['libretranslate', 'deepl'].includes(cfg['clinical_assistant.translate_provider']) ? cfg['clinical_assistant.translate_provider'] : 'libretranslate'); + setValue('assistant-translate-provider', 'libretranslate'); // the only provider the server accepts var budgetInput = document.getElementById('assistant-conversation-budget'); - if (budgetInput && budget && Number.isInteger(budget.limit)) { - var cfgBudget = cfg['clinical_assistant.conversation_chars']; - budgetInput.value = cfgBudget ? String(cfgBudget) : String(budget.limit); + var cfgBudget = cfg['clinical_assistant.conversation_chars']; + if (budgetInput) { + // Empty means "no override". Prefilling the environment value here would + // turn the next Save into an accidental override, making the documented + // "leave empty to use the environment" path unreachable. + budgetInput.value = cfgBudget ? String(cfgBudget) : ''; + if (budget && Number.isInteger(budget.limit)) budgetInput.placeholder = String(budget.limit); } var budgetMeta = document.getElementById('assistant-conversation-budget-meta'); - if (budgetMeta) budgetMeta.textContent = budget && budget.source === 'environment' - ? 'Currently from CLINICAL_ASSISTANT_CONVERSATION_CHARS (' + budget.limit.toLocaleString() + '). Set a value here to override it; clear it to use the environment again.' - : 'UTF-16 code units. Leave empty to use CLINICAL_ASSISTANT_CONVERSATION_CHARS.'; + if (budgetMeta) budgetMeta.textContent = assistantBudgetMeta(budget, cfgBudget); configState = 'ready'; updateAssistantLoadState(); loadAssistantImageModels(); @@ -152,8 +168,8 @@ export function initClinicalAssistantAdmin(adminEscapeHtml) { }).catch(function() { configState = 'failed'; updateAssistantLoadState(); - var budgetLabel = document.getElementById('assistant-conversation-budget'); - if (budgetLabel) budgetLabel.textContent = 'Conversation budget unavailable. Check server environment configuration; no fallback limit is assumed.'; + var budgetMetaFailed = document.getElementById('assistant-conversation-budget-meta'); + if (budgetMetaFailed) budgetMetaFailed.textContent = 'Conversation budget unavailable. Check server environment configuration; no fallback limit is assumed.'; }); } diff --git a/public/js/app.js b/public/js/app.js index d6859a25..d9e80f77 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -124,8 +124,12 @@ document.addEventListener('DOMContentLoaded', function() { } else { document.dispatchEvent(new CustomEvent('tabChanged', { detail: { tab: tabName } })); } - // Persist last tab in localStorage (restored after login in auth.js) - try { localStorage.setItem('ped_last_tab', tabName); } catch(e) {} + // The assistant's location is the URL (/assistant), not ped_last_tab — + // storing it there made "/" reopen the assistant and defeated its own page. + if (tabName !== 'assistant') { + try { localStorage.setItem('ped_last_tab', tabName); } catch(e) {} + } + syncTabLocation(tabName); // Close sidebar on mobile after tab click var sidebar = document.getElementById('sidebar'); if (sidebar && window.innerWidth <= 768) { @@ -134,6 +138,26 @@ document.addEventListener('DOMContentLoaded', function() { return true; } + // The assistant is served from its own path; every other tab lives at "/". + // replaceState keeps the URL honest without stacking a history entry per tab. + function syncTabLocation(tabName) { + if (typeof history === 'undefined' || typeof history.replaceState !== 'function') return; + var target = tabName === 'assistant' ? '/assistant' : '/'; + if (window.location.pathname === target) return; + try { history.replaceState({ tab: tabName }, '', target + window.location.search + window.location.hash); } catch (e) {} + } + + // Browser Back out of /assistant must actually leave the assistant. + window.addEventListener('popstate', function() { + var wanted = window.location.pathname === '/assistant' ? 'assistant' : null; + if (wanted) { activateTab(wanted); return; } + var active = document.querySelector('.tab-btn.active'); + if (!active || active.getAttribute('data-tab') !== 'assistant') return; + var fallback = null; + try { fallback = localStorage.getItem('ped_last_tab'); } catch (e) {} + activateTab(fallback && fallback !== 'assistant' ? fallback : 'encounter'); + }); + // Expose activateTab globally so auth.js can call it after login window.activateTab = activateTab; diff --git a/public/js/auth.js b/public/js/auth.js index 3f08091a..fcc0516d 100644 --- a/public/js/auth.js +++ b/public/js/auth.js @@ -463,6 +463,9 @@ document.addEventListener('DOMContentLoaded', function() { // Restore last visited tab (saved by activateTab) var lastTab = null; try { lastTab = localStorage.getItem('ped_last_tab'); } catch(e) {} + // The URL is authoritative for the assistant. A stored 'assistant' is a + // legacy value from before the split and must not hijack "/". + if (lastTab === 'assistant') lastTab = null; if (typeof window !== 'undefined' && window.location && window.location.pathname === '/assistant') lastTab = 'assistant'; if (typeof window.activateTab === 'function') { if (!lastTab || !window.activateTab(lastTab)) { diff --git a/public/js/clinicalAssistant.js b/public/js/clinicalAssistant.js index d1a7f968..846c131d 100644 --- a/public/js/clinicalAssistant.js +++ b/public/js/clinicalAssistant.js @@ -4,7 +4,7 @@ // server can call native MCP directly without routing through mcpo. // ============================================================ import { EMPTY_PROMPT_SETS } from './assistant/data.js'; -import { escapeAttr, escapeHtml, renderAssistantMarkdown, safeImageUrl } from './assistant/citations.js'; +import { escapeAttr, escapeHtml, renderAssistantMarkdown, renderCitationLinks, safeImageUrl } from './assistant/citations.js'; import { renderSourcesList } from './assistant/sources.js'; import { createAssistantExporter } from './assistant/export.js'; import { createAssistantImageStore } from './assistant/images.js'; @@ -54,7 +54,7 @@ import { var TRANSLATE_LANGUAGES = [['en', 'English'], ['es', 'Español'], ['fr', 'Français'], ['de', 'Deutsch'], ['it', 'Italiano'], ['pt', 'Português'], ['zh', '中文'], ['ar', 'العربية'], ['ru', 'Русский'], ['hi', 'हिन्दी'], ['nl', 'Nederlands'], ['pl', 'Polski'], ['tr', 'Türkçe'], ['uk', 'Українська'], ['fa', 'فارسی'], ['sw', 'Kiswahili']]; var savedChatCache = []; - var translateLanguagesAvailable = null; // { libretranslate: [...], deepl: [...] } + var translateLanguagesAvailable = null; // { libretranslate: [...] } var translateLanguagesLoading = false; function refreshTranslateLanguages(pop) { @@ -71,18 +71,23 @@ import { if (typeof document === 'undefined') return; var open = document.querySelector('[data-assistant-translate-pop]'); if (open) renderTranslateLanguages(open, open.assistantMessageRow); + renderTakehomeLanguageOptions(); }); } + function availableTranslateLanguages() { + var available = translateLanguagesAvailable; + return TRANSLATE_LANGUAGES.filter(function(pair) { + if (!available) return true; + var codes = available[translateProvider] || available.libretranslate || []; + return codes.indexOf(pair[0]) !== -1; + }); + } + function renderTranslateLanguages(pop, row) { var list = pop.querySelector('.assistant-translate-langs'); if (!list) return; - var available = translateLanguagesAvailable; - var pairs = TRANSLATE_LANGUAGES.filter(function(pair) { - if (!available) return true; - var codes = available[translateProvider] || available.libretranslate || []; - return codes.indexOf(pair[0]) !== -1; - }); + var pairs = availableTranslateLanguages(); list.innerHTML = pairs.map(function(pair) { return ''; }).join(''); @@ -217,7 +222,7 @@ import { label.textContent = data.chatModel ? ('Chat: ' + data.chatModel) : 'Admin model'; } conversationChars = data.success && validConversationLimit(data.conversationChars) ? data.conversationChars : null; - if (data.success && ['libretranslate', 'deepl'].includes(data.translateProvider)) translateProvider = data.translateProvider; + if (data.success && data.translateProvider === 'libretranslate') translateProvider = data.translateProvider; if (data.success) { statusChoices = { allowedChatModels: data.allowedChatModels || [], @@ -818,7 +823,9 @@ import { if (showOriginal) { var translatedBubble = showOriginal.closest('.assistant-bubble'); if (translatedBubble && translatedBubble.assistantOriginalHtml) { + var keptCards = detachImageCards(translatedBubble); translatedBubble.innerHTML = translatedBubble.assistantOriginalHtml; + reattachImageCards(translatedBubble, keptCards); renderEmbeddedBlocks(translatedBubble); } return; @@ -1499,7 +1506,14 @@ import { // ── Patient take home: plain-language summary + copy/export/email ──── var takehomeBusy = false; - var takehomeText = ''; + var takehomeText = ''; // canonical original, never overwritten + var takehomeTranslated = ''; // current translation, '' when showing the original + var takehomeLang = ''; + + // Copy / Export / Email must hand over what the parent is actually reading. + function takehomeVisibleText() { + return takehomeTranslated || takehomeText; + } function openPatientTakehome() { if (takehomeBusy) return; @@ -1510,6 +1524,8 @@ import { closePatientTakehomeModal(); takehomeBusy = true; takehomeText = ''; + takehomeTranslated = ''; + takehomeLang = ''; var modal = document.createElement('div'); modal.className = 'assistant-takehome-modal'; modal.id = 'assistant-takehome-modal'; @@ -1521,6 +1537,8 @@ import { '
'; @@ -1546,12 +1564,53 @@ import { result.classList.remove('hidden'); } if (actions) actions.classList.remove('hidden'); + renderTakehomeLanguageOptions(); + if (!translateLanguagesAvailable) refreshTranslateLanguages(); }).catch(function(err) { var loading = modal.querySelector('.assistant-takehome-loading'); if (loading) { loading.innerHTML = ' ' + escapeHtml(err.message); } }).finally(function() { takehomeBusy = false; }); } + function renderTakehomeLanguageOptions() { + var select = document.getElementById('assistant-takehome-lang'); + if (!select) return; + select.innerHTML = '' + + availableTranslateLanguages().map(function(pair) { + return ''; + }).join(''); + select.value = takehomeLang; + } + + function renderTakehomeBody() { + var result = document.querySelector('#assistant-takehome-modal .assistant-takehome-result'); + if (result) result.innerHTML = renderMarkdown(takehomeVisibleText(), [], {}); + } + + function translateTakehome(target) { + var select = document.getElementById('assistant-takehome-lang'); + if (!takehomeText) return; + if (!target) { // back to the original + takehomeLang = ''; + takehomeTranslated = ''; + renderTakehomeBody(); + return; + } + if (select) select.disabled = true; + translateAssistantMessage(takehomeText, target, translateProvider, 'text') + .then(function(data) { + if (!data.success) throw new Error(data.error || 'Translation failed'); + takehomeLang = target; + takehomeTranslated = String(data.translated || ''); + renderTakehomeBody(); + }) + .catch(function(err) { + if (select) select.value = takehomeLang; // keep the select honest about what is shown + if (typeof showToast === 'function') showToast(err && err.message ? err.message : 'Translation failed', 'error'); + }) + .finally(function() { if (select) select.disabled = false; }); + } + function exportTakehomePdf() { var owner; try { owner = captureSharingOwner(); } catch (_) { return; } @@ -1562,7 +1621,7 @@ import { return; } assertSharingOwner(owner); - var html = renderMarkdown(takehomeText, [], {}); + var html = renderMarkdown(takehomeVisibleText(), [], {}); doc.document.write('Patient Take Home' + '