diff --git a/public/js/clinicalAssistant.js b/public/js/clinicalAssistant.js index 597af909..98351403 100644 --- a/public/js/clinicalAssistant.js +++ b/public/js/clinicalAssistant.js @@ -167,7 +167,11 @@ import { // Open WebUI behavior: the send button becomes Stop while working. if (assistantBusy) { e.preventDefault(); cancelAssistantSearch(); } }); - if (clearBtn) clearBtn.addEventListener('click', clearConversation); + if (clearBtn) clearBtn.addEventListener('click', function(ev) { + var layout = document.getElementById('assistant-layout'); + if (layout) layout.classList.remove('mobile-chats-open'); // back to the chat + clearConversation(ev); + }); document.getElementById('btn-assistant-download-chat').addEventListener('click', downloadTranscript); if (goBackBtn) goBackBtn.addEventListener('click', goBackToMainMenu); if (input) { @@ -920,6 +924,7 @@ import { } function startAssistantMic() { + unlockAudioPlayback(); var input = document.getElementById('assistant-input'); assistantMic.finalText = String(input && input.value || ''); assistantMic.sessionFinals = ''; @@ -1023,6 +1028,7 @@ import { } function conversationStartListening() { + unlockAudioPlayback(); var status = document.getElementById('assistant-voice-status'); if (status) status.textContent = 'Listening…'; conversationMode.listening = true; @@ -1096,6 +1102,17 @@ import { if (st) st.textContent = msg || 'Tap to speak'; }; if (!text) { done(); return; } + // Device TTS first when the browser supports it; the model voice is the fallback. + if (typeof window !== 'undefined' && window.speechSynthesis && typeof SpeechSynthesisUtterance === 'function') { + try { + var utter = new SpeechSynthesisUtterance(text); + utter.onend = function() { done('Tap to speak'); }; + utter.onerror = function() { done('Tap to speak'); }; + window.speechSynthesis.cancel(); + window.speechSynthesis.speak(utter); + return; + } catch (e) { /* fall through to the model voice */ } + } fetch('/api/text-to-speech', { method: 'POST', headers: getAuthHeaders(Object.assign({ 'Content-Type': 'application/json' })), @@ -1600,6 +1617,18 @@ import { document.querySelectorAll('[data-assistant-translate-pop]').forEach(function(pop) { pop.remove(); }); } + function simplifyHtmlForTranslation(html) { + var doc = new DOMParser().parseFromString(String(html || ''), 'text/html'); + doc.body.querySelectorAll('.katex, .assistant-cite, .assistant-code-copy, .assistant-table-actions, .assistant-msg-actions, mjx-container, script, style').forEach(function(el) { + el.replaceWith(doc.createTextNode(el.textContent || '')); + }); + doc.body.querySelectorAll('.assistant-table-scroll').forEach(function(el) { + var table = el.querySelector('table'); + if (table) el.replaceWith(table); + }); + return doc.body.innerHTML; + } + function requestMessageTranslation(row, target, provider) { var bubble = row && row.querySelector('.assistant-bubble'); if (!bubble) return; @@ -1607,20 +1636,34 @@ import { var imageCards = Array.prototype.slice.call(bubble.querySelectorAll('.assistant-image-card')); imageCards.forEach(function(card) { card.remove(); }); if (!bubble.assistantOriginalHtml) bubble.assistantOriginalHtml = bubble.innerHTML; - // Translate the raw markdown as text (LibreTranslate rejects rendered HTML), - // then re-render the translated text through the shared markdown pipeline. + // Translate simplified rendered HTML so headings, bold, lists and tables + // survive; LibreTranslate keeps those tags in html mode. Citations render + // as plain numbers inside their chips. var raw = bubble.assistantRawContent !== undefined ? bubble.assistantRawContent : String(bubble.textContent || '').trim(); if (!String(raw).trim()) return; - translateAssistantMessage(raw, target, provider, 'text') + var simpleHtml = simplifyHtmlForTranslation(renderMarkdown(raw, [], {})); + translateAssistantMessage(simpleHtml, target, provider, 'html') .then(function(data) { if (!data.success) throw new Error(data.error || 'Translation failed'); - bubble.innerHTML = renderMarkdown(String(data.translated || ''), [], {}) + + bubble.innerHTML = sanitize(String(data.translated || '')) + ''; imageCards.forEach(function(card) { bubble.appendChild(card); }); // same node — its status polling continues renderEmbeddedBlocks(bubble); }) .catch(function(err) { - if (typeof showToast === 'function') showToast(err && err.message ? err.message : 'Translation failed', 'error'); + // Html mode can be rejected by some LibreTranslate builds; fall back + // to plain text and re-render, so formatting is never fully lost. + translateAssistantMessage(raw, target, provider, 'text') + .then(function(data2) { + if (!data2.success) throw new Error(data2.error || 'Translation failed'); + bubble.innerHTML = renderMarkdown(String(data2.translated || ''), [], {}) + + ''; + imageCards.forEach(function(card) { bubble.appendChild(card); }); + renderEmbeddedBlocks(bubble); + }) + .catch(function(err2) { + if (typeof showToast === 'function') showToast(err2 && err2.message ? err2.message : 'Translation failed', 'error'); + }); }); } diff --git a/src/utils/clinicalAnswer.js b/src/utils/clinicalAnswer.js index 781d2804..26da7394 100644 --- a/src/utils/clinicalAnswer.js +++ b/src/utils/clinicalAnswer.js @@ -35,6 +35,8 @@ async function finalizeAssistantAnswer(ai, options) { function stripModelSourcesSection(answer) { return cleanDanglingSourceLeadIn(String(answer || '') + .replace(/(?:^|\s)(?:\bsrc\b\s*){2,}$/i, '') + .replace(/\bsrc\b(?=\s*src\b)/gi, '') .replace(/\n\s*(---\s*)?(#{1,3}\s*)?(Sources|References)\s*\n[\s\S]*$/i, '') .replace(/\n\s*>?\s*(⚠️\s*)?Clinical decision support[^\n]*$/gim, '') .trim());