Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions application/single_app/functions_ai_notice.py
Original file line number Diff line number Diff line change
@@ -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}$")

Check warning on line 14 in application/single_app/functions_ai_notice.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Moderate - Changed line contains obfuscation, dynamic loading, or hidden payload marker. Recommendation%3A Confirm the changed code is not hiding behavior, decoding payloads, or bypassing normal review.


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

Check warning on line 48 in application/single_app/functions_ai_notice.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains security control, sanitization, or audit marker. Recommendation%3A Confirm the change does not weaken auth, CSRF, CSP, XSS defenses, settings sanitization, redaction, audit logging, or tests.
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."""

Check warning on line 71 in application/single_app/functions_ai_notice.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains security control, sanitization, or audit marker. Recommendation%3A Confirm the change does not weaken auth, CSRF, CSP, XSS defenses, settings sanitization, redaction, audit logging, or tests.
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")
3 changes: 3 additions & 0 deletions application/single_app/functions_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
15 changes: 15 additions & 0 deletions application/single_app/route_backend_users.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -506,6 +510,7 @@
# Chat UI settings
'navbar_layout', 'chatLayout', 'showChatTitle', 'chatSplitSizes',
'deepResearchDefaultEnabled',
'aiNoticeDismissal',
'sidebarToggleStyle', 'sidebarMenuState', 'fontSizePreference',
'conversationContentsDrawerEnabled',
LATEST_FEATURES_HIDDEN_VERSION_SETTING,
Expand Down Expand Up @@ -555,6 +560,16 @@
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:

Check warning on line 564 in application/single_app/route_backend_users.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains security control, sanitization, or audit marker. Recommendation%3A Confirm the change does not weaken auth, CSRF, CSP, XSS defenses, settings sanitization, redaction, audit logging, or tests.
settings_to_update[AI_NOTICE_USER_SETTINGS_KEY] = (
build_ai_notice_dismissal_record(
settings_to_update[AI_NOTICE_USER_SETTINGS_KEY]
)
)
except ValueError:
return jsonify({"error": "Invalid AI notice dismissal"}), 400

if "sidebarMenuState" in settings_to_update:
sidebar_menu_state = settings_to_update.get("sidebarMenuState")
allowed_sidebar_menu_keys = {
Expand Down
29 changes: 29 additions & 0 deletions application/single_app/route_frontend_admin_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions application/single_app/route_frontend_chats.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down
33 changes: 33 additions & 0 deletions application/single_app/static/css/chats.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
10 changes: 10 additions & 0 deletions application/single_app/static/js/admin/admin_settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -6793,6 +6793,16 @@
});
}

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() {

Check warning on line 6800 in application/single_app/static/js/admin/admin_settings.js

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Moderate - Changed line contains obfuscation, dynamic loading, or hidden payload marker. Recommendation%3A Confirm the changed code is not hiding behavior, decoding payloads, or bypassing normal review.
toggleVisibility(aiNoticeSettings, this.checked);
markFormAsModified();
});
}

const enableUrlAccess = document.getElementById('enable_url_access');
const urlAccessSettings = document.getElementById('url_access_settings');
const applyUrlAccessDefaults = () => {
Expand Down
89 changes: 89 additions & 0 deletions application/single_app/static/js/chat/chat-ai-notice.js
Original file line number Diff line number Diff line change
@@ -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';

Check warning on line 10 in application/single_app/static/js/chat/chat-ai-notice.js

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains secret or sensitive data source marker. Recommendation%3A Pair this source with any nearby network, logging, serialization, or process execution sink before approving.


function getSessionDismissalKey(noticeHash) {
return `${aiNoticeSessionStorageKey}.${noticeHash}`;

Check warning on line 14 in application/single_app/static/js/chat/chat-ai-notice.js

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains secret or sensitive data source marker. Recommendation%3A Pair this source with any nearby network, logging, serialization, or process execution sink before approving.
}


function isSessionNoticeDismissed(noticeHash) {
try {
return sessionStorage.getItem(getSessionDismissalKey(noticeHash)) === 'true';

Check warning on line 20 in application/single_app/static/js/chat/chat-ai-notice.js

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains secret or sensitive data source marker. Recommendation%3A Pair this source with any nearby network, logging, serialization, or process execution sink before approving.
} catch (error) {
if (!(error instanceof DOMException)) {
throw error;
}
console.warn('Session storage is unavailable; the AI notice will remain visible.', error);

Check warning on line 25 in application/single_app/static/js/chat/chat-ai-notice.js

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains secret or sensitive data source marker. Recommendation%3A Pair this source with any nearby network, logging, serialization, or process execution sink before approving.
return false;
}
}


function showAiNotice() {
aiNotice?.classList.remove('d-none');

Check warning on line 32 in application/single_app/static/js/chat/chat-ai-notice.js

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains security control, sanitization, or audit marker. Recommendation%3A Confirm the change does not weaken auth, CSRF, CSP, XSS defenses, settings sanitization, redaction, audit logging, or tests.
}


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);
}
Loading
Loading