diff --git a/application/single_app/Dockerfile b/application/single_app/Dockerfile index 1f98f6da..7d3d79c9 100644 --- a/application/single_app/Dockerfile +++ b/application/single_app/Dockerfile @@ -2,12 +2,14 @@ ARG UID=65532 ARG GID=65532 ARG INSTALL_PLAYWRIGHT_CHROMIUM=true +ARG INSTALL_AUDIO_FFMPEG=true FROM mcr.microsoft.com/azurelinux/base/python:3.12 AS builder ARG UID ARG GID ARG INSTALL_PLAYWRIGHT_CHROMIUM +ARG INSTALL_AUDIO_FFMPEG # Copy pip.conf into the image for pip configuration COPY docker-customization/pip.conf /etc/pip.conf @@ -142,6 +144,17 @@ WORKDIR /app COPY --chown=${UID}:${GID} application/single_app/requirements.txt . RUN python3 -m pip install --no-cache-dir -r requirements.txt +RUN set -eux; \ + mkdir -p /audio-runtime/usr/bin; \ + case "$(printf '%s' "${INSTALL_AUDIO_FFMPEG}" | tr '[:upper:]' '[:lower:]')" in \ + true|1|yes|on) \ + python3 -c "import pathlib, shutil, ffmpeg_binaries as ffmpeg_bin; ffmpeg_bin.init(); target_dir = pathlib.Path('/audio-runtime/usr/bin'); shutil.copy2(ffmpeg_bin.FFMPEG_PATH, target_dir / 'ffmpeg'); shutil.copy2(ffmpeg_bin.FFPROBE_PATH, target_dir / 'ffprobe'); (target_dir / 'ffmpeg').chmod(0o755); (target_dir / 'ffprobe').chmod(0o755)"; \ + ;; \ + *) \ + echo "Skipping FFmpeg runtime package installation because INSTALL_AUDIO_FFMPEG=${INSTALL_AUDIO_FFMPEG}"; \ + ;; \ + esac + RUN set -eux; \ mkdir -p /playwright-runtime; \ case "$(printf '%s' "${INSTALL_PLAYWRIGHT_CHROMIUM}" | tr '[:upper:]' '[:lower:]')" in \ @@ -173,6 +186,7 @@ COPY --from=builder /etc/group /etc/group COPY --from=builder /usr/lib/python3.12 /usr/lib/python3.12 COPY --from=builder /odbc-runtime/ / COPY --from=builder /playwright-runtime/ / +COPY --from=builder /audio-runtime/ / USER ${UID}:${GID} @@ -181,7 +195,7 @@ COPY --from=builder --chown=${UID}:${GID} /sc-temp-files /sc-temp-files ENV HOME=/home/nonroot \ LD_LIBRARY_PATH="/usr/lib64:/opt/microsoft/msodbcsql18/lib64" \ - PATH="/home/nonroot/.local/bin:$PATH" \ + PATH="/usr/bin:/home/nonroot/.local/bin:$PATH" \ PYTHONIOENCODING=utf-8 \ LANG=C.UTF-8 \ LC_ALL=C.UTF-8 \ diff --git a/application/single_app/config.py b/application/single_app/config.py index 66c513ae..6e6c879e 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.008" +VERSION = "0.250.014" SESSION_COOKIE_SAMESITE = os.getenv('SESSION_COOKIE_SAMESITE', 'Lax') SESSION_COOKIE_HTTPONLY = os.getenv('SESSION_COOKIE_HTTPONLY', 'true').lower() != 'false' @@ -156,7 +156,12 @@ def _split_origin_list(raw_value): 'mpg', 'wmv', 'asf', 'm4v', 'isma', 'ismv', 'dvr-ms', 'webm', 'mpeg' } -AUDIO_EXTENSIONS = {'mp3', 'wav', 'ogg', 'aac', 'flac', 'm4a'} +AUDIO_EXTENSIONS = { + '3ga', 'aac', 'ac3', 'aif', 'aifc', 'aiff', 'amr', 'ape', 'au', 'caf', + 'dts', 'f4a', 'flac', 'm4a', 'm4b', 'm4r', 'mka', 'mp2', 'mp3', 'mpa', + 'oga', 'ogg', 'opus', 'spx', 'wav', 'weba', 'wma', 'wv' +} +AUDIO_FAST_TRANSCRIPTION_SOURCE_EXTENSIONS = {'aac', 'flac', 'm4a', 'mp3', 'ogg', 'wav'} def get_allowed_extensions(enable_video=False, enable_audio=False): """ @@ -183,6 +188,49 @@ def get_allowed_extensions(enable_video=False, enable_audio=False): return extensions +def get_allowed_extension_categories(enable_video=False, enable_audio=False): + """ + Get allowed file extensions grouped for display in workspace upload dialogs. + """ + categories = [ + { + 'name': 'Documents and presentations', + 'extensions': BASE_ALLOWED_EXTENSIONS | DOCUMENT_EXTENSIONS, + }, + { + 'name': 'Tables and data', + 'extensions': TABULAR_EXTENSIONS, + }, + { + 'name': 'Images', + 'extensions': IMAGE_EXTENSIONS, + }, + { + 'name': 'Email and diagrams', + 'extensions': EMAIL_EXTENSIONS | VISIO_EXTENSIONS, + }, + ] + + if enable_audio: + categories.append({ + 'name': 'Audio', + 'extensions': AUDIO_EXTENSIONS, + }) + + if enable_video: + categories.append({ + 'name': 'Video', + 'extensions': VIDEO_EXTENSIONS, + }) + + return [ + { + 'name': category['name'], + 'extensions': sorted(category['extensions']), + } + for category in categories + ] + ALLOWED_EXTENSIONS = get_allowed_extensions(enable_video=True, enable_audio=True) # Admin UI specific extensions (for logo/favicon uploads) diff --git a/application/single_app/functions_documents.py b/application/single_app/functions_documents.py index 153105a6..3cb0ce3e 100644 --- a/application/single_app/functions_documents.py +++ b/application/single_app/functions_documents.py @@ -2,6 +2,7 @@ import re import shutil +import subprocess import traceback import zipfile from io import BytesIO @@ -19,6 +20,8 @@ from functions_model_endpoint_runtime import MODEL_ENDPOINT_PROVIDER_ALLOWLIST, build_model_endpoint_sync_chat_client import azure.cognitiveservices.speech as speechsdk +_AUDIO_RUNTIME_CAPABILITIES_CACHE = None + def allowed_file(filename, allowed_extensions=None): if not allowed_extensions: allowed_extensions = ALLOWED_EXTENSIONS @@ -7345,13 +7348,90 @@ def update_doc_callback(**kwargs): def _get_content_type(path: str) -> str: ext = os.path.splitext(path)[1].lower() mapping = { - '.wav': 'audio/wav', - '.mp3': 'audio/mpeg', + '.3ga': 'audio/3gpp', + '.aac': 'audio/aac', + '.ac3': 'audio/ac3', + '.aif': 'audio/aiff', + '.aifc': 'audio/aiff', + '.aiff': 'audio/aiff', + '.amr': 'audio/amr', + '.ape': 'audio/x-ape', + '.au': 'audio/basic', + '.caf': 'audio/x-caf', + '.dts': 'audio/vnd.dts', + '.f4a': 'audio/mp4', + '.flac': 'audio/flac', '.m4a': 'audio/mp4', - '.mp4': 'audio/mp4' + '.m4b': 'audio/mp4', + '.m4r': 'audio/mp4', + '.mka': 'audio/x-matroska', + '.mp2': 'audio/mpeg', + '.mp3': 'audio/mpeg', + '.mpa': 'audio/mpeg', + '.mp4': 'audio/mp4', + '.oga': 'audio/ogg', + '.ogg': 'audio/ogg', + '.opus': 'audio/opus', + '.spx': 'audio/ogg', + '.wav': 'audio/wav', + '.weba': 'audio/webm', + '.wma': 'audio/x-ms-wma', + '.wv': 'audio/x-wavpack' } return mapping.get(ext, 'application/octet-stream') + +def get_audio_runtime_capabilities(force_refresh: bool = False): + """Return cached runtime support details for audio upload transcoding.""" + global _AUDIO_RUNTIME_CAPABILITIES_CACHE + if _AUDIO_RUNTIME_CAPABILITIES_CACHE is not None and not force_refresh: + return dict(_AUDIO_RUNTIME_CAPABILITIES_CACHE) + + supported_extensions = sorted(f'.{extension}' for extension in AUDIO_EXTENSIONS) + source_extensions = sorted( + f'.{extension}' + for extension in AUDIO_FAST_TRANSCRIPTION_SOURCE_EXTENSIONS + if extension in AUDIO_EXTENSIONS + ) + ffmpeg_path = shutil.which('ffmpeg') or '' + ffprobe_path = shutil.which('ffprobe') or '' + + capabilities = { + 'ffmpeg_available': False, + 'ffprobe_available': bool(ffprobe_path), + 'broad_transcoding_available': False, + 'ffmpeg_path': ffmpeg_path, + 'ffprobe_path': ffprobe_path, + 'ffmpeg_version': '', + 'supported_extensions': supported_extensions, + 'direct_transcription_extensions': source_extensions, + 'recommended_container_packages': ['ffmpeg', 'ffprobe'], + 'message': 'FFmpeg was not found in this app runtime; audio uploads use Azure Speech source-file fallback only.', + } + + if ffmpeg_path: + capabilities['ffmpeg_available'] = True + try: + ffmpeg_result = subprocess.run( + [ffmpeg_path, '-hide_banner', '-version'], + capture_output=True, + check=True, + text=True, + timeout=5, + ) + version_line = (ffmpeg_result.stdout or '').splitlines()[0] if ffmpeg_result.stdout else '' + capabilities.update({ + 'broad_transcoding_available': True, + 'ffmpeg_version': version_line, + 'message': 'FFmpeg runtime detected; broad audio transcoding is available before Azure Speech transcription.', + }) + except Exception as runtime_error: + capabilities['message'] = f"FFmpeg was found, but runtime validation failed: {str(runtime_error)[:220]}" + + _AUDIO_RUNTIME_CAPABILITIES_CACHE = capabilities + return dict(capabilities) + + def _split_audio_file(input_path: str, chunk_seconds: int = 540) -> List[str]: """ Splits `input_path` into WAV segments of length `chunk_seconds` seconds, @@ -7373,7 +7453,8 @@ def _split_audio_file(input_path: str, chunk_seconds: int = 540) -> List[str]: f='segment', segment_time=chunk_seconds, reset_timestamps=1, - map='0' + map='0:a:0', + ac='1' ) .run(quiet=True, overwrite_output=True) ) @@ -7388,6 +7469,45 @@ def _split_audio_file(input_path: str, chunk_seconds: int = 540) -> List[str]: print(f"Produced {len(chunks)} WAV chunks: {chunks}") return chunks + +def _is_missing_ffmpeg_error(error) -> bool: + error_text = str(error or '').lower() + missing_binary_markers = ( + 'no such file or directory', + 'the system cannot find the file specified', + 'cannot find the file specified', + 'not recognized as an internal or external command', + ) + return 'ffmpeg' in error_text and any(marker in error_text for marker in missing_binary_markers) + + +def _transcribe_audio_with_fast_api(audio_path, upload_filename, content_type, settings, endpoint, locale): + url = f"{endpoint}/speechtotext/transcriptions:transcribe?api-version=2024-11-15" + with open(audio_path, 'rb') as audio_f: + files = { + 'audio': (upload_filename, audio_f, content_type), + 'definition': (None, json.dumps({'locales': [locale]}), 'application/json') + } + if settings.get("speech_service_authentication_type") == "managed_identity": + credential = DefaultAzureCredential() + token = credential.get_token(cognitive_services_scope) + headers = {'Authorization': f'Bearer {token.token}'} + else: + key = settings.get("speech_service_key", "") + headers = {'Ocp-Apim-Subscription-Key': key} + + resp = requests.post(url, headers=headers, files=files) + try: + resp.raise_for_status() + except Exception as e: + print(f"[Error] HTTP error for {audio_path}: {e}") + raise + + result = resp.json() + phrases = result.get('combinedPhrases', []) + print(f"[Debug] Received {len(phrases)} phrases") + return [p.get('text', '').strip() for p in phrases if p.get('text')] + # Azure Speech SDK helper to get speech config with fresh token def _get_speech_config(settings, endpoint: str, locale: str): """Get speech config with fresh token""" @@ -7467,15 +7587,27 @@ def process_audio_document( if file_size > 300 * 1024 * 1024: raise ValueError("Audio exceeds 300 MB limit.") - # 2) split to WAV chunks - update_callback(status="Preparing audio for transcription…") - chunk_paths = _split_audio_file(temp_file_path, chunk_seconds=540) - - # 3) transcribe each WAV chunk + # 2) prepare speech configuration settings = get_settings() endpoint = settings.get("speech_service_endpoint", "").rstrip('/') locale = settings.get("speech_service_locale", "en-US") + # 3) split to WAV chunks unless fast transcription can use the source file directly + update_callback(status="Preparing audio for transcription…") + chunk_paths = [] + use_source_audio_for_fast_api = False + try: + chunk_paths = _split_audio_file(temp_file_path, chunk_seconds=540) + except RuntimeError as split_error: + if AZURE_ENVIRONMENT not in ("usgovernment", "custom") and _is_missing_ffmpeg_error(split_error): + use_source_audio_for_fast_api = True + print( + "[Warning] FFmpeg executable unavailable; using source audio with Azure Speech fast transcription API." + ) + else: + raise + + # 4) transcribe audio all_phrases: List[str] = [] # Fast Transcription API not yet available in sovereign clouds, so use SDK @@ -7619,37 +7751,31 @@ def canceled_cb(evt): else: # Use the fast-transcription API if not in sovereign or custom cloud - url = f"{endpoint}/speechtotext/transcriptions:transcribe?api-version=2024-11-15" - for idx, chunk_path in enumerate(chunk_paths, start=1): - update_callback(current_file_chunk=idx, status=f"Transcribing chunk {idx}/{len(chunk_paths)}…") - print(f"[Debug] Transcribing WAV chunk: {chunk_path}") - - with open(chunk_path, 'rb') as audio_f: - files = { - 'audio': (os.path.basename(chunk_path), audio_f, 'audio/wav'), - 'definition': (None, json.dumps({'locales':[locale]}), 'application/json') - } - if settings.get("speech_service_authentication_type") == "managed_identity": - credential = DefaultAzureCredential() - token = credential.get_token(cognitive_services_scope) - headers = {'Authorization': f'Bearer {token.token}'} - else: - key = settings.get("speech_service_key", "") - headers = {'Ocp-Apim-Subscription-Key': key} - - resp = requests.post(url, headers=headers, files=files) - try: - resp.raise_for_status() - except Exception as e: - print(f"[Error] HTTP error for {chunk_path}: {e}") - raise - - result = resp.json() - phrases = result.get('combinedPhrases', []) - print(f"[Debug] Received {len(phrases)} phrases") - all_phrases += [p.get('text','').strip() for p in phrases if p.get('text')] + if use_source_audio_for_fast_api: + update_callback(current_file_chunk=1, status="Transcribing audio with Azure Speech…") + print(f"[Debug] Transcribing source audio: {temp_file_path}") + all_phrases += _transcribe_audio_with_fast_api( + temp_file_path, + original_filename, + _get_content_type(original_filename or temp_file_path), + settings, + endpoint, + locale + ) + else: + for idx, chunk_path in enumerate(chunk_paths, start=1): + update_callback(current_file_chunk=idx, status=f"Transcribing chunk {idx}/{len(chunk_paths)}…") + print(f"[Debug] Transcribing WAV chunk: {chunk_path}") + all_phrases += _transcribe_audio_with_fast_api( + chunk_path, + os.path.basename(chunk_path), + 'audio/wav', + settings, + endpoint, + locale + ) - # 4) cleanup WAV chunks + # 5) cleanup WAV chunks for p in chunk_paths: try: os.remove(p) @@ -7657,7 +7783,7 @@ def canceled_cb(evt): except Exception as e: print(f"[Warning] Could not remove chunk {p}: {e}") - # 5) stitch and save transcript chunks + # 6) stitch and save transcript chunks full_text = ' '.join(all_phrases).strip() words = full_text.split() chunk_settings = get_chunk_size_config(settings) diff --git a/application/single_app/route_frontend_admin_settings.py b/application/single_app/route_frontend_admin_settings.py index 0f88ad08..d324a552 100644 --- a/application/single_app/route_frontend_admin_settings.py +++ b/application/single_app/route_frontend_admin_settings.py @@ -541,6 +541,7 @@ def admin_settings(): user_settings = get_user_settings(user_id) settings_for_template = dict(settings) settings_for_template['model_endpoints'] = frontend_model_endpoints + audio_runtime_capabilities = get_audio_runtime_capabilities() source_review_runtime_capabilities = get_source_review_runtime_capabilities() settings_for_template['source_review_allow_js_rendering'] = normalize_source_review_js_rendering_enabled( settings_for_template.get('source_review_allow_js_rendering'), @@ -567,6 +568,7 @@ def admin_settings(): chunk_size_settings=settings.get('chunk_size', {}), chunk_size_cap=get_chunk_size_cap(settings), chunk_size_effective=get_chunk_size_config(settings), + audio_runtime_capabilities=audio_runtime_capabilities, source_review_runtime_capabilities=source_review_runtime_capabilities # You don't need to pass deployments separately if they are added to settings['..._model']['all'] # gpt_deployments=gpt_deployments, diff --git a/application/single_app/route_frontend_group_workspaces.py b/application/single_app/route_frontend_group_workspaces.py index aedc2d76..410183f1 100644 --- a/application/single_app/route_frontend_group_workspaces.py +++ b/application/single_app/route_frontend_group_workspaces.py @@ -58,12 +58,12 @@ def group_workspaces(): ) legacy_count = legacy_docs_from_cosmos[0] if legacy_docs_from_cosmos else 0 - # Get allowed extensions from central function and build allowed extensions string - allowed_extensions = sorted(get_allowed_extensions( - enable_video=enable_video_file_support in [True, 'True', 'true'], - enable_audio=enable_audio_file_support in [True, 'True', 'true'] - )) - allowed_extensions_str = "Allowed: " + ", ".join(allowed_extensions) + enable_video_uploads = enable_video_file_support in [True, 'True', 'true'] + enable_audio_uploads = enable_audio_file_support in [True, 'True', 'true'] + allowed_extension_categories = get_allowed_extension_categories( + enable_video=enable_video_uploads, + enable_audio=enable_audio_uploads + ) workspace_governance = { "group_agents": is_governance_access_allowed("governance_group_agents", user_id), @@ -89,7 +89,7 @@ def group_workspaces(): enable_audio_file_support=enable_audio_file_support, enable_file_sharing=enable_file_sharing, legacy_docs_count=legacy_count, - allowed_extensions=allowed_extensions_str, + allowed_extension_categories=allowed_extension_categories, group_model_endpoints=group_model_endpoints, global_model_endpoints=global_model_endpoints, file_sync_enabled=file_sync_enabled, diff --git a/application/single_app/route_frontend_public_workspaces.py b/application/single_app/route_frontend_public_workspaces.py index d6f9ea36..2c7b69ad 100644 --- a/application/single_app/route_frontend_public_workspaces.py +++ b/application/single_app/route_frontend_public_workspaces.py @@ -58,12 +58,12 @@ def public_workspaces(): enable_video_file_support = settings.get('enable_video_file_support', False) enable_audio_file_support = settings.get('enable_audio_file_support', False) - # Get allowed extensions from central function and build allowed extensions string - allowed_extensions = sorted(get_allowed_extensions( - enable_video=enable_video_file_support in [True, 'True', 'true'], - enable_audio=enable_audio_file_support in [True, 'True', 'true'] - )) - allowed_extensions_str = "Allowed: " + ", ".join(allowed_extensions) + enable_video_uploads = enable_video_file_support in [True, 'True', 'true'] + enable_audio_uploads = enable_audio_file_support in [True, 'True', 'true'] + allowed_extension_categories = get_allowed_extension_categories( + enable_video=enable_video_uploads, + enable_audio=enable_audio_uploads + ) return render_template( 'public_workspaces.html', @@ -73,7 +73,7 @@ def public_workspaces(): enable_extract_meta_data=enable_extract_meta_data, enable_video_file_support=enable_video_file_support, enable_audio_file_support=enable_audio_file_support, - allowed_extensions=allowed_extensions_str + allowed_extension_categories=allowed_extension_categories ) @bp.route("/public_directory", methods=["GET"]) diff --git a/application/single_app/route_frontend_workspace.py b/application/single_app/route_frontend_workspace.py index 84bb9342..62008107 100644 --- a/application/single_app/route_frontend_workspace.py +++ b/application/single_app/route_frontend_workspace.py @@ -62,12 +62,12 @@ def workspace(): ) legacy_count = legacy_docs_from_cosmos[0] if legacy_docs_from_cosmos else 0 - # Get allowed extensions from central function and build allowed extensions string - allowed_extensions = sorted(get_allowed_extensions( - enable_video=enable_video_file_support in [True, 'True', 'true'], - enable_audio=enable_audio_file_support in [True, 'True', 'true'] - )) - allowed_extensions_str = "Allowed: " + ", ".join(allowed_extensions) + enable_video_uploads = enable_video_file_support in [True, 'True', 'true'] + enable_audio_uploads = enable_audio_file_support in [True, 'True', 'true'] + allowed_extension_categories = get_allowed_extension_categories( + enable_video=enable_video_uploads, + enable_audio=enable_audio_uploads + ) workspace_governance = { "user_agents": is_governance_access_allowed("governance_user_agents", user_id), @@ -128,7 +128,7 @@ def workspace(): enable_audio_file_support=enable_audio_file_support, enable_file_sharing=enable_file_sharing, legacy_docs_count=legacy_count, - allowed_extensions=allowed_extensions_str, + allowed_extension_categories=allowed_extension_categories, personal_model_endpoints=personal_model_endpoints, global_model_endpoints=global_model_endpoints, file_sync_enabled=file_sync_enabled, diff --git a/application/single_app/static/css/workspace-responsive.css b/application/single_app/static/css/workspace-responsive.css index 3b45648a..a853844e 100644 --- a/application/single_app/static/css/workspace-responsive.css +++ b/application/single_app/static/css/workspace-responsive.css @@ -51,6 +51,26 @@ white-space: nowrap; } +.workspace-upload-area { + min-height: 6.5rem; +} + +.workspace-upload-area .bi-cloud-arrow-up { + font-size: 2rem; + line-height: 1; +} + +.workspace-upload-supported-types-trigger { + position: relative; + z-index: 2; +} + +.supported-file-type-list { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} + .document-item-card { border-color: rgba(15, 23, 42, 0.08); cursor: pointer; diff --git a/application/single_app/static/js/agent_modal_stepper.js b/application/single_app/static/js/agent_modal_stepper.js index 8880d2e5..aece466d 100644 --- a/application/single_app/static/js/agent_modal_stepper.js +++ b/application/single_app/static/js/agent_modal_stepper.js @@ -343,6 +343,9 @@ export class AgentModalStepper { // Set up display name to generated name conversion this.setupNameGeneration(); + + // Set up shared agent icon picker and image upload controls + agentsCommon.initializeIconControls(document); // Set up model change listener for reasoning effort this.setupModelChangeListener(); @@ -2050,6 +2053,11 @@ export class AgentModalStepper { if (additionalSettings) additionalSettings.value = '{}'; if (instructionBrief) instructionBrief.value = ''; if (draftStatus) draftStatus.textContent = ''; + const iconImageData = document.getElementById('agent-icon-image-data'); + const iconImageFile = document.getElementById('agent-icon-image-file'); + if (iconImageData) iconImageData.value = ''; + if (iconImageFile) iconImageFile.value = ''; + agentsCommon.setIconPayload(document, { kind: 'bootstrap', value: 'bi-robot' }); this.resetAssignedKnowledgeControls(); // Clear any selected actions diff --git a/application/single_app/static/js/public/public_workspace.js b/application/single_app/static/js/public/public_workspace.js index 19715d1d..1fe52cc0 100644 --- a/application/single_app/static/js/public/public_workspace.js +++ b/application/single_app/static/js/public/public_workspace.js @@ -504,7 +504,7 @@ document.addEventListener('DOMContentLoaded', ()=>{ // Click on area triggers file input uploadArea.addEventListener('click', (e) => { // Only trigger if not clicking the hidden input itself - if (e.target !== fileInput) { + if (e.target !== fileInput && !e.target.closest('.workspace-upload-supported-types-trigger')) { fileInput.click(); } }); diff --git a/application/single_app/static/js/workspace/workspace-documents.js b/application/single_app/static/js/workspace/workspace-documents.js index 13d8d594..64b3460f 100644 --- a/application/single_app/static/js/workspace/workspace-documents.js +++ b/application/single_app/static/js/workspace/workspace-documents.js @@ -1572,7 +1572,7 @@ if (fileInput && uploadArea && uploadStatusSpan) { // Click on area triggers file input uploadArea.addEventListener("click", (e) => { // Only trigger if not clicking the hidden input itself - if (e.target !== fileInput) { + if (e.target !== fileInput && !e.target.closest(".workspace-upload-supported-types-trigger")) { fileInput.click(); } }); diff --git a/application/single_app/templates/_sidebar_nav.html b/application/single_app/templates/_sidebar_nav.html index 08e9b31b..aedab427 100644 --- a/application/single_app/templates/_sidebar_nav.html +++ b/application/single_app/templates/_sidebar_nav.html @@ -96,7 +96,7 @@ {% if sidebar_settings.enable_semantic_kernel %}