diff --git a/application/single_app/config.py b/application/single_app/config.py index 4b6fef4b..1d44c7ff 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.102" +VERSION = "0.250.103" IS_DEVELOPMENT = is_development_env_enabled() SESSION_COOKIE_SAMESITE = os.getenv('SESSION_COOKIE_SAMESITE', 'Lax') diff --git a/application/single_app/functions_notifications.py b/application/single_app/functions_notifications.py index b4fda2ed..8b7e1376 100644 --- a/application/single_app/functions_notifications.py +++ b/application/single_app/functions_notifications.py @@ -18,6 +18,7 @@ from flask import current_app import logging from config import cosmos_notifications_container +from functions_appinsights import log_event from functions_group import find_group_by_id from functions_debug import debug_print from functions_public_workspaces import find_public_workspace_by_id, get_user_public_workspaces @@ -755,6 +756,42 @@ def get_unread_notification_count(user_id): return 0 +def get_recent_chat_response_notifications(user_id, limit=50): + """Return recent personal chat completion event identities for one user.""" + try: + normalized_limit = max(1, min(int(limit or 50), 100)) + except (TypeError, ValueError): + normalized_limit = 50 + + try: + notifications = list(cosmos_notifications_container.query_items( + query=( + f"SELECT TOP {normalized_limit} " + "c.id, c.created_at, c.link_context, c.metadata " + "FROM c WHERE c.user_id = @user_id " + "AND c.notification_type = @notification_type " + "ORDER BY c.created_at DESC" + ), + parameters=[ + {"name": "@user_id", "value": user_id}, + {"name": "@notification_type", "value": "chat_response_complete"}, + ], + partition_key=user_id, + )) + return notifications + except Exception as e: + log_event( + "[Notifications] Failed to load recent chat completion events.", + extra={ + "user_id": user_id, + "error": str(e), + }, + level=logging.ERROR, + exceptionTraceback=True, + ) + raise + + def get_unread_workflow_priority_notifications(user_id, limit=5): """Return the most recent unread workflow alert notifications for a user.""" try: diff --git a/application/single_app/functions_settings.py b/application/single_app/functions_settings.py index e49c9822..564d00fe 100644 --- a/application/single_app/functions_settings.py +++ b/application/single_app/functions_settings.py @@ -30,6 +30,20 @@ USER_SETTINGS_REQUEST_CACHE_ATTR = "simplechat_user_settings_request_cache" FONT_SIZE_PREFERENCES = ("xs", "s", "m", "l", "xl") DEFAULT_FONT_SIZE_PREFERENCE = "m" +CHAT_COMPLETION_AUDIO_SOUND_IDS = ( + "aurora", + "bell", + "bloom", + "chime", + "crystal", + "glimmer", + "marimba", + "pulse", + "spark", + "summit", +) +DEFAULT_CHAT_COMPLETION_AUDIO_SOUND = CHAT_COMPLETION_AUDIO_SOUND_IDS[0] +DEFAULT_CHAT_COMPLETION_AUDIO_VOLUME = 5 USER_UI_SETTINGS_KEYS = ( "profileImage", "navLayout", @@ -43,6 +57,10 @@ "sidebarMenuState", LATEST_FEATURES_HIDDEN_VERSION_SETTING, "fontSizePreference", + "chatCompletionAudioEnabled", + "chatCompletionAudioMuted", + "chatCompletionAudioSound", + "chatCompletionAudioVolume", ) ADMIN_SETTINGS_SECRET_REDACTED_VALUE = "***REDACTED***" ADMIN_SETTINGS_FORM_SECRET_FIELDS = ( @@ -141,6 +159,31 @@ def normalize_font_size_preference(value): return DEFAULT_FONT_SIZE_PREFERENCE +def normalize_chat_completion_audio_preferences(settings): + """Return validated completion-audio preferences with opt-in defaults.""" + source = settings if isinstance(settings, dict) else {} + selected_sound = str( + source.get("chatCompletionAudioSound") or DEFAULT_CHAT_COMPLETION_AUDIO_SOUND + ).strip().lower() + if selected_sound not in CHAT_COMPLETION_AUDIO_SOUND_IDS: + selected_sound = DEFAULT_CHAT_COMPLETION_AUDIO_SOUND + + try: + volume = int(source.get( + "chatCompletionAudioVolume", + DEFAULT_CHAT_COMPLETION_AUDIO_VOLUME, + )) + except (TypeError, ValueError): + volume = DEFAULT_CHAT_COMPLETION_AUDIO_VOLUME + + return { + "chatCompletionAudioEnabled": source.get("chatCompletionAudioEnabled") is True, + "chatCompletionAudioMuted": source.get("chatCompletionAudioMuted") is True, + "chatCompletionAudioSound": selected_sound, + "chatCompletionAudioVolume": min(10, max(1, volume)), + } + + def _get_user_settings_request_cache(): if not has_request_context(): return None @@ -183,6 +226,7 @@ def _extract_user_ui_settings(doc): ui_settings["fontSizePreference"] = normalize_font_size_preference( settings.get("fontSizePreference") ) + ui_settings.update(normalize_chat_completion_audio_preferences(settings)) return ui_settings @@ -1020,6 +1064,8 @@ def get_settings(use_cosmos=False, include_source=False): # Multimedia 'enable_video_file_support': False, 'enable_audio_file_support': False, + 'enable_chat_completion_audio_cues': False, + 'chat_completion_audio_cues_updated_at': None, # Metadata Extraction 'enable_extract_meta_data': False, diff --git a/application/single_app/route_backend_notifications.py b/application/single_app/route_backend_notifications.py index 3754d0d1..3698636d 100644 --- a/application/single_app/route_backend_notifications.py +++ b/application/single_app/route_backend_notifications.py @@ -2,6 +2,7 @@ from config import * from functions_authentication import * +from functions_appinsights import log_event from functions_conversation_cache import bump_conversation_cache_version from functions_settings import * from functions_notifications import * @@ -71,10 +72,19 @@ def api_get_notification_count(): try: user_id = get_current_user_id() count = get_unread_notification_count(user_id) + app_settings = get_settings() + completion_audio_enabled = bool( + app_settings.get("enable_chat_completion_audio_cues", False) + ) + completion_audio_updated_at = app_settings.get( + "chat_completion_audio_cues_updated_at" + ) return jsonify({ 'success': True, - 'count': count + 'count': count, + 'chat_completion_audio_enabled': completion_audio_enabled, + 'chat_completion_audio_updated_at': completion_audio_updated_at, }) except Exception as e: @@ -84,6 +94,84 @@ def api_get_notification_count(): 'count': 0 }), 500 + @bp.route("/api/notifications/chat-completions", methods=["GET"]) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def api_get_chat_completion_notifications(): + """Get recent personal chat completion event identities for audio cues.""" + user_id = None + try: + user_id = get_current_user_id() + app_settings = get_settings() + completion_audio_enabled = bool( + app_settings.get("enable_chat_completion_audio_cues", False) + ) + completion_audio_updated_at = app_settings.get( + "chat_completion_audio_cues_updated_at" + ) + if not completion_audio_enabled: + return jsonify({ + "success": True, + "enabled": False, + "updated_at": completion_audio_updated_at, + "notifications": [], + }) + limit = request.args.get("limit", 50) + notifications = get_recent_chat_response_notifications(user_id, limit=limit) + return jsonify({ + "success": True, + "enabled": True, + "updated_at": completion_audio_updated_at, + "notifications": notifications, + }) + except Exception as e: + log_event( + "[Notifications] Chat completion event request failed.", + extra={ + "user_id": user_id, + "error": str(e), + }, + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({ + "success": False, + "notifications": [], + "error": "Failed to fetch chat completion events", + }), 500 + + @bp.route("/api/notifications/chat-completion-audio-status", methods=["GET"]) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def api_get_chat_completion_audio_status(): + """Return the current server-authoritative completion audio gate.""" + try: + app_settings = get_settings() + enabled = bool( + app_settings.get("enable_chat_completion_audio_cues", False) + ) + return jsonify({ + "success": True, + "enabled": enabled, + "updated_at": app_settings.get( + "chat_completion_audio_cues_updated_at" + ), + }) + except Exception as e: + log_event( + "[Notifications] Completion audio status request failed.", + extra={"error": str(e)}, + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({ + "success": False, + "enabled": False, + "error": "Failed to load completion audio status", + }), 500 + @bp.route("/api/notifications/workflow-alerts", methods=["GET"]) @swagger_route(security=get_auth_security()) @login_required diff --git a/application/single_app/route_backend_users.py b/application/single_app/route_backend_users.py index 5b3e4183..fbb6e73f 100644 --- a/application/single_app/route_backend_users.py +++ b/application/single_app/route_backend_users.py @@ -518,6 +518,9 @@ def user_settings(): 'microphonePermissionPreference', 'microphonePermissionState', # Text-to-speech settings 'ttsEnabled', 'ttsVoice', 'ttsSpeed', 'ttsAutoplay', + # AI response completion audio settings + 'chatCompletionAudioEnabled', 'chatCompletionAudioMuted', + 'chatCompletionAudioSound', 'chatCompletionAudioVolume', # Tutorial visibility settings 'showTutorialButtons', # Desktop conversation notification settings @@ -562,6 +565,36 @@ def user_settings(): if not isinstance(settings_to_update["conversationContentsDrawerEnabled"], bool): return jsonify({"error": "Invalid conversation contents drawer preference"}), 400 + for boolean_key in ( + "chatCompletionAudioEnabled", + "chatCompletionAudioMuted", + ): + if ( + boolean_key in settings_to_update + and not isinstance(settings_to_update[boolean_key], bool) + ): + return jsonify({"error": f"Invalid {boolean_key} preference"}), 400 + + if "chatCompletionAudioSound" in settings_to_update: + selected_sound = str( + settings_to_update.get("chatCompletionAudioSound") or "" + ).strip().lower() + if selected_sound not in CHAT_COMPLETION_AUDIO_SOUND_IDS: + return jsonify({"error": "Invalid completion audio sound"}), 400 + settings_to_update["chatCompletionAudioSound"] = selected_sound + + if "chatCompletionAudioVolume" in settings_to_update: + volume = settings_to_update.get("chatCompletionAudioVolume") + if isinstance(volume, bool): + return jsonify({"error": "Invalid completion audio volume"}), 400 + try: + volume = int(volume) + except (TypeError, ValueError): + return jsonify({"error": "Invalid completion audio volume"}), 400 + if volume < 1 or volume > 10: + return jsonify({"error": "Completion audio volume must be between 1 and 10"}), 400 + settings_to_update["chatCompletionAudioVolume"] = volume + if "desktopNotificationsEnabled" in settings_to_update: if not isinstance(settings_to_update["desktopNotificationsEnabled"], bool): return jsonify({"error": "Invalid desktop notification preference"}), 400 diff --git a/application/single_app/route_frontend_admin_settings.py b/application/single_app/route_frontend_admin_settings.py index 932ce2d3..94bbf1ad 100644 --- a/application/single_app/route_frontend_admin_settings.py +++ b/application/single_app/route_frontend_admin_settings.py @@ -1104,6 +1104,14 @@ def parse_admin_int(raw_value, fallback_value, field_name="unknown", hard_defaul # ... (fetch all other fields using form_data.get) ... enable_video_file_support = form_data.get('enable_video_file_support') == 'on' enable_audio_file_support = form_data.get('enable_audio_file_support') == 'on' + enable_chat_completion_audio_cues = form_data.get('enable_chat_completion_audio_cues') == 'on' + chat_completion_audio_cues_updated_at = settings.get( + 'chat_completion_audio_cues_updated_at' + ) + if enable_chat_completion_audio_cues != bool( + settings.get('enable_chat_completion_audio_cues', False) + ): + chat_completion_audio_cues_updated_at = datetime.now(timezone.utc).isoformat() enable_extract_meta_data = form_data.get('enable_extract_meta_data') == 'on' # Vision settings @@ -2689,6 +2697,8 @@ def is_valid_url(url): 'video_index_timeout': int(form_data.get('video_index_timeout', 600)), # Audio file settings with Azure speech service + 'enable_chat_completion_audio_cues': enable_chat_completion_audio_cues, + 'chat_completion_audio_cues_updated_at': chat_completion_audio_cues_updated_at, 'speech_service_endpoint': form_data.get('speech_service_endpoint', '').strip(), 'speech_service_location': form_data.get('speech_service_location', '').strip(), 'speech_service_subscription_id': form_data.get('speech_service_subscription_id', '').strip(), diff --git a/application/single_app/static/audio/completion-cues/LICENSE.txt b/application/single_app/static/audio/completion-cues/LICENSE.txt new file mode 100644 index 00000000..ae419025 --- /dev/null +++ b/application/single_app/static/audio/completion-cues/LICENSE.txt @@ -0,0 +1,6 @@ +SimpleChat Completion Cue Assets + +The ten WAV files in this directory were generated specifically for the +SimpleChat project from synthesized sine waves and contain no third-party +recordings or samples. They are distributed under the same license as the +SimpleChat repository. diff --git a/application/single_app/static/audio/completion-cues/aurora.wav b/application/single_app/static/audio/completion-cues/aurora.wav new file mode 100644 index 00000000..27c947e1 Binary files /dev/null and b/application/single_app/static/audio/completion-cues/aurora.wav differ diff --git a/application/single_app/static/audio/completion-cues/bell.wav b/application/single_app/static/audio/completion-cues/bell.wav new file mode 100644 index 00000000..ca72de11 Binary files /dev/null and b/application/single_app/static/audio/completion-cues/bell.wav differ diff --git a/application/single_app/static/audio/completion-cues/bloom.wav b/application/single_app/static/audio/completion-cues/bloom.wav new file mode 100644 index 00000000..f691c2e0 Binary files /dev/null and b/application/single_app/static/audio/completion-cues/bloom.wav differ diff --git a/application/single_app/static/audio/completion-cues/chime.wav b/application/single_app/static/audio/completion-cues/chime.wav new file mode 100644 index 00000000..f66bbfba Binary files /dev/null and b/application/single_app/static/audio/completion-cues/chime.wav differ diff --git a/application/single_app/static/audio/completion-cues/crystal.wav b/application/single_app/static/audio/completion-cues/crystal.wav new file mode 100644 index 00000000..7b7b5e05 Binary files /dev/null and b/application/single_app/static/audio/completion-cues/crystal.wav differ diff --git a/application/single_app/static/audio/completion-cues/glimmer.wav b/application/single_app/static/audio/completion-cues/glimmer.wav new file mode 100644 index 00000000..787f3b3e Binary files /dev/null and b/application/single_app/static/audio/completion-cues/glimmer.wav differ diff --git a/application/single_app/static/audio/completion-cues/marimba.wav b/application/single_app/static/audio/completion-cues/marimba.wav new file mode 100644 index 00000000..80eda325 Binary files /dev/null and b/application/single_app/static/audio/completion-cues/marimba.wav differ diff --git a/application/single_app/static/audio/completion-cues/pulse.wav b/application/single_app/static/audio/completion-cues/pulse.wav new file mode 100644 index 00000000..fbfb50a3 Binary files /dev/null and b/application/single_app/static/audio/completion-cues/pulse.wav differ diff --git a/application/single_app/static/audio/completion-cues/spark.wav b/application/single_app/static/audio/completion-cues/spark.wav new file mode 100644 index 00000000..b8d9507a Binary files /dev/null and b/application/single_app/static/audio/completion-cues/spark.wav differ diff --git a/application/single_app/static/audio/completion-cues/summit.wav b/application/single_app/static/audio/completion-cues/summit.wav new file mode 100644 index 00000000..aaa0dd52 Binary files /dev/null and b/application/single_app/static/audio/completion-cues/summit.wav differ diff --git a/application/single_app/static/js/chat/chat-streaming.js b/application/single_app/static/js/chat/chat-streaming.js index 6380e114..ad0e4c75 100644 --- a/application/single_app/static/js/chat/chat-streaming.js +++ b/application/single_app/static/js/chat/chat-streaming.js @@ -90,6 +90,19 @@ function markStreamingConversationReadIfActive(conversationId, contextLabel) { }); } +function notifySuccessfulStreamingCompletion(finalData) { + if (!window.simpleChatCompletionAudio?.handleCompletion) { + return; + } + + window.simpleChatCompletionAudio.handleCompletion({ + conversationId: finalData?.conversation_id, + messageId: finalData?.message_id, + }, { + refreshAdminGate: true, + }); +} + function buildDefaultCancelEndpoint(conversationId) { const normalizedConversationId = String(conversationId || '').trim(); if (!normalizedConversationId) { @@ -1244,6 +1257,7 @@ function handleStreamError(messageId, partialContent, errorMessage, errorDetails function finalizeStreamingMessage(messageId, userMessageId, finalData, fallbackAgentInfo = null) { finalData = applyFallbackAgentIcon(finalData, fallbackAgentInfo); + notifySuccessfulStreamingCompletion(finalData); const messageElement = document.querySelector(`[data-message-id="${messageId}"]`); if (!messageElement) return; diff --git a/application/single_app/static/js/completion-audio-cues.js b/application/single_app/static/js/completion-audio-cues.js new file mode 100644 index 00000000..cbf7323f --- /dev/null +++ b/application/single_app/static/js/completion-audio-cues.js @@ -0,0 +1,648 @@ +// completion-audio-cues.js + +(function() { + 'use strict'; + + const soundCatalog = [ + { id: 'aurora', label: 'Aurora' }, + { id: 'bell', label: 'Bell' }, + { id: 'bloom', label: 'Bloom' }, + { id: 'chime', label: 'Chime' }, + { id: 'crystal', label: 'Crystal' }, + { id: 'glimmer', label: 'Glimmer' }, + { id: 'marimba', label: 'Marimba' }, + { id: 'pulse', label: 'Pulse' }, + { id: 'spark', label: 'Spark' }, + { id: 'summit', label: 'Summit' }, + ]; + const soundIds = new Set(soundCatalog.map(sound => sound.id)); + const defaultSoundId = soundCatalog[0].id; + const defaultVolume = 5; + const handledEventsStorageKey = 'simplechat-handled-completion-audio-events'; + const completionBaselineStorageKey = 'simplechat-completion-audio-baseline-ready'; + const completionBaselineStartedAtStorageKey = 'simplechat-completion-audio-baseline-started-at'; + const completionPreferencesStorageKey = 'simplechat-completion-audio-preferences'; + const maxHandledEvents = 200; + const fallbackHandledEventKeysByUser = new Map(); + const fallbackBaselineUsers = new Set(); + let playbackQueue = Promise.resolve(); + let previewAudio = null; + + function getCurrentUserStorageSuffix() { + const currentUserId = String( + window.userContext?.id + || window.current_user_id + || 'anonymous' + ).trim(); + return encodeURIComponent(currentUserId || 'anonymous'); + } + + function getUserStorageKey(baseKey) { + return `${baseKey}:${getCurrentUserStorageSuffix()}`; + } + + function readStoredHandledEventKeys() { + try { + const parsedValue = JSON.parse( + localStorage.getItem(getUserStorageKey(handledEventsStorageKey)) || '[]' + ); + return Array.isArray(parsedValue) ? parsedValue : []; + } catch (error) { + console.warn('Unable to read completion audio event history:', error); + return fallbackHandledEventKeysByUser.get(getCurrentUserStorageSuffix()) || []; + } + } + + function writeStoredHandledEventKeys(eventKeys) { + const boundedKeys = eventKeys.slice(-maxHandledEvents); + fallbackHandledEventKeysByUser.set( + getCurrentUserStorageSuffix(), + boundedKeys + ); + try { + localStorage.setItem( + getUserStorageKey(handledEventsStorageKey), + JSON.stringify(boundedKeys) + ); + } catch (error) { + console.warn('Unable to persist completion audio event history:', error); + } + } + + function isBaselineReady() { + try { + return localStorage.getItem( + getUserStorageKey(completionBaselineStorageKey) + ) === 'true'; + } catch (error) { + console.warn('Unable to read completion audio baseline state:', error); + return fallbackBaselineUsers.has(getCurrentUserStorageSuffix()); + } + } + + function markBaselineReady() { + fallbackBaselineUsers.add(getCurrentUserStorageSuffix()); + try { + localStorage.setItem( + getUserStorageKey(completionBaselineStorageKey), + 'true' + ); + } catch (error) { + console.warn('Unable to persist completion audio baseline state:', error); + } + } + + function clearBaselineReady() { + fallbackBaselineUsers.delete(getCurrentUserStorageSuffix()); + try { + localStorage.removeItem(getUserStorageKey(completionBaselineStorageKey)); + localStorage.removeItem( + getUserStorageKey(completionBaselineStartedAtStorageKey) + ); + } catch (error) { + console.warn('Unable to clear completion audio baseline state:', error); + } + } + + function getBaselineStartedAt() { + try { + return localStorage.getItem( + getUserStorageKey(completionBaselineStartedAtStorageKey) + ); + } catch (error) { + console.warn('Unable to read completion audio baseline timestamp:', error); + return null; + } + } + + function startBaselineWindow(startedAt = null) { + if (getBaselineStartedAt()) { + return; + } + + const parsedStartedAt = Date.parse(String(startedAt || '')); + const normalizedStartedAt = Number.isFinite(parsedStartedAt) + ? new Date(parsedStartedAt).toISOString() + : new Date().toISOString(); + try { + localStorage.setItem( + getUserStorageKey(completionBaselineStartedAtStorageKey), + normalizedStartedAt + ); + } catch (error) { + console.warn('Unable to persist completion audio baseline timestamp:', error); + } + } + + function normalizeVolume(value) { + const parsedVolume = Number.parseInt(value, 10); + if (!Number.isInteger(parsedVolume)) { + return defaultVolume; + } + return Math.min(10, Math.max(1, parsedVolume)); + } + + function normalizeSoundId(value) { + const normalizedValue = String(value || '').trim().toLowerCase(); + return soundIds.has(normalizedValue) ? normalizedValue : defaultSoundId; + } + + function getPreferences() { + const userSettings = window.simplechatUserSettings || {}; + return { + adminEnabled: window.appSettings?.enable_chat_completion_audio_cues === true, + enabled: userSettings.chatCompletionAudioEnabled === true, + muted: userSettings.chatCompletionAudioMuted === true, + soundId: normalizeSoundId(userSettings.chatCompletionAudioSound), + volume: normalizeVolume(userSettings.chatCompletionAudioVolume), + }; + } + + function updatePreferences(preferences) { + window.simplechatUserSettings = { + ...(window.simplechatUserSettings || {}), + ...preferences, + }; + const normalizedPreferences = getPreferences(); + const synchronizedPreferences = { + chatCompletionAudioEnabled: normalizedPreferences.enabled, + chatCompletionAudioMuted: normalizedPreferences.muted, + chatCompletionAudioSound: normalizedPreferences.soundId, + chatCompletionAudioVolume: normalizedPreferences.volume, + }; + try { + localStorage.setItem( + getUserStorageKey(completionPreferencesStorageKey), + JSON.stringify(synchronizedPreferences) + ); + } catch (error) { + console.warn('Unable to synchronize completion audio preferences:', error); + } + } + + function setAdminEnabled(enabled, updatedAt = null) { + const wasEnabled = window.appSettings?.enable_chat_completion_audio_cues === true; + window.appSettings = { + ...(window.appSettings || {}), + enable_chat_completion_audio_cues: enabled === true, + chat_completion_audio_cues_updated_at: updatedAt || null, + }; + if (!enabled) { + clearBaselineReady(); + } else if (!wasEnabled) { + startBaselineWindow(updatedAt); + } else if (!isBaselineReady()) { + startBaselineWindow(); + } + } + + function refreshAdminEnabled() { + return fetch('/api/notifications/chat-completion-audio-status', { + headers: { + 'Accept': 'application/json', + }, + }) + .then(response => { + if (!response.ok) { + throw new Error(`Completion audio status returned HTTP ${response.status}.`); + } + return response.json(); + }) + .then(data => { + setAdminEnabled(data.enabled === true, data.updated_at); + return data.enabled === true; + }); + } + + function getEventMessageId(completionEvent) { + return String( + completionEvent?.messageId + || completionEvent?.message_id + || completionEvent?.metadata?.message_id + || '' + ).trim(); + } + + function getEventConversationId(completionEvent) { + return String( + completionEvent?.conversationId + || completionEvent?.conversation_id + || completionEvent?.metadata?.conversation_id + || completionEvent?.link_context?.conversation_id + || '' + ).trim(); + } + + function getCompletionEventKey(completionEvent) { + const messageId = getEventMessageId(completionEvent); + if (messageId) { + return `message:${messageId}`; + } + + const notificationId = String( + completionEvent?.notificationId + || completionEvent?.notification_id + || completionEvent?.id + || '' + ).trim(); + return notificationId ? `notification:${notificationId}` : ''; + } + + function rememberCompletionEvent(completionEvent) { + const eventKey = getCompletionEventKey(completionEvent); + if (!eventKey) { + return false; + } + + const handledEventKeys = readStoredHandledEventKeys(); + if (handledEventKeys.includes(eventKey)) { + return false; + } + + handledEventKeys.push(eventKey); + writeStoredHandledEventKeys(handledEventKeys); + return true; + } + + function shouldPlayCompletionEvent(completionEvent) { + const preferences = getPreferences(); + if ( + !preferences.adminEnabled + || !preferences.enabled + || preferences.muted + ) { + return false; + } + + const completedConversationId = getEventConversationId(completionEvent); + const activeConversationId = String(window.currentConversationId || '').trim(); + const documentInactive = document.visibilityState !== 'visible'; + const windowUnfocused = typeof document.hasFocus === 'function' + ? !document.hasFocus() + : false; + + return ( + documentInactive + || windowUnfocused + || !activeConversationId + || activeConversationId !== completedConversationId + ); + } + + function getSoundUrl(soundId) { + return `/static/audio/completion-cues/${normalizeSoundId(soundId)}.wav`; + } + + function playAudio(soundId, volume) { + return new Promise((resolve, reject) => { + const audio = new Audio(getSoundUrl(soundId)); + let settled = false; + const playbackTimeout = window.setTimeout(() => { + settle(resolve, false); + }, 3000); + + function settle(callback, value) { + if (settled) { + return; + } + settled = true; + window.clearTimeout(playbackTimeout); + callback(value); + } + + audio.volume = normalizeVolume(volume) / 10; + audio.addEventListener('ended', () => settle(resolve, true), { once: true }); + audio.addEventListener( + 'error', + () => settle(reject, new Error('Completion audio asset could not be played.')), + { once: true } + ); + + try { + const playResult = audio.play(); + if (playResult && typeof playResult.catch === 'function') { + playResult.catch(error => settle(reject, error)); + } + } catch (error) { + settle(reject, error); + } + }); + } + + function enqueueCompletionSound(preferences) { + playbackQueue = playbackQueue + .catch(() => undefined) + .then(() => playAudio(preferences.soundId, preferences.volume)) + .catch(error => { + console.warn('Completion audio playback was blocked or failed:', error); + }); + return playbackQueue; + } + + function handleClaimedCompletion(completionEvent) { + if (!shouldPlayCompletionEvent(completionEvent)) { + return Promise.resolve(false); + } + + return enqueueCompletionSound(getPreferences()).then(() => true); + } + + function handleCompletion(completionEvent, options = {}) { + const eventKey = getCompletionEventKey(completionEvent); + if (!eventKey) { + return Promise.resolve(false); + } + + const claimCompletion = () => { + if (options.refreshAdminGate === true) { + return refreshAdminEnabled() + .then(() => { + if (!rememberCompletionEvent(completionEvent)) { + return false; + } + return handleClaimedCompletion(completionEvent); + }) + .catch(error => { + console.warn( + 'Unable to verify completion audio admin status:', + error + ); + return false; + }); + } + if (!rememberCompletionEvent(completionEvent)) { + return false; + } + return handleClaimedCompletion(completionEvent); + }; + const lockManager = typeof navigator !== 'undefined' + ? navigator.locks + : null; + if (lockManager?.request) { + const lockName = [ + 'simplechat-completion-audio', + getCurrentUserStorageSuffix(), + eventKey, + ].join(':'); + return lockManager.request(lockName, claimCompletion); + } + + return Promise.resolve(claimCompletion()); + } + + function processPolledEvents(completionEvents) { + const normalizedEvents = Array.isArray(completionEvents) + ? completionEvents + : []; + + if (!isBaselineReady()) { + startBaselineWindow(); + const baselineStartedAt = Date.parse(getBaselineStartedAt() || ''); + const playbackResults = []; + const chronologicalEvents = [...normalizedEvents].reverse(); + return chronologicalEvents.reduce( + (chain, completionEvent) => chain.then(() => { + const completedAt = Date.parse( + String(completionEvent?.created_at || '') + ); + if ( + Number.isFinite(completedAt) + && Number.isFinite(baselineStartedAt) + && completedAt > baselineStartedAt + ) { + return handleCompletion(completionEvent).then(result => { + playbackResults.push(result); + }); + } + rememberCompletionEvent(completionEvent); + return undefined; + }), + Promise.resolve() + ).then(() => { + markBaselineReady(); + return playbackResults; + }); + } + + const playbackResults = []; + const chronologicalEvents = [...normalizedEvents].reverse(); + return chronologicalEvents.reduce( + (chain, completionEvent) => chain.then(() => ( + handleCompletion(completionEvent).then(result => { + playbackResults.push(result); + }) + )), + Promise.resolve() + ).then(() => playbackResults); + } + + function previewSound(soundId, volume) { + if (previewAudio) { + previewAudio.pause(); + previewAudio.currentTime = 0; + } + + previewAudio = new Audio(getSoundUrl(soundId)); + previewAudio.volume = normalizeVolume(volume) / 10; + try { + const playResult = previewAudio.play(); + return playResult && typeof playResult.catch === 'function' + ? playResult + : Promise.resolve(); + } catch (error) { + return Promise.reject(error); + } + } + + function resetTestState() { + fallbackHandledEventKeysByUser.delete(getCurrentUserStorageSuffix()); + fallbackBaselineUsers.delete(getCurrentUserStorageSuffix()); + try { + localStorage.removeItem(getUserStorageKey(handledEventsStorageKey)); + localStorage.removeItem(getUserStorageKey(completionBaselineStorageKey)); + localStorage.removeItem( + getUserStorageKey(completionBaselineStartedAtStorageKey) + ); + } catch (error) { + console.warn('Unable to reset completion audio test state:', error); + } + } + + function initializePreferenceSync() { + const initialPreferences = normalizeChatCompletionPreferences( + window.simplechatUserSettings || {} + ); + updatePreferences(initialPreferences); + window.addEventListener('storage', event => { + if ( + event.key !== getUserStorageKey(completionPreferencesStorageKey) + || !event.newValue + ) { + return; + } + try { + const preferences = JSON.parse(event.newValue); + window.simplechatUserSettings = { + ...(window.simplechatUserSettings || {}), + ...preferences, + }; + } catch (error) { + console.warn('Unable to apply synchronized completion audio preferences:', error); + } + }); + } + + function normalizeChatCompletionPreferences(settings) { + const source = settings || {}; + return { + chatCompletionAudioEnabled: source.chatCompletionAudioEnabled === true, + chatCompletionAudioMuted: source.chatCompletionAudioMuted === true, + chatCompletionAudioSound: normalizeSoundId( + source.chatCompletionAudioSound + ), + chatCompletionAudioVolume: normalizeVolume( + source.chatCompletionAudioVolume + ), + }; + } + + function updateProfileStatus(message, type = 'muted') { + const statusElement = document.getElementById('completion-audio-preference-status'); + if (!statusElement) { + return; + } + + const classMap = { + danger: 'text-danger', + info: 'text-info', + muted: 'text-muted', + success: 'text-success', + }; + statusElement.className = `preference-status small ${classMap[type] || classMap.muted}`; + statusElement.textContent = message; + } + + function initializeProfileControls() { + const enabledToggle = document.getElementById('completion-audio-enabled-toggle'); + const mutedToggle = document.getElementById('completion-audio-muted-toggle'); + const soundSelect = document.getElementById('completion-audio-sound-select'); + const volumeRange = document.getElementById('completion-audio-volume-range'); + const volumeValue = document.getElementById('completion-audio-volume-value'); + const previewButton = document.getElementById('preview-completion-audio-btn'); + const saveButton = document.getElementById('save-completion-audio-preferences-btn'); + if ( + !enabledToggle + || !mutedToggle + || !soundSelect + || !volumeRange + || !volumeValue + || !previewButton + || !saveButton + ) { + return; + } + + volumeRange.addEventListener('input', () => { + volumeValue.textContent = String(normalizeVolume(volumeRange.value)); + }); + + previewButton.addEventListener('click', () => { + previewButton.disabled = true; + updateProfileStatus('Playing the selected completion sound...', 'info'); + previewSound(soundSelect.value, volumeRange.value) + .then(() => { + updateProfileStatus('Preview played. Save to keep this selection.', 'success'); + }) + .catch(error => { + console.warn('Completion sound preview failed:', error); + updateProfileStatus( + 'The browser blocked the preview. Interact with the page and try again.', + 'danger' + ); + }) + .finally(() => { + previewButton.disabled = false; + }); + }); + + saveButton.addEventListener('click', () => { + const preferences = { + chatCompletionAudioEnabled: enabledToggle.checked, + chatCompletionAudioMuted: mutedToggle.checked, + chatCompletionAudioSound: normalizeSoundId(soundSelect.value), + chatCompletionAudioVolume: normalizeVolume(volumeRange.value), + }; + + saveButton.disabled = true; + updateProfileStatus('Saving your completion audio preferences...', 'info'); + fetch('/api/user/settings', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + settings: preferences, + }), + }) + .then(async response => { + if (!response.ok) { + const data = await response.json().catch(() => ({})); + throw new Error(data.error || 'Failed to save completion audio preferences.'); + } + return response.json(); + }) + .then(() => { + updatePreferences(preferences); + updateProfileStatus( + preferences.chatCompletionAudioEnabled + ? 'Completion audio preferences saved.' + : 'Completion audio remains off until you enable it here.', + 'success' + ); + if (typeof window.showToastMessage === 'function') { + window.showToastMessage('Completion audio preferences saved', 'success'); + } + }) + .catch(error => { + console.error('Error saving completion audio preferences:', error); + updateProfileStatus(error.message, 'danger'); + }) + .finally(() => { + saveButton.disabled = false; + }); + }); + + updateProfileStatus( + enabledToggle.checked + ? 'Completion cues are enabled for background responses.' + : 'Completion cues are off by default until you opt in.' + ); + } + + window.simpleChatCompletionAudio = { + catalog: soundCatalog.map(sound => ({ ...sound })), + getPreferences, + handleCompletion, + isPollingEnabled: () => { + const preferences = getPreferences(); + return preferences.adminEnabled; + }, + previewSound, + processPolledEvents, + resetTestState, + setAdminEnabled, + shouldPlayCompletionEvent, + updatePreferences, + }; + + initializePreferenceSync(); + if (getPreferences().adminEnabled && !isBaselineReady()) { + startBaselineWindow(); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initializeProfileControls); + } else { + initializeProfileControls(); + } +})(); diff --git a/application/single_app/static/js/notifications.js b/application/single_app/static/js/notifications.js index 618d1255..ad0c0060 100644 --- a/application/single_app/static/js/notifications.js +++ b/application/single_app/static/js/notifications.js @@ -28,6 +28,7 @@ let activeWorkflowAlert = null; let activeWorkflowAlertTargets = []; let isLoadingWorkflowAlerts = false; + let isLoadingChatCompletionEvents = false; const shownWorkflowAlertsStorageKey = 'simplechat-shown-workflow-alerts'; const workflowAlertModalEl = document.getElementById('workflowAlertModal'); const workflowAlertModal = workflowAlertModalEl && window.bootstrap @@ -560,6 +561,44 @@ }); } + function loadChatCompletionEvents() { + const completionAudio = window.simpleChatCompletionAudio; + if ( + notificationPollingDisabled + || isLoadingChatCompletionEvents + || !completionAudio?.isPollingEnabled() + ) { + return Promise.resolve(); + } + + isLoadingChatCompletionEvents = true; + return fetch('/api/notifications/chat-completions?limit=50', { + headers: { + 'Accept': 'application/json', + }, + }) + .then(response => parseNotificationJsonResponse(response, 'chat completion events')) + .then(data => { + if (!data.success) { + return undefined; + } + completionAudio.setAdminEnabled( + data.enabled === true, + data.updated_at + ); + if (data.enabled !== true) { + return undefined; + } + return completionAudio.processPolledEvents(data.notifications || []); + }) + .catch(error => { + console.warn('Unable to process chat completion audio events:', error); + }) + .finally(() => { + isLoadingChatCompletionEvents = false; + }); + } + function fetchNotificationCount() { if (notificationPollingDisabled) { return Promise.resolve(); @@ -575,9 +614,15 @@ if (data.success) { consecutivePollFailures = 0; updateNotificationBadge(data.count); + window.simpleChatCompletionAudio?.setAdminEnabled( + data.chat_completion_audio_enabled === true, + data.chat_completion_audio_updated_at + ); + const followUpRequests = [loadChatCompletionEvents()]; if (data.count > 0) { - return loadWorkflowAlerts(); + followUpRequests.push(loadWorkflowAlerts()); } + return Promise.all(followUpRequests); } return undefined; }) @@ -679,6 +724,7 @@ stopPolling: disableNotificationPolling, isPollingDisabled: () => notificationPollingDisabled, refreshCount: fetchNotificationCount, + refreshCompletionEvents: loadChatCompletionEvents, }; /** diff --git a/application/single_app/templates/admin_settings.html b/application/single_app/templates/admin_settings.html index 1ac0942a..7cbc707b 100644 --- a/application/single_app/templates/admin_settings.html +++ b/application/single_app/templates/admin_settings.html @@ -11248,6 +11248,26 @@
+ Let users opt in to a bundled sound when an AI response finishes outside their active view. + These local cues do not require Azure Speech Service. +
+Choose an optional sound for responses that finish outside your active view.
++ Cues stay silent when you are already viewing the completed conversation. Browser audio policies may require an interaction with SimpleChat before background audio can play. +
+