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 = '