feat: device TTS preferred in voice mode with model fallback; permanent src-garbage scrub; simplified-HTML translation with text fallback; New chat closes the drawer; real FA icons; audio unlock restored

This commit is contained in:
Daniel 2026-09-09 05:06:40 +02:00
parent 3a55937177
commit 07af95b89a
2 changed files with 51 additions and 6 deletions

View file

@ -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 || '')) +
'<button type="button" class="btn-sm btn-ghost" data-assistant-msg-show-original><i class="fas fa-undo"></i> Show original</button>';
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 || ''), [], {}) +
'<button type="button" class="btn-sm btn-ghost" data-assistant-msg-show-original><i class="fas fa-undo"></i> Show original</button>';
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');
});
});
}

View file

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