diff --git a/application/single_app/config.py b/application/single_app/config.py index 86e9a5c32..4b6fef4b6 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -95,7 +95,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.250.101" +VERSION = "0.250.102" IS_DEVELOPMENT = is_development_env_enabled() SESSION_COOKIE_SAMESITE = os.getenv('SESSION_COOKIE_SAMESITE', 'Lax') diff --git a/application/single_app/static/js/chat/chat-message-export.js b/application/single_app/static/js/chat/chat-message-export.js index c9f722679..27481725b 100644 --- a/application/single_app/static/js/chat/chat-message-export.js +++ b/application/single_app/static/js/chat/chat-message-export.js @@ -30,6 +30,14 @@ function getMessageMarkdown(messageDiv, role) { return ''; } +function getMessagePlainText(messageDiv) { + const messageText = messageDiv.querySelector('.message-text'); + if (!messageText) { + return ''; + } + return String(messageText.innerText || messageText.textContent || '').trim(); +} + /** * Get the sender label from a message div. */ @@ -187,6 +195,33 @@ export function exportMessageAsMarkdown(messageDiv, messageId, role) { showToast('Message exported as Markdown.', 'success'); } +/** + * Export a single message as an MP3 using the active TTS voice and speed. + */ +export async function exportMessageAsAudio(messageDiv, messageId, role) { + if (!window.appSettings?.enable_text_to_speech) { + showToast('Text-to-speech is not enabled.', 'warning'); + return; + } + + const content = getMessagePlainText(messageDiv); + if (!content) { + showToast('No message content to export.', 'warning'); + return; + } + + try { + const { synthesizeSpeechBlob } = await import('./chat-tts.js'); + const audioBlob = await synthesizeSpeechBlob(content); + const filename = `message_audio_${filenameTimestamp()}.mp3`; + downloadBlob(audioBlob, filename); + showToast('Message exported as audio.', 'success'); + } catch (err) { + console.error('Error exporting message to audio:', err); + showToast(err.message || 'Failed to export message as audio.', 'danger'); + } +} + /** * Export a single message as a Word (.docx) file by calling the backend * endpoint which uses python-docx to generate the document. diff --git a/application/single_app/static/js/chat/chat-messages.js b/application/single_app/static/js/chat/chat-messages.js index 1e6822c71..5138acf16 100644 --- a/application/single_app/static/js/chat/chat-messages.js +++ b/application/single_app/static/js/chat/chat-messages.js @@ -589,6 +589,14 @@ function isWorkspaceDocumentSearchEnabled() { } const INLINE_ASSISTANT_EXPORT_ACTIONS = Object.freeze({ + audio: { + actionName: 'exportMessageAsAudio', + buttonClass: 'inline-export-audio-btn', + iconClass: 'bi bi-file-earmark-music', + label: 'Create Audio File', + pendingLabel: 'Creating Audio File...', + title: 'Create Audio File', + }, powerpoint: { actionName: 'exportMessageAsPowerPoint', buttonClass: 'inline-export-ppt-btn', @@ -4769,6 +4777,10 @@ function renderReplyQuoteHtml(fullMessageObject = null) { function attachMessageExportActionListeners(messageDiv, role) { const actionMappings = [ + { + selectors: ['.dropdown-export-audio-btn', '.inline-export-audio-btn'], + actionName: 'exportMessageAsAudio', + }, { selectors: ['.dropdown-export-md-btn', '.inline-export-md-btn'], actionName: 'exportMessageAsMarkdown', @@ -4796,6 +4808,9 @@ function renderReplyQuoteHtml(fullMessageObject = null) { messageDiv.querySelectorAll(selector).forEach(button => { button.addEventListener('click', (event) => { event.preventDefault(); + if (button.getAttribute('aria-busy') === 'true') { + return; + } void triggerMessageExportAction(messageDiv, role, actionName, button); }); }); @@ -4911,11 +4926,15 @@ export function appendMessage( `; const maskButtonHtml = buildMaskControlsHtml(messageId, maskState); + const audioExportMenuItemHtml = window.appSettings?.enable_text_to_speech + ? '