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:
parent
3a55937177
commit
07af95b89a
2 changed files with 51 additions and 6 deletions
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
Loading…
Reference in a new issue