diff --git a/application/single_app/app.py b/application/single_app/app.py index 1b1adc34..fdbaee55 100644 --- a/application/single_app/app.py +++ b/application/single_app/app.py @@ -50,6 +50,7 @@ from route_frontend_public_workspaces import * from route_frontend_safety import * from route_frontend_feedback import * +from route_frontend_support import * from route_frontend_notifications import * from route_backend_chats import * @@ -863,6 +864,9 @@ def list_semantic_kernel_plugins(): # ------------------- Feedback Routes ------------------- register_route_frontend_feedback(app) +# ------------------- Support Routes -------------------- +register_route_frontend_support(app) + # ------------------- Notifications Routes -------------- register_route_frontend_notifications(app) diff --git a/application/single_app/config.py b/application/single_app/config.py index e8c682fe..a3200961 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -94,7 +94,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.240.056" +VERSION = "0.240.066" SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production') diff --git a/application/single_app/functions_activity_logging.py b/application/single_app/functions_activity_logging.py index 4ee05939..987a3dec 100644 --- a/application/single_app/functions_activity_logging.py +++ b/application/single_app/functions_activity_logging.py @@ -203,6 +203,73 @@ def log_admin_feedback_email_submission( debug_print(f"[Admin Feedback] Failed to log feedback email submission for user {user_id}") +def log_user_support_feedback_email_submission( + user_id: str, + user_email: str, + feedback_type: str, + reporter_name: str, + reporter_email: str, + organization: str, + details: str, + recipient_email: str, + source: str = 'support_menu' +) -> None: + """Log a user-initiated support feedback email draft event.""" + + feedback_metadata = { + 'feedback_type': feedback_type, + 'details_length': len(details or ''), + **_build_contact_metadata(reporter_name, reporter_email, organization), + } + + try: + timestamp = datetime.utcnow().isoformat() + activity_record = { + 'id': str(uuid.uuid4()), + 'partitionKey': user_id, + 'user_id': user_id, + 'timestamp': timestamp, + 'activity_type': 'user_support_feedback_email_submission', + 'submission_channel': 'mailto', + 'recipient_email': recipient_email, + 'source': source, + 'feedback_submission': feedback_metadata, + } + + cosmos_activity_logs_container.create_item(body=activity_record) + + log_event( + message='[Support Feedback] Mailto draft prepared', + extra={ + 'user_id': user_id, + 'activity_type': 'user_support_feedback_email_submission', + 'submission_channel': 'mailto', + 'recipient_email': recipient_email, + 'source': source, + **feedback_metadata, + }, + level=logging.INFO + ) + debug_print(f"[Support Feedback] Logged support feedback email submission for user {user_id}") + + except Exception: + log_event( + message='[Support Feedback] Failed to record support feedback mailto draft', + extra={ + 'user_id': user_id, + 'feedback_type': feedback_type, + 'activity_type': 'user_support_feedback_email_submission', + 'recipient_email': recipient_email, + 'source': source, + 'details_length': len(details or ''), + **_build_contact_metadata(reporter_name, reporter_email, organization), + }, + level=logging.ERROR, + exceptionTraceback=True + ) + debug_print(f"[Support Feedback] Failed to log support feedback email submission for user {user_id}") + + def log_admin_release_notifications_registration( user_id: str, admin_email: str, diff --git a/application/single_app/functions_message_artifacts.py b/application/single_app/functions_message_artifacts.py index cf57412d..25be2b2b 100644 --- a/application/single_app/functions_message_artifacts.py +++ b/application/single_app/functions_message_artifacts.py @@ -2,6 +2,8 @@ """Helpers for storing large assistant-side payloads outside primary chat items.""" import json +import math +import numbers from copy import deepcopy from typing import Any, Dict, List, Optional, Tuple @@ -33,13 +35,50 @@ def filter_assistant_artifact_items(items: List[Dict[str, Any]]) -> List[Dict[st return [item for item in items or [] if not is_assistant_artifact_role(item.get('role'))] +def _normalize_json_scalar(value: Any) -> Any: + """Normalize scalar values into JSON-safe primitives.""" + if hasattr(value, 'item') and not isinstance(value, (str, bytes)): + try: + value = value.item() + except (TypeError, ValueError): + pass + + if value is None: + return None + + if isinstance(value, bytes): + return value.decode('utf-8', errors='replace') + + if isinstance(value, bool): + return value + + if isinstance(value, numbers.Integral): + return int(value) + + if isinstance(value, numbers.Real): + numeric_value = float(value) + if not math.isfinite(numeric_value): + return None + return numeric_value + + if hasattr(value, 'isoformat') and not isinstance(value, str): + try: + return value.isoformat() + except TypeError: + pass + + return value + + def make_json_serializable(value: Any) -> Any: """Convert nested values into JSON-serializable structures.""" + value = _normalize_json_scalar(value) + if value is None or isinstance(value, (str, int, float, bool)): return value if isinstance(value, dict): return {str(key): make_json_serializable(item) for key, item in value.items()} - if isinstance(value, (list, tuple)): + if isinstance(value, (list, tuple, set)): return [make_json_serializable(item) for item in value] return str(value) @@ -212,7 +251,7 @@ def _build_artifact_documents( user_info: Optional[Dict[str, Any]] = None, citation: Optional[Dict[str, Any]] = None, ) -> List[Dict[str, Any]]: - serialized_payload = json.dumps(payload, default=str) + serialized_payload = json.dumps(make_json_serializable(payload), default=str, allow_nan=False) chunks = [ serialized_payload[index:index + ASSISTANT_ARTIFACT_CHUNK_SIZE] for index in range(0, len(serialized_payload), ASSISTANT_ARTIFACT_CHUNK_SIZE) @@ -374,6 +413,8 @@ def _compact_tabular_result_payload(function_name: str, payload: Any) -> Any: def _compact_value(value: Any, depth: int = 0) -> Any: + value = _normalize_json_scalar(value) + if value is None or isinstance(value, (int, float, bool)): return value diff --git a/application/single_app/functions_settings.py b/application/single_app/functions_settings.py index 50318bef..d053e9e7 100644 --- a/application/single_app/functions_settings.py +++ b/application/single_app/functions_settings.py @@ -5,6 +5,11 @@ import app_settings_cache import inspect import copy +from support_menu_config import ( + get_default_support_latest_features_visibility, + has_visible_support_latest_features, + normalize_support_latest_features_visibility, +) def is_tabular_processing_enabled(settings): @@ -207,6 +212,14 @@ def get_settings(use_cosmos=False, include_source=False): {"label": "Prompt Ideas", "url": "https://example.com/prompts"} ], + # Support Menu + 'enable_support_menu': False, + 'support_menu_name': 'Support', + 'enable_support_send_feedback': True, + 'support_feedback_recipient_email': '', + 'enable_support_latest_features': True, + 'support_latest_features_visibility': get_default_support_latest_features_visibility(), + # Enhanced Citations 'enable_enhanced_citations': False, 'enable_enhanced_citations_mount': False, @@ -1295,6 +1308,8 @@ def sanitize_settings_for_user(full_settings: dict) -> dict: sanitized = {} for k, v in full_settings.items(): + if k == 'support_feedback_recipient_email': + continue if any(term in k.lower() for term in sensitive_terms): continue if k in ('model_endpoints', 'personal_model_endpoints') and isinstance(v, list): @@ -1319,6 +1334,15 @@ def sanitize_settings_for_user(full_settings: dict) -> dict: if 'custom_favicon_base64' in full_settings: sanitized['custom_favicon_base64'] = bool(full_settings.get('custom_favicon_base64')) + if 'support_latest_features_visibility' in full_settings or 'enable_support_latest_features' in full_settings: + sanitized['support_latest_features_visibility'] = normalize_support_latest_features_visibility( + full_settings.get('support_latest_features_visibility', {}) + ) + sanitized['support_latest_features_has_visible_items'] = has_visible_support_latest_features(full_settings) + sanitized['support_feedback_recipient_configured'] = bool( + str(full_settings.get('support_feedback_recipient_email') or '').strip() + ) + return sanitized def sanitize_settings_for_logging(full_settings: dict) -> dict: diff --git a/application/single_app/route_backend_chats.py b/application/single_app/route_backend_chats.py index 26a71f41..0da68dad 100644 --- a/application/single_app/route_backend_chats.py +++ b/application/single_app/route_backend_chats.py @@ -49,6 +49,7 @@ build_message_artifact_payload_map, filter_assistant_artifact_items, hydrate_agent_citations_from_artifacts, + make_json_serializable, ) from functions_thoughts import ThoughtTracker @@ -4245,18 +4246,6 @@ def collect_tabular_sk_citations(user_id, conversation_id): if not plugin_invocations: return [] - def make_json_serializable(obj): - if obj is None: - return None - elif isinstance(obj, (str, int, float, bool)): - return obj - elif isinstance(obj, dict): - return {str(k): make_json_serializable(v) for k, v in obj.items()} - elif isinstance(obj, (list, tuple)): - return [make_json_serializable(item) for item in obj] - else: - return str(obj) - citations = [] for inv in plugin_invocations: timestamp_str = None @@ -7556,19 +7545,6 @@ def agent_success(result): else: timestamp_str = str(inv.timestamp) - # Ensure all values are JSON serializable - def make_json_serializable(obj): - if obj is None: - return None - elif isinstance(obj, (str, int, float, bool)): - return obj - elif isinstance(obj, dict): - return {str(k): make_json_serializable(v) for k, v in obj.items()} - elif isinstance(obj, (list, tuple)): - return [make_json_serializable(item) for item in obj] - else: - return str(obj) - citation = { 'tool_name': f"{inv.plugin_name}.{inv.function_name}", 'function_name': inv.function_name, @@ -7677,9 +7653,8 @@ def foundry_agent_success(result): f"Agent retrieved citation from Azure AI Foundry" ) for citation in foundry_citations: - try: - serializable = json.loads(json.dumps(citation, default=str)) - except (TypeError, ValueError): + serializable = make_json_serializable(citation) + if not isinstance(serializable, dict): serializable = {'value': str(citation)} agent_citations_list.append({ 'tool_name': agent_used, @@ -8007,7 +7982,7 @@ def gpt_error(e): user_info=user_info_for_assistant, ) - assistant_doc = { + assistant_doc = make_json_serializable({ 'id': assistant_message_id, 'conversation_id': conversation_id, 'role': 'assistant', @@ -8033,7 +8008,7 @@ def gpt_error(e): }, 'token_usage': token_usage_data # Store token usage information } # Used by SK and reasoning effort - } + }) debug_print(f"🔍 Chat API - Creating assistant message with thread_info:") debug_print(f" thread_id: {user_thread_id}") @@ -8137,7 +8112,7 @@ def gpt_error(e): enable_redis_for_kernel = False if enable_semantic_kernel and per_user_semantic_kernel and redis_client and enable_redis_for_kernel: save_user_kernel(user_id, g.kernel, g.kernel_agents, redis_client) - return jsonify({ + return jsonify(make_json_serializable({ 'reply': ai_message, # Send the AI's response (or the error message) back 'conversation_id': conversation_id, 'conversation_title': conversation_item['title'], # Send updated title @@ -8159,7 +8134,7 @@ def gpt_error(e): 'reload_messages': reload_messages_required, 'kernel_fallback_notice': kernel_fallback_notice, 'thoughts_enabled': thought_tracker.enabled - }), 200 + })), 200 except Exception as e: import traceback @@ -8235,7 +8210,7 @@ def chat_stream_api(): def normalize_legacy_chat_payload(payload): """Convert the legacy JSON response shape into the streaming terminal payload.""" - return { + return make_json_serializable({ 'done': True, 'conversation_id': payload.get('conversation_id'), 'conversation_title': payload.get('conversation_title'), @@ -8255,7 +8230,7 @@ def normalize_legacy_chat_payload(payload): 'kernel_fallback_notice': payload.get('kernel_fallback_notice'), 'thoughts_enabled': payload.get('thoughts_enabled', False), 'blocked': payload.get('blocked', False), - } + }) def generate_compatibility_response(): """Bridge legacy JSON chat handling into a terminal SSE event for parity cases.""" @@ -9974,18 +9949,6 @@ def publish_live_plugin_thought(thought_payload): else: timestamp_str = str(inv.timestamp) - def make_json_serializable(obj): - if obj is None: - return None - elif isinstance(obj, (str, int, float, bool)): - return obj - elif isinstance(obj, dict): - return {str(k): make_json_serializable(v) for k, v in obj.items()} - elif isinstance(obj, (list, tuple)): - return [make_json_serializable(item) for item in obj] - else: - return str(obj) - citation = { 'tool_name': f"{inv.plugin_name}.{inv.function_name}", 'function_name': inv.function_name, @@ -10006,9 +9969,8 @@ def make_json_serializable(obj): foundry_label = agent_name_used or ('New Foundry Application' if stream_selected_agent_type == 'new_foundry' else 'Azure AI Foundry Agent') for citation in foundry_citations: yield emit_thought('agent_tool_call', 'Agent retrieved citation from Azure AI Foundry') - try: - serializable = json.loads(json.dumps(citation, default=str)) - except (TypeError, ValueError): + serializable = make_json_serializable(citation) + if not isinstance(serializable, dict): serializable = {'value': str(citation)} agent_citations_list.append({ 'tool_name': foundry_label, @@ -10105,7 +10067,7 @@ def make_json_serializable(obj): user_info=user_info_for_assistant, ) - assistant_doc = { + assistant_doc = make_json_serializable({ 'id': assistant_message_id, 'conversation_id': conversation_id, 'role': 'assistant', @@ -10130,7 +10092,7 @@ def make_json_serializable(obj): }, 'token_usage': token_usage_data if token_usage_data else None # Store token usage from stream } - } + }) cosmos_messages_container.upsert_item(assistant_doc) # Log chat token usage to activity_logs for easy reporting @@ -10220,7 +10182,7 @@ def make_json_serializable(obj): cosmos_conversations_container.upsert_item(conversation_item) # Send final message with metadata - final_data = { + final_data = make_json_serializable({ 'done': True, 'conversation_id': conversation_id, 'conversation_title': conversation_item['title'], @@ -10240,7 +10202,7 @@ def make_json_serializable(obj): 'agent_name': agent_name_used if use_agent_streaming else None, 'full_content': accumulated_content, 'thoughts_enabled': thought_tracker.enabled - } + }) debug_print( "[Streaming] Finalizing stream response | " f"conversation_id={conversation_id} | message_id={assistant_message_id} | " @@ -10266,7 +10228,7 @@ def make_json_serializable(obj): user_info=user_info_for_assistant, ) - assistant_doc = { + assistant_doc = make_json_serializable({ 'id': assistant_message_id, 'conversation_id': conversation_id, 'role': 'assistant', @@ -10292,7 +10254,7 @@ def make_json_serializable(obj): 'thread_attempt': 1 } } - } + }) try: cosmos_messages_container.upsert_item(assistant_doc) except Exception as ex: @@ -11772,9 +11734,8 @@ def perform_web_search( if citations: for i, citation in enumerate(citations): debug_print(f"[WebSearch] Processing citation {i}: {json.dumps(citation, default=str)[:200]}...") - try: - serializable = json.loads(json.dumps(citation, default=str)) - except (TypeError, ValueError): + serializable = make_json_serializable(citation) + if not isinstance(serializable, dict): serializable = {"value": str(citation)} citation_title = serializable.get("title") or serializable.get("url") or "Web search source" debug_print(f"[WebSearch] Adding agent citation with title: {citation_title}") diff --git a/application/single_app/route_backend_settings.py b/application/single_app/route_backend_settings.py index 2e6448f0..34c9f8d1 100644 --- a/application/single_app/route_backend_settings.py +++ b/application/single_app/route_backend_settings.py @@ -7,6 +7,7 @@ from functions_activity_logging import ( log_admin_feedback_email_submission, log_admin_release_notifications_registration, + log_user_support_feedback_email_submission, ) from functions_appinsights import log_event from azure.identity import DefaultAzureCredential @@ -500,6 +501,81 @@ def send_feedback_email(): ) return jsonify({'error': 'Failed to prepare feedback email'}), 500 + @app.route('/api/support/send_feedback_email', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + @enabled_required("enable_support_menu") + def send_support_feedback_email(): + """Log a support feedback draft request before the client opens mailto.""" + user_id = get_current_user_id() or 'unknown' + feedback_type = '' + try: + user = session.get('user', {}) + roles = user.get('roles', []) if isinstance(user.get('roles', []), list) else [] + if 'Admin' not in roles and 'User' not in roles: + return jsonify({'error': 'Support menu is available to signed-in app users only'}), 403 + + settings = get_settings() + if not settings.get('enable_support_send_feedback', True): + return jsonify({'error': 'Send Feedback is disabled'}), 400 + + recipient_email = (settings.get('support_feedback_recipient_email') or '').strip() + if not recipient_email or '@' not in recipient_email: + return jsonify({'error': 'Support feedback recipient email is not configured'}), 400 + + data = request.get_json(force=True) + + feedback_type = (data.get('feedbackType') or '').strip() + reporter_name = (data.get('reporterName') or '').strip() + reporter_email = (data.get('reporterEmail') or '').strip() + organization = (data.get('organization') or '').strip() + details = (data.get('details') or '').strip() + if feedback_type not in ['bug_report', 'feature_request']: + return jsonify({'error': 'Invalid feedback type'}), 400 + + if not reporter_name or not reporter_email or not organization or not details: + return jsonify({'error': 'Name, email, organization, and details are required'}), 400 + + if '@' not in reporter_email: + return jsonify({'error': 'Reporter email must be a valid email address'}), 400 + + user_email = user.get('preferred_username', user.get('email', reporter_email)) + feedback_label = 'Bug Report' if feedback_type == 'bug_report' else 'Feature Request' + subject_line = f'[SimpleChat User Support] {feedback_label} - {organization}' + + log_user_support_feedback_email_submission( + user_id=user_id, + user_email=user_email, + feedback_type=feedback_type, + reporter_name=reporter_name, + reporter_email=reporter_email, + organization=organization, + details=details, + recipient_email=recipient_email, + ) + + return jsonify({ + 'success': True, + 'recipientEmail': recipient_email, + 'subjectLine': subject_line, + 'feedbackLabel': feedback_label + }), 200 + + except Exception: + log_event( + '[Support Feedback] Failed to prepare feedback email', + extra={ + 'user_id': user_id, + 'activity_type': 'user_support_feedback_email_submission', + 'route': 'send_support_feedback_email', + 'feedback_type': feedback_type, + }, + level=logging.ERROR, + exceptionTraceback=True + ) + return jsonify({'error': 'Failed to prepare feedback email'}), 500 + @app.route('/api/admin/settings/release_notifications_registration', methods=['POST']) @swagger_route(security=get_auth_security()) @login_required diff --git a/application/single_app/route_frontend_admin_settings.py b/application/single_app/route_frontend_admin_settings.py index c2642642..eb07b0f2 100644 --- a/application/single_app/route_frontend_admin_settings.py +++ b/application/single_app/route_frontend_admin_settings.py @@ -11,6 +11,11 @@ from swagger_wrapper import swagger_route, get_auth_security from datetime import datetime, timedelta, timezone from admin_settings_int_utils import safe_int_with_source +from support_menu_config import ( + get_support_latest_feature_catalog, + has_visible_support_latest_features, + normalize_support_latest_features_visibility, +) ALLOWED_PIL_IMAGE_UPLOAD_FORMATS = ('PNG', 'JPEG') @@ -111,6 +116,23 @@ def admin_settings(): {"label": "Acceptable Use Policy", "url": "https://example.com/policy"}, {"label": "Prompt Ideas", "url": "https://example.com/prompts"} ] + if 'enable_support_menu' not in settings: + settings['enable_support_menu'] = False + if 'support_menu_name' not in settings: + settings['support_menu_name'] = 'Support' + if 'enable_support_send_feedback' not in settings: + settings['enable_support_send_feedback'] = True + if 'support_feedback_recipient_email' not in settings: + settings['support_feedback_recipient_email'] = '' + if 'enable_support_latest_features' not in settings: + settings['enable_support_latest_features'] = True + settings['support_latest_features_visibility'] = normalize_support_latest_features_visibility( + settings.get('support_latest_features_visibility', {}) + ) + settings['support_latest_features_has_visible_items'] = has_visible_support_latest_features(settings) + settings['support_feedback_recipient_configured'] = bool( + str(settings.get('support_feedback_recipient_email') or '').strip() + ) # --- End Refined Default Checks --- @@ -348,6 +370,7 @@ def admin_settings(): update_available=update_available, latest_version=latest_version, download_url=download_url, + support_latest_feature_catalog=get_support_latest_feature_catalog(), chunk_size_defaults=get_chunk_size_defaults(), chunk_size_settings=settings.get('chunk_size', {}), chunk_size_cap=get_chunk_size_cap(settings), @@ -530,6 +553,30 @@ def parse_admin_int(raw_value, fallback_value, field_name="unknown", hard_defaul # Keep existing external links from the database instead of overwriting with bad data parsed_external_links = settings.get('external_links', []) # Fallback to existing + enable_support_menu = form_data.get('enable_support_menu') == 'on' + support_menu_name = form_data.get('support_menu_name', 'Support').strip() + if not support_menu_name: + support_menu_name = 'Support' + + enable_support_send_feedback = form_data.get('enable_support_send_feedback') == 'on' + support_feedback_recipient_email = form_data.get('support_feedback_recipient_email', '').strip() + if enable_support_send_feedback and not support_feedback_recipient_email: + flash('Support Send Feedback requires a recipient email. The Send Feedback entry was disabled.', 'warning') + enable_support_send_feedback = False + elif support_feedback_recipient_email and '@' not in support_feedback_recipient_email: + flash('Support feedback recipient email must be a valid email address. The Send Feedback entry was disabled.', 'warning') + support_feedback_recipient_email = '' + enable_support_send_feedback = False + + enable_support_latest_features = form_data.get('enable_support_latest_features') == 'on' + support_latest_features_visibility = {} + for feature in get_support_latest_feature_catalog(): + field_name = f"support_latest_feature_{feature['id']}" + support_latest_features_visibility[feature['id']] = form_data.get(field_name) == 'on' + support_latest_features_visibility = normalize_support_latest_features_visibility( + support_latest_features_visibility + ) + # Enhanced Citations... enable_enhanced_citations = form_data.get('enable_enhanced_citations') == 'on' office_docs_storage_account_blob_endpoint = form_data.get('office_docs_storage_account_blob_endpoint', '').strip() @@ -1162,6 +1209,14 @@ def is_valid_url(url): 'external_links_force_menu': external_links_force_menu, 'external_links': parsed_external_links, # Store the PARSED LIST + # *** Support Menu *** + 'enable_support_menu': enable_support_menu, + 'support_menu_name': support_menu_name, + 'enable_support_send_feedback': enable_support_send_feedback, + 'support_feedback_recipient_email': support_feedback_recipient_email, + 'enable_support_latest_features': enable_support_latest_features, + 'support_latest_features_visibility': support_latest_features_visibility, + # Enhanced Citations 'enable_enhanced_citations': enable_enhanced_citations, 'enable_enhanced_citations_mount': form_data.get('enable_enhanced_citations_mount') == 'on' and enable_enhanced_citations, diff --git a/application/single_app/route_frontend_support.py b/application/single_app/route_frontend_support.py new file mode 100644 index 00000000..b6549457 --- /dev/null +++ b/application/single_app/route_frontend_support.py @@ -0,0 +1,53 @@ +# route_frontend_support.py + +from config import * +from functions_authentication import * +from functions_settings import * +from swagger_wrapper import swagger_route, get_auth_security +from support_menu_config import get_visible_support_latest_features + + +def _support_menu_access_allowed(): + user = session.get('user', {}) + roles = user.get('roles', []) if isinstance(user.get('roles', []), list) else [] + return 'Admin' in roles or 'User' in roles + + +def register_route_frontend_support(app): + + @app.route('/support/latest-features') + @swagger_route(security=get_auth_security()) + @login_required + @user_required + @enabled_required('enable_support_menu') + def support_latest_features(): + """Render the latest features page exposed from the Support menu.""" + if not _support_menu_access_allowed(): + return 'Forbidden', 403 + + settings = get_settings() + if not settings.get('enable_support_latest_features', True): + return 'Not Found', 404 + + visible_features = get_visible_support_latest_features(settings) + return render_template( + 'latest_features.html', + support_latest_features=visible_features, + ) + + @app.route('/support/send-feedback') + @swagger_route(security=get_auth_security()) + @login_required + @user_required + @enabled_required('enable_support_menu') + def support_send_feedback(): + """Render the support feedback page.""" + if not _support_menu_access_allowed(): + return 'Forbidden', 403 + + settings = get_settings() + recipient_email = str(settings.get('support_feedback_recipient_email') or '').strip() + if not settings.get('enable_support_send_feedback', True) or not recipient_email: + return 'Not Found', 404 + + return render_template('support_send_feedback.html') \ No newline at end of file diff --git a/application/single_app/static/css/chats.css b/application/single_app/static/css/chats.css index 0225e20d..d4b038ef 100644 --- a/application/single_app/static/css/chats.css +++ b/application/single_app/static/css/chats.css @@ -1452,7 +1452,8 @@ a.citation-link:hover { .message-content { display: flex; align-items: flex-end; - overflow: auto; /* Make message content scrollable when it overflows while minimizing effects elsewhere. */ + overflow: visible; /* Keep scrolling on the conversation pane; nested message scrollbars cause width jitter during TTS highlighting. */ + min-width: 0; } .message-content.flex-row-reverse { @@ -2063,15 +2064,14 @@ mark.search-highlight { /* Word-by-word highlighting for TTS */ .tts-word { display: inline; - transition: background-color 0.2s ease, color 0.2s ease; + transition: background-color 0.2s ease, color 0.2s ease, box-shadow 0.2s ease; } .tts-word.tts-current-word { - background-color: rgba(var(--bs-primary-rgb), 0.3); + background-color: rgba(var(--bs-primary-rgb), 0.18); color: var(--bs-primary); - font-weight: 500; border-radius: 2px; - padding: 0 2px; + box-shadow: inset 0 -0.2em 0 rgba(var(--bs-primary-rgb), 0.22); } @keyframes tts-avatar-pulse { diff --git a/application/single_app/static/images/features/citation_improvements_amplified_results.png b/application/single_app/static/images/features/citation_improvements_amplified_results.png new file mode 100644 index 00000000..edc8b4d0 Binary files /dev/null and b/application/single_app/static/images/features/citation_improvements_amplified_results.png differ diff --git a/application/single_app/static/images/features/citation_improvements_history_replay.png b/application/single_app/static/images/features/citation_improvements_history_replay.png new file mode 100644 index 00000000..ea75bbe8 Binary files /dev/null and b/application/single_app/static/images/features/citation_improvements_history_replay.png differ diff --git a/application/single_app/static/images/features/document_revision_delete_compare.png b/application/single_app/static/images/features/document_revision_delete_compare.png new file mode 100644 index 00000000..69e389c9 Binary files /dev/null and b/application/single_app/static/images/features/document_revision_delete_compare.png differ diff --git a/application/single_app/static/images/features/document_revision_workspace.png b/application/single_app/static/images/features/document_revision_workspace.png new file mode 100644 index 00000000..2bbe5fd8 Binary files /dev/null and b/application/single_app/static/images/features/document_revision_workspace.png differ diff --git a/application/single_app/static/images/features/enable_support_menu_for_end_users.png b/application/single_app/static/images/features/enable_support_menu_for_end_users.png new file mode 100644 index 00000000..3fadc079 Binary files /dev/null and b/application/single_app/static/images/features/enable_support_menu_for_end_users.png differ diff --git a/application/single_app/static/images/features/model_selection_chat_selector.png b/application/single_app/static/images/features/model_selection_chat_selector.png new file mode 100644 index 00000000..b8602c8a Binary files /dev/null and b/application/single_app/static/images/features/model_selection_chat_selector.png differ diff --git a/application/single_app/static/images/features/model_selection_multi_endpoint_admin.png b/application/single_app/static/images/features/model_selection_multi_endpoint_admin.png new file mode 100644 index 00000000..71c5bdcd Binary files /dev/null and b/application/single_app/static/images/features/model_selection_multi_endpoint_admin.png differ diff --git a/application/single_app/static/images/features/support_menu_entry.png b/application/single_app/static/images/features/support_menu_entry.png new file mode 100644 index 00000000..961e07ee Binary files /dev/null and b/application/single_app/static/images/features/support_menu_entry.png differ diff --git a/application/single_app/static/js/admin/admin_settings.js b/application/single_app/static/js/admin/admin_settings.js index c9127b26..b007b6b0 100644 --- a/application/single_app/static/js/admin/admin_settings.js +++ b/application/single_app/static/js/admin/admin_settings.js @@ -42,6 +42,13 @@ const externalLinksTbody = document.getElementById('external-links-tbody'); const addExternalLinkBtn = document.getElementById('add-external-link-btn'); const externalLinksJsonInput = document.getElementById('external_links_json'); +const enableSupportMenuToggle = document.getElementById('enable_support_menu'); +const supportMenuSettingsDiv = document.getElementById('support_menu_settings'); +const enableSupportSendFeedbackToggle = document.getElementById('enable_support_send_feedback'); +const supportFeedbackRecipientGroup = document.getElementById('support_feedback_recipient_group'); +const enableSupportLatestFeaturesToggle = document.getElementById('enable_support_latest_features'); +const supportLatestFeaturesSettingsDiv = document.getElementById('support_latest_features_settings'); + const adminForm = document.getElementById('admin-settings-form'); const saveButton = document.getElementById('floating-save-btn') || (adminForm ? adminForm.querySelector('button[type="submit"]') : null); const enableGroupWorkspacesToggle = document.getElementById('enable_group_workspaces'); @@ -116,6 +123,9 @@ document.addEventListener('DOMContentLoaded', () => { // --- NEW: External Links Setup --- setupExternalLinks(); // Initialize external links section + // --- NEW: Support Menu Setup --- + setupSupportMenuSettings(); + // --- NEW: Chunk size controls --- setupChunkSizeControls(); @@ -935,6 +945,50 @@ function updateClassificationJsonInput() { return "[]"; } +function setupSupportMenuSettings() { + if (enableSupportMenuToggle) { + enableSupportMenuToggle.addEventListener('change', toggleSupportMenuSettingsVisibility); + } + + if (enableSupportSendFeedbackToggle) { + enableSupportSendFeedbackToggle.addEventListener('change', toggleSupportFeedbackRecipientVisibility); + } + + if (enableSupportLatestFeaturesToggle) { + enableSupportLatestFeaturesToggle.addEventListener('change', toggleSupportLatestFeaturesVisibility); + } + + toggleSupportMenuSettingsVisibility(); +} + + +function toggleSupportMenuSettingsVisibility() { + if (supportMenuSettingsDiv && enableSupportMenuToggle) { + supportMenuSettingsDiv.style.display = enableSupportMenuToggle.checked ? 'block' : 'none'; + } + + toggleSupportFeedbackRecipientVisibility(); + toggleSupportLatestFeaturesVisibility(); +} + + +function toggleSupportFeedbackRecipientVisibility() { + if (supportFeedbackRecipientGroup && enableSupportMenuToggle && enableSupportSendFeedbackToggle) { + supportFeedbackRecipientGroup.style.display = ( + enableSupportMenuToggle.checked && enableSupportSendFeedbackToggle.checked + ) ? 'block' : 'none'; + } +} + + +function toggleSupportLatestFeaturesVisibility() { + if (supportLatestFeaturesSettingsDiv && enableSupportMenuToggle && enableSupportLatestFeaturesToggle) { + supportLatestFeaturesSettingsDiv.style.display = ( + enableSupportMenuToggle.checked && enableSupportLatestFeaturesToggle.checked + ) ? 'block' : 'none'; + } +} + // --- *** NEW: External Links Functions *** --- /** @@ -3314,6 +3368,11 @@ function setStatusAlert(statusAlert, message, variant) { } +function updateSendFeedbackStatus(statusAlert, message, variant) { + setStatusAlert(statusAlert, message, variant); +} + + function clearStatusAlert(statusAlert) { if (!statusAlert) { return; diff --git a/application/single_app/static/js/support/latest_features.js b/application/single_app/static/js/support/latest_features.js new file mode 100644 index 00000000..aae17bfb --- /dev/null +++ b/application/single_app/static/js/support/latest_features.js @@ -0,0 +1,44 @@ +// latest_features.js + +function setupLatestFeatureImageModal() { + const modalElement = document.getElementById('latestFeatureImageModal'); + const modalImage = document.getElementById('latestFeatureImageModalImage'); + const modalTitle = document.getElementById('latestFeatureImageModalLabel'); + const modalCaption = document.getElementById('latestFeatureImageModalCaption'); + const imageTriggers = document.querySelectorAll('[data-latest-feature-image-src]'); + + if (!modalElement || !modalImage || !modalTitle || !modalCaption || imageTriggers.length === 0) { + return; + } + + const imageModal = bootstrap.Modal.getOrCreateInstance(modalElement); + + imageTriggers.forEach(trigger => { + trigger.addEventListener('click', () => { + const imageSrc = trigger.dataset.latestFeatureImageSrc; + const imageTitle = trigger.dataset.latestFeatureImageTitle || 'Latest Feature Preview'; + const imageCaption = trigger.dataset.latestFeatureImageCaption || 'Click outside the popup to close it.'; + const imageAlt = trigger.querySelector('img')?.getAttribute('alt') || imageTitle; + + if (!imageSrc) { + return; + } + + modalImage.src = imageSrc; + modalImage.alt = imageAlt; + modalTitle.textContent = imageTitle; + modalCaption.textContent = imageCaption; + imageModal.show(); + }); + }); + + modalElement.addEventListener('hidden.bs.modal', () => { + modalImage.src = ''; + modalImage.alt = 'Latest feature preview'; + }); +} + + +document.addEventListener('DOMContentLoaded', () => { + setupLatestFeatureImageModal(); +}); \ No newline at end of file diff --git a/application/single_app/static/js/support/support_feedback.js b/application/single_app/static/js/support/support_feedback.js new file mode 100644 index 00000000..f5dfbe2d --- /dev/null +++ b/application/single_app/static/js/support/support_feedback.js @@ -0,0 +1,138 @@ +// support_feedback.js +import { showToast } from "../chat/chat-toast.js"; + + +document.addEventListener('DOMContentLoaded', () => { + const feedbackForms = document.querySelectorAll('.support-send-feedback-form'); + feedbackForms.forEach(form => { + const submitButton = form.querySelector('.support-send-feedback-submit'); + if (!submitButton) { + return; + } + + submitButton.addEventListener('click', event => { + event.preventDefault(); + submitSupportFeedbackForm(form); + }); + }); +}); + + +async function submitSupportFeedbackForm(form) { + const feedbackType = form.dataset.feedbackType; + const feedbackLabel = form.dataset.feedbackLabel || 'Feedback'; + const nameInput = form.querySelector('[data-feedback-field="name"]'); + const emailInput = form.querySelector('[data-feedback-field="email"]'); + const organizationInput = form.querySelector('[data-feedback-field="organization"]'); + const detailsInput = form.querySelector('[data-feedback-field="details"]'); + const statusAlert = form.querySelector('.support-send-feedback-status'); + const submitButton = form.querySelector('.support-send-feedback-submit'); + + if (!nameInput || !emailInput || !organizationInput || !detailsInput) { + setStatusAlert(statusAlert, 'Unable to load the Send Feedback form fields.', 'danger'); + showToast('Unable to load the Send Feedback form fields.', 'danger'); + return; + } + + const reporterName = nameInput?.value.trim() || ''; + const reporterEmail = emailInput?.value.trim() || ''; + const organization = organizationInput?.value.trim() || ''; + const details = detailsInput?.value.trim() || ''; + + if (!reporterName || !reporterEmail || !organization || !details) { + setStatusAlert(statusAlert, 'Please complete name, email, organization, and details before opening the email draft.', 'danger'); + showToast('Please complete the Send Feedback form first.', 'warning'); + return; + } + + if (!reporterEmail.includes('@')) { + setStatusAlert(statusAlert, 'Please enter a valid email address.', 'danger'); + showToast('Please enter a valid email address.', 'warning'); + return; + } + + submitButton.disabled = true; + + try { + const response = await fetch('/api/support/send_feedback_email', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + credentials: 'same-origin', + body: JSON.stringify({ + feedbackType, + reporterName, + reporterEmail, + organization, + details + }) + }); + + const result = await response.json(); + if (!response.ok) { + throw new Error(result.error || 'Unable to prepare the feedback email draft.'); + } + + const mailtoUrl = buildSupportFeedbackMailtoUrl({ + recipientEmail: result.recipientEmail, + subjectLine: result.subjectLine, + feedbackLabel, + reporterName, + reporterEmail, + organization, + details + }); + + setStatusAlert( + statusAlert, + 'Email draft prepared. Your local email client should open next.', + 'success' + ); + showToast(`${feedbackLabel} email draft prepared.`, 'success'); + window.location.href = mailtoUrl; + } catch (error) { + setStatusAlert(statusAlert, error.message || 'Unable to prepare the feedback email draft.', 'danger'); + showToast(error.message || 'Unable to prepare the feedback email draft.', 'danger'); + } finally { + submitButton.disabled = false; + } +} + + +function buildSupportFeedbackMailtoUrl({ + recipientEmail, + subjectLine, + feedbackLabel, + reporterName, + reporterEmail, + organization, + details +}) { + const sendFeedbackPane = document.getElementById('support-send-feedback-pane'); + const appVersion = sendFeedbackPane?.dataset.appVersion || ''; + const bodyLines = [ + `Feedback Type: ${feedbackLabel}`, + `Name: ${reporterName}`, + `Email: ${reporterEmail}`, + `Organization: ${organization}`, + `App Version: ${appVersion || 'Unknown'}`, + '' + ]; + + bodyLines.push('Details:'); + bodyLines.push(details); + + return `mailto:${recipientEmail}?subject=${encodeURIComponent(subjectLine)}&body=${encodeURIComponent(bodyLines.join('\n'))}`; +} + + +function setStatusAlert(statusAlert, message, variant) { + if (!statusAlert) { + return; + } + + statusAlert.className = `alert alert-${variant} support-send-feedback-status`; + statusAlert.textContent = message; + statusAlert.classList.remove('d-none'); +} \ No newline at end of file diff --git a/application/single_app/support_menu_config.py b/application/single_app/support_menu_config.py new file mode 100644 index 00000000..f7fca0d2 --- /dev/null +++ b/application/single_app/support_menu_config.py @@ -0,0 +1,595 @@ +# support_menu_config.py +"""Shared support menu configuration for user-facing latest features.""" + +from copy import deepcopy + + +_SUPPORT_LATEST_FEATURE_CATALOG = [ + { + 'id': 'guided_tutorials', + 'title': 'Guided Tutorials', + 'icon': 'bi-signpost-split', + 'summary': 'Step-by-step walkthroughs help users discover core chat, workspace, and onboarding flows faster.', + 'details': 'Guided Tutorials add in-product walkthroughs so you can learn the interface in context instead of hunting through menus first.', + 'why': 'This matters because the fastest way to learn a new workflow is usually inside the workflow itself, with the right controls highlighted as you go.', + 'guidance': [ + 'Start with the Chat Tutorial to learn message tools, uploads, prompts, and follow-up workflows.', + 'If Personal Workspace is enabled for your environment, open the Workspace Tutorial to learn uploads, filters, tags, prompts, agents, and actions.', + ], + 'actions': [ + { + 'label': 'Open Chat Tutorial', + 'description': 'Jump to Chat and launch the guided walkthrough from the floating tutorial button.', + 'endpoint': 'chats', + 'fragment': 'chat-tutorial-launch', + 'icon': 'bi-chat-dots', + }, + { + 'label': 'Open Workspace Tutorial', + 'description': 'Jump to Personal Workspace and launch the workspace walkthrough when that workspace is enabled.', + 'endpoint': 'workspace', + 'fragment': 'workspace-tutorial-launch', + 'icon': 'bi-folder2-open', + 'requires_settings': ['enable_user_workspace'], + }, + ], + 'image': 'images/features/guided_tutorials_chat.png', + 'image_alt': 'Guided tutorials feature screenshot', + 'images': [ + { + 'path': 'images/features/guided_tutorials_chat.png', + 'alt': 'Guided chat tutorial screenshot', + 'title': 'Guided Chat Tutorial', + 'caption': 'Guided walkthrough entry point for the live chat experience.', + 'label': 'Chat Tutorial', + }, + { + 'path': 'images/features/guided_tutorials_workspace.png', + 'alt': 'Workspace guided tutorial screenshot', + 'title': 'Guided Workspace Tutorial', + 'caption': 'Walkthrough entry point for Personal Workspace uploads, filters, tools, and tags.', + 'label': 'Workspace Tutorial', + }, + ], + }, + { + 'id': 'background_chat', + 'title': 'Background Chat', + 'icon': 'bi-bell', + 'summary': 'Long-running chat requests can finish in the background while users continue working elsewhere in the app.', + 'details': 'Background Chat lets a long-running request keep working after you move away from the chat page.', + 'why': 'This matters most for larger uploads and heavier prompts, where waiting on one screen is wasted time and makes the app feel blocked.', + 'guidance': [ + 'Start the request from Chat the same way you normally would.', + 'If the request takes longer, you can keep using the app and come back when the completion notification appears.', + ], + 'actions': [ + { + 'label': 'Open Chat', + 'description': 'Start a prompt in Chat and let the app notify you when longer work finishes.', + 'endpoint': 'chats', + 'icon': 'bi-chat-dots', + }, + ], + 'image': 'images/features/background_completion_notifications-01.png', + 'image_alt': 'Background chat notification screenshot', + 'images': [ + { + 'path': 'images/features/background_completion_notifications-01.png', + 'alt': 'Background completion notification screenshot', + 'title': 'Background Completion Notification', + 'caption': 'Notification example showing that a chat response completed after the user moved away.', + 'label': 'Completion Notification', + }, + { + 'path': 'images/features/background_completion_notifications-02.png', + 'alt': 'Background completion deep link screenshot', + 'title': 'Notification Deep Link', + 'caption': 'Notification detail showing how users can jump back into the finished chat result.', + 'label': 'Return to Finished Chat', + }, + ], + }, + { + 'id': 'gpt_selection', + 'title': 'GPT Selection', + 'icon': 'bi-cpu', + 'summary': 'Teams can expose better model-selection options so users can choose the best experience for a task.', + 'details': 'GPT Selection gives users a clearer way to choose the model that best fits a task when multiple options are available.', + 'why': 'That matters because different prompts often need different tradeoffs in speed, cost, or reasoning depth.', + 'guidance': [ + 'Open Chat and look for the model picker in the composer toolbar.', + 'Try another model when you need faster output, stronger reasoning, or a different cost profile.', + ], + 'actions': [ + { + 'label': 'Open Chat Model Picker', + 'description': 'Go to Chat and jump to the model selector in the composer area.', + 'endpoint': 'chats', + 'fragment': 'model-select-container', + 'icon': 'bi-cpu', + }, + ], + 'image': 'images/features/model_selection_multi_endpoint_admin.png', + 'image_alt': 'Admin multi-endpoint model management screenshot', + 'images': [ + { + 'path': 'images/features/model_selection_multi_endpoint_admin.png', + 'alt': 'Admin multi-endpoint model management screenshot', + 'title': 'Admin Multi-Endpoint Model Management', + 'caption': 'Admin endpoint table showing configured Azure OpenAI and Foundry model endpoints.', + 'label': 'Admin Endpoint Table', + }, + { + 'path': 'images/features/model_selection_chat_selector.png', + 'alt': 'User chat model selector screenshot', + 'title': 'User Chat Model Selector', + 'caption': 'Chat composer model selector showing multiple available GPT choices.', + 'label': 'Chat Model Selector', + }, + ], + }, + { + 'id': 'tabular_analysis', + 'title': 'Tabular Analysis', + 'icon': 'bi-table', + 'summary': 'Spreadsheet and table workflows continue to improve for exploration, filtering, and grounded follow-up questions.', + 'details': 'Tabular Analysis improves how SimpleChat works with CSV and spreadsheet files for filtering, comparisons, and grounded follow-up questions.', + 'why': 'You get the most value after the file is uploaded, because the assistant can reason over the stored rows and columns instead of only whatever is pasted into one message.', + 'guidance': [ + 'Upload your CSV or XLSX to Personal Workspace if it is enabled, or add the file directly to Chat when you want a quicker one-off analysis.', + 'If you are updating an existing table, upload the newer file with the same name. You do not need to delete the previous version first.', + 'Ask follow-up questions after the upload so the assistant can stay grounded in the stored tabular data.', + ], + 'actions': [ + { + 'label': 'Upload in Personal Workspace', + 'description': 'Jump to the Personal Workspace upload area for a durable tabular file workflow.', + 'endpoint': 'workspace', + 'fragment': 'upload-area', + 'icon': 'bi-upload', + 'requires_settings': ['enable_user_workspace'], + }, + { + 'label': 'Upload a New Revision', + 'description': 'Jump to the same upload area and add the updated file with the same name to create a new revision.', + 'endpoint': 'workspace', + 'fragment': 'upload-area', + 'icon': 'bi-arrow-repeat', + 'requires_settings': ['enable_user_workspace'], + }, + { + 'label': 'Add a File to Chat', + 'description': 'Use Chat when you want to attach a spreadsheet directly to a conversation.', + 'endpoint': 'chats', + 'fragment': 'choose-file-btn', + 'icon': 'bi-paperclip', + }, + ], + 'image': 'images/features/tabular_analysis_enhanced_citations.png', + 'image_alt': 'Tabular analysis enhanced citations screenshot', + 'images': [ + { + 'path': 'images/features/tabular_analysis_enhanced_citations.png', + 'alt': 'Tabular analysis enhanced citations screenshot', + 'title': 'Tabular Analysis with Enhanced Citations', + 'caption': 'Tabular analysis preview showing the improved citation-backed experience for spreadsheet content.', + 'label': 'Tabular Analysis Preview', + }, + ], + }, + { + 'id': 'citation_improvements', + 'title': 'Citation Improvements', + 'icon': 'bi-journal-text', + 'summary': 'Enhanced citations give users better source traceability, document previews, and history-aware grounding.', + 'details': 'Citation Improvements help you see where answers came from and keep grounded evidence available across follow-up questions.', + 'why': 'That matters because better citation carry-forward means fewer follow-up turns lose context or force you to rebuild the same evidence chain from scratch.', + 'guidance': [ + 'Stay in the same conversation when you ask follow-up questions so the assistant can reuse the earlier grounded evidence.', + 'Open citations or previews when you want to inspect the supporting material behind an answer.', + ], + 'actions': [ + { + 'label': 'Open Chat for Follow-ups', + 'description': 'Ask a follow-up in Chat and review how citations stay available across turns.', + 'endpoint': 'chats', + 'fragment': 'chatbox', + 'icon': 'bi-chat-dots', + }, + ], + 'image': 'images/features/citation_improvements_history_replay.png', + 'image_alt': 'Conversation history citation replay screenshot', + 'images': [ + { + 'path': 'images/features/citation_improvements_history_replay.png', + 'alt': 'Conversation history citation replay screenshot', + 'title': 'Conversation History Citation Replay', + 'caption': 'Follow-up chat where prior citation summaries are replayed into the next turn\'s reasoning context.', + 'label': 'History Citation Replay', + }, + { + 'path': 'images/features/citation_improvements_amplified_results.png', + 'alt': 'Citation amplification details screenshot', + 'title': 'Citation Amplification Details', + 'caption': 'Expanded citation detail showing amplified supporting evidence and fuller artifact-backed results.', + 'label': 'Amplified Citation Detail', + }, + ], + }, + { + 'id': 'document_versioning', + 'title': 'Document Versioning', + 'icon': 'bi-files', + 'summary': 'Document revision visibility has improved so users can work with the right version of shared content.', + 'details': 'Document Versioning keeps same-name uploads organized as revisions so newer files become current without erasing the older record.', + 'why': 'That matters because ongoing chats and citations can stay tied to the right version while you continue updating the same document over time.', + 'guidance': [ + 'Upload the updated file with the same name to create a new current revision.', + 'You do not need to delete the older file first unless you no longer want to keep its history.', + 'Use the workspace document list to confirm which revision is current before you ask more questions about it.', + ], + 'actions': [ + { + 'label': 'Review Workspace Documents', + 'description': 'Open Personal Workspace and review the current document list for revision-aware uploads.', + 'endpoint': 'workspace', + 'fragment': 'documents-table', + 'icon': 'bi-files', + 'requires_settings': ['enable_user_workspace'], + }, + { + 'label': 'Upload an Updated Version', + 'description': 'Jump to the upload area and add the newer file with the same name to create a new revision.', + 'endpoint': 'workspace', + 'fragment': 'upload-area', + 'icon': 'bi-arrow-repeat', + 'requires_settings': ['enable_user_workspace'], + }, + ], + 'image': 'images/features/document_revision_workspace.png', + 'image_alt': 'Document revision workspace screenshot', + 'images': [ + { + 'path': 'images/features/document_revision_workspace.png', + 'alt': 'Document revision workspace screenshot', + 'title': 'Current Revision in Workspace', + 'caption': 'Workspace document list showing the current revision state for same-name uploads.', + 'label': 'Current Revision View', + }, + { + 'path': 'images/features/document_revision_delete_compare.png', + 'alt': 'Document revision actions and comparison screenshot', + 'title': 'Revision Actions and Comparison', + 'caption': 'Version-aware actions such as comparison, analysis of previous revisions, or current-versus-all-versions deletion choices.', + 'label': 'Revision Actions', + }, + ], + }, + { + 'id': 'summaries_export', + 'title': 'Summaries and Export', + 'icon': 'bi-file-earmark-arrow-down', + 'summary': 'Conversation summaries and export workflows continue to expand for reporting and follow-up sharing.', + 'details': 'Summaries and Export features make it easier to capture, reuse, and share the important parts of a chat session.', + 'why': 'This matters when a long chat needs a reusable summary, a PDF handoff, or per-message reuse in email, documents, or other downstream workflows.', + 'guidance': [ + 'Open an existing conversation when you want to generate or refresh a summary.', + 'Use export options when you need to share the full conversation or reuse a single message outside the app.', + ], + 'actions': [ + { + 'label': 'Open Chat History', + 'description': 'Go to Chat and open a conversation with enough content to summarize, export, or reuse.', + 'endpoint': 'chats', + 'fragment': 'chatbox', + 'icon': 'bi-file-earmark-arrow-down', + }, + ], + 'image': 'images/features/conversation_summary_card.png', + 'image_alt': 'Conversation summary card screenshot', + 'images': [ + { + 'path': 'images/features/conversation_summary_card.png', + 'alt': 'Conversation summary card screenshot', + 'title': 'Conversation Summary Card', + 'caption': 'Conversation summary panel preview in the chat experience.', + 'label': 'Summary Card', + }, + { + 'path': 'images/features/pdf_export_option.png', + 'alt': 'PDF export option screenshot', + 'title': 'PDF Export Option', + 'caption': 'PDF export entry in the conversation export workflow.', + 'label': 'PDF Export', + }, + { + 'path': 'images/features/per_message_export_menu.png', + 'alt': 'Per-message export menu screenshot', + 'title': 'Per-Message Export Menu', + 'caption': 'Expanded per-message export and reuse actions.', + 'label': 'Per-Message Actions', + }, + ], + }, + { + 'id': 'agent_operations', + 'title': 'Agent Operations', + 'icon': 'bi-grid', + 'summary': 'Agent creation, organization, and operational controls keep getting smoother for advanced scenarios.', + 'details': 'Agent Operations updates improve how teams browse, manage, and reason about reusable AI assistants and their connected actions.', + 'why': 'That matters because advanced agent workflows are only useful when users can find the right assistant quickly and trust the connected tools behind it.', + 'guidance': [ + 'Open Personal Workspace if your environment exposes per-user agents and actions.', + 'Use list or grid views to browse agents based on whether you want denser detail or quicker scanning.', + ], + 'actions': [ + { + 'label': 'Open Personal Workspace', + 'description': 'Jump to Personal Workspace, then switch to the Agents tab if agents are enabled in your environment.', + 'endpoint': 'workspace', + 'icon': 'bi-grid', + 'requires_settings': ['enable_user_workspace', 'enable_semantic_kernel', 'per_user_semantic_kernel'], + }, + ], + 'image': 'images/features/agent_action_grid_view.png', + 'image_alt': 'Agent and action grid view screenshot', + 'images': [ + { + 'path': 'images/features/agent_action_grid_view.png', + 'alt': 'Agent and action grid view screenshot', + 'title': 'Agent and Action Grid View', + 'caption': 'Grid browsing experience for agents and actions.', + 'label': 'Grid View', + }, + { + 'path': 'images/features/sql_test_connection.png', + 'alt': 'SQL test connection screenshot', + 'title': 'SQL Test Connection', + 'caption': 'Inline SQL connection test preview before save.', + 'label': 'SQL Test Connection', + }, + ], + }, + { + 'id': 'ai_transparency', + 'title': 'AI Transparency', + 'icon': 'bi-stars', + 'summary': 'Thought and reasoning transparency options help users better understand what the assistant is doing.', + 'details': 'AI Transparency adds clearer visibility into the assistant\'s in-flight work when your team chooses to expose it.', + 'why': 'This helps the app feel less opaque during longer responses because you can see progress instead of guessing whether the request stalled.', + 'guidance': [ + 'Look for Processing Thoughts while a response is being generated in Chat.', + 'If you do not see them, your admins may have kept this feature turned off for your environment.', + ], + 'actions': [ + { + 'label': 'Open Chat', + 'description': 'Go to Chat and watch for processing-state visibility while a response is generated.', + 'endpoint': 'chats', + 'fragment': 'chatbox', + 'icon': 'bi-stars', + }, + ], + 'image': 'images/features/thoughts_visibility.png', + 'image_alt': 'Processing thoughts visibility screenshot', + 'images': [ + { + 'path': 'images/features/thoughts_visibility.png', + 'alt': 'Processing thoughts visibility screenshot', + 'title': 'Processing Thoughts Visibility', + 'caption': 'Processing thoughts state and timing details preview.', + 'label': 'Processing Thoughts', + }, + ], + }, + { + 'id': 'deployment', + 'title': 'Deployment', + 'icon': 'bi-hdd-rack', + 'summary': 'Deployment guidance and diagnostics keep improving so admins can roll out changes with less guesswork.', + 'details': 'Deployment updates focus on making configuration, startup validation, and operational guidance easier for admins to follow.', + 'why': 'For users, this usually shows up as a more stable rollout of new capabilities rather than a brand-new button on the page.', + 'guidance': [ + 'This is mainly an operational improvement managed by your admins.', + 'If a newly announced feature is not visible yet, your environment may still be rolling forward to the latest configuration.', + ], + 'actions': [], + 'image': 'images/features/gunicorn_startup_guidance.png', + 'image_alt': 'Deployment guidance screenshot', + 'images': [ + { + 'path': 'images/features/gunicorn_startup_guidance.png', + 'alt': 'Deployment guidance screenshot', + 'title': 'Deployment Startup Guidance', + 'caption': 'Startup guidance that helps admins configure the app runtime more predictably.', + 'label': 'Deployment Guidance', + }, + ], + }, + { + 'id': 'redis_key_vault', + 'title': 'Redis and Key Vault', + 'icon': 'bi-key', + 'summary': 'Caching and secret-management setup guidance has expanded for more secure and predictable operations.', + 'details': 'Redis and Key Vault improvements make it easier for teams to configure caching and secret storage patterns correctly.', + 'why': 'For users, the practical outcome is usually reliability and performance, with fewer environment-level issues caused by secret or cache misconfiguration.', + 'guidance': [ + 'This is another behind-the-scenes improvement mostly managed by your admins.', + 'You may notice it indirectly through smoother repeated access patterns or fewer environment issues.', + ], + 'actions': [], + 'image': 'images/features/redis_key_vault.png', + 'image_alt': 'Redis and Key Vault screenshot', + 'images': [ + { + 'path': 'images/features/redis_key_vault.png', + 'alt': 'Redis and Key Vault screenshot', + 'title': 'Redis Key Vault Configuration', + 'caption': 'Redis authentication with Key Vault secret name preview.', + 'label': 'Redis Key Vault', + }, + ], + }, + { + 'id': 'send_feedback', + 'title': 'Send Feedback', + 'icon': 'bi-envelope-paper', + 'summary': 'End users can prepare bug reports and feature requests for their SimpleChat admins directly from the Support menu.', + 'details': 'Send Feedback opens a guided, text-only email draft workflow so you can report issues or request improvements without leaving the app.', + 'why': 'That gives your admins a cleaner starting point for triage than a vague message without context or reproduction details.', + 'guidance': [ + 'Choose Bug Report when something is broken, confusing, or behaving differently than you expected.', + 'Choose Feature Request when you want a new workflow, capability, or quality-of-life improvement.', + 'Your draft is addressed to the internal support recipient configured by your admins.', + ], + 'actions': [ + { + 'label': 'Open Send Feedback', + 'description': 'Go straight to the Support feedback page and prepare a structured email draft.', + 'endpoint': 'support_send_feedback', + 'icon': 'bi-envelope-paper', + 'requires_settings': ['enable_support_send_feedback'], + }, + ], + 'image': 'images/features/support_menu_entry.png', + 'image_alt': 'Support menu entry showing Send Feedback access', + 'images': [ + { + 'path': 'images/features/support_menu_entry.png', + 'alt': 'Support menu entry screenshot', + 'title': 'Send Feedback Entry Point', + 'caption': 'Support menu entry showing where Send Feedback lives for end users.', + 'label': 'Support Entry Point', + }, + ], + }, + { + 'id': 'support_menu', + 'title': 'Support Menu', + 'icon': 'bi-life-preserver', + 'summary': 'Admins can surface a dedicated Support menu in navigation with Latest Features and Send Feedback entries for end users.', + 'details': 'Support Menu configuration lets admins rename the menu, choose the internal feedback recipient, and decide which user-facing release notes are shared.', + 'why': 'That matters because new capabilities are easier to discover when help, feature announcements, and feedback all live in one predictable place.', + 'guidance': [ + 'Use Latest Features when you want a curated explanation of what changed and why it matters.', + 'Use Send Feedback when you want to tell your admins what is missing, confusing, or especially helpful.', + ], + 'actions': [ + { + 'label': 'Browse Latest Features', + 'description': 'Refresh this page later when you want to review other recently shared updates.', + 'endpoint': 'support_latest_features', + 'icon': 'bi-life-preserver', + }, + { + 'label': 'Open Send Feedback', + 'description': 'Go from Support directly into the structured feedback workflow when that destination is enabled.', + 'endpoint': 'support_send_feedback', + 'icon': 'bi-envelope-paper', + 'requires_settings': ['enable_support_send_feedback'], + }, + ], + 'image': 'images/features/support_menu_entry.png', + 'image_alt': 'Support menu entry screenshot', + 'images': [ + { + 'path': 'images/features/support_menu_entry.png', + 'alt': 'Support menu entry screenshot', + 'title': 'User Support Menu Entry', + 'caption': 'User-facing Support menu entry exposing Latest Features and Send Feedback.', + 'label': 'Support Menu Entry', + }, + ], + }, +] + + +def _setting_enabled(settings, key): + """Return True when the named setting is enabled.""" + value = (settings or {}).get(key, False) + if isinstance(value, str): + return value.strip().lower() == 'true' + return bool(value) + + +def _action_enabled(action, settings): + """Return True when an action should be exposed for the current settings.""" + required_settings = action.get('requires_settings', []) + return all(_setting_enabled(settings, setting_key) for setting_key in required_settings) + + +def _normalize_feature_media(feature): + """Ensure every visible feature exposes at least one image entry for the template.""" + images = feature.get('images') or [] + if images: + if not feature.get('image'): + feature['image'] = images[0].get('path') + feature['image_alt'] = images[0].get('alt', '') + return + + image_path = feature.get('image') + if not image_path: + return + + feature['images'] = [ + { + 'path': image_path, + 'alt': feature.get('image_alt') or f"{feature.get('title', 'Feature')} screenshot", + 'title': feature.get('title', 'Feature Preview'), + 'caption': feature.get('summary', ''), + 'label': feature.get('title', 'Preview'), + } + ] + + +def get_support_latest_feature_catalog(): + """Return a copy of the support latest-features catalog.""" + return deepcopy(_SUPPORT_LATEST_FEATURE_CATALOG) + + +def get_default_support_latest_features_visibility(): + """Return default visibility for each user-facing latest feature.""" + return {item['id']: True for item in _SUPPORT_LATEST_FEATURE_CATALOG} + + +def normalize_support_latest_features_visibility(raw_visibility): + """Normalize persisted latest-feature visibility to the current catalog.""" + defaults = get_default_support_latest_features_visibility() + if not isinstance(raw_visibility, dict): + return defaults + + normalized = defaults.copy() + for feature_id in defaults: + if feature_id in raw_visibility: + normalized[feature_id] = bool(raw_visibility.get(feature_id)) + + return normalized + + +def get_visible_support_latest_features(settings): + """Return the subset of latest-feature entries enabled for end users.""" + normalized_visibility = normalize_support_latest_features_visibility( + (settings or {}).get('support_latest_features_visibility', {}) + ) + visible_items = [] + + for item in _SUPPORT_LATEST_FEATURE_CATALOG: + if normalized_visibility.get(item['id'], True): + visible_item = deepcopy(item) + visible_item['actions'] = [ + action for action in visible_item.get('actions', []) + if _action_enabled(action, settings) + ] + _normalize_feature_media(visible_item) + visible_items.append(visible_item) + + return visible_items + + +def has_visible_support_latest_features(settings): + """Return True when at least one latest-feature entry is enabled for users.""" + normalized_visibility = normalize_support_latest_features_visibility( + (settings or {}).get('support_latest_features_visibility', {}) + ) + return any(normalized_visibility.values()) \ No newline at end of file diff --git a/application/single_app/templates/_sidebar_nav.html b/application/single_app/templates/_sidebar_nav.html index dd1265d2..5e5a8b77 100644 --- a/application/single_app/templates/_sidebar_nav.html +++ b/application/single_app/templates/_sidebar_nav.html @@ -168,6 +168,42 @@ {% endif %} + + {% set support_menu_role_allowed = session.get('user') and session['user'].get('roles') and ('Admin' in session['user']['roles'] or 'User' in session['user']['roles']) %} + {% if support_menu_role_allowed and app_settings.enable_support_menu %} + {% set show_support_latest_features = app_settings.enable_support_latest_features and app_settings.support_latest_features_has_visible_items %} + {% set show_support_send_feedback = app_settings.enable_support_send_feedback and app_settings.support_feedback_recipient_configured %} + {% if show_support_latest_features or show_support_send_feedback %} +
Prepare a support email for your SimpleChat administrators. This opens a text-only draft in your local mail client.
+Share what happened, what you expected, and how your admins can reproduce the problem.
+Describe the improvement you want, why it matters, and the outcome you need.
+