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