From 4db9f931e4749c4a5a14fab940e4988142470b89 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Fri, 3 Jul 2026 14:16:04 -0500 Subject: [PATCH] Fix audio runtime support and workspace guidance Add ffmpeg fallback support for iPhone M4A uploads, expose supported file type guidance in workspace views, and polish related agent modal and workflow tab UI. --- application/single_app/Dockerfile | 16 +- application/single_app/config.py | 52 +++- application/single_app/functions_documents.py | 206 ++++++++++--- .../route_frontend_admin_settings.py | 2 + .../route_frontend_group_workspaces.py | 14 +- .../route_frontend_public_workspaces.py | 14 +- .../single_app/route_frontend_workspace.py | 14 +- .../static/css/workspace-responsive.css | 20 ++ .../static/js/agent_modal_stepper.js | 8 + .../static/js/public/public_workspace.js | 2 +- .../js/workspace/workspace-documents.js | 2 +- .../single_app/templates/_sidebar_nav.html | 42 +-- .../_supported_file_types_modal.html | 30 ++ .../single_app/templates/admin_settings.html | 16 + .../templates/group_workspaces.html | 27 +- .../single_app/templates/manage_group.html | 4 + .../templates/manage_public_workspace.html | 6 + .../templates/public_workspaces.html | 16 +- .../single_app/templates/workspace.html | 22 +- deployers/azure.yaml | 25 +- deployers/version.txt | 2 +- .../features/AUDIO_FILE_RUNTIME_SUPPORT.md | 51 ++++ .../fixes/AGENT_MODAL_ICON_CONTROLS_FIX.md | 39 +++ .../fixes/IPHONE_M4A_FFMPEG_FALLBACK_FIX.md | 67 +++++ docs/explanation/release_notes.md | 19 ++ .../test_audio_ffmpeg_fallback.py | 273 ++++++++++++++++++ ui_tests/test_admin_multimedia_guidance.py | 10 +- ui_tests/test_agent_modal_icon_controls.py | 145 ++++++++++ ...st_workspace_supported_file_types_modal.py | 94 ++++++ ui_tests/test_workspace_tab_descriptions.py | 88 ++++++ .../test_workspace_workflow_tab_labels.py | 75 +++++ 31 files changed, 1291 insertions(+), 110 deletions(-) create mode 100644 application/single_app/templates/_supported_file_types_modal.html create mode 100644 docs/explanation/features/AUDIO_FILE_RUNTIME_SUPPORT.md create mode 100644 docs/explanation/fixes/AGENT_MODAL_ICON_CONTROLS_FIX.md create mode 100644 docs/explanation/fixes/IPHONE_M4A_FFMPEG_FALLBACK_FIX.md create mode 100644 functional_tests/test_audio_ffmpeg_fallback.py create mode 100644 ui_tests/test_agent_modal_icon_controls.py create mode 100644 ui_tests/test_workspace_supported_file_types_modal.py create mode 100644 ui_tests/test_workspace_tab_descriptions.py create mode 100644 ui_tests/test_workspace_workflow_tab_labels.py 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 %} @@ -118,57 +118,57 @@ diff --git a/application/single_app/templates/_supported_file_types_modal.html b/application/single_app/templates/_supported_file_types_modal.html new file mode 100644 index 00000000..8b9ce4b7 --- /dev/null +++ b/application/single_app/templates/_supported_file_types_modal.html @@ -0,0 +1,30 @@ + +{% macro supported_file_types_modal(modal_id, title, allowed_extension_categories) %} + +{% endmacro %} \ No newline at end of file diff --git a/application/single_app/templates/admin_settings.html b/application/single_app/templates/admin_settings.html index 67917846..59fd094b 100644 --- a/application/single_app/templates/admin_settings.html +++ b/application/single_app/templates/admin_settings.html @@ -8912,6 +8912,9 @@

+ {% set audio_runtime = audio_runtime_capabilities or {} %} + {% set audio_supported_extensions = audio_runtime.supported_extensions or [] %} + {% set audio_direct_extensions = audio_runtime.direct_transcription_extensions or [] %}
Upload audio files (MP3, WAV, M4A, etc.) to workspaces where they're automatically transcribed and indexed. The AI can then search, analyze, and answer questions about audio content—perfect for meetings, interviews, lectures, and podcasts.

+
+
+ {{ audio_runtime.message or 'Audio runtime support has not been checked yet.' }} +
+
+ Supported audio upload extensions: {{ audio_supported_extensions|join(', ') }} +
+ {% if audio_direct_extensions %} +
+ Without FFmpeg, source-file fallback is best for: {{ audio_direct_extensions|join(', ') }}. Container builds can include FFmpeg for broader codec support. +
+ {% endif %} +
diff --git a/application/single_app/templates/group_workspaces.html b/application/single_app/templates/group_workspaces.html index e57b1a88..4ee59270 100644 --- a/application/single_app/templates/group_workspaces.html +++ b/application/single_app/templates/group_workspaces.html @@ -1,5 +1,7 @@ -{% extends "base.html" %} {% block title %} Group Workspace - {{ +{% extends "base.html" %} +{% from "_supported_file_types_modal.html" import supported_file_types_modal %} +{% block title %} Group Workspace - {{ app_settings.app_title }} {% endblock %} {% block head %} No active group selected

{% endif %} {% if settings.allow_group_workflows %} - + {% endif %} {% if settings.per_user_semantic_kernel and settings.enable_semantic_kernel %} {% if settings.allow_group_agents %} @@ -522,6 +524,7 @@

No active group selected

id="documents-tab-btn" data-bs-toggle="tab" data-bs-target="#documents-tab" + title="Manage shared group files with role-aware upload, metadata filters, tags, bulk actions, extraction changes, and chat grounding." type="button" role="tab" aria-controls="documents-tab" @@ -537,6 +540,7 @@

No active group selected

id="sync-tab-btn" data-bs-toggle="tab" data-bs-target="#sync-tab" + title="Configure group-scoped file sources that bring approved files into this workspace and run them through the document processing pipeline." type="button" role="tab" aria-controls="sync-tab" @@ -553,6 +557,7 @@

No active group selected

id="identities-tab-btn" data-bs-toggle="tab" data-bs-target="#identities-tab" + title="Manage reusable group authentication profiles for File Sync and Actions so credentials stay scoped and resolved server-side." type="button" role="tab" aria-controls="identities-tab" @@ -568,6 +573,7 @@

No active group selected

id="prompts-tab-btn" data-bs-toggle="tab" data-bs-target="#prompts-tab" + title="Create and manage reusable prompts shared within the active group, with role-based editing." type="button" role="tab" aria-controls="prompts-tab" @@ -583,12 +589,13 @@

No active group selected

id="workflows-tab-btn" data-bs-toggle="tab" data-bs-target="#workflows-tab" + title="Create repeatable group tasks that run manually, on a schedule, or after File Sync changes using group agents, documents, or models." type="button" role="tab" aria-controls="workflows-tab" aria-selected="false" > - Group Workflows + Workflows {% endif %} @@ -600,6 +607,7 @@

No active group selected

id="group-agents-tab-btn" data-bs-toggle="tab" data-bs-target="#group-agents-tab" + title="Reusable, specialized AI assistants that combine instructions, approved knowledge, and optional actions. Actions are tasks an agent is allowed to perform." type="button" role="tab" aria-controls="group-agents-tab" @@ -616,6 +624,7 @@

No active group selected

id="group-plugins-tab-btn" data-bs-toggle="tab" data-bs-target="#group-plugins-tab" + title="Configure governed group tools that agents can call, such as APIs, databases, or other connected systems." type="button" role="tab" aria-controls="group-plugins-tab" @@ -633,6 +642,7 @@

No active group selected

id="group-endpoints-tab-btn" data-bs-toggle="tab" data-bs-target="#group-endpoints-tab" + title="Manage model endpoints available to group agents and workflows alongside admin-managed global endpoints." type="button" role="tab" aria-controls="group-endpoints-tab" @@ -659,12 +669,14 @@

No active group selected