From b66a007c0e832b501bd0e590f35ed53656e6c404 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Thu, 30 Jul 2026 15:21:50 -0400 Subject: [PATCH 1/2] Add configurable chat AI notice Adds admin-controlled notice text and recurrence policies below the chat composer, with validated dismissal persistence and regression coverage.\n\nCo-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- application/single_app/config.py | 2 +- application/single_app/functions_ai_notice.py | 112 ++++++++++ application/single_app/functions_settings.py | 3 + application/single_app/route_backend_users.py | 15 ++ .../route_frontend_admin_settings.py | 29 +++ .../single_app/route_frontend_chats.py | 7 + application/single_app/static/css/chats.css | 33 +++ .../static/js/admin/admin_settings.js | 10 + .../static/js/chat/chat-ai-notice.js | 89 ++++++++ .../single_app/templates/admin_settings.html | 45 ++++ application/single_app/templates/chats.html | 23 ++ docs/explanation/features/CUSTOM_AI_NOTICE.md | 51 +++++ functional_tests/test_ai_notice.py | 170 +++++++++++++++ .../test_user_settings_allowlist_keys.py | 4 +- ui_tests/test_chat_ai_notice_ui.py | 197 ++++++++++++++++++ 15 files changed, 788 insertions(+), 2 deletions(-) create mode 100644 application/single_app/functions_ai_notice.py create mode 100644 application/single_app/static/js/chat/chat-ai-notice.js create mode 100644 docs/explanation/features/CUSTOM_AI_NOTICE.md create mode 100644 functional_tests/test_ai_notice.py create mode 100644 ui_tests/test_chat_ai_notice_ui.py diff --git a/application/single_app/config.py b/application/single_app/config.py index 86e9a5c32..4b6fef4b6 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -95,7 +95,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.250.101" +VERSION = "0.250.102" IS_DEVELOPMENT = is_development_env_enabled() SESSION_COOKIE_SAMESITE = os.getenv('SESSION_COOKIE_SAMESITE', 'Lax') diff --git a/application/single_app/functions_ai_notice.py b/application/single_app/functions_ai_notice.py new file mode 100644 index 000000000..4da8719da --- /dev/null +++ b/application/single_app/functions_ai_notice.py @@ -0,0 +1,112 @@ +# functions_ai_notice.py +"""Helpers for the configurable AI notice shown below the chat composer.""" + +import hashlib +import json +import re +from datetime import datetime, timezone + + +AI_NOTICE_FREQUENCIES = ("non_dismissible", "every_session", "daily", "once") +AI_NOTICE_DEFAULT_FREQUENCY = "non_dismissible" +AI_NOTICE_MAX_MESSAGE_LENGTH = 1000 +AI_NOTICE_USER_SETTINGS_KEY = "aiNoticeDismissal" +_AI_NOTICE_HASH_PATTERN = re.compile(r"^[0-9a-f]{64}$") + + +def normalize_ai_notice_frequency(value): + """Normalize the configured AI notice display frequency.""" + normalized_value = str(value or "").strip().lower().replace("-", "_") + aliases = { + "always": "non_dismissible", + "non_dismissible": "non_dismissible", + "persistent": "non_dismissible", + "session": "every_session", + "every_session": "every_session", + "per_session": "every_session", + "daily": "daily", + "once_per_day": "daily", + "per_day": "daily", + "once": "once", + "one_time": "once", + "just_once": "once", + } + return aliases.get(normalized_value, AI_NOTICE_DEFAULT_FREQUENCY) + + +def normalize_ai_notice_message(value): + """Normalize administrator-entered AI notice text.""" + return ( + str(value or "") + .replace("\r\n", "\n") + .replace("\r", "\n") + .strip()[:AI_NOTICE_MAX_MESSAGE_LENGTH] + ) + + +def compute_ai_notice_hash(message, frequency): + """Compute the version hash used to invalidate stale dismissals.""" + payload = { + "message": normalize_ai_notice_message(message), + "frequency": normalize_ai_notice_frequency(frequency), + } + encoded_payload = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") + return hashlib.sha256(encoded_payload).hexdigest() + + +def get_ai_notice_config(settings): + """Build normalized browser-safe AI notice configuration.""" + source_settings = settings or {} + message = normalize_ai_notice_message(source_settings.get("ai_notice_message")) + frequency = normalize_ai_notice_frequency(source_settings.get("ai_notice_frequency")) + return { + "enabled": bool(source_settings.get("enable_ai_notice", False) and message), + "message": message, + "frequency": frequency, + "hash": compute_ai_notice_hash(message, frequency), + } + + +def build_ai_notice_dismissal_record(value, dismissed_at=None): + """Validate a requested dismissal and create a server-timestamped record.""" + if not isinstance(value, dict): + raise ValueError("AI notice dismissal must be an object.") + + notice_hash = str(value.get("hash") or "").strip().lower() + if not _AI_NOTICE_HASH_PATTERN.fullmatch(notice_hash): + raise ValueError("AI notice dismissal hash is invalid.") + + frequency = normalize_ai_notice_frequency(value.get("frequency")) + if frequency not in {"daily", "once"}: + raise ValueError("AI notice dismissal frequency is invalid.") + + dismissed_at = dismissed_at or datetime.now(timezone.utc) + return { + "hash": notice_hash, + "frequency": frequency, + "dismissed_at": dismissed_at.isoformat(), + "dismissed_date": dismissed_at.strftime("%Y-%m-%d"), + } + + +def is_ai_notice_dismissed(ai_notice_config, user_settings, current_time=None): + """Return whether the current persisted dismissal hides this notice.""" + if not ai_notice_config.get("enabled"): + return True + + frequency = ai_notice_config.get("frequency") + if frequency not in {"daily", "once"}: + return False + + dismissal = (user_settings or {}).get(AI_NOTICE_USER_SETTINGS_KEY, {}) + if not isinstance(dismissal, dict): + return False + if dismissal.get("hash") != ai_notice_config.get("hash"): + return False + if dismissal.get("frequency") != frequency: + return False + if frequency == "once": + return True + + current_time = current_time or datetime.now(timezone.utc) + return dismissal.get("dismissed_date") == current_time.strftime("%Y-%m-%d") diff --git a/application/single_app/functions_settings.py b/application/single_app/functions_settings.py index bcebb15e1..4eb0ca77f 100644 --- a/application/single_app/functions_settings.py +++ b/application/single_app/functions_settings.py @@ -862,6 +862,9 @@ def get_settings(use_cosmos=False, include_source=False): 'terms_of_use_decline_redirect_url': '/', 'terms_of_use_accept_button_text': 'Accept and continue', 'terms_of_use_decline_button_text': 'Cancel', + 'enable_ai_notice': False, + 'ai_notice_message': '', + 'ai_notice_frequency': 'non_dismissible', 'enable_dark_mode_default': False, 'enable_left_nav_default': True, 'release_notifications_registered': False, diff --git a/application/single_app/route_backend_users.py b/application/single_app/route_backend_users.py index 20d5cf1f9..a2d96af57 100644 --- a/application/single_app/route_backend_users.py +++ b/application/single_app/route_backend_users.py @@ -11,6 +11,10 @@ normalize_collaboration_user, ) from functions_appinsights import log_event +from functions_ai_notice import ( + AI_NOTICE_USER_SETTINGS_KEY, + build_ai_notice_dismissal_record, +) from functions_authentication import * from functions_group import ( check_group_status_allows_operation, @@ -506,6 +510,7 @@ def user_settings(): # Chat UI settings 'navbar_layout', 'chatLayout', 'showChatTitle', 'chatSplitSizes', 'deepResearchDefaultEnabled', + 'aiNoticeDismissal', 'sidebarToggleStyle', 'sidebarMenuState', 'fontSizePreference', 'conversationContentsDrawerEnabled', LATEST_FEATURES_HIDDEN_VERSION_SETTING, @@ -555,6 +560,16 @@ def user_settings(): if not isinstance(settings_to_update["conversationContentsDrawerEnabled"], bool): return jsonify({"error": "Invalid conversation contents drawer preference"}), 400 + if AI_NOTICE_USER_SETTINGS_KEY in settings_to_update: + try: + settings_to_update[AI_NOTICE_USER_SETTINGS_KEY] = ( + build_ai_notice_dismissal_record( + settings_to_update[AI_NOTICE_USER_SETTINGS_KEY] + ) + ) + except ValueError as exc: + return jsonify({"error": str(exc)}), 400 + if "sidebarMenuState" in settings_to_update: sidebar_menu_state = settings_to_update.get("sidebarMenuState") allowed_sidebar_menu_keys = { diff --git a/application/single_app/route_frontend_admin_settings.py b/application/single_app/route_frontend_admin_settings.py index a2b8f6b08..1cc72aeb4 100644 --- a/application/single_app/route_frontend_admin_settings.py +++ b/application/single_app/route_frontend_admin_settings.py @@ -41,6 +41,10 @@ from functions_logging import * from functions_document_actions import normalize_document_action_capabilities from functions_model_capabilities import is_vision_capable_model +from functions_ai_notice import ( + normalize_ai_notice_frequency, + normalize_ai_notice_message, +) from functions_terms_of_use import ( TERMS_OF_USE_DEFAULT_REDIRECT, TERMS_OF_USE_MAX_BUTTON_TEXT_LENGTH, @@ -841,6 +845,14 @@ def admin_settings(): settings['terms_of_use_accept_button_text'] = 'Accept and continue' if 'terms_of_use_decline_button_text' not in settings: settings['terms_of_use_decline_button_text'] = 'Cancel' + if 'enable_ai_notice' not in settings: + settings['enable_ai_notice'] = False + settings['ai_notice_message'] = normalize_ai_notice_message( + settings.get('ai_notice_message') + ) + settings['ai_notice_frequency'] = normalize_ai_notice_frequency( + settings.get('ai_notice_frequency') + ) # --- Add defaults for user agreement --- if 'enable_user_agreement' not in settings: settings['enable_user_agreement'] = False @@ -2077,6 +2089,18 @@ def parse_admin_int(raw_value, fallback_value, field_name="unknown", hard_defaul flash('Terms of Use message is required when the feature is enabled.', 'danger') return redirect(url_for('frontend_admin_settings.admin_settings')) + # --- AI Notice Settings --- + enable_ai_notice = form_data.get('enable_ai_notice') == 'on' + ai_notice_message = normalize_ai_notice_message( + form_data.get('ai_notice_message') + ) + ai_notice_frequency = normalize_ai_notice_frequency( + form_data.get('ai_notice_frequency') + ) + if enable_ai_notice and not ai_notice_message: + flash('AI notice message is required when the feature is enabled.', 'danger') + return redirect(url_for('frontend_admin_settings.admin_settings')) + # --- Authentication & Redirect Settings --- enable_front_door = form_data.get('enable_front_door') == 'on' front_door_url = form_data.get('front_door_url', '').strip() @@ -2460,6 +2484,11 @@ def is_valid_url(url): 'terms_of_use_accept_button_text': terms_of_use_accept_button_text, 'terms_of_use_decline_button_text': terms_of_use_decline_button_text, + # AI Notice + 'enable_ai_notice': enable_ai_notice, + 'ai_notice_message': ai_notice_message, + 'ai_notice_frequency': ai_notice_frequency, + # Multimedia & Metadata 'enable_video_file_support': enable_video_file_support, 'enable_audio_file_support': enable_audio_file_support, diff --git a/application/single_app/route_frontend_chats.py b/application/single_app/route_frontend_chats.py index 8f81fcc23..340032513 100644 --- a/application/single_app/route_frontend_chats.py +++ b/application/single_app/route_frontend_chats.py @@ -6,6 +6,7 @@ from functions_content import * from functions_settings import * from functions_agent_catalog import build_accessible_agent_catalog +from functions_ai_notice import get_ai_notice_config, is_ai_notice_dismissed from functions_collaboration import ( assert_user_can_participate_in_collaboration_conversation, create_collaboration_message_notifications, @@ -677,6 +678,11 @@ def chats(): user_settings = get_user_settings(user_id) user_settings_dict = user_settings.get("settings", {}) if isinstance(user_settings, dict) else {} public_settings = sanitize_settings_for_user(settings) + ai_notice = get_ai_notice_config(public_settings) + ai_notice['dismissed'] = is_ai_notice_dismissed( + ai_notice, + user_settings_dict, + ) current_user_info = get_current_user_info() or {} current_user_roles = (session.get('user') or {}).get('roles', []) user_workflows_enabled_for_user = is_user_workflows_enabled_for_user( @@ -855,6 +861,7 @@ def chats(): return render_template( 'chats.html', settings=public_settings, + ai_notice=ai_notice, enable_user_feedback=enable_user_feedback, active_group_id=active_group_id, active_group_name=active_group_name, diff --git a/application/single_app/static/css/chats.css b/application/single_app/static/css/chats.css index b40a02717..23462a751 100644 --- a/application/single_app/static/css/chats.css +++ b/application/single_app/static/css/chats.css @@ -4351,4 +4351,37 @@ mark.search-highlight { [data-bs-theme="dark"] .thoughts-container { border-top-color: #495057 !important; +} + +.ai-notice { + align-items: flex-start; + gap: 0.5rem; + padding: 0.5rem 0.25rem 0.125rem; + color: var(--bs-secondary-color); + font-size: 0.78rem; + line-height: 1.4; +} + +.ai-notice:not(.d-none) { + display: flex; +} + +.ai-notice-icon { + flex: 0 0 auto; + margin-top: 0.1rem; + color: var(--bs-primary); +} + +.ai-notice-message { + flex: 1 1 auto; + min-width: 0; + white-space: pre-line; +} + +.ai-notice-dismiss { + flex: 0 0 auto; + width: 0.7rem; + height: 0.7rem; + margin-top: 0.15rem; + font-size: 0.6rem; } \ No newline at end of file diff --git a/application/single_app/static/js/admin/admin_settings.js b/application/single_app/static/js/admin/admin_settings.js index f1526dceb..a369ab48f 100644 --- a/application/single_app/static/js/admin/admin_settings.js +++ b/application/single_app/static/js/admin/admin_settings.js @@ -6739,6 +6739,16 @@ function setupToggles() { }); } + const enableAiNotice = document.getElementById('enable_ai_notice'); + const aiNoticeSettings = document.getElementById('ai_notice_settings'); + if (enableAiNotice && aiNoticeSettings) { + toggleVisibility(aiNoticeSettings, enableAiNotice.checked); + enableAiNotice.addEventListener('change', function() { + toggleVisibility(aiNoticeSettings, this.checked); + markFormAsModified(); + }); + } + const enableUrlAccess = document.getElementById('enable_url_access'); const urlAccessSettings = document.getElementById('url_access_settings'); const applyUrlAccessDefaults = () => { diff --git a/application/single_app/static/js/chat/chat-ai-notice.js b/application/single_app/static/js/chat/chat-ai-notice.js new file mode 100644 index 000000000..12b161175 --- /dev/null +++ b/application/single_app/static/js/chat/chat-ai-notice.js @@ -0,0 +1,89 @@ +// chat-ai-notice.js + +import { saveUserSetting } from './chat-layout.js'; +import { showToast } from './chat-toast.js'; + + +const aiNotice = document.getElementById('ai-notice'); +const aiNoticeDismiss = document.getElementById('ai-notice-dismiss'); +const aiNoticeUserSettingKey = 'aiNoticeDismissal'; +const aiNoticeSessionStorageKey = 'simplechat.aiNoticeDismissal'; + + +function getSessionDismissalKey(noticeHash) { + return `${aiNoticeSessionStorageKey}.${noticeHash}`; +} + + +function isSessionNoticeDismissed(noticeHash) { + try { + return sessionStorage.getItem(getSessionDismissalKey(noticeHash)) === 'true'; + } catch (error) { + if (!(error instanceof DOMException)) { + throw error; + } + console.warn('Session storage is unavailable; the AI notice will remain visible.', error); + return false; + } +} + + +function showAiNotice() { + aiNotice?.classList.remove('d-none'); +} + + +function hideAiNotice() { + aiNotice?.classList.add('d-none'); +} + + +async function dismissAiNotice() { + if (!aiNotice || !aiNoticeDismiss) { + return; + } + + const noticeFrequency = aiNotice.dataset.noticeFrequency || ''; + const noticeHash = aiNotice.dataset.noticeHash || ''; + if (noticeFrequency === 'every_session') { + try { + sessionStorage.setItem(getSessionDismissalKey(noticeHash), 'true'); + } catch (error) { + if (!(error instanceof DOMException)) { + throw error; + } + showToast('This browser cannot save the session dismissal.', 'warning'); + return; + } + hideAiNotice(); + return; + } + + aiNoticeDismiss.disabled = true; + const saved = await saveUserSetting({ + [aiNoticeUserSettingKey]: { + hash: noticeHash, + frequency: noticeFrequency + } + }); + if (saved) { + hideAiNotice(); + return; + } + + aiNoticeDismiss.disabled = false; + showToast('The AI notice could not be dismissed. Please try again.', 'warning'); +} + + +if (aiNotice) { + const noticeFrequency = aiNotice.dataset.noticeFrequency || ''; + const noticeHash = aiNotice.dataset.noticeHash || ''; + const dismissedForSession = noticeFrequency === 'every_session' + && isSessionNoticeDismissed(noticeHash); + + if (!dismissedForSession) { + showAiNotice(); + } + aiNoticeDismiss?.addEventListener('click', dismissAiNotice); +} diff --git a/application/single_app/templates/admin_settings.html b/application/single_app/templates/admin_settings.html index 4f2c9a9cf..4f2b53181 100644 --- a/application/single_app/templates/admin_settings.html +++ b/application/single_app/templates/admin_settings.html @@ -2861,6 +2861,51 @@
+
+
+ Chat AI Notice +
+

+ Display an administrator-provided reminder directly below the chat input, such as guidance that responses are AI-generated and should be reviewed. +

+
+ + +
+
+
+ + +
Plain text only. Line breaks are preserved.
+
+
+ + +
Changing the notice text or display behavior creates a new message version and shows it again.
+
+
+
+
Terms of Use diff --git a/application/single_app/templates/chats.html b/application/single_app/templates/chats.html index 17c7ae3b4..b33edb413 100644 --- a/application/single_app/templates/chats.html +++ b/application/single_app/templates/chats.html @@ -803,6 +803,28 @@
Grounded Search
+ {% if ai_notice.enabled and not ai_notice.dismissed %} +
+ +

{{ ai_notice.message }}

+ {% if ai_notice.frequency != 'non_dismissible' %} + + {% endif %} +
+ {% endif %} {% if conversation_contents_drawer_enabled %}