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. +

+
+
One Speech resource, three features: Audio file uploads, Voice Input, and Voice Responses all use the same Azure Speech Service section below. Configure the Speech resource once, then turn on whichever speech features you need. diff --git a/application/single_app/templates/base.html b/application/single_app/templates/base.html index e1be0101..da25b62b 100644 --- a/application/single_app/templates/base.html +++ b/application/single_app/templates/base.html @@ -721,8 +721,9 @@
Appearance Preferenc >
+ {% if app_settings.enable_chat_completion_audio_cues %} + {% set completion_audio_enabled = user_settings.get('settings', {}).get('chatCompletionAudioEnabled', false) %} + {% set completion_audio_muted = user_settings.get('settings', {}).get('chatCompletionAudioMuted', false) %} + {% set completion_audio_sound = user_settings.get('settings', {}).get('chatCompletionAudioSound', 'aurora') %} + {% set completion_audio_volume = user_settings.get('settings', {}).get('chatCompletionAudioVolume', 5) %} +
+
+
+ +
+
AI Response Completion Audio
+

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. +

+
+
+
+ +
+
+
+ + +
+
+ + +
+
+
+ +
+ + +
+
+
+ + +
+
+ +
+
+ +
+
+ {% endif %} + {% set sidebar_toggle_style = user_settings.get('settings', {}).get('sidebarToggleStyle', 'large') %} {% set latest_features_hidden_version = user_settings.get('settings', {}).get('latestFeaturesHiddenVersion') %} {% set latest_features_hidden_for_current_version = latest_features_hidden_version == config['VERSION'] %} diff --git a/docs/explanation/features/AI_RESPONSE_COMPLETION_AUDIO_CUES.md b/docs/explanation/features/AI_RESPONSE_COMPLETION_AUDIO_CUES.md new file mode 100644 index 00000000..c0bf44da --- /dev/null +++ b/docs/explanation/features/AI_RESPONSE_COMPLETION_AUDIO_CUES.md @@ -0,0 +1,131 @@ +# AI Response Completion Audio Cues + +## Overview + +AI response completion audio cues notify users when a personal-chat response +finishes outside the conversation they are actively viewing. Administrators +control whether the capability is available, and each user explicitly opts in +and chooses a local sound and volume. + +**Implemented in version: 0.250.103** + +## Dependencies + +- Existing personal-chat `chat_response_complete` notifications +- Shared notification polling in `static/js/notifications.js` +- Successful chat stream finalization in `static/js/chat/chat-streaming.js` +- User settings persistence through `/api/user/settings` +- Ten bundled WAV assets under `static/audio/completion-cues/` + +## Technical Specifications + +### Architecture + +The backend continues to create a `chat_response_complete` notification only +after a personal AI response is successfully persisted. A focused authenticated +endpoint returns recent completion event identities for the signed-in user. +The browser establishes a silent initial baseline, then handles only newly +observed message or notification IDs. + +Successful live streams also send their completion identity directly to the +same browser manager. This provides an immediate cue in the originating tab, +while notification polling recovers completions that finish after navigation, +reload, or loss of the original stream connection. + +Handled identities are kept in bounded, per-user `localStorage`, with the Web +Locks API coordinating claims across tabs when available. An event is marked +handled even when audio is muted, disabled, suppressed in the foreground, or +blocked by the browser. This prevents historical unread notifications, repeated +polls, reloads, duplicate tabs, and duplicate live/polled events from replaying +a cue. + +Saved enable, mute, sound, and volume preferences are also synchronized through +per-user browser storage so existing tabs apply profile changes immediately. + +### Playback Rules + +A newly completed response plays once when either condition is true: + +- Its conversation is not the conversation currently selected in Chat. +- The SimpleChat document is hidden or the browser window is unfocused. + +No cue plays when the completed conversation is selected and the page is both +visible and focused. Failed, cancelled, and interrupted streams do not reach the +successful completion hook and therefore do not play a cue. + +### Configuration + +The admin setting is: + +- `enable_chat_completion_audio_cues` (default: `false`) + +Notification count responses and live-stream completion checks refresh this +gate from the server. Open pages therefore stop playing promptly after an +administrator disables the capability. Disabling also clears the browser's completion baseline. The server records the +admin transition timestamp, so the next poll after re-enable suppresses events +from the disabled period while retaining responses completed after activation. + +The per-user settings are: + +- `chatCompletionAudioEnabled` (default: `false`) +- `chatCompletionAudioMuted` (default: `false`) +- `chatCompletionAudioSound` (default: `aurora`) +- `chatCompletionAudioVolume` (integer `1..10`, default: `5`) + +The available sounds are Aurora, Bell, Bloom, Chime, Crystal, Glimmer, Marimba, +Pulse, Spark, and Summit. All ten WAV files were synthesized specifically for +SimpleChat, contain no third-party samples, and are served only from the local +SimpleChat static asset path. + +### API + +`GET /api/notifications/chat-completions?limit=50` + +Returns recent personal `chat_response_complete` event identities for the +authenticated user. Read events are included because an active conversation +can be marked read while its browser document is hidden or unfocused. + +`GET /api/notifications/chat-completion-audio-status` + +Returns the current server-authoritative admin gate for successful live-stream +completion checks. + +## Usage Instructions + +### Enable the Capability + +1. Open **Admin Settings**. +2. Go to the AI voice and audio section. +3. Turn on **Enable AI Response Completion Audio Cues**. +4. Save Admin Settings. + +### Configure a User Preference + +1. Open **Profile** and select the **Settings** tab. +2. In **AI Response Completion Audio**, turn on **Play completion cues**. +3. Choose one of the ten sounds and use **Preview** to audition it. +4. Set volume from `1` to `10`. +5. Optionally mute cues without losing the selected sound or volume. +6. Select **Save Audio Preferences**. + +## Testing and Validation + +- `functional_tests/test_chat_completion_audio_cues.py` validates settings, + assets, route/runtime wiring, foreground suppression, hidden playback, + volume mapping, concurrency, and deduplication. +- `ui_tests/test_profile_completion_audio_cues.py` validates admin gating, + profile persistence, sound preview, and browser playback behavior. +- Route policy tests cover authentication and Blueprint policy for the new API. + +## Browser Limitations + +Browsers can block programmatic audio until the user has interacted with the +site, and some operating systems or browser profiles suppress background sound. +SimpleChat catches these failures without interrupting chat or notification +polling. A blocked event is not retried automatically, which avoids delayed or +duplicate sounds. + +The browser records a baseline timestamp when the feature becomes active. +Events completed before that timestamp are treated as historical, while a +response that finishes between activation/page load and the first poll remains +eligible for one cue. diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md index 28413856..0fb0efb3 100644 --- a/docs/explanation/release_notes.md +++ b/docs/explanation/release_notes.md @@ -2,6 +2,15 @@ For feature-focused and fix-focused drill-downs by version, see [Features by Version](/explanation/features/) and [Fixes by Version](/explanation/fixes/). +### **(v0.250.103)** + +#### New Features + +* **Configurable AI Response Completion Audio Cues** + * Administrators can enable locally bundled completion sounds, while each user can opt in, choose and preview one of ten cues, set volume, or mute cues without losing their preferences. + * Cues play once for newly completed personal-chat responses outside the active visible conversation, with server-authoritative gating, cross-tab preference synchronization, and historical/duplicate suppression. + * (Ref: Closes #1062, `completion-audio-cues.js`, notification polling, Profile and Admin Settings, `AI_RESPONSE_COMPLETION_AUDIO_CUES.md`) + ### **(v0.250.102)** #### New Features diff --git a/functional_tests/test_chat_completion_audio_cues.py b/functional_tests/test_chat_completion_audio_cues.py new file mode 100644 index 00000000..966c4d56 --- /dev/null +++ b/functional_tests/test_chat_completion_audio_cues.py @@ -0,0 +1,384 @@ +# test_chat_completion_audio_cues.py +""" +Functional test for configurable AI response completion audio cues. +Version: 0.250.103 +Implemented in: 0.250.103 + +This test validates local assets, settings and route wiring, and executable +browser behavior for foreground suppression, background playback, volume +mapping, concurrent completions, and duplicate prevention. +""" + +import json +import hashlib +import subprocess +import sys +import wave +from pathlib import Path + + +ROOT_DIR = Path(__file__).resolve().parents[1] +APP_DIR = ROOT_DIR / "application" / "single_app" +AUDIO_DIR = APP_DIR / "static" / "audio" / "completion-cues" +RUNTIME_JS = APP_DIR / "static" / "js" / "completion-audio-cues.js" +NOTIFICATIONS_JS = APP_DIR / "static" / "js" / "notifications.js" +STREAMING_JS = APP_DIR / "static" / "js" / "chat" / "chat-streaming.js" +SETTINGS_PY = APP_DIR / "functions_settings.py" +USERS_ROUTE_PY = APP_DIR / "route_backend_users.py" +NOTIFICATIONS_ROUTE_PY = APP_DIR / "route_backend_notifications.py" +ADMIN_ROUTE_PY = APP_DIR / "route_frontend_admin_settings.py" +ADMIN_TEMPLATE = APP_DIR / "templates" / "admin_settings.html" +PROFILE_TEMPLATE = APP_DIR / "templates" / "profile.html" +BASE_TEMPLATE = APP_DIR / "templates" / "base.html" +CHATS_TEMPLATE = APP_DIR / "templates" / "chats.html" + + +EXPECTED_SOUND_IDS = [ + "aurora", + "bell", + "bloom", + "chime", + "crystal", + "glimmer", + "marimba", + "pulse", + "spark", + "summit", +] + + +def read_text(path): + """Read a UTF-8 source file.""" + return path.read_text(encoding="utf-8") + + +def test_local_audio_assets(): + """Validate exactly ten readable, non-empty, locally bundled WAV cues.""" + wav_files = sorted(AUDIO_DIR.glob("*.wav")) + assert [path.stem for path in wav_files] == EXPECTED_SOUND_IDS + assert (AUDIO_DIR / "LICENSE.txt").exists() + + asset_hashes = set() + for wav_path in wav_files: + asset_hashes.add(hashlib.sha256(wav_path.read_bytes()).hexdigest()) + with wave.open(str(wav_path), "rb") as cue: + assert cue.getnchannels() == 1 + assert cue.getsampwidth() == 2 + assert cue.getframerate() == 44100 + assert cue.getnframes() > 0 + assert len(asset_hashes) == len(EXPECTED_SOUND_IDS) + + +def test_settings_and_ui_contracts(): + """Validate admin gating and normalized user preference persistence wiring.""" + settings_source = read_text(SETTINGS_PY) + users_route_source = read_text(USERS_ROUTE_PY) + notifications_route_source = read_text(NOTIFICATIONS_ROUTE_PY) + admin_route_source = read_text(ADMIN_ROUTE_PY) + admin_template_source = read_text(ADMIN_TEMPLATE) + profile_template_source = read_text(PROFILE_TEMPLATE) + base_template_source = read_text(BASE_TEMPLATE) + chats_template_source = read_text(CHATS_TEMPLATE) + + assert "'enable_chat_completion_audio_cues': False" in settings_source + assert '"chatCompletionAudioEnabled": source.get("chatCompletionAudioEnabled") is True' in settings_source + assert '"chatCompletionAudioMuted": source.get("chatCompletionAudioMuted") is True' in settings_source + assert "DEFAULT_CHAT_COMPLETION_AUDIO_VOLUME = 5" in settings_source + assert "min(10, max(1, volume))" in settings_source + assert "CHAT_COMPLETION_AUDIO_SOUND_IDS" in users_route_source + assert "Completion audio volume must be between 1 and 10" in users_route_source + + assert "enable_chat_completion_audio_cues" in admin_route_source + assert 'id="enable_chat_completion_audio_cues"' in admin_template_source + assert "{% if app_settings.enable_chat_completion_audio_cues %}" in profile_template_source + assert 'id="completion-audio-enabled-toggle"' in profile_template_source + assert 'id="completion-audio-muted-toggle"' in profile_template_source + assert 'id="completion-audio-sound-select"' in profile_template_source + assert 'id="completion-audio-volume-range"' in profile_template_source + assert "completion-audio-cues.js" in base_template_source + assert "...(window.appSettings || {})" in chats_template_source + + assert '@bp.route("/api/notifications/chat-completions", methods=["GET"])' in notifications_route_source + assert '@bp.route("/api/notifications/chat-completion-audio-status", methods=["GET"])' in notifications_route_source + assert "@swagger_route(security=get_auth_security())" in notifications_route_source + assert "get_recent_chat_response_notifications" in notifications_route_source + assert "'chat_completion_audio_enabled': completion_audio_enabled" in notifications_route_source + assert "getUserStorageKey(handledEventsStorageKey)" in read_text(RUNTIME_JS) + assert "navigator.locks" in read_text(RUNTIME_JS) + + +def test_completion_event_wiring(): + """Validate polling and successful-stream hooks share the audio manager.""" + notifications_source = read_text(NOTIFICATIONS_JS) + streaming_source = read_text(STREAMING_JS) + + assert "/api/notifications/chat-completions?limit=50" in notifications_source + assert "completionAudio.processPolledEvents" in notifications_source + assert "refreshCompletionEvents: loadChatCompletionEvents" in notifications_source + assert "function notifySuccessfulStreamingCompletion(finalData)" in streaming_source + assert "window.simpleChatCompletionAudio.handleCompletion" in streaming_source + assert "refreshAdminGate: true" in streaming_source + assert streaming_source.count("notifySuccessfulStreamingCompletion(finalData);") == 1 + finalize_source = streaming_source.split( + "function finalizeStreamingMessage(", + maxsplit=1, + )[1] + assert ( + finalize_source.index("notifySuccessfulStreamingCompletion(finalData);") + < finalize_source.index("if (!messageElement) return;") + ) + + +def test_browser_runtime_behavior(): + """Execute the browser manager in Node with deterministic DOM and Audio fakes.""" + node_harness = r""" +const fs = require('fs'); +const source = fs.readFileSync(process.argv[1], 'utf8'); +const storage = new Map(); +const playedAudio = []; + +global.localStorage = { + getItem: key => storage.has(key) ? storage.get(key) : null, + setItem: (key, value) => storage.set(key, String(value)), + removeItem: key => storage.delete(key), +}; +global.navigator = { + locks: { + request: async (_name, callback) => callback(), + }, +}; +global.document = { + readyState: 'complete', + visibilityState: 'visible', + hasFocus: () => true, + getElementById: () => null, + addEventListener: () => undefined, +}; +global.window = { + appSettings: { enable_chat_completion_audio_cues: true }, + simplechatUserSettings: { + chatCompletionAudioEnabled: true, + chatCompletionAudioMuted: false, + chatCompletionAudioSound: 'aurora', + chatCompletionAudioVolume: 7, + }, + currentConversationId: 'active-conversation', + setTimeout, + clearTimeout, + addEventListener: () => undefined, +}; +global.Audio = class FakeAudio { + constructor(url) { + this.url = url; + this.volume = 1; + this.listeners = {}; + } + addEventListener(name, callback) { + this.listeners[name] = callback; + } + pause() {} + play() { + playedAudio.push({ url: this.url, volume: this.volume }); + queueMicrotask(() => { + if (this.listeners.ended) { + this.listeners.ended(); + } + }); + return Promise.resolve(); + } +}; +global.fetch = async () => { + throw new Error('simulated status failure'); +}; + +eval(source); +const manager = window.simpleChatCompletionAudio; + +async function run() { + manager.resetTestState(); + const futureCompletion = new Date(Date.now() + 1000).toISOString(); + await manager.processPolledEvents([ + { + id: 'fresh-before-first-poll', + created_at: futureCompletion, + metadata: { + message_id: 'fresh-before-first-poll', + conversation_id: 'other' + } + }, + ]); + const freshFirstPollCount = playedAudio.length; + playedAudio.splice(0, playedAudio.length); + + manager.resetTestState(); + await manager.processPolledEvents([ + { id: 'historical', metadata: { message_id: 'old', conversation_id: 'other' } }, + ]); + const afterBaseline = playedAudio.length; + + const foregroundResult = await manager.handleCompletion({ + messageId: 'foreground', + conversationId: 'active-conversation', + }); + const afterForeground = playedAudio.length; + + const backgroundResult = await manager.handleCompletion({ + messageId: 'background', + conversationId: 'other-conversation', + }); + const afterBackground = playedAudio.length; + await manager.handleCompletion({ + messageId: 'background', + conversationId: 'other-conversation', + }); + const afterDuplicate = playedAudio.length; + + document.visibilityState = 'hidden'; + const hiddenResult = await manager.handleCompletion({ + messageId: 'hidden-active', + conversationId: 'active-conversation', + }); + const afterHidden = playedAudio.length; + + document.visibilityState = 'visible'; + document.hasFocus = () => false; + const unfocusedResult = await manager.handleCompletion({ + messageId: 'unfocused-active', + conversationId: 'active-conversation', + }); + const afterUnfocused = playedAudio.length; + + document.hasFocus = () => true; + manager.updatePreferences({ chatCompletionAudioMuted: true }); + await manager.handleCompletion({ + messageId: 'muted', + conversationId: 'other-conversation', + }); + const afterMuted = playedAudio.length; + + manager.updatePreferences({ chatCompletionAudioMuted: false }); + await manager.processPolledEvents([ + { id: 'second', metadata: { message_id: 'concurrent-2', conversation_id: 'two' } }, + { id: 'first', metadata: { message_id: 'concurrent-1', conversation_id: 'one' } }, + ]); + const afterConcurrent = playedAudio.length; + + manager.setAdminEnabled(false); + await manager.handleCompletion({ + messageId: 'disabled-period-claimed', + conversationId: 'other-conversation', + }); + const afterAdminDisabled = playedAudio.length; + + manager.setAdminEnabled(true); + await manager.processPolledEvents([ + { + id: 'disabled-period-event', + metadata: { + message_id: 'disabled-period-unseen', + conversation_id: 'other-conversation' + } + }, + ]); + const afterReenableBaseline = playedAudio.length; + await manager.handleCompletion({ + messageId: 'post-reenable', + conversationId: 'other-conversation', + }); + const afterPostReenable = playedAudio.length; + + const failedRefreshResult = await manager.handleCompletion({ + messageId: 'status-retry', + conversationId: 'other-conversation', + }, { + refreshAdminGate: true, + }); + const afterFailedRefresh = playedAudio.length; + await manager.handleCompletion({ + messageId: 'status-retry', + conversationId: 'other-conversation', + }); + const afterStatusRetry = playedAudio.length; + + console.log(JSON.stringify({ + afterBaseline, + freshFirstPollCount, + foregroundResult, + afterForeground, + backgroundResult, + afterBackground, + afterDuplicate, + hiddenResult, + afterHidden, + unfocusedResult, + afterUnfocused, + afterMuted, + afterConcurrent, + afterAdminDisabled, + afterReenableBaseline, + afterPostReenable, + failedRefreshResult, + afterFailedRefresh, + afterStatusRetry, + volumes: playedAudio.map(item => item.volume), + urls: playedAudio.map(item => item.url), + })); +} + +run().catch(error => { + console.error(error); + process.exit(1); +}); +""" + result = subprocess.run( + ["node", "-e", node_harness, str(RUNTIME_JS)], + check=True, + capture_output=True, + text=True, + ) + runtime_result = json.loads(result.stdout.strip().splitlines()[-1]) + + assert runtime_result["freshFirstPollCount"] == 1 + assert runtime_result["afterBaseline"] == 0 + assert runtime_result["foregroundResult"] is False + assert runtime_result["afterForeground"] == 0 + assert runtime_result["backgroundResult"] is True + assert runtime_result["afterBackground"] == 1 + assert runtime_result["afterDuplicate"] == 1 + assert runtime_result["hiddenResult"] is True + assert runtime_result["afterHidden"] == 2 + assert runtime_result["unfocusedResult"] is True + assert runtime_result["afterUnfocused"] == 3 + assert runtime_result["afterMuted"] == 3 + assert runtime_result["afterConcurrent"] == 5 + assert runtime_result["afterAdminDisabled"] == 5 + assert runtime_result["afterReenableBaseline"] == 5 + assert runtime_result["afterPostReenable"] == 6 + assert runtime_result["failedRefreshResult"] is False + assert runtime_result["afterFailedRefresh"] == 6 + assert runtime_result["afterStatusRetry"] == 7 + assert runtime_result["volumes"] == [0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7] + assert all(url.startswith("/static/audio/completion-cues/") for url in runtime_result["urls"]) + + +def main(): + """Run all completion audio functional checks.""" + tests = [ + test_local_audio_assets, + test_settings_and_ui_contracts, + test_completion_event_wiring, + test_browser_runtime_behavior, + ] + for test in tests: + test() + print(f"Passed: {test.__name__}") + return True + + +if __name__ == "__main__": + try: + success = main() + except Exception as error: + print(f"Failed: {error}") + raise + sys.exit(0 if success else 1) diff --git a/ui_tests/test_profile_completion_audio_cues.py b/ui_tests/test_profile_completion_audio_cues.py new file mode 100644 index 00000000..c5c03b96 --- /dev/null +++ b/ui_tests/test_profile_completion_audio_cues.py @@ -0,0 +1,207 @@ +# test_profile_completion_audio_cues.py +""" +UI test for configurable AI response completion audio cues. +Version: 0.250.103 +Implemented in: 0.250.103 + +This test verifies admin gating, profile persistence and preview controls, plus +foreground suppression and duplicate-free background playback. +""" + +import os +from pathlib import Path + +import pytest +from playwright.sync_api import expect + + +BASE_URL = os.getenv("SIMPLECHAT_UI_BASE_URL", "").rstrip("/") +STORAGE_STATE = os.getenv("SIMPLECHAT_UI_STORAGE_STATE", "") +ADMIN_STORAGE_STATE = os.getenv("SIMPLECHAT_UI_ADMIN_STORAGE_STATE", "") + + +def get_storage_state_path(): + """Return an available authenticated storage state path.""" + for candidate in (STORAGE_STATE, ADMIN_STORAGE_STATE): + if candidate and Path(candidate).exists(): + return candidate + pytest.skip( + "Set SIMPLECHAT_UI_STORAGE_STATE or SIMPLECHAT_UI_ADMIN_STORAGE_STATE " + "to a valid authenticated Playwright storage state file." + ) + return None + + +def get_user_settings(page): + """Fetch current user settings through the authenticated browser context.""" + return page.evaluate( + """ + async () => { + const response = await fetch('/api/user/settings'); + const data = await response.json(); + return data.settings || {}; + } + """ + ) + + +def set_user_settings(page, settings): + """Persist selected user settings through the normal API.""" + return page.evaluate( + """ + async (nextSettings) => { + const response = await fetch('/api/user/settings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ settings: nextSettings }) + }); + return response.ok; + } + """, + settings, + ) + + +@pytest.mark.ui +def test_profile_completion_audio_preferences_and_runtime(playwright): + """Validate the admin gate, saved controls, preview, and playback rules.""" + if not BASE_URL: + pytest.skip("Set SIMPLECHAT_UI_BASE_URL to run this UI test.") + + browser = playwright.chromium.launch() + context = browser.new_context( + storage_state=get_storage_state_path(), + viewport={"width": 1440, "height": 900}, + ) + page = context.new_page() + original_settings = None + + page.add_init_script( + """ + window.__completionAudioPlays = []; + window.Audio = class FakeAudio { + constructor(url) { + this.url = url; + this.volume = 1; + this.listeners = {}; + this.currentTime = 0; + } + addEventListener(name, callback) { + this.listeners[name] = callback; + } + pause() {} + play() { + window.__completionAudioPlays.push({ + url: this.url, + volume: this.volume + }); + queueMicrotask(() => this.listeners.ended?.()); + return Promise.resolve(); + } + }; + """ + ) + + try: + response = page.goto( + f"{BASE_URL}/profile?tab=settings", + wait_until="domcontentloaded", + ) + assert response is not None and response.ok + + admin_enabled = page.evaluate( + "window.appSettings?.enable_chat_completion_audio_cues === true" + ) + controls = page.locator("#completion-audio-preferences") + if not admin_enabled: + expect(controls).to_have_count(0) + return + + expect(controls).to_be_visible() + expect(page.locator("#completion-audio-sound-select option")).to_have_count(10) + original_settings = get_user_settings(page) + + page.locator("#completion-audio-enabled-toggle").check() + page.locator("#completion-audio-muted-toggle").uncheck() + page.locator("#completion-audio-sound-select").select_option("spark") + page.locator("#completion-audio-volume-range").fill("8") + expect(page.locator("#completion-audio-volume-value")).to_have_text("8") + + page.locator("#preview-completion-audio-btn").click() + expect(page.locator("#completion-audio-preference-status")).to_contain_text( + "Preview played" + ) + preview = page.evaluate("window.__completionAudioPlays.at(-1)") + assert preview["url"].endswith("/spark.wav") + assert preview["volume"] == 0.8 + + page.locator("#save-completion-audio-preferences-btn").click() + expect(page.locator("#completion-audio-preference-status")).to_contain_text( + "preferences saved" + ) + page.reload(wait_until="domcontentloaded") + expect(page.locator("#completion-audio-enabled-toggle")).to_be_checked() + expect(page.locator("#completion-audio-muted-toggle")).not_to_be_checked() + expect(page.locator("#completion-audio-sound-select")).to_have_value("spark") + expect(page.locator("#completion-audio-volume-range")).to_have_value("8") + + runtime_result = page.evaluate( + """ + async () => { + const manager = window.simpleChatCompletionAudio; + manager.resetTestState(); + window.__completionAudioPlays = []; + window.currentConversationId = 'active-conversation'; + + await manager.processPolledEvents([{ + id: 'baseline', + metadata: { + message_id: 'baseline-message', + conversation_id: 'other-conversation' + } + }]); + await manager.handleCompletion({ + messageId: 'foreground-message', + conversationId: 'active-conversation' + }); + await manager.handleCompletion({ + messageId: 'background-message', + conversationId: 'other-conversation' + }); + await manager.handleCompletion({ + messageId: 'background-message', + conversationId: 'other-conversation' + }); + + return window.__completionAudioPlays; + } + """ + ) + assert len(runtime_result) == 1 + assert runtime_result[0]["url"].endswith("/spark.wav") + assert runtime_result[0]["volume"] == 0.8 + finally: + if original_settings is not None: + set_user_settings( + page, + { + "chatCompletionAudioEnabled": original_settings.get( + "chatCompletionAudioEnabled", + False, + ), + "chatCompletionAudioMuted": original_settings.get( + "chatCompletionAudioMuted", + False, + ), + "chatCompletionAudioSound": original_settings.get( + "chatCompletionAudioSound", + "aurora", + ), + "chatCompletionAudioVolume": original_settings.get( + "chatCompletionAudioVolume", + 5, + ), + }, + ) + context.close() + browser.close()