diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 3e6be37d..859fa74e 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -50,4 +50,8 @@ Make code changes only if you have high confidence they can solve the problem. W Confirm the root cause is fixed. Review your solution for logic correctness and robustness. Iterate until you are extremely confident the fix is complete and all tests pass. 7. Final Reflection and Additional Testing -Reflect carefully on the original intent of the user and the problem statement. Think about potential edge cases or scenarios that may not be covered by existing tests. Write additional tests that would need to pass to fully validate the correctness of your solution. Run these new tests and ensure they all pass. Be aware that there are additional hidden tests that must also pass for the solution to be successful. Do not assume the task is complete just because the visible tests pass; continue refining until you are confident the fix is robust and comprehensive. \ No newline at end of file +Reflect carefully on the original intent of the user and the problem statement. Think about potential edge cases or scenarios that may not be covered by existing tests. Write additional tests that would need to pass to fully validate the correctness of your solution. Run these new tests and ensure they all pass. Be aware that there are additional hidden tests that must also pass for the solution to be successful. Do not assume the task is complete just because the visible tests pass; continue refining until you are confident the fix is robust and comprehensive. + +VERSIONING +Application versioning remains in `application/single_app/config.py`. +Deployer and CI/CD versioning lives separately in `deployers/version.txt`; when files under `deployers/` are modified, increment `deployers/version.txt` as part of the same change, defaulting to a patch bump unless a deliberate minor or major compatibility change is intended. \ No newline at end of file diff --git a/.github/instructions/update_deployer_version.instructions.md b/.github/instructions/update_deployer_version.instructions.md new file mode 100644 index 00000000..a68861ba --- /dev/null +++ b/.github/instructions/update_deployer_version.instructions.md @@ -0,0 +1,19 @@ +--- +applyTo: 'deployers/**' +--- + +# Deployer Version Management + +When a change modifies files under `deployers/`, include an update to `deployers/version.txt` in the same change. + +## Rules + +- Keep the deployer version separate from `application/single_app/config.py`. +- `deployers/version.txt` must contain only a plain semantic version string in the format `X.Y.Z`. +- Default to a patch increment when a deployer change is made: `1.0.0` -> `1.0.1`. +- Use a minor or major increment only when the deployer workflow or CI/CD compatibility contract changes intentionally. +- If the only deployer file being changed is `deployers/version.txt`, do not add an extra bump beyond the intended version update. + +## Applies To + +This rule covers deployer scripts, `azure.yaml`, `.azure` environment helpers, Bicep/Terraform deployer files, and other deployment workflow assets under `deployers/`. \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 2ad07a8a..78d57e5e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,6 +62,8 @@ return render_template('page.html', settings=public_settings) - Version is stored in `config.py`: `VERSION = "X.XXX.XXX"` - When incrementing, only change the third segment (e.g., `0.238.024` -> `0.238.025`) - Include the current version in functional test file headers and documentation files +- Deployer CI/CD logic version is tracked separately in `deployers/version.txt` +- When modifying files under `deployers/`, increment `deployers/version.txt`; default to a patch bump unless a deliberate deployment compatibility change warrants a minor or major increment ## Documentation Locations diff --git a/application/single_app/Dockerfile b/application/single_app/Dockerfile index 6f04b41a..9569371d 100644 --- a/application/single_app/Dockerfile +++ b/application/single_app/Dockerfile @@ -18,6 +18,22 @@ RUN update-ca-trust enable \ ENV PYTHONUNBUFFERED=1 +RUN set -eux; \ + tdnf install -y curl ca-certificates; \ + printf '%s\n' \ + '[microsoft-azurelinux-3-ms-non-oss]' \ + 'name=Microsoft Azure Linux 3 Non-OSS' \ + 'baseurl=https://packages.microsoft.com/azurelinux/3.0/prod/ms-non-oss/$basearch' \ + 'enabled=1' \ + 'gpgcheck=1' \ + 'gpgkey=https://packages.microsoft.com/keys/microsoft.asc' \ + > /etc/yum.repos.d/microsoft-ms-non-oss.repo; \ + mkdir -p /opt/microsoft/msodbcsql18; \ + touch /opt/microsoft/msodbcsql18/ACCEPT_EULA; \ + tdnf install -y unixODBC unixODBC-devel msodbcsql18; \ + tdnf clean all; \ + rm -rf /var/cache/tdnf + RUN set -eux; \ echo "nonroot:x:${GID}:" >> /etc/group; \ echo "nonroot:x:${UID}:${GID}:nonroot:/home/nonroot:/bin/bash" >> /etc/passwd; \ @@ -29,6 +45,24 @@ RUN set -eux; \ RUN mkdir -p /app/flask_session && chown -R ${UID}:${GID} /app/flask_session RUN mkdir /sc-temp-files && chown -R ${UID}:${GID} /sc-temp-files +# Preserve the package-selected unixODBC driver registry path for the distroless runtime. +RUN set -eux; \ + driver_config_path="$(odbcinst -j | while IFS= read -r line; do case "$line" in DRIVERS*) printf '%s\n' "${line##*: }"; break ;; esac; done)"; \ + test -n "${driver_config_path}"; \ + case "${driver_config_path}" in \ + */odbcinst.ini) driver_config_file="${driver_config_path}" ;; \ + *) driver_config_file="${driver_config_path}/odbcinst.ini" ;; \ + esac; \ + driver_config_dir="${driver_config_file%/odbcinst.ini}"; \ + test -f "${driver_config_file}"; \ + mkdir -p /odbc-runtime/usr/lib64 /odbc-runtime/opt "/odbc-runtime${driver_config_dir}" /odbc-runtime/etc; \ + cp -a "${driver_config_file}" "/odbc-runtime${driver_config_dir}/"; \ + if [ "${driver_config_dir}" != "/etc" ]; then cp -a "${driver_config_file}" /odbc-runtime/etc/; fi; \ + cp -a /opt/microsoft /odbc-runtime/opt/; \ + cp -a /usr/lib64/libodbc.so* /odbc-runtime/usr/lib64/; \ + cp -a /usr/lib64/libodbcinst.so* /odbc-runtime/usr/lib64/; \ + cp -a /usr/lib64/libodbccr.so* /odbc-runtime/usr/lib64/; \ + cp -a /usr/lib64/libltdl.so* /odbc-runtime/usr/lib64/ WORKDIR /app @@ -47,6 +81,7 @@ COPY --from=builder /home/nonroot /home/nonroot COPY --from=builder /etc/passwd /etc/passwd COPY --from=builder /etc/group /etc/group COPY --from=builder /usr/lib/python3.12 /usr/lib/python3.12 +COPY --from=builder /odbc-runtime/ / USER ${UID}:${GID} @@ -54,6 +89,7 @@ COPY --from=builder --chown=${UID}:${GID} /app /app 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" \ PYTHONIOENCODING=utf-8 \ LANG=C.UTF-8 \ diff --git a/application/single_app/app.py b/application/single_app/app.py index fdbaee55..2b907388 100644 --- a/application/single_app/app.py +++ b/application/single_app/app.py @@ -54,12 +54,14 @@ from route_frontend_notifications import * from route_backend_chats import * +from route_backend_search import * from route_backend_conversations import * from route_backend_documents import * from route_backend_groups import * from route_backend_users import * from route_backend_group_documents import * from route_backend_models import * +from route_backend_workflows import * from route_backend_safety import * from route_backend_feedback import * from route_backend_settings import * @@ -79,6 +81,7 @@ from route_backend_thoughts import register_route_backend_thoughts from route_backend_speech import register_route_backend_speech from route_backend_tts import register_route_backend_tts +from route_backend_collaboration import register_route_backend_collaboration from route_enhanced_citations import register_enhanced_citations_routes from plugin_validation_endpoint import plugin_validation_bp from route_openapi import register_openapi_routes @@ -873,9 +876,15 @@ def list_semantic_kernel_plugins(): # ------------------- API Chat Routes -------------------- register_route_backend_chats(app) +# ------------------- API Search Routes ------------------ +register_route_backend_search(app) + # ------------------- API Conversation Routes ------------ register_route_backend_conversations(app) +# ------------------- API Collaboration Routes ----------- +register_route_backend_collaboration(app) + # ------------------- API Documents Routes --------------- register_route_backend_documents(app) @@ -891,6 +900,9 @@ def list_semantic_kernel_plugins(): # ------------------- API Model Routes ------------------- register_route_backend_models(app) +# ------------------- API Workflow Routes ---------------- +register_route_backend_workflows(app) + # ------------------- API Safety Logs Routes ------------- register_route_backend_safety(app) diff --git a/application/single_app/background_tasks.py b/application/single_app/background_tasks.py index c978bf9e..8af8aa34 100644 --- a/application/single_app/background_tasks.py +++ b/application/single_app/background_tasks.py @@ -15,7 +15,14 @@ from config import cosmos_settings_container, exceptions from functions_appinsights import log_event from functions_debug import debug_print +from functions_personal_workflows import ( + compute_next_run_at, + get_due_personal_workflows, + get_personal_workflow, + update_personal_workflow_runtime_fields, +) from functions_settings import get_settings, update_settings +from functions_workflow_runner import run_personal_workflow def _get_lock_holder_id(): @@ -303,12 +310,109 @@ def run_retention_policy_loop(): time.sleep(300) +def check_due_workflows_once(): + """Execute interval-based personal workflows that are due.""" + settings = get_settings() + if not settings.get('allow_user_workflows', True): + return [] + + due_workflows = get_due_personal_workflows(limit=20) + if not due_workflows: + return [] + + results = [] + for workflow in due_workflows: + workflow_id = str(workflow.get('id') or '').strip() + user_id = str(workflow.get('user_id') or '').strip() + if not workflow_id or not user_id: + continue + + lock_document = acquire_distributed_task_lock(f'workflow_run_{workflow_id}', lease_seconds=900) + if not lock_document: + continue + + refreshed_workflow = None + try: + refreshed_workflow = get_personal_workflow(user_id, workflow_id) + if not refreshed_workflow: + continue + if refreshed_workflow.get('trigger_type') != 'interval' or not refreshed_workflow.get('is_enabled', False): + continue + + next_run_at = refreshed_workflow.get('next_run_at') + if next_run_at: + try: + if datetime.fromisoformat(next_run_at) > datetime.now(timezone.utc): + continue + except Exception: + pass + + started_at = datetime.now(timezone.utc).isoformat() + update_personal_workflow_runtime_fields( + user_id, + workflow_id, + { + 'status': 'running', + 'last_run_started_at': started_at, + 'last_run_trigger_source': 'scheduled', + 'last_run_error': '', + }, + ) + + result = run_personal_workflow(refreshed_workflow, trigger_source='scheduled') + update_fields = dict(result.get('workflow_updates') or {}) + update_fields['status'] = 'idle' + update_fields['next_run_at'] = compute_next_run_at(refreshed_workflow, from_time=datetime.now(timezone.utc)) + update_personal_workflow_runtime_fields(user_id, workflow_id, update_fields) + results.append({'workflow_id': workflow_id, 'success': bool(result.get('success'))}) + except Exception as exc: + log_event( + f"[WorkflowScheduler] Error executing workflow {workflow_id}: {exc}", + extra={ + 'workflow_id': workflow_id, + 'user_id': user_id, + }, + level=logging.ERROR, + exceptionTraceback=True, + ) + if refreshed_workflow: + update_personal_workflow_runtime_fields( + user_id, + workflow_id, + { + 'status': 'idle', + 'last_run_status': 'failed', + 'last_run_error': str(exc), + 'last_run_at': datetime.now(timezone.utc).isoformat(), + 'last_run_trigger_source': 'scheduled', + 'next_run_at': compute_next_run_at(refreshed_workflow, from_time=datetime.now(timezone.utc)), + }, + ) + finally: + release_distributed_task_lock(lock_document) + + return results + + +def run_workflow_scheduler_loop(): + """Run personal workflow scheduling checks forever.""" + while True: + try: + check_due_workflows_once() + except Exception as exc: + print(f"Error in workflow scheduler check: {exc}") + log_event(f"[WorkflowScheduler] Error in workflow scheduler check: {exc}", level=logging.ERROR) + + time.sleep(5) + + def start_background_task_threads(): """Start all background task loops for the current process.""" task_specs = [ ('Logging timer background task started.', run_logging_timer_loop), ('Approval expiration background task started.', run_approval_expiration_loop), ('Retention policy background task started.', run_retention_policy_loop), + ('Workflow scheduler background task started.', run_workflow_scheduler_loop), ] started_threads = [] diff --git a/application/single_app/collaboration_models.py b/application/single_app/collaboration_models.py new file mode 100644 index 00000000..f6c3d9d6 --- /dev/null +++ b/application/single_app/collaboration_models.py @@ -0,0 +1,671 @@ +# collaboration_models.py + +"""Pure collaboration data-model helpers for multi-user conversations.""" + +from datetime import UTC, datetime, timedelta +import uuid + + +COLLABORATION_KIND = 'collaborative' + +PERSONAL_MULTI_USER_CHAT_TYPE = 'personal_multi_user' +GROUP_MULTI_USER_CHAT_TYPE = 'group_multi_user' + +MEMBERSHIP_STATUS_ACCEPTED = 'accepted' +MEMBERSHIP_STATUS_PENDING = 'pending' +MEMBERSHIP_STATUS_DECLINED = 'declined' +MEMBERSHIP_STATUS_REMOVED = 'removed' + +MEMBERSHIP_ROLE_OWNER = 'owner' +MEMBERSHIP_ROLE_ADMIN = 'admin' +MEMBERSHIP_ROLE_MEMBER = 'member' + +MESSAGE_KIND_HUMAN = 'human_message' +MESSAGE_KIND_AI_REQUEST = 'ai_request' +MESSAGE_KIND_ASSISTANT = 'assistant_response' + +DEFAULT_PERSONAL_COLLABORATION_TITLE = 'New Collaborative Conversation' +DEFAULT_GROUP_COLLABORATION_TITLE = 'New Group Collaborative Conversation' + + +def utc_now_iso(): + return datetime.now(UTC).isoformat() + + +def add_seconds_to_iso(timestamp, seconds): + base_timestamp = datetime.fromisoformat(str(timestamp or utc_now_iso())) + return (base_timestamp + timedelta(seconds=int(seconds or 0))).isoformat() + + +def _clean_string(value): + return str(value or '').strip() + + +def normalize_collaboration_user(raw_user, fallback_user_id=None): + raw_user = raw_user or {} + if not isinstance(raw_user, dict): + raw_user = {} + + user_id = _clean_string( + raw_user.get('user_id') + or raw_user.get('userId') + or raw_user.get('id') + or fallback_user_id + ) + if not user_id: + return None + + display_name = _clean_string( + raw_user.get('display_name') + or raw_user.get('displayName') + or raw_user.get('name') + or raw_user.get('username') + ) + email = _clean_string( + raw_user.get('email') + or raw_user.get('mail') + or raw_user.get('userPrincipalName') + ) + + return { + 'user_id': user_id, + 'display_name': display_name or email or 'Unknown User', + 'email': email, + } + + +def build_collaboration_context(scope_type, scope_id, scope_name): + return [ + { + 'type': 'primary', + 'scope': _clean_string(scope_type), + 'id': _clean_string(scope_id), + 'name': _clean_string(scope_name), + } + ] + + +def build_default_collaboration_title(conversation_type, group_name=''): + normalized_type = _clean_string(conversation_type).lower() + normalized_group_name = _clean_string(group_name) + if normalized_type == 'group': + if normalized_group_name: + return f'{normalized_group_name} collaborative conversation' + return DEFAULT_GROUP_COLLABORATION_TITLE + return DEFAULT_PERSONAL_COLLABORATION_TITLE + + +def refresh_personal_participant_indexes(conversation_doc): + participants = conversation_doc.setdefault('participants', []) + accepted_participant_ids = [] + pending_participant_ids = [] + owner_user_ids = [] + admin_user_ids = [] + + for participant in participants: + participant_user_id = _clean_string(participant.get('user_id')) + participant_status = _clean_string(participant.get('status')) + participant_role = _clean_string(participant.get('role')) + + if not participant_user_id: + continue + + if participant_status == MEMBERSHIP_STATUS_ACCEPTED: + if participant_user_id not in accepted_participant_ids: + accepted_participant_ids.append(participant_user_id) + if participant_role == MEMBERSHIP_ROLE_OWNER and participant_user_id not in owner_user_ids: + owner_user_ids.append(participant_user_id) + if participant_role == MEMBERSHIP_ROLE_ADMIN and participant_user_id not in admin_user_ids: + admin_user_ids.append(participant_user_id) + elif participant_status == MEMBERSHIP_STATUS_PENDING: + if participant_user_id not in pending_participant_ids: + pending_participant_ids.append(participant_user_id) + + conversation_doc['accepted_participant_ids'] = accepted_participant_ids + conversation_doc['pending_participant_ids'] = pending_participant_ids + conversation_doc['owner_user_ids'] = owner_user_ids + conversation_doc['admin_user_ids'] = admin_user_ids + conversation_doc['participant_count'] = len(accepted_participant_ids) + conversation_doc['pending_invite_count'] = len(pending_participant_ids) + return conversation_doc + + +def build_personal_collaboration_conversation( + title, + creator_user, + invited_participants=None, + conversation_id=None, + created_at=None, +): + created_at = _clean_string(created_at) or utc_now_iso() + conversation_id = _clean_string(conversation_id) or str(uuid.uuid4()) + creator_summary = normalize_collaboration_user(creator_user) + if not creator_summary: + raise ValueError('creator_user is required') + + conversation_title = _clean_string(title) or build_default_collaboration_title('personal') + + participants = [ + { + 'user_id': creator_summary['user_id'], + 'display_name': creator_summary['display_name'], + 'email': creator_summary['email'], + 'role': MEMBERSHIP_ROLE_OWNER, + 'status': MEMBERSHIP_STATUS_ACCEPTED, + 'invited_at': created_at, + 'joined_at': created_at, + } + ] + existing_user_ids = {creator_summary['user_id']} + + for raw_participant in invited_participants or []: + participant_summary = normalize_collaboration_user(raw_participant) + if not participant_summary: + continue + if participant_summary['user_id'] in existing_user_ids: + continue + + existing_user_ids.add(participant_summary['user_id']) + participants.append({ + 'user_id': participant_summary['user_id'], + 'display_name': participant_summary['display_name'], + 'email': participant_summary['email'], + 'role': MEMBERSHIP_ROLE_MEMBER, + 'status': MEMBERSHIP_STATUS_PENDING, + 'invited_at': created_at, + }) + + conversation_doc = { + 'id': conversation_id, + 'conversation_kind': COLLABORATION_KIND, + 'chat_type': PERSONAL_MULTI_USER_CHAT_TYPE, + 'title': conversation_title, + 'created_at': created_at, + 'updated_at': created_at, + 'last_message_at': None, + 'last_message_preview': '', + 'status': 'active', + 'created_by_user_id': creator_summary['user_id'], + 'created_by_display_name': creator_summary['display_name'], + 'scope': { + 'type': 'personal', + 'group_id': None, + 'group_name': None, + 'visibility_mode': 'invited_members', + 'allowed_scope_types': ['personal', 'public'], + }, + 'context': build_collaboration_context( + 'personal', + creator_summary['user_id'], + creator_summary['display_name'], + ), + 'scope_locked': True, + 'locked_contexts': [{'scope': 'personal', 'id': creator_summary['user_id']}], + 'participants': participants, + 'conversation_settings': { + 'ai_invocation_mode': 'explicit_only', + 'reply_mode_enabled': True, + }, + 'message_count': 0, + 'tags': [], + } + return refresh_personal_participant_indexes(conversation_doc) + + +def build_group_collaboration_conversation( + title, + creator_user, + group_id, + group_name, + invited_participants=None, + conversation_id=None, + created_at=None, +): + created_at = _clean_string(created_at) or utc_now_iso() + conversation_id = _clean_string(conversation_id) or str(uuid.uuid4()) + creator_summary = normalize_collaboration_user(creator_user) + if not creator_summary: + raise ValueError('creator_user is required') + + normalized_group_id = _clean_string(group_id) + if not normalized_group_id: + raise ValueError('group_id is required') + + normalized_group_name = _clean_string(group_name) or 'Group Workspace' + conversation_title = _clean_string(title) or build_default_collaboration_title( + 'group', + normalized_group_name, + ) + + participants = [ + { + 'user_id': creator_summary['user_id'], + 'display_name': creator_summary['display_name'], + 'email': creator_summary['email'], + 'role': MEMBERSHIP_ROLE_OWNER, + 'status': MEMBERSHIP_STATUS_ACCEPTED, + 'invited_at': created_at, + 'joined_at': created_at, + } + ] + existing_user_ids = {creator_summary['user_id']} + + for raw_participant in invited_participants or []: + participant_summary = normalize_collaboration_user(raw_participant) + if not participant_summary: + continue + if participant_summary['user_id'] in existing_user_ids: + continue + + existing_user_ids.add(participant_summary['user_id']) + participants.append({ + 'user_id': participant_summary['user_id'], + 'display_name': participant_summary['display_name'], + 'email': participant_summary['email'], + 'role': MEMBERSHIP_ROLE_MEMBER, + 'status': MEMBERSHIP_STATUS_PENDING, + 'invited_at': created_at, + }) + + conversation_doc = { + 'id': conversation_id, + 'conversation_kind': COLLABORATION_KIND, + 'chat_type': GROUP_MULTI_USER_CHAT_TYPE, + 'title': conversation_title, + 'created_at': created_at, + 'updated_at': created_at, + 'last_message_at': None, + 'last_message_preview': '', + 'status': 'active', + 'created_by_user_id': creator_summary['user_id'], + 'created_by_display_name': creator_summary['display_name'], + 'scope': { + 'type': 'group', + 'group_id': normalized_group_id, + 'group_name': normalized_group_name, + 'visibility_mode': 'invited_members', + 'allowed_scope_types': ['group', 'public'], + }, + 'context': build_collaboration_context('group', normalized_group_id, normalized_group_name), + 'scope_locked': True, + 'locked_contexts': [{'scope': 'group', 'id': normalized_group_id}], + 'participants': participants, + 'conversation_settings': { + 'ai_invocation_mode': 'explicit_only', + 'reply_mode_enabled': True, + }, + 'message_count': 0, + 'tags': [], + } + return refresh_personal_participant_indexes(conversation_doc) + + +def get_collaboration_user_state_doc_id(user_id, conversation_id): + return f'{_clean_string(user_id)}:{_clean_string(conversation_id)}' + + +def build_collaboration_user_state( + conversation_doc, + user_summary, + role, + membership_status, + invited_by_user_id=None, + created_at=None, +): + normalized_user = normalize_collaboration_user(user_summary) + if not normalized_user: + raise ValueError('user_summary is required') + + created_at = _clean_string(created_at) or utc_now_iso() + conversation_id = _clean_string(conversation_doc.get('id')) + scope = conversation_doc.get('scope', {}) if isinstance(conversation_doc, dict) else {} + + state_doc = { + 'id': get_collaboration_user_state_doc_id(normalized_user['user_id'], conversation_id), + 'conversation_kind': COLLABORATION_KIND, + 'conversation_id': conversation_id, + 'user_id': normalized_user['user_id'], + 'user_display_name': normalized_user['display_name'], + 'user_email': normalized_user['email'], + 'chat_type': conversation_doc.get('chat_type'), + 'scope_type': scope.get('type'), + 'group_id': scope.get('group_id'), + 'group_name': scope.get('group_name'), + 'title_snapshot': conversation_doc.get('title'), + 'role': _clean_string(role) or MEMBERSHIP_ROLE_MEMBER, + 'membership_status': _clean_string(membership_status) or MEMBERSHIP_STATUS_PENDING, + 'invited_by_user_id': _clean_string(invited_by_user_id), + 'created_at': created_at, + 'updated_at': created_at, + 'last_read_message_id': None, + 'last_read_at': None, + 'last_seen_at': None, + 'is_hidden': False, + 'is_pinned': False, + } + + if state_doc['membership_status'] == MEMBERSHIP_STATUS_ACCEPTED: + state_doc['joined_at'] = created_at + + return state_doc + + +def apply_personal_invite_response(conversation_doc, invited_user_id, action, responded_at=None): + normalized_user_id = _clean_string(invited_user_id) + normalized_action = _clean_string(action).lower() + if normalized_action not in ('accept', 'decline'): + raise ValueError('action must be accept or decline') + + responded_at = _clean_string(responded_at) or utc_now_iso() + + participant_record = None + for participant in conversation_doc.get('participants', []): + if _clean_string(participant.get('user_id')) != normalized_user_id: + continue + + if _clean_string(participant.get('status')) != MEMBERSHIP_STATUS_PENDING: + raise ValueError('participant invite is not pending') + + participant_record = participant + if normalized_action == 'accept': + participant['status'] = MEMBERSHIP_STATUS_ACCEPTED + participant['joined_at'] = responded_at + else: + participant['status'] = MEMBERSHIP_STATUS_DECLINED + participant['responded_at'] = responded_at + break + + if participant_record is None: + raise LookupError('pending participant not found') + + conversation_doc['updated_at'] = responded_at + refresh_personal_participant_indexes(conversation_doc) + return participant_record + + +def add_personal_pending_participants(conversation_doc, new_participants, invited_at=None): + invited_at = _clean_string(invited_at) or utc_now_iso() + existing_by_user_id = { + _clean_string(participant.get('user_id')): participant + for participant in conversation_doc.get('participants', []) + if _clean_string(participant.get('user_id')) + } + + added_participants = [] + for raw_participant in new_participants or []: + participant_summary = normalize_collaboration_user(raw_participant) + if not participant_summary: + continue + + participant_user_id = participant_summary['user_id'] + existing_participant = existing_by_user_id.get(participant_user_id) + if existing_participant: + existing_status = _clean_string(existing_participant.get('status')) + if existing_status in (MEMBERSHIP_STATUS_ACCEPTED, MEMBERSHIP_STATUS_PENDING): + continue + + existing_participant['display_name'] = participant_summary['display_name'] + existing_participant['email'] = participant_summary['email'] + existing_participant['status'] = MEMBERSHIP_STATUS_PENDING + existing_participant['role'] = MEMBERSHIP_ROLE_MEMBER + existing_participant['invited_at'] = invited_at + existing_participant.pop('joined_at', None) + existing_participant.pop('removed_at', None) + existing_participant.pop('responded_at', None) + added_participants.append(existing_participant) + continue + + participant_record = { + 'user_id': participant_user_id, + 'display_name': participant_summary['display_name'], + 'email': participant_summary['email'], + 'role': MEMBERSHIP_ROLE_MEMBER, + 'status': MEMBERSHIP_STATUS_PENDING, + 'invited_at': invited_at, + } + conversation_doc.setdefault('participants', []).append(participant_record) + existing_by_user_id[participant_user_id] = participant_record + added_participants.append(participant_record) + + if added_participants: + conversation_doc['updated_at'] = invited_at + refresh_personal_participant_indexes(conversation_doc) + + return added_participants + + +def remove_personal_participant(conversation_doc, participant_user_id, removed_at=None): + normalized_user_id = _clean_string(participant_user_id) + removed_at = _clean_string(removed_at) or utc_now_iso() + owner_ids = set(conversation_doc.get('owner_user_ids', []) or []) + if normalized_user_id in owner_ids: + raise ValueError('owners cannot be removed from personal collaborative conversations') + + removed_participant = None + for participant in conversation_doc.get('participants', []): + if _clean_string(participant.get('user_id')) != normalized_user_id: + continue + + participant['status'] = MEMBERSHIP_STATUS_REMOVED + participant['removed_at'] = removed_at + removed_participant = participant + break + + if removed_participant is None: + raise LookupError('participant not found') + + conversation_doc['updated_at'] = removed_at + refresh_personal_participant_indexes(conversation_doc) + return removed_participant + + +def ensure_group_participant_record(conversation_doc, user_summary, joined_at=None): + joined_at = _clean_string(joined_at) or utc_now_iso() + normalized_user = normalize_collaboration_user(user_summary) + if not normalized_user: + raise ValueError('user_summary is required') + + participant_record = None + for participant in conversation_doc.get('participants', []): + if _clean_string(participant.get('user_id')) != normalized_user['user_id']: + continue + + participant['display_name'] = normalized_user['display_name'] + participant['email'] = normalized_user['email'] + participant['status'] = MEMBERSHIP_STATUS_ACCEPTED + participant.setdefault('joined_at', joined_at) + participant_record = participant + break + + if participant_record is None: + participant_record = { + 'user_id': normalized_user['user_id'], + 'display_name': normalized_user['display_name'], + 'email': normalized_user['email'], + 'role': MEMBERSHIP_ROLE_MEMBER, + 'status': MEMBERSHIP_STATUS_ACCEPTED, + 'invited_at': joined_at, + 'joined_at': joined_at, + } + conversation_doc.setdefault('participants', []).append(participant_record) + + accepted_participant_ids = list(conversation_doc.get('accepted_participant_ids', []) or []) + if normalized_user['user_id'] not in accepted_participant_ids: + accepted_participant_ids.append(normalized_user['user_id']) + conversation_doc['accepted_participant_ids'] = accepted_participant_ids + conversation_doc['participant_count'] = len(accepted_participant_ids) + conversation_doc['updated_at'] = joined_at + return participant_record + + +def _truncate_preview(content, max_length=160): + content = _clean_string(content) + if len(content) <= int(max_length): + return content + return f"{content[: int(max_length) - 3]}..." + + +def build_collaboration_message_doc( + conversation_id, + sender_user, + content, + reply_to_message_id=None, + mentioned_participants=None, + message_kind=MESSAGE_KIND_HUMAN, + message_id=None, + timestamp=None, +): + normalized_sender = normalize_collaboration_user(sender_user) + if not normalized_sender: + raise ValueError('sender_user is required') + + normalized_conversation_id = _clean_string(conversation_id) + if not normalized_conversation_id: + raise ValueError('conversation_id is required') + + normalized_content = str(content or '') + normalized_timestamp = _clean_string(timestamp) or utc_now_iso() + normalized_message_kind = _clean_string(message_kind) or MESSAGE_KIND_HUMAN + + if normalized_message_kind == MESSAGE_KIND_ASSISTANT: + role = 'assistant' + else: + role = 'user' + + normalized_mentions = [] + seen_mentioned_user_ids = set() + for raw_participant in mentioned_participants or []: + mentioned_user = normalize_collaboration_user(raw_participant) + if not mentioned_user: + continue + + mentioned_user_id = mentioned_user['user_id'] + if mentioned_user_id in seen_mentioned_user_ids: + continue + + seen_mentioned_user_ids.add(mentioned_user_id) + normalized_mentions.append(mentioned_user) + + metadata = { + 'sender': normalized_sender, + 'user_info': { + 'user_id': normalized_sender['user_id'], + 'display_name': normalized_sender['display_name'], + 'email': normalized_sender['email'], + 'username': normalized_sender['user_id'], + 'timestamp': normalized_timestamp, + }, + 'explicit_ai_invocation': normalized_message_kind == MESSAGE_KIND_AI_REQUEST, + 'last_message_preview': _truncate_preview(normalized_content), + } + if normalized_mentions: + metadata['mentioned_participants'] = normalized_mentions + metadata['mentioned_user_ids'] = [participant['user_id'] for participant in normalized_mentions] + + return { + 'id': _clean_string(message_id) or f'{normalized_conversation_id}_{uuid.uuid4().hex}', + 'conversation_id': normalized_conversation_id, + 'role': role, + 'message_kind': normalized_message_kind, + 'content': normalized_content, + 'reply_to_message_id': _clean_string(reply_to_message_id) or None, + 'timestamp': normalized_timestamp, + 'metadata': metadata, + } + + +def build_collaboration_message_doc_from_legacy( + conversation_id, + legacy_message, + default_sender_user, +): + legacy_message = legacy_message or {} + legacy_role = _clean_string(legacy_message.get('role')).lower() + legacy_metadata = legacy_message.get('metadata', {}) if isinstance(legacy_message.get('metadata'), dict) else {} + + if legacy_role in ('assistant_artifact', 'assistant_artifact_chunk'): + return None + + content = str(legacy_message.get('content') or '') + message_kind = MESSAGE_KIND_HUMAN + sender_user = normalize_collaboration_user( + legacy_metadata.get('user_info') or default_sender_user, + ) + + if legacy_role == 'assistant': + message_kind = MESSAGE_KIND_ASSISTANT + sender_user = { + 'user_id': 'assistant', + 'display_name': _clean_string(legacy_message.get('agent_display_name')) or 'AI', + 'email': '', + } + elif legacy_role == 'safety': + message_kind = MESSAGE_KIND_ASSISTANT + sender_user = { + 'user_id': 'assistant', + 'display_name': 'Content Safety', + 'email': '', + } + elif legacy_role == 'file': + filename = _clean_string(legacy_message.get('filename')) or 'file' + content = f'[File shared] {filename}' + elif legacy_role == 'image': + is_user_upload = bool(legacy_metadata.get('is_user_upload')) + if is_user_upload: + filename = _clean_string(legacy_message.get('filename')) or 'image' + content = f'[Uploaded image] {filename}' + else: + message_kind = MESSAGE_KIND_ASSISTANT + sender_user = { + 'user_id': 'assistant', + 'display_name': _clean_string(legacy_message.get('agent_display_name')) or 'AI', + 'email': '', + } + content = '[Generated image]' + elif legacy_role not in ('user', '') and not content.strip(): + return None + + if not sender_user: + return None + + collaboration_message = build_collaboration_message_doc( + conversation_id=conversation_id, + sender_user=sender_user, + content=content, + reply_to_message_id=legacy_message.get('reply_to_message_id'), + message_kind=message_kind, + timestamp=legacy_message.get('timestamp'), + ) + + collaboration_metadata = collaboration_message.get('metadata', {}) + collaboration_message['metadata'] = { + **dict(legacy_metadata), + **collaboration_metadata, + 'source_message_id': _clean_string(legacy_message.get('id')) or None, + 'source_role': legacy_role or None, + } + if legacy_role == 'image': + legacy_image_url = _clean_string(legacy_message.get('content')) + if legacy_image_url and not legacy_image_url.startswith('data:image/'): + collaboration_message['metadata']['legacy_image_url'] = legacy_image_url + if legacy_role == 'file': + collaboration_message['metadata']['legacy_filename'] = _clean_string(legacy_message.get('filename')) or None + + for optional_key in ( + 'model_deployment_name', + 'augmented', + 'hybrid_citations', + 'web_search_citations', + 'agent_citations', + 'agent_display_name', + 'agent_name', + 'extracted_text', + 'vision_analysis', + 'filename', + 'prompt', + 'is_table', + ): + if optional_key in legacy_message: + collaboration_message[optional_key] = legacy_message.get(optional_key) + + return collaboration_message \ No newline at end of file diff --git a/application/single_app/config.py b/application/single_app/config.py index 7196cfe8..19871684 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.241.006" +VERSION = "0.241.083" SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production') @@ -106,9 +106,9 @@ 'Referrer-Policy': 'strict-origin-when-cross-origin', 'Content-Security-Policy': ( "default-src 'self'; " - "script-src 'self' 'unsafe-inline' 'unsafe-eval'; " + "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net; " #"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net https://code.jquery.com https://stackpath.bootstrapcdn.com; " - "style-src 'self' 'unsafe-inline'; " + "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; " #"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://stackpath.bootstrapcdn.com; " "img-src 'self' data: https: blob:; " "font-src 'self'; " @@ -308,6 +308,18 @@ def get_redis_cache_infrastructure_endpoint(redis_hostname: str) -> str: partition_key=PartitionKey(path="/conversation_id") ) +cosmos_personal_workflows_container_name = "personal_workflows" +cosmos_personal_workflows_container = cosmos_database.create_container_if_not_exists( + id=cosmos_personal_workflows_container_name, + partition_key=PartitionKey(path="/user_id") +) + +cosmos_personal_workflow_runs_container_name = "personal_workflow_runs" +cosmos_personal_workflow_runs_container = cosmos_database.create_container_if_not_exists( + id=cosmos_personal_workflow_runs_container_name, + partition_key=PartitionKey(path="/user_id") +) + cosmos_group_conversations_container_name = "group_conversations" cosmos_group_conversations_container = cosmos_database.create_container_if_not_exists( id=cosmos_group_conversations_container_name, @@ -320,6 +332,24 @@ def get_redis_cache_infrastructure_endpoint(redis_hostname: str) -> str: partition_key=PartitionKey(path="/conversation_id") ) +cosmos_collaboration_conversations_container_name = "collaboration_conversations" +cosmos_collaboration_conversations_container = cosmos_database.create_container_if_not_exists( + id=cosmos_collaboration_conversations_container_name, + partition_key=PartitionKey(path="/id") +) + +cosmos_collaboration_messages_container_name = "collaboration_messages" +cosmos_collaboration_messages_container = cosmos_database.create_container_if_not_exists( + id=cosmos_collaboration_messages_container_name, + partition_key=PartitionKey(path="/conversation_id") +) + +cosmos_collaboration_user_state_container_name = "collaboration_user_state" +cosmos_collaboration_user_state_container = cosmos_database.create_container_if_not_exists( + id=cosmos_collaboration_user_state_container_name, + partition_key=PartitionKey(path="/user_id") +) + cosmos_settings_container_name = "settings" cosmos_settings_container = cosmos_database.create_container_if_not_exists( id=cosmos_settings_container_name, diff --git a/application/single_app/functions_activity_logging.py b/application/single_app/functions_activity_logging.py index 987a3dec..8313cde6 100644 --- a/application/single_app/functions_activity_logging.py +++ b/application/single_app/functions_activity_logging.py @@ -2008,6 +2008,172 @@ def log_action_deletion( debug_print(f"⚠️ Warning: Failed to log action deletion: {str(e)}") +def log_workflow_creation( + user_id: str, + workflow_id: str, + workflow_name: str, + runner_type: Optional[str] = None, + trigger_type: Optional[str] = None, +) -> None: + """Log a personal workflow creation activity.""" + try: + activity_record = { + 'id': str(uuid.uuid4()), + 'user_id': user_id, + 'activity_type': 'workflow_creation', + 'timestamp': datetime.utcnow().isoformat(), + 'created_at': datetime.utcnow().isoformat(), + 'entity_type': 'workflow', + 'operation': 'create', + 'entity': { + 'id': workflow_id, + 'name': workflow_name, + 'runner_type': runner_type, + 'trigger_type': trigger_type, + }, + 'workspace_type': 'personal', + 'workspace_context': {}, + } + cosmos_activity_logs_container.create_item(body=activity_record) + log_event( + message=f"[WorkflowActivity] Workflow created: {workflow_name} by user {user_id}", + extra=activity_record, + level=logging.INFO, + ) + except Exception as e: + log_event( + message=f"[WorkflowActivity] Error logging workflow creation: {str(e)}", + extra={'user_id': user_id, 'workflow_id': workflow_id, 'error': str(e)}, + level=logging.ERROR, + ) + + +def log_workflow_update( + user_id: str, + workflow_id: str, + workflow_name: str, + runner_type: Optional[str] = None, + trigger_type: Optional[str] = None, +) -> None: + """Log a personal workflow update activity.""" + try: + activity_record = { + 'id': str(uuid.uuid4()), + 'user_id': user_id, + 'activity_type': 'workflow_update', + 'timestamp': datetime.utcnow().isoformat(), + 'created_at': datetime.utcnow().isoformat(), + 'entity_type': 'workflow', + 'operation': 'update', + 'entity': { + 'id': workflow_id, + 'name': workflow_name, + 'runner_type': runner_type, + 'trigger_type': trigger_type, + }, + 'workspace_type': 'personal', + 'workspace_context': {}, + } + cosmos_activity_logs_container.create_item(body=activity_record) + log_event( + message=f"[WorkflowActivity] Workflow updated: {workflow_name} by user {user_id}", + extra=activity_record, + level=logging.INFO, + ) + except Exception as e: + log_event( + message=f"[WorkflowActivity] Error logging workflow update: {str(e)}", + extra={'user_id': user_id, 'workflow_id': workflow_id, 'error': str(e)}, + level=logging.ERROR, + ) + + +def log_workflow_deletion( + user_id: str, + workflow_id: str, + workflow_name: str, +) -> None: + """Log a personal workflow deletion activity.""" + try: + activity_record = { + 'id': str(uuid.uuid4()), + 'user_id': user_id, + 'activity_type': 'workflow_deletion', + 'timestamp': datetime.utcnow().isoformat(), + 'created_at': datetime.utcnow().isoformat(), + 'entity_type': 'workflow', + 'operation': 'delete', + 'entity': { + 'id': workflow_id, + 'name': workflow_name, + }, + 'workspace_type': 'personal', + 'workspace_context': {}, + } + cosmos_activity_logs_container.create_item(body=activity_record) + log_event( + message=f"[WorkflowActivity] Workflow deleted: {workflow_name} by user {user_id}", + extra=activity_record, + level=logging.INFO, + ) + except Exception as e: + log_event( + message=f"[WorkflowActivity] Error logging workflow deletion: {str(e)}", + extra={'user_id': user_id, 'workflow_id': workflow_id, 'error': str(e)}, + level=logging.ERROR, + ) + + +def log_workflow_run( + user_id: str, + workflow_id: str, + workflow_name: str, + status: str, + trigger_source: str, + run_id: Optional[str] = None, + conversation_id: Optional[str] = None, + runner_type: Optional[str] = None, + error: Optional[str] = None, +) -> None: + """Log a personal workflow run activity.""" + try: + activity_record = { + 'id': str(uuid.uuid4()), + 'user_id': user_id, + 'activity_type': 'workflow_run', + 'timestamp': datetime.utcnow().isoformat(), + 'created_at': datetime.utcnow().isoformat(), + 'entity_type': 'workflow', + 'operation': 'run', + 'entity': { + 'id': workflow_id, + 'name': workflow_name, + 'runner_type': runner_type, + }, + 'workspace_type': 'personal', + 'workspace_context': {}, + 'run': { + 'id': run_id, + 'status': status, + 'trigger_source': trigger_source, + 'conversation_id': conversation_id, + 'error': error, + }, + } + cosmos_activity_logs_container.create_item(body=activity_record) + log_event( + message=f"[WorkflowActivity] Workflow run {status}: {workflow_name} by user {user_id}", + extra=activity_record, + level=logging.INFO if status == 'completed' else logging.WARNING, + ) + except Exception as e: + log_event( + message=f"[WorkflowActivity] Error logging workflow run: {str(e)}", + extra={'user_id': user_id, 'workflow_id': workflow_id, 'error': str(e)}, + level=logging.ERROR, + ) + + def _log_agent_template_activity( user_id: str, template_id: str, diff --git a/application/single_app/functions_agent_scope.py b/application/single_app/functions_agent_scope.py index 660647b9..526c7d58 100644 --- a/application/single_app/functions_agent_scope.py +++ b/application/single_app/functions_agent_scope.py @@ -30,4 +30,18 @@ def scope_matches(candidate): if selected_agent_name: return next((agent for agent in agents_cfg if agent.get("name") == selected_agent_name and scope_matches(agent)), None) - return None \ No newline at end of file + return None + + +def is_selected_agent_scope_enabled(settings, selected_agent_data): + """Return whether app settings allow the selected agent's scope.""" + if not isinstance(selected_agent_data, dict): + return True + + if selected_agent_data.get("is_group", False): + return bool((settings or {}).get("allow_group_agents", False)) + + if selected_agent_data.get("is_global", False): + return True + + return bool((settings or {}).get("allow_user_agents", False)) \ No newline at end of file diff --git a/application/single_app/functions_azure_maps.py b/application/single_app/functions_azure_maps.py new file mode 100644 index 00000000..850eec8f --- /dev/null +++ b/application/single_app/functions_azure_maps.py @@ -0,0 +1,321 @@ +# functions_azure_maps.py +"""Shared Azure Maps constants and secure tile proxy helpers.""" + +import base64 +import hashlib +import json +import logging +from copy import deepcopy +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, Optional +from urllib.parse import parse_qs, quote_plus, urlparse + +from cryptography.fernet import Fernet, InvalidToken + +from config import SECRET_KEY +from functions_appinsights import log_event + + +AZURE_MAPS_PLUGIN_TYPE = "azure_maps_openlayers" +AZURE_MAPS_PLUGIN_DISPLAY_NAME = "Azure Maps (OpenLayers)" +AZURE_MAPS_RENDER_TYPE = "azure_maps_openlayers" +AZURE_MAPS_DEFAULT_ENDPOINT = "https://atlas.microsoft.com" +AZURE_MAPS_TILE_API_VERSION = "2024-04-01" +AZURE_MAPS_DEFAULT_TILESET_ID = "microsoft.base.road" +AZURE_MAPS_DEFAULT_LANGUAGE = "en-US" +AZURE_MAPS_DEFAULT_VIEW = "Auto" +AZURE_MAPS_TILE_PROXY_ROUTE = "/api/azure-maps/tile" +AZURE_MAPS_TILE_TOKEN_TTL_MINUTES = 240 +AZURE_MAPS_TILE_ATTRIBUTION = "© Microsoft Corporation © OpenStreetMap contributors" +AZURE_MAPS_INLINE_BLOCK_PREFIX = "{{map:" + + +def _build_fernet_cipher() -> Fernet: + normalized_secret = str(SECRET_KEY or "").encode("utf-8") + derived_key = base64.urlsafe_b64encode(hashlib.sha256(normalized_secret).digest()) + return Fernet(derived_key) + + +def create_tile_proxy_token( + subscription_key: str, + *, + expires_in_minutes: int = AZURE_MAPS_TILE_TOKEN_TTL_MINUTES, +) -> str: + normalized_key = str(subscription_key or "").strip() + if not normalized_key: + raise ValueError("Azure Maps subscription key is required.") + + ttl_minutes = max(1, int(expires_in_minutes or AZURE_MAPS_TILE_TOKEN_TTL_MINUTES)) + payload = { + "subscription_key": normalized_key, + "expires_at": (datetime.now(timezone.utc) + timedelta(minutes=ttl_minutes)).isoformat(), + } + encrypted_payload = _build_fernet_cipher().encrypt(json.dumps(payload).encode("utf-8")) + return encrypted_payload.decode("utf-8") + + +def decode_tile_proxy_token(tile_proxy_token: str, *, allow_expired: bool = False) -> Optional[Dict[str, Any]]: + normalized_token = str(tile_proxy_token or "").strip() + if not normalized_token: + return None + + try: + decrypted_payload = _build_fernet_cipher().decrypt(normalized_token.encode("utf-8")) + payload = json.loads(decrypted_payload.decode("utf-8")) + except InvalidToken: + log_event("[AzureMaps] Rejected an invalid Azure Maps tile proxy token.", level=logging.WARNING) + return None + except (json.JSONDecodeError, UnicodeDecodeError) as exc: + log_event(f"[AzureMaps] Failed to decode Azure Maps tile proxy token payload: {exc}", level=logging.WARNING) + return None + + subscription_key = str(payload.get("subscription_key") or "").strip() + expires_at_raw = str(payload.get("expires_at") or "").strip() + if not subscription_key or not expires_at_raw: + return None + + try: + expires_at = datetime.fromisoformat(expires_at_raw.replace("Z", "+00:00")) + except ValueError: + log_event("[AzureMaps] Azure Maps tile proxy token had an invalid expiration timestamp.", level=logging.WARNING) + return None + + if expires_at.tzinfo is None: + expires_at = expires_at.replace(tzinfo=timezone.utc) + + is_expired = expires_at <= datetime.now(timezone.utc) + if is_expired and not allow_expired: + log_event("[AzureMaps] Rejected an expired Azure Maps tile proxy token.", level=logging.INFO) + return None + + return { + "subscription_key": subscription_key, + "expires_at": expires_at.isoformat(), + "is_expired": is_expired, + } + + +def refresh_tile_proxy_token( + tile_proxy_token: str, + *, + expires_in_minutes: int = AZURE_MAPS_TILE_TOKEN_TTL_MINUTES, +) -> Optional[str]: + token_payload = decode_tile_proxy_token(tile_proxy_token, allow_expired=True) + if not token_payload: + return None + + subscription_key = str(token_payload.get("subscription_key") or "").strip() + if not subscription_key: + return None + + return create_tile_proxy_token(subscription_key, expires_in_minutes=expires_in_minutes) + + +def build_tile_proxy_url_template( + tile_proxy_token: str, + *, + tileset_id: str = AZURE_MAPS_DEFAULT_TILESET_ID, + language: str = AZURE_MAPS_DEFAULT_LANGUAGE, + view: str = AZURE_MAPS_DEFAULT_VIEW, + tile_size: int = 256, +) -> str: + normalized_tile_size = 512 if int(tile_size or 256) == 512 else 256 + encoded_token = quote_plus(str(tile_proxy_token or "").strip()) + encoded_tileset = quote_plus(str(tileset_id or AZURE_MAPS_DEFAULT_TILESET_ID).strip()) + encoded_language = quote_plus(str(language or AZURE_MAPS_DEFAULT_LANGUAGE).strip()) + encoded_view = quote_plus(str(view or AZURE_MAPS_DEFAULT_VIEW).strip()) + + return ( + f"{AZURE_MAPS_TILE_PROXY_ROUTE}" + f"?token={encoded_token}" + f"&api-version={AZURE_MAPS_TILE_API_VERSION}" + f"&tilesetId={encoded_tileset}" + f"&zoom={{z}}" + f"&x={{x}}" + f"&y={{y}}" + f"&tileSize={normalized_tile_size}" + f"&language={encoded_language}" + f"&view={encoded_view}" + ) + + +def refresh_tile_proxy_url_template(tile_url_template: str) -> Optional[str]: + normalized_tile_url_template = str(tile_url_template or "").strip() + if not normalized_tile_url_template: + return None + + parsed_url = urlparse(normalized_tile_url_template) + query_params = parse_qs(parsed_url.query) + current_token = str((query_params.get("token") or [""])[0] or "").strip() + if not current_token: + return None + + refreshed_token = refresh_tile_proxy_token(current_token) + if not refreshed_token: + return None + + raw_tile_size = str((query_params.get("tileSize") or ["256"])[0] or "256").strip() + try: + tile_size = int(raw_tile_size) + except ValueError: + tile_size = 256 + + return build_tile_proxy_url_template( + refreshed_token, + tileset_id=str((query_params.get("tilesetId") or [AZURE_MAPS_DEFAULT_TILESET_ID])[0] or AZURE_MAPS_DEFAULT_TILESET_ID).strip(), + language=str((query_params.get("language") or [AZURE_MAPS_DEFAULT_LANGUAGE])[0] or AZURE_MAPS_DEFAULT_LANGUAGE).strip(), + view=str((query_params.get("view") or [AZURE_MAPS_DEFAULT_VIEW])[0] or AZURE_MAPS_DEFAULT_VIEW).strip(), + tile_size=tile_size, + ) + + +def refresh_azure_maps_map_payload(map_payload: Any) -> Any: + if not isinstance(map_payload, dict): + return map_payload + + refreshed_tile_url_template = refresh_tile_proxy_url_template(map_payload.get("tile_url_template")) + if not refreshed_tile_url_template: + return map_payload + + refreshed_payload = deepcopy(map_payload) + refreshed_payload["tile_url_template"] = refreshed_tile_url_template + return refreshed_payload + + +def refresh_azure_maps_function_result(function_result: Any) -> Any: + if function_result is None: + return function_result + + parsed_result = function_result + was_serialized = False + if isinstance(function_result, str): + try: + parsed_result = json.loads(function_result) + was_serialized = True + except json.JSONDecodeError: + return function_result + + if not isinstance(parsed_result, dict): + return function_result + + if parsed_result.get("render_type") != AZURE_MAPS_RENDER_TYPE: + return function_result + + map_payload = parsed_result.get("map_payload") + refreshed_map_payload = refresh_azure_maps_map_payload(map_payload) + if refreshed_map_payload == map_payload: + return function_result + + refreshed_result = deepcopy(parsed_result) + refreshed_result["map_payload"] = refreshed_map_payload + if was_serialized: + return json.dumps(refreshed_result, ensure_ascii=False) + + return refreshed_result + + +def refresh_azure_maps_citation_payload(citation: Any) -> Any: + if not isinstance(citation, dict): + return citation + + refreshed_function_result = refresh_azure_maps_function_result(citation.get("function_result")) + if refreshed_function_result == citation.get("function_result"): + return citation + + refreshed_citation = deepcopy(citation) + refreshed_citation["function_result"] = refreshed_function_result + return refreshed_citation + + +def refresh_azure_maps_citation_payloads(citations: Any) -> Any: + if not isinstance(citations, list): + return citations + + return [refresh_azure_maps_citation_payload(citation) for citation in citations] + + +def _find_inline_map_block_end(message_content: str, start_index: int) -> Optional[int]: + payload_index = start_index + len(AZURE_MAPS_INLINE_BLOCK_PREFIX) + if payload_index >= len(message_content) or message_content[payload_index] != "{": + return None + + brace_depth = 0 + in_string = False + is_escaped = False + + for current_index in range(payload_index, len(message_content)): + current_character = message_content[current_index] + + if in_string: + if is_escaped: + is_escaped = False + elif current_character == "\\": + is_escaped = True + elif current_character == '"': + in_string = False + continue + + if current_character == '"': + in_string = True + continue + + if current_character == "{": + brace_depth += 1 + continue + + if current_character != "}": + continue + + brace_depth -= 1 + if brace_depth == 0: + closing_index = current_index + 1 + if closing_index < len(message_content) and message_content[closing_index] == "}": + return closing_index + 1 + return None + + return None + + +def refresh_azure_maps_message_content(message_content: Any) -> Any: + if not isinstance(message_content, str) or AZURE_MAPS_INLINE_BLOCK_PREFIX not in message_content: + return message_content + + refreshed_content_parts = [] + current_position = 0 + content_changed = False + + while current_position < len(message_content): + block_start = message_content.find(AZURE_MAPS_INLINE_BLOCK_PREFIX, current_position) + if block_start == -1: + refreshed_content_parts.append(message_content[current_position:]) + break + + refreshed_content_parts.append(message_content[current_position:block_start]) + block_end = _find_inline_map_block_end(message_content, block_start) + if block_end is None: + refreshed_content_parts.append(message_content[block_start:]) + break + + payload_start = block_start + len(AZURE_MAPS_INLINE_BLOCK_PREFIX) + payload_text = message_content[payload_start:block_end - 1] + + try: + parsed_payload = json.loads(payload_text) + except json.JSONDecodeError: + refreshed_content_parts.append(message_content[block_start:block_end]) + current_position = block_end + continue + + refreshed_payload = refresh_azure_maps_map_payload(parsed_payload) + if refreshed_payload != parsed_payload: + content_changed = True + + refreshed_content_parts.append( + f"{AZURE_MAPS_INLINE_BLOCK_PREFIX}{json.dumps(refreshed_payload, ensure_ascii=False)}}}" + ) + current_position = block_end + + if not content_changed: + return message_content + + return "".join(refreshed_content_parts) \ No newline at end of file diff --git a/application/single_app/functions_blob_storage_operations.py b/application/single_app/functions_blob_storage_operations.py new file mode 100644 index 00000000..4d86f1d9 --- /dev/null +++ b/application/single_app/functions_blob_storage_operations.py @@ -0,0 +1,224 @@ +# functions_blob_storage_operations.py +"""Shared configuration helpers for the blob storage action.""" + +from typing import Any, Dict, List, Optional + + +BLOB_STORAGE_PLUGIN_TYPE = "blob_storage" +BLOB_STORAGE_CAPABILITY_DEFINITIONS = [ + { + "key": "list_container_contents", + "function_name": "list_container_contents", + "label": "List container contents", + "description": "List blobs in the configured container and optional prefix.", + }, + { + "key": "read_file_content", + "function_name": "read_file_content", + "label": "Read file content", + "description": "Read the contents of supported files from the configured container.", + }, + { + "key": "upload_file_to_container", + "function_name": "upload_file_to_container", + "label": "Upload file to container", + "description": "Upload supported files into the configured container.", + }, +] + +BLOB_STORAGE_FILE_TYPE_DEFINITIONS = [ + { + "key": "markdown", + "label": "Markdown", + "description": "Supports .md and .markdown files stored as UTF-8 text.", + "extensions": [".md", ".markdown"], + "content_type": "text/markdown; charset=utf-8", + } +] + + +def get_default_blob_storage_capabilities() -> Dict[str, bool]: + """Return the default enabled blob storage capabilities.""" + return { + "list_container_contents": True, + "read_file_content": True, + "upload_file_to_container": False, + } + + +def normalize_blob_storage_capabilities(raw_capabilities: Any = None) -> Dict[str, bool]: + """Normalize stored blob storage capability settings into a complete boolean map.""" + normalized = get_default_blob_storage_capabilities() + + if raw_capabilities is None: + return normalized + + if isinstance(raw_capabilities, dict): + for capability_key in normalized: + if capability_key in raw_capabilities: + normalized[capability_key] = bool(raw_capabilities[capability_key]) + return normalized + + if isinstance(raw_capabilities, (list, tuple, set)): + enabled_items = {str(item or "").strip() for item in raw_capabilities if str(item or "").strip()} + return { + definition["key"]: ( + definition["key"] in enabled_items or definition["function_name"] in enabled_items + ) + for definition in BLOB_STORAGE_CAPABILITY_DEFINITIONS + } + + return normalized + + +def get_blob_storage_enabled_function_names(raw_capabilities: Any = None) -> List[str]: + """Return the enabled blob storage function names in display order.""" + normalized = normalize_blob_storage_capabilities(raw_capabilities) + return [ + definition["function_name"] + for definition in BLOB_STORAGE_CAPABILITY_DEFINITIONS + if normalized.get(definition["key"], False) + ] + + +def resolve_blob_storage_action_capabilities( + action_capability_map: Any, + action_defaults: Any = None, + action_id: Optional[str] = None, + action_name: Optional[str] = None, +) -> Dict[str, bool]: + """Merge per-agent overrides with action-level default blob storage capabilities.""" + resolved_defaults = normalize_blob_storage_capabilities(action_defaults) + + if not isinstance(action_capability_map, dict): + return resolved_defaults + + for candidate_key in (str(action_id or "").strip(), str(action_name or "").strip()): + if candidate_key and candidate_key in action_capability_map: + return normalize_blob_storage_capabilities(action_capability_map.get(candidate_key)) + + return resolved_defaults + + +def get_default_blob_storage_read_file_types() -> Dict[str, bool]: + """Return the default enabled file types for blob reads.""" + return {definition["key"]: True for definition in BLOB_STORAGE_FILE_TYPE_DEFINITIONS} + + +def get_default_blob_storage_upload_file_types() -> Dict[str, bool]: + """Return the default enabled file types for blob uploads.""" + return {definition["key"]: True for definition in BLOB_STORAGE_FILE_TYPE_DEFINITIONS} + + +def _normalize_blob_storage_file_types(raw_file_types: Any, defaults: Dict[str, bool]) -> Dict[str, bool]: + normalized = dict(defaults) + + if raw_file_types is None: + return normalized + + if isinstance(raw_file_types, dict): + for file_type_key in normalized: + if file_type_key in raw_file_types: + normalized[file_type_key] = bool(raw_file_types[file_type_key]) + return normalized + + if isinstance(raw_file_types, (list, tuple, set)): + enabled_items = {str(item or "").strip() for item in raw_file_types if str(item or "").strip()} + for file_type_key in normalized: + normalized[file_type_key] = file_type_key in enabled_items + return normalized + + return normalized + + +def normalize_blob_storage_read_file_types(raw_file_types: Any = None) -> Dict[str, bool]: + """Normalize stored blob read file type settings into a complete boolean map.""" + return _normalize_blob_storage_file_types(raw_file_types, get_default_blob_storage_read_file_types()) + + +def normalize_blob_storage_upload_file_types(raw_file_types: Any = None) -> Dict[str, bool]: + """Normalize stored blob upload file type settings into a complete boolean map.""" + return _normalize_blob_storage_file_types(raw_file_types, get_default_blob_storage_upload_file_types()) + + +def get_enabled_blob_storage_read_file_types(raw_file_types: Any = None) -> List[str]: + """Return enabled file types for blob reads in display order.""" + normalized = normalize_blob_storage_read_file_types(raw_file_types) + return [definition["key"] for definition in BLOB_STORAGE_FILE_TYPE_DEFINITIONS if normalized.get(definition["key"], False)] + + +def get_enabled_blob_storage_upload_file_types(raw_file_types: Any = None) -> List[str]: + """Return enabled file types for blob uploads in display order.""" + normalized = normalize_blob_storage_upload_file_types(raw_file_types) + return [definition["key"] for definition in BLOB_STORAGE_FILE_TYPE_DEFINITIONS if normalized.get(definition["key"], False)] + + +def parse_storage_connection_string(connection_string: str) -> Dict[str, str]: + """Parse an Azure Storage connection string into key/value pairs.""" + parsed: Dict[str, str] = {} + for segment in str(connection_string or "").split(";"): + normalized_segment = segment.strip() + if not normalized_segment or "=" not in normalized_segment: + continue + key, value = normalized_segment.split("=", 1) + parsed[key.strip()] = value.strip() + return parsed + + +def derive_blob_endpoint_from_connection_string(connection_string: str) -> str: + """Derive the blob endpoint from a storage connection string when possible.""" + parsed = parse_storage_connection_string(connection_string) + if not parsed: + return "" + + if str(parsed.get("UseDevelopmentStorage", "")).strip().lower() == "true": + return "http://127.0.0.1:10000/devstoreaccount1" + + blob_endpoint = str(parsed.get("BlobEndpoint") or "").strip() + if blob_endpoint: + return blob_endpoint.rstrip("/") + + account_name = str(parsed.get("AccountName") or "").strip() + if not account_name: + return "" + + protocol = str(parsed.get("DefaultEndpointsProtocol") or "https").strip() or "https" + endpoint_suffix = str(parsed.get("EndpointSuffix") or "core.windows.net").strip() or "core.windows.net" + return f"{protocol}://{account_name}.blob.{endpoint_suffix}".rstrip("/") + + +def normalize_blob_prefix(blob_prefix: str = "") -> str: + """Normalize a stored blob prefix to a clean slash-separated path fragment.""" + return str(blob_prefix or "").strip().strip("/") + + +def detect_blob_storage_file_type(blob_name: str) -> str: + """Return the normalized supported file type for a blob name, or an empty string.""" + candidate = str(blob_name or "").strip().lower() + for definition in BLOB_STORAGE_FILE_TYPE_DEFINITIONS: + for extension in definition["extensions"]: + if candidate.endswith(extension): + return definition["key"] + return "" + + +def is_blob_storage_file_type_enabled(blob_name: str, enabled_file_types: Any) -> bool: + """Return True when the blob name matches one of the enabled file types.""" + file_type = detect_blob_storage_file_type(blob_name) + if not file_type: + return False + + normalized = _normalize_blob_storage_file_types( + enabled_file_types, + {definition["key"]: True for definition in BLOB_STORAGE_FILE_TYPE_DEFINITIONS}, + ) + return bool(normalized.get(file_type, False)) + + +def get_blob_storage_content_type(file_type: str) -> str: + """Return the preferred content type for a supported blob file type.""" + normalized_type = str(file_type or "").strip().lower() + for definition in BLOB_STORAGE_FILE_TYPE_DEFINITIONS: + if definition["key"] == normalized_type: + return definition["content_type"] + return "application/octet-stream" \ No newline at end of file diff --git a/application/single_app/functions_chart_export.py b/application/single_app/functions_chart_export.py new file mode 100644 index 00000000..11619b2d --- /dev/null +++ b/application/single_app/functions_chart_export.py @@ -0,0 +1,544 @@ +"""Helpers for rendering inline chart markdown blocks into export-friendly images.""" + +import base64 +import io +import json +import math +import re +from functools import lru_cache +from html import escape as escape_html +from typing import Any, Dict, List, Optional, Sequence, Tuple + +from functions_chart_operations import INLINE_CHART_BLOCK_LANGUAGE + + +INLINE_CHART_EXPORT_REGEX = re.compile( + rf"```{re.escape(INLINE_CHART_BLOCK_LANGUAGE)}\s*([\s\S]*?)```", + re.IGNORECASE, +) +CSS_RGB_COLOR_REGEX = re.compile( + r"rgba?\(\s*([0-9.]+)\s*,\s*([0-9.]+)\s*,\s*([0-9.]+)(?:\s*,\s*([0-9.]+))?\s*\)", + re.IGNORECASE, +) +EXPORT_CHART_DPI = 144 +EXPORT_CHART_SIZE_INCHES = (8.2, 4.8) + + +def replace_inline_chart_blocks_with_export_html(content: str) -> str: + """Replace simplechart fences with embeddable PNG-backed HTML blocks.""" + rendered_content = str(content or '') + if not rendered_content or INLINE_CHART_EXPORT_REGEX.search(rendered_content) is None: + return rendered_content + + def replace_match(match: re.Match[str]) -> str: + export_html = _build_export_chart_html_from_payload(match.group(1) or '') + return export_html or match.group(0) + + return INLINE_CHART_EXPORT_REGEX.sub(replace_match, rendered_content) + + +def decode_base64_image_data_uri(data_uri: str) -> Optional[bytes]: + """Decode a base64 image data URI into bytes for DOCX embedding.""" + candidate = str(data_uri or '').strip() + if not candidate.startswith('data:image/') or ';base64,' not in candidate: + return None + + try: + _, encoded_payload = candidate.split(',', 1) + return base64.b64decode(encoded_payload) + except (ValueError, TypeError, base64.binascii.Error): + return None + + +def _build_export_chart_html_from_payload(payload_text: str) -> str: + payload_json = str(payload_text or '').strip() + if not payload_json: + return '' + + image_data_uri, chart_spec = _render_chart_payload_to_data_uri(payload_json) + if not image_data_uri or not isinstance(chart_spec, dict): + return '' + + alt_text = _build_chart_alt_text(chart_spec) + caption_text = _build_chart_caption_text(chart_spec) + caption_html = '' + if caption_text: + caption_html = ( + '

' + f'{escape_html(caption_text)}' + '

' + ) + + return ( + '\n\n' + '
' + f'

{escape_html(alt_text)}

' + f'{caption_html}' + '
' + '\n\n' + ) + + +@lru_cache(maxsize=128) +def _render_chart_payload_to_data_uri(payload_json: str) -> Tuple[str, Optional[Dict[str, Any]]]: + try: + parsed_payload = json.loads(payload_json) + except (TypeError, ValueError): + return '', None + + if not isinstance(parsed_payload, dict): + return '', None + + try: + png_bytes = _render_chart_spec_to_png_bytes(parsed_payload) + except Exception: + return '', parsed_payload + + encoded_payload = base64.b64encode(png_bytes).decode('ascii') + return f'data:image/png;base64,{encoded_payload}', parsed_payload + + +def _build_chart_alt_text(chart_spec: Dict[str, Any]) -> str: + for field_name in ('title', 'subtitle', 'summary', 'description'): + value = str(chart_spec.get(field_name) or '').strip() + if value: + return value[:240] + + kind = str(chart_spec.get('kind') or chart_spec.get('chartType') or 'chart').strip() + return f'{kind.title()} chart' + + +def _build_chart_caption_text(chart_spec: Dict[str, Any]) -> str: + caption_parts: List[str] = [] + title = str(chart_spec.get('title') or '').strip() + subtitle = str(chart_spec.get('subtitle') or '').strip() + summary = str(chart_spec.get('summary') or '').strip() + description = str(chart_spec.get('description') or '').strip() + + if title: + caption_parts.append(title) + if subtitle: + caption_parts.append(subtitle) + if summary and summary not in caption_parts: + caption_parts.append(summary) + elif description and description not in caption_parts: + caption_parts.append(description) + + if not caption_parts: + return '' + return ' - '.join(caption_parts[:3]) + + +def _render_chart_spec_to_png_bytes(chart_spec: Dict[str, Any]) -> bytes: + from matplotlib.backends.backend_agg import FigureCanvasAgg + from matplotlib.figure import Figure + + chart_kind = str(chart_spec.get('kind') or chart_spec.get('chartType') or 'bar').strip().lower() + if not chart_kind: + raise ValueError('Chart specification is missing kind.') + + figure = Figure(figsize=EXPORT_CHART_SIZE_INCHES, dpi=EXPORT_CHART_DPI, facecolor='white') + if chart_kind in {'radar', 'polar_area'}: + axis = figure.add_subplot(111, projection='polar') + else: + axis = figure.add_subplot(111) + + options = chart_spec.get('options') if isinstance(chart_spec.get('options'), dict) else {} + datasets = chart_spec.get('data', {}).get('datasets') if isinstance(chart_spec.get('data'), dict) else [] + labels = chart_spec.get('data', {}).get('labels') if isinstance(chart_spec.get('data'), dict) else [] + + datasets = datasets if isinstance(datasets, list) else [] + labels = labels if isinstance(labels, list) else [] + + if chart_kind in {'pie', 'doughnut'}: + _render_pie_like_chart(axis, chart_spec, chart_kind) + elif chart_kind == 'polar_area': + _render_polar_area_chart(axis, chart_spec) + elif chart_kind == 'radar': + _render_radar_chart(axis, chart_spec) + elif chart_kind in {'scatter', 'bubble'}: + _render_scatter_like_chart(axis, chart_spec, chart_kind) + else: + _render_cartesian_chart(axis, chart_spec, chart_kind) + + if chart_kind not in {'pie', 'doughnut'}: + axis.grid(True, alpha=0.25) + _apply_chart_titles(figure, axis, chart_spec) + _apply_axis_labels(axis, options, chart_kind) + _apply_legend(axis, options, datasets, chart_kind, labels) + + if chart_kind not in {'pie', 'doughnut', 'polar_area'} and bool(options.get('beginAtZero', True)): + try: + _, current_upper = axis.get_ylim() + lower_bound = 0 if current_upper >= 0 else current_upper + axis.set_ylim(bottom=lower_bound) + except Exception: + pass + + figure.tight_layout(rect=(0, 0, 1, 0.94)) + canvas = FigureCanvasAgg(figure) + buffer = io.BytesIO() + canvas.print_png(buffer) + buffer.seek(0) + return buffer.read() + + +def _render_cartesian_chart(axis, chart_spec: Dict[str, Any], chart_kind: str): + chart_data = chart_spec.get('data') if isinstance(chart_spec.get('data'), dict) else {} + datasets = chart_data.get('datasets') if isinstance(chart_data.get('datasets'), list) else [] + labels = chart_data.get('labels') if isinstance(chart_data.get('labels'), list) else [] + options = chart_spec.get('options') if isinstance(chart_spec.get('options'), dict) else {} + + if not datasets: + raise ValueError('Chart specification does not contain datasets.') + + max_points = max(len(dataset.get('data') or []) for dataset in datasets) + if not labels: + labels = [f'Item {index + 1}' for index in range(max_points)] + + x_positions = list(range(len(labels))) + is_horizontal = bool(options.get('horizontal', False)) and chart_kind in {'bar', 'stacked_bar'} + is_stacked = bool(options.get('stacked', False)) or chart_kind in {'stacked_bar', 'stacked_line'} + + if chart_kind == 'stacked_line': + cumulative_values = [0.0] * len(labels) + stackplot_values = [] + fill_colors = [] + legend_labels = [] + + for dataset in datasets: + series_values = _coerce_series(dataset.get('data'), len(labels), fill_none_with_zero=True) + stackplot_values.append(series_values) + fill_colors.append(_resolve_chart_color(dataset.get('backgroundColor'), 'rgba(28, 110, 164, 0.18)')) + legend_labels.append(str(dataset.get('label') or 'Series').strip() or 'Series') + + axis.stackplot(x_positions, *stackplot_values, colors=fill_colors, alpha=0.55) + + for dataset_index, dataset in enumerate(datasets): + series_values = stackplot_values[dataset_index] + cumulative_values = [ + current_total + current_value + for current_total, current_value in zip(cumulative_values, series_values) + ] + axis.plot( + x_positions, + cumulative_values, + label=legend_labels[dataset_index], + color=_resolve_chart_color(dataset.get('borderColor'), '#1c6ea4'), + linewidth=2, + marker='o', + markersize=3, + ) + else: + stack_offsets = [0.0] * len(labels) + for dataset_index, dataset in enumerate(datasets): + dataset_type = str(dataset.get('type') or '').strip().lower() + if chart_kind in {'bar', 'stacked_bar'} and dataset_type not in {'line', 'bar'}: + dataset_type = 'bar' + elif chart_kind in {'line', 'area'} and dataset_type not in {'line', 'bar'}: + dataset_type = 'line' + elif dataset_type not in {'line', 'bar'}: + dataset_type = 'line' + + border_color = _resolve_chart_color(dataset.get('borderColor'), '#1c6ea4') + background_color = _resolve_chart_color(dataset.get('backgroundColor'), 'rgba(28, 110, 164, 0.18)') + label = str(dataset.get('label') or f'Series {dataset_index + 1}').strip() or f'Series {dataset_index + 1}' + + if dataset_type == 'bar': + values = _coerce_series(dataset.get('data'), len(labels), fill_none_with_zero=True) + if is_horizontal: + axis.barh( + x_positions, + values, + left=stack_offsets if is_stacked else None, + label=label, + color=background_color, + edgecolor=border_color, + linewidth=1.0, + ) + else: + axis.bar( + x_positions, + values, + bottom=stack_offsets if is_stacked else None, + label=label, + color=background_color, + edgecolor=border_color, + linewidth=1.0, + ) + if is_stacked: + stack_offsets = [current_total + current_value for current_total, current_value in zip(stack_offsets, values)] + else: + values = _coerce_series(dataset.get('data'), len(labels), fill_none_with_zero=False) + axis.plot( + x_positions, + values, + label=label, + color=border_color, + linewidth=2, + marker='o', + markersize=3, + ) + if chart_kind == 'area' or bool(dataset.get('fill')): + fill_values = [0.0 if _is_nan(value) else value for value in values] + axis.fill_between(x_positions, fill_values, color=background_color, alpha=0.35) + + should_rotate_labels = _should_rotate_axis_labels(labels) + if is_horizontal: + axis.set_yticks(x_positions) + axis.set_yticklabels([str(label) for label in labels]) + else: + axis.set_xticks(x_positions) + axis.set_xticklabels( + [str(label) for label in labels], + rotation=30 if should_rotate_labels else 0, + ha='right' if should_rotate_labels else 'center', + ) + + +def _render_pie_like_chart(axis, chart_spec: Dict[str, Any], chart_kind: str): + from matplotlib.patches import Circle + + chart_data = chart_spec.get('data') if isinstance(chart_spec.get('data'), dict) else {} + datasets = chart_data.get('datasets') if isinstance(chart_data.get('datasets'), list) else [] + labels = chart_data.get('labels') if isinstance(chart_data.get('labels'), list) else [] + if not datasets: + raise ValueError('Chart specification does not contain datasets.') + + dataset = datasets[0] + values = [max(0.0, value) for value in _coerce_series(dataset.get('data'), len(labels), fill_none_with_zero=True)] + if sum(values) <= 0: + values = [1.0 for _ in values] or [1.0] + + colors = _resolve_color_list(dataset.get('backgroundColor'), len(values), default_color='rgba(28, 110, 164, 0.18)') + axis.pie( + values, + labels=[str(label) for label in labels] if labels else None, + colors=colors, + startangle=90, + autopct='%1.1f%%' if sum(values) > 0 else None, + wedgeprops={'linewidth': 1.0, 'edgecolor': '#ffffff'}, + ) + axis.axis('equal') + + if chart_kind == 'doughnut': + center_circle = Circle((0, 0), 0.6, fc='white') + axis.add_artist(center_circle) + + +def _render_polar_area_chart(axis, chart_spec: Dict[str, Any]): + chart_data = chart_spec.get('data') if isinstance(chart_spec.get('data'), dict) else {} + datasets = chart_data.get('datasets') if isinstance(chart_data.get('datasets'), list) else [] + labels = chart_data.get('labels') if isinstance(chart_data.get('labels'), list) else [] + if not datasets: + raise ValueError('Chart specification does not contain datasets.') + + dataset = datasets[0] + values = [max(0.0, value) for value in _coerce_series(dataset.get('data'), len(labels), fill_none_with_zero=True)] + if not values: + raise ValueError('Polar area chart does not contain values.') + + bar_count = len(values) + theta_positions = [2 * math.pi * index / bar_count for index in range(bar_count)] + widths = [(2 * math.pi) / bar_count for _ in range(bar_count)] + colors = _resolve_color_list(dataset.get('backgroundColor'), bar_count, default_color='rgba(28, 110, 164, 0.18)') + edge_colors = _resolve_color_list(dataset.get('borderColor'), bar_count, default_color='#1c6ea4') + + axis.bar(theta_positions, values, width=widths, color=colors, edgecolor=edge_colors, linewidth=1.0, alpha=0.85) + axis.set_xticks(theta_positions) + axis.set_xticklabels([str(label) for label in labels] if labels else [f'Item {index + 1}' for index in range(bar_count)]) + + +def _render_radar_chart(axis, chart_spec: Dict[str, Any]): + chart_data = chart_spec.get('data') if isinstance(chart_spec.get('data'), dict) else {} + datasets = chart_data.get('datasets') if isinstance(chart_data.get('datasets'), list) else [] + labels = chart_data.get('labels') if isinstance(chart_data.get('labels'), list) else [] + if not datasets: + raise ValueError('Chart specification does not contain datasets.') + + label_count = len(labels) or max(len(dataset.get('data') or []) for dataset in datasets) + if label_count == 0: + raise ValueError('Radar chart does not contain labels or values.') + + if not labels: + labels = [f'Item {index + 1}' for index in range(label_count)] + + angles = [2 * math.pi * index / label_count for index in range(label_count)] + angles.append(angles[0]) + + for dataset_index, dataset in enumerate(datasets): + values = _coerce_series(dataset.get('data'), label_count, fill_none_with_zero=True) + values.append(values[0]) + border_color = _resolve_chart_color(dataset.get('borderColor'), '#1c6ea4') + background_color = _resolve_chart_color(dataset.get('backgroundColor'), 'rgba(28, 110, 164, 0.18)') + label = str(dataset.get('label') or f'Series {dataset_index + 1}').strip() or f'Series {dataset_index + 1}' + + axis.plot(angles, values, color=border_color, linewidth=2, label=label) + axis.fill(angles, values, color=background_color, alpha=0.25) + + axis.set_xticks(angles[:-1]) + axis.set_xticklabels([str(label) for label in labels]) + + +def _render_scatter_like_chart(axis, chart_spec: Dict[str, Any], chart_kind: str): + chart_data = chart_spec.get('data') if isinstance(chart_spec.get('data'), dict) else {} + datasets = chart_data.get('datasets') if isinstance(chart_data.get('datasets'), list) else [] + if not datasets: + raise ValueError('Chart specification does not contain datasets.') + + for dataset_index, dataset in enumerate(datasets): + points = dataset.get('data') if isinstance(dataset.get('data'), list) else [] + x_values = [] + y_values = [] + point_sizes = [] + for point in points: + if not isinstance(point, dict): + continue + x_value = _coerce_float(point.get('x')) + y_value = _coerce_float(point.get('y')) + if x_value is None or y_value is None: + continue + x_values.append(x_value) + y_values.append(y_value) + radius = _coerce_float(point.get('r')) if chart_kind == 'bubble' else None + point_sizes.append(max(24.0, (radius or 6.0) * 18.0)) + + if not x_values: + continue + + border_color = _resolve_chart_color(dataset.get('borderColor'), '#1c6ea4') + background_color = _resolve_chart_color(dataset.get('backgroundColor'), 'rgba(28, 110, 164, 0.18)') + label = str(dataset.get('label') or f'Series {dataset_index + 1}').strip() or f'Series {dataset_index + 1}' + axis.scatter( + x_values, + y_values, + s=point_sizes, + label=label, + color=background_color, + edgecolors=border_color, + linewidths=1.0, + alpha=0.85, + ) + + +def _apply_chart_titles(figure, axis, chart_spec: Dict[str, Any]): + title = str(chart_spec.get('title') or '').strip() + subtitle = str(chart_spec.get('subtitle') or '').strip() + if title: + figure.suptitle(title, fontsize=14, y=0.98) + if subtitle: + axis.set_title(subtitle, fontsize=10, loc='left', pad=12) + + +def _apply_axis_labels(axis, options: Dict[str, Any], chart_kind: str): + if chart_kind in {'pie', 'doughnut', 'radar', 'polar_area'}: + return + + x_axis_label = str(options.get('xAxisLabel') or '').strip() + y_axis_label = str(options.get('yAxisLabel') or '').strip() + horizontal = bool(options.get('horizontal', False)) and chart_kind in {'bar', 'stacked_bar'} + + if horizontal: + if y_axis_label: + axis.set_xlabel(y_axis_label) + if x_axis_label: + axis.set_ylabel(x_axis_label) + return + + if x_axis_label: + axis.set_xlabel(x_axis_label) + if y_axis_label: + axis.set_ylabel(y_axis_label) + + +def _apply_legend(axis, options: Dict[str, Any], datasets: Sequence[Dict[str, Any]], chart_kind: str, labels: Sequence[Any]): + if not bool(options.get('showLegend', True)): + return + if chart_kind in {'pie', 'doughnut'} and len(labels) <= 1: + return + if len(datasets) <= 1 and chart_kind not in {'pie', 'doughnut', 'polar_area'}: + return + + legend_position = str(options.get('legendPosition') or 'top').strip().lower() + legend_locations = { + 'top': 'upper center', + 'bottom': 'lower center', + 'left': 'center left', + 'right': 'center right', + } + axis.legend(loc=legend_locations.get(legend_position, 'upper center'), frameon=False) + + +def _resolve_chart_color(value: Any, default_color: str): + candidate = value[0] if isinstance(value, list) and value else value + if not isinstance(candidate, str): + candidate = default_color + + parsed_color = _parse_css_rgb_color(candidate) + if parsed_color is not None: + return parsed_color + + return str(candidate or default_color).strip() or default_color + + +def _resolve_color_list(value: Any, count: int, default_color: str) -> List[Any]: + if isinstance(value, list) and value: + colors = [_resolve_chart_color(item, default_color) for item in value[:count]] + while len(colors) < count: + colors.append(_resolve_chart_color(default_color, default_color)) + return colors + + return [_resolve_chart_color(value, default_color) for _ in range(count)] + + +def _parse_css_rgb_color(color_value: str) -> Optional[Tuple[float, float, float, float]]: + match = CSS_RGB_COLOR_REGEX.fullmatch(str(color_value or '').strip()) + if not match: + return None + + red = max(0.0, min(255.0, float(match.group(1)))) / 255.0 + green = max(0.0, min(255.0, float(match.group(2)))) / 255.0 + blue = max(0.0, min(255.0, float(match.group(3)))) / 255.0 + alpha = match.group(4) + alpha_value = max(0.0, min(1.0, float(alpha))) if alpha is not None else 1.0 + return (red, green, blue, alpha_value) + + +def _coerce_series(values: Any, target_length: int, fill_none_with_zero: bool) -> List[float]: + series = list(values) if isinstance(values, list) else [] + coerced_values: List[float] = [] + for index in range(target_length): + value = _coerce_float(series[index] if index < len(series) else None) + if value is None: + coerced_values.append(0.0 if fill_none_with_zero else float('nan')) + else: + coerced_values.append(value) + return coerced_values + + +def _coerce_float(value: Any) -> Optional[float]: + if value in (None, ''): + return None + if isinstance(value, bool): + return float(value) + if isinstance(value, (int, float)): + if isinstance(value, float) and math.isnan(value): + return None + return float(value) + + try: + candidate = str(value).strip().replace(',', '') + if not candidate: + return None + numeric_value = float(candidate) + return None if math.isnan(numeric_value) else numeric_value + except (TypeError, ValueError): + return None + + +def _should_rotate_axis_labels(labels: Sequence[Any]) -> bool: + return any(len(str(label or '')) > 12 for label in labels) + + +def _is_nan(value: Any) -> bool: + return isinstance(value, float) and math.isnan(value) \ No newline at end of file diff --git a/application/single_app/functions_chart_operations.py b/application/single_app/functions_chart_operations.py new file mode 100644 index 00000000..9df1c991 --- /dev/null +++ b/application/single_app/functions_chart_operations.py @@ -0,0 +1,169 @@ +# functions_chart_operations.py +"""Shared configuration helpers for the built-in chart action.""" + +import json + + +CHART_PLUGIN_TYPE = 'chart' +CHART_DEFAULT_ENDPOINT = 'chart://internal' +INLINE_CHART_BLOCK_LANGUAGE = 'simplechart' + +CHART_KIND_ALIASES = { + 'line': 'line', + 'lines': 'line', + 'bar': 'bar', + 'bars': 'bar', + 'pie': 'pie', + 'doughnut': 'doughnut', + 'donut': 'doughnut', + 'scatter': 'scatter', + 'scatterplot': 'scatter', + 'scatter_plot': 'scatter', + 'bubble': 'bubble', + 'area': 'area', + 'radar': 'radar', + 'polar_area': 'polar_area', + 'polararea': 'polar_area', + 'stacked_bar': 'stacked_bar', + 'stacked bar': 'stacked_bar', + 'stackedbar': 'stacked_bar', + 'stacked_line': 'stacked_line', + 'stacked line': 'stacked_line', + 'stackedline': 'stacked_line', +} + +CHART_CAPABILITY_DEFINITIONS = [ + { + 'key': 'line', + 'label': 'Line charts', + 'description': 'Render single-series or multi-series line charts.', + 'chart_kind': 'line', + }, + { + 'key': 'bar', + 'label': 'Bar charts', + 'description': 'Render categorical bar charts, including grouped multi-series bars.', + 'chart_kind': 'bar', + }, + { + 'key': 'pie', + 'label': 'Pie charts', + 'description': 'Render proportional pie charts for part-to-whole comparisons.', + 'chart_kind': 'pie', + }, + { + 'key': 'doughnut', + 'label': 'Doughnut charts', + 'description': 'Render proportional doughnut charts using the existing Chart.js stack.', + 'chart_kind': 'doughnut', + }, + { + 'key': 'scatter', + 'label': 'Scatter plots', + 'description': 'Render XY scatter plots with optional series grouping.', + 'chart_kind': 'scatter', + }, + { + 'key': 'area', + 'label': 'Area charts', + 'description': 'Render filled line charts for trend visualization.', + 'chart_kind': 'area', + }, + { + 'key': 'bubble', + 'label': 'Bubble charts', + 'description': 'Render bubble charts with x, y, and size dimensions.', + 'chart_kind': 'bubble', + }, + { + 'key': 'radar', + 'label': 'Radar charts', + 'description': 'Render radar charts for multi-axis comparisons.', + 'chart_kind': 'radar', + }, + { + 'key': 'stacked_bar', + 'label': 'Stacked bar charts', + 'description': 'Render stacked bar charts for cumulative category comparisons.', + 'chart_kind': 'stacked_bar', + }, + { + 'key': 'stacked_line', + 'label': 'Stacked line charts', + 'description': 'Render stacked line charts for cumulative trends across series.', + 'chart_kind': 'stacked_line', + }, +] + + +def get_default_chart_capabilities(): + """Return the default enabled chart kinds for built-in chart actions.""" + return { + definition['key']: True + for definition in CHART_CAPABILITY_DEFINITIONS + } + + +def normalize_chart_capabilities(raw_capabilities): + """Normalize stored chart capability settings into a complete boolean map.""" + normalized = get_default_chart_capabilities() + if not isinstance(raw_capabilities, dict): + return normalized + + for definition in CHART_CAPABILITY_DEFINITIONS: + key = definition['key'] + if key in raw_capabilities: + normalized[key] = bool(raw_capabilities.get(key)) + + return normalized + + +def resolve_chart_action_capabilities( + action_capability_map=None, + default_capabilities=None, + action_id=None, + action_name=None, +): + """Merge per-agent overrides with action-level default chart capabilities.""" + resolved = normalize_chart_capabilities(default_capabilities) + if not isinstance(action_capability_map, dict): + return resolved + + for candidate_key in (str(action_id or '').strip(), str(action_name or '').strip()): + if candidate_key and candidate_key in action_capability_map: + return normalize_chart_capabilities(action_capability_map.get(candidate_key)) + + return resolved + + +def get_enabled_chart_type_keys(raw_capabilities=None): + """Return the enabled chart capability keys in display order.""" + normalized = normalize_chart_capabilities(raw_capabilities) + return [ + definition['key'] + for definition in CHART_CAPABILITY_DEFINITIONS + if normalized.get(definition['key']) + ] + + +def normalize_chart_kind(chart_kind): + """Normalize user-supplied chart type aliases to a supported capability key.""" + candidate = str(chart_kind or '').strip().lower().replace('-', '_') + if not candidate: + return '' + + candidate = CHART_KIND_ALIASES.get(candidate, candidate) + for definition in CHART_CAPABILITY_DEFINITIONS: + if candidate in {definition['key'], definition['chart_kind']}: + return definition['key'] + + return candidate + + +def build_inline_chart_markdown(chart_payload): + """Serialize a validated chart payload into an inline chat fence.""" + return ( + f"```{INLINE_CHART_BLOCK_LANGUAGE}\n" + f"{json.dumps(chart_payload, separators=(',', ':'))}\n" + f"```" + ) \ No newline at end of file diff --git a/application/single_app/functions_collaboration.py b/application/single_app/functions_collaboration.py new file mode 100644 index 00000000..f7fe46a9 --- /dev/null +++ b/application/single_app/functions_collaboration.py @@ -0,0 +1,2288 @@ +# functions_collaboration.py + +"""Persistence, authorization, and serialization helpers for collaborative conversations.""" + +from copy import deepcopy +import uuid + +from config import * +from collaboration_models import ( + COLLABORATION_KIND, + GROUP_MULTI_USER_CHAT_TYPE, + MEMBERSHIP_ROLE_ADMIN, + MEMBERSHIP_ROLE_MEMBER, + MEMBERSHIP_ROLE_OWNER, + MEMBERSHIP_STATUS_ACCEPTED, + MEMBERSHIP_STATUS_DECLINED, + MEMBERSHIP_STATUS_PENDING, + MEMBERSHIP_STATUS_REMOVED, + MESSAGE_KIND_AI_REQUEST, + MESSAGE_KIND_HUMAN, + PERSONAL_MULTI_USER_CHAT_TYPE, + add_personal_pending_participants, + apply_personal_invite_response, + build_collaboration_message_doc, + build_collaboration_message_doc_from_legacy, + build_collaboration_user_state, + build_group_collaboration_conversation, + build_personal_collaboration_conversation, + ensure_group_participant_record, + get_collaboration_user_state_doc_id, + normalize_collaboration_user, + refresh_personal_participant_indexes, + remove_personal_participant, + utc_now_iso, +) +from functions_appinsights import log_event +from functions_group import ( + assert_group_role, + check_group_status_allows_operation, + find_group_by_id, + get_user_groups, +) +from functions_message_artifacts import filter_assistant_artifact_items +from functions_notifications import create_collaboration_message_notification +from functions_thoughts import delete_thoughts_for_conversation, get_thoughts_for_message + + +PERSONAL_COLLABORATION_MANAGER_ROLES = { + MEMBERSHIP_ROLE_OWNER, + MEMBERSHIP_ROLE_ADMIN, +} + + +def is_collaboration_conversation(conversation_doc): + return bool((conversation_doc or {}).get('conversation_kind') == COLLABORATION_KIND) + + +def is_personal_collaboration_conversation(conversation_doc): + return bool((conversation_doc or {}).get('chat_type') == PERSONAL_MULTI_USER_CHAT_TYPE) + + +def is_group_collaboration_conversation(conversation_doc): + return bool((conversation_doc or {}).get('chat_type') == GROUP_MULTI_USER_CHAT_TYPE) + + +def get_collaboration_visibility_mode(conversation_doc): + scope = (conversation_doc or {}).get('scope', {}) if isinstance((conversation_doc or {}).get('scope'), dict) else {} + visibility_mode = str(scope.get('visibility_mode') or '').strip().lower() + if visibility_mode: + return visibility_mode + if is_group_collaboration_conversation(conversation_doc): + return 'group_membership' + return 'invited_members' + + +def is_invited_group_collaboration_conversation(conversation_doc): + return bool( + is_group_collaboration_conversation(conversation_doc) + and get_collaboration_visibility_mode(conversation_doc) == 'invited_members' + ) + + +def is_explicit_membership_collaboration(conversation_doc): + return bool( + is_personal_collaboration_conversation(conversation_doc) + or is_invited_group_collaboration_conversation(conversation_doc) + ) + + +def get_collaboration_conversation(conversation_id): + return cosmos_collaboration_conversations_container.read_item( + item=conversation_id, + partition_key=conversation_id, + ) + + +def get_collaboration_user_state(user_id, conversation_id): + return cosmos_collaboration_user_state_container.read_item( + item=get_collaboration_user_state_doc_id(user_id, conversation_id), + partition_key=user_id, + ) + + +def get_collaboration_user_state_or_none(user_id, conversation_id): + try: + return get_collaboration_user_state(user_id, conversation_id) + except CosmosResourceNotFoundError: + return None + + +def get_collaboration_message(message_id): + query = 'SELECT TOP 1 * FROM c WHERE c.id = @message_id' + items = list(cosmos_collaboration_messages_container.query_items( + query=query, + parameters=[{'name': '@message_id', 'value': message_id}], + enable_cross_partition_query=True, + )) + if not items: + raise CosmosResourceNotFoundError(message='Collaborative message not found') + return items[0] + + +def get_collaboration_message_by_source_message(conversation_id, source_message_id): + normalized_conversation_id = str(conversation_id or '').strip() + normalized_source_message_id = str(source_message_id or '').strip() + if not normalized_conversation_id or not normalized_source_message_id: + return None + + query = ( + 'SELECT TOP 1 * FROM c WHERE c.conversation_id = @conversation_id ' + 'AND c.metadata.source_message_id = @source_message_id' + ) + items = list(cosmos_collaboration_messages_container.query_items( + query=query, + parameters=[ + {'name': '@conversation_id', 'value': normalized_conversation_id}, + {'name': '@source_message_id', 'value': normalized_source_message_id}, + ], + partition_key=normalized_conversation_id, + )) + return items[0] if items else None + + +def get_personal_collaboration_participant(conversation_doc, participant_user_id): + normalized_user_id = str(participant_user_id or '').strip() + for participant in list((conversation_doc or {}).get('participants', []) or []): + if str(participant.get('user_id') or '').strip() == normalized_user_id: + return participant + return None + + +def get_personal_collaboration_role(conversation_doc, participant_user_id, user_state=None): + if user_state and str(user_state.get('role') or '').strip(): + return str(user_state.get('role') or '').strip() + + participant = get_personal_collaboration_participant(conversation_doc, participant_user_id) + if not participant: + return '' + return str(participant.get('role') or '').strip() + + +def _build_group_member_lookup(group_doc): + member_lookup = {} + if not isinstance(group_doc, dict): + return member_lookup + + owner = group_doc.get('owner', {}) if isinstance(group_doc.get('owner'), dict) else {} + owner_summary = normalize_collaboration_user({ + 'userId': owner.get('id'), + 'displayName': owner.get('displayName'), + 'email': owner.get('email'), + }) + if owner_summary: + member_lookup[owner_summary['user_id']] = owner_summary + + for raw_member in list(group_doc.get('users', []) or []): + member_summary = normalize_collaboration_user(raw_member) + if not member_summary: + continue + member_lookup[member_summary['user_id']] = member_summary + + return member_lookup + + +def _resolve_group_member_summary(group_doc, user_id): + normalized_user_id = str(user_id or '').strip() + if not normalized_user_id: + return None + return _build_group_member_lookup(group_doc).get(normalized_user_id) + + +def _normalize_group_conversation_participants(group_doc, participants_to_add): + member_lookup = _build_group_member_lookup(group_doc) + normalized_participants = [] + missing_participant_labels = [] + + for raw_participant in participants_to_add or []: + participant_summary = normalize_collaboration_user(raw_participant) + if not participant_summary: + continue + + group_member_summary = member_lookup.get(participant_summary['user_id']) + if not group_member_summary: + missing_participant_labels.append( + participant_summary.get('display_name') + or participant_summary.get('email') + or participant_summary['user_id'] + ) + continue + + normalized_participants.append(group_member_summary) + + if missing_participant_labels: + missing_labels = ', '.join(sorted(set(missing_participant_labels))) + raise ValueError( + f'Only current group members can be added to this shared conversation: {missing_labels}' + ) + + return normalized_participants + + +def ensure_collaboration_user_state_for_participant( + conversation_doc, + participant_summary, + role, + membership_status, + invited_by_user_id=None, + created_at=None, +): + normalized_participant = normalize_collaboration_user(participant_summary) + if not normalized_participant: + raise ValueError('participant_summary is required') + + normalized_conversation_id = str((conversation_doc or {}).get('id') or '').strip() + if not normalized_conversation_id: + raise ValueError('conversation_id is required') + + timestamp = str(created_at or '').strip() or utc_now_iso() + state_doc = get_collaboration_user_state_or_none( + normalized_participant['user_id'], + normalized_conversation_id, + ) + if state_doc is None: + state_doc = build_collaboration_user_state( + conversation_doc=conversation_doc, + user_summary=normalized_participant, + role=role, + membership_status=membership_status, + invited_by_user_id=invited_by_user_id, + created_at=timestamp, + ) + else: + state_doc['user_display_name'] = normalized_participant['display_name'] + state_doc['user_email'] = normalized_participant['email'] + state_doc['title_snapshot'] = conversation_doc.get('title') + state_doc['role'] = str(role or state_doc.get('role') or MEMBERSHIP_ROLE_MEMBER).strip() or MEMBERSHIP_ROLE_MEMBER + state_doc['membership_status'] = str( + membership_status or state_doc.get('membership_status') or MEMBERSHIP_STATUS_PENDING + ).strip() or MEMBERSHIP_STATUS_PENDING + state_doc['updated_at'] = timestamp + state_doc['scope_type'] = ((conversation_doc or {}).get('scope') or {}).get('type') + state_doc['group_id'] = ((conversation_doc or {}).get('scope') or {}).get('group_id') + state_doc['group_name'] = ((conversation_doc or {}).get('scope') or {}).get('group_name') + state_doc['chat_type'] = conversation_doc.get('chat_type') + if invited_by_user_id is not None: + state_doc['invited_by_user_id'] = str(invited_by_user_id or '').strip() + + if state_doc.get('membership_status') == MEMBERSHIP_STATUS_ACCEPTED and not state_doc.get('joined_at'): + state_doc['joined_at'] = timestamp + + cosmos_collaboration_user_state_container.upsert_item(state_doc) + return state_doc + + +def _bootstrap_collaboration_user_state_from_participant(conversation_doc, participant_record, invited_by_user_id=None): + if not isinstance(participant_record, dict): + return None + + membership_status = str(participant_record.get('status') or '').strip() or MEMBERSHIP_STATUS_PENDING + if membership_status not in (MEMBERSHIP_STATUS_ACCEPTED, MEMBERSHIP_STATUS_PENDING): + return None + + created_at = ( + participant_record.get('joined_at') + or participant_record.get('invited_at') + or (conversation_doc or {}).get('created_at') + or utc_now_iso() + ) + state_doc = ensure_collaboration_user_state_for_participant( + conversation_doc, + participant_record, + role=participant_record.get('role') or MEMBERSHIP_ROLE_MEMBER, + membership_status=membership_status, + invited_by_user_id=invited_by_user_id, + created_at=created_at, + ) + + if participant_record.get('responded_at'): + state_doc['responded_at'] = participant_record.get('responded_at') + if participant_record.get('removed_at'): + state_doc['removed_at'] = participant_record.get('removed_at') + cosmos_collaboration_user_state_container.upsert_item(state_doc) + return state_doc + + +def build_collaboration_image_url(conversation_id, message_id): + normalized_conversation_id = str(conversation_id or '').strip() + normalized_message_id = str(message_id or '').strip() + if not normalized_conversation_id or not normalized_message_id: + return '' + + return f'/api/collaboration/conversations/{normalized_conversation_id}/images/{normalized_message_id}' + + +def serialize_collaboration_message(message_doc): + metadata = message_doc.get('metadata', {}) if isinstance(message_doc, dict) else {} + display_role = _get_collaboration_display_role(message_doc) + serialized_role = 'image' if display_role == 'image' else message_doc.get('role') + serialized_content = message_doc.get('content', '') + if display_role == 'image': + serialized_content = build_collaboration_image_url( + message_doc.get('conversation_id'), + message_doc.get('id'), + ) or serialized_content + + return { + 'id': message_doc.get('id'), + 'conversation_id': message_doc.get('conversation_id'), + 'role': serialized_role, + 'message_kind': message_doc.get('message_kind', MESSAGE_KIND_HUMAN), + 'content': serialized_content, + 'reply_to_message_id': message_doc.get('reply_to_message_id'), + 'timestamp': message_doc.get('timestamp'), + 'sender': metadata.get('sender', {}), + 'metadata': metadata, + 'explicit_ai_invocation': bool(metadata.get('explicit_ai_invocation', False)), + 'model_deployment_name': message_doc.get('model_deployment_name'), + 'augmented': bool(message_doc.get('augmented', False)), + 'hybrid_citations': list(message_doc.get('hybrid_citations', []) or []), + 'web_search_citations': list(message_doc.get('web_search_citations', []) or []), + 'agent_citations': list(message_doc.get('agent_citations', []) or []), + 'agent_display_name': message_doc.get('agent_display_name'), + 'agent_name': message_doc.get('agent_name'), + 'filename': message_doc.get('filename'), + 'prompt': message_doc.get('prompt'), + 'extracted_text': message_doc.get('extracted_text'), + 'vision_analysis': message_doc.get('vision_analysis'), + } + + +def _get_collaboration_source_message(message_doc): + metadata = message_doc.get('metadata', {}) if isinstance(message_doc, dict) else {} + source_message_id = str(metadata.get('source_message_id') or '').strip() + if not source_message_id: + return None + + query = 'SELECT TOP 1 * FROM c WHERE c.id = @message_id' + items = list(cosmos_messages_container.query_items( + query=query, + parameters=[{'name': '@message_id', 'value': source_message_id}], + enable_cross_partition_query=True, + )) + return items[0] if items else None + + +def _get_collaboration_display_role(message_doc): + metadata = message_doc.get('metadata', {}) if isinstance(message_doc, dict) else {} + source_role = str(metadata.get('source_role') or '').strip().lower() + if source_role == 'safety': + return 'assistant' + if source_role in ('assistant', 'image', 'file'): + return source_role + + role = str(message_doc.get('role') or '').strip().lower() + return role or 'user' + + +def _build_collaboration_chat_context(conversation_doc, message_doc): + scope = conversation_doc.get('scope', {}) if isinstance(conversation_doc, dict) else {} + chat_type = str(conversation_doc.get('chat_type') or '').strip().lower() + chat_scope = 'group' if chat_type == GROUP_MULTI_USER_CHAT_TYPE else 'personal' + return { + 'conversation_id': message_doc.get('conversation_id') or conversation_doc.get('id'), + 'chat_type': chat_scope, + 'workspace_context': chat_scope, + 'group_id': scope.get('group_id'), + 'group_name': scope.get('group_name'), + 'conversation_title': conversation_doc.get('title'), + } + + +def _build_collaboration_mentions(message_doc): + metadata = message_doc.get('metadata', {}) if isinstance(message_doc, dict) else {} + mentions = [] + seen_user_ids = set() + for raw_participant in list(metadata.get('mentioned_participants', []) or []): + participant = normalize_collaboration_user(raw_participant) + if not participant: + continue + + participant_user_id = participant['user_id'] + if participant_user_id in seen_user_ids: + continue + + seen_user_ids.add(participant_user_id) + mentions.append(participant) + return mentions + + +def _build_collaboration_reply_context(message_doc): + reply_to_message_id = str(message_doc.get('reply_to_message_id') or '').strip() + if not reply_to_message_id: + return None + + try: + reply_message_doc = get_collaboration_message(reply_to_message_id) + except CosmosResourceNotFoundError: + return { + 'message_id': reply_to_message_id, + } + + reply_metadata = reply_message_doc.get('metadata', {}) if isinstance(reply_message_doc, dict) else {} + reply_sender = normalize_collaboration_user(reply_metadata.get('sender') or {}) or { + 'user_id': '', + 'display_name': 'Participant', + 'email': '', + } + reply_preview = str(reply_message_doc.get('content') or '').strip() + if len(reply_preview) > 160: + reply_preview = f'{reply_preview[:157]}...' + + return { + 'message_id': reply_message_doc.get('id') or reply_to_message_id, + 'sender_display_name': reply_sender.get('display_name') or 'Participant', + 'content_preview': reply_preview or 'No message content', + } + + +def _build_collaboration_generation_details(message_doc, source_message_doc=None): + generation_details = {} + for field_name, value in { + 'selected_model': message_doc.get('model_deployment_name') or (source_message_doc or {}).get('model_deployment_name'), + 'agent_name': message_doc.get('agent_name') or (source_message_doc or {}).get('agent_name'), + 'agent_display_name': message_doc.get('agent_display_name') or (source_message_doc or {}).get('agent_display_name'), + 'augmented': message_doc.get('augmented') if 'augmented' in message_doc else (source_message_doc or {}).get('augmented'), + }.items(): + if value not in (None, '', []): + generation_details[field_name] = value + + hybrid_citations = list(message_doc.get('hybrid_citations', []) or (source_message_doc or {}).get('hybrid_citations', []) or []) + web_search_citations = list(message_doc.get('web_search_citations', []) or (source_message_doc or {}).get('web_search_citations', []) or []) + agent_citations = list(message_doc.get('agent_citations', []) or (source_message_doc or {}).get('agent_citations', []) or []) + + if hybrid_citations: + generation_details['document_citation_count'] = len(hybrid_citations) + if web_search_citations: + generation_details['web_citation_count'] = len(web_search_citations) + if agent_citations: + generation_details['agent_citation_count'] = len(agent_citations) + + return generation_details + + +def resolve_collaboration_mentions(conversation_doc, raw_mentions): + normalized_mentions = [] + if not isinstance(raw_mentions, list): + return normalized_mentions + + accepted_participants = {} + for participant in list((conversation_doc or {}).get('participants', []) or []): + participant_user_id = str(participant.get('user_id') or '').strip() + participant_status = str(participant.get('status') or '').strip().lower() + if not participant_user_id or participant_status != MEMBERSHIP_STATUS_ACCEPTED: + continue + accepted_participants[participant_user_id] = participant + + seen_user_ids = set() + for raw_participant in raw_mentions: + candidate = normalize_collaboration_user(raw_participant) + if not candidate: + continue + + candidate_user_id = candidate['user_id'] + if candidate_user_id in seen_user_ids: + continue + + participant = accepted_participants.get(candidate_user_id) + if not participant: + continue + + seen_user_ids.add(candidate_user_id) + normalized_mentions.append({ + 'user_id': candidate_user_id, + 'display_name': str(participant.get('display_name') or candidate.get('display_name') or '').strip() or 'Unknown User', + 'email': str(participant.get('email') or candidate.get('email') or '').strip(), + }) + + return normalized_mentions + + +def _get_group_collaboration_notification_recipient_ids(conversation_doc, sender_user_id): + scope = conversation_doc.get('scope', {}) if isinstance(conversation_doc, dict) else {} + group_id = str(scope.get('group_id') or '').strip() + if not group_id: + return [] + + group_doc = find_group_by_id(group_id) + if not group_doc: + return [] + + recipient_ids = set() + owner_user_id = str((group_doc.get('owner') or {}).get('id') or '').strip() + if owner_user_id: + recipient_ids.add(owner_user_id) + + for member in list(group_doc.get('users', []) or []): + member_user_id = str(member.get('userId') or '').strip() + if member_user_id: + recipient_ids.add(member_user_id) + + normalized_sender_user_id = str(sender_user_id or '').strip() + if normalized_sender_user_id: + recipient_ids.discard(normalized_sender_user_id) + + return sorted(recipient_ids) + + +def list_collaboration_notification_recipient_ids(conversation_doc, sender_user_id): + if ( + is_group_collaboration_conversation(conversation_doc) + and get_collaboration_visibility_mode(conversation_doc) == 'group_membership' + ): + return _get_group_collaboration_notification_recipient_ids(conversation_doc, sender_user_id) + + accepted_participant_ids = set(conversation_doc.get('accepted_participant_ids', []) or []) + normalized_sender_user_id = str(sender_user_id or '').strip() + if normalized_sender_user_id: + accepted_participant_ids.discard(normalized_sender_user_id) + + return sorted(user_id for user_id in accepted_participant_ids if str(user_id or '').strip()) + + +def create_collaboration_message_notifications(conversation_doc, message_doc): + """Fan out personal inbox notifications for recipients of a shared message.""" + if not conversation_doc or not message_doc: + return [] + + metadata = message_doc.get('metadata', {}) if isinstance(message_doc, dict) else {} + sender = normalize_collaboration_user(metadata.get('sender') or {}) or {} + sender_user_id = str(sender.get('user_id') or '').strip() + recipient_ids = list_collaboration_notification_recipient_ids(conversation_doc, sender_user_id) + if not recipient_ids: + return [] + + mentioned_user_ids = { + str(participant.get('user_id') or '').strip() + for participant in list(metadata.get('mentioned_participants', []) or []) + if str(participant.get('user_id') or '').strip() + } + scope = conversation_doc.get('scope', {}) if isinstance(conversation_doc, dict) else {} + created_notifications = [] + + for recipient_user_id in recipient_ids: + try: + notification_doc = create_collaboration_message_notification( + user_id=recipient_user_id, + conversation_id=message_doc.get('conversation_id'), + message_id=message_doc.get('id'), + conversation_title=conversation_doc.get('title'), + sender_display_name=sender.get('display_name'), + message_preview=message_doc.get('content'), + chat_type=conversation_doc.get('chat_type'), + group_id=scope.get('group_id'), + mentioned_user=recipient_user_id in mentioned_user_ids, + ) + if notification_doc: + created_notifications.append(notification_doc) + except Exception as exc: + log_event( + f'[Collaboration Notifications] Failed to create notification for conversation {message_doc.get("conversation_id")}: {exc}', + level=logging.WARNING, + exceptionTraceback=True, + debug_only=True, + ) + + return created_notifications + + +def build_collaboration_message_metadata_payload(message_doc, conversation_doc): + source_message_doc = _get_collaboration_source_message(message_doc) + message_metadata = deepcopy(message_doc.get('metadata', {}) if isinstance(message_doc.get('metadata'), dict) else {}) + source_metadata = deepcopy(source_message_doc.get('metadata', {}) if isinstance((source_message_doc or {}).get('metadata'), dict) else {}) + merged_metadata = { + **source_metadata, + **message_metadata, + } + + chat_context = _build_collaboration_chat_context(conversation_doc, message_doc) + mentions = _build_collaboration_mentions(message_doc) + reply_context = _build_collaboration_reply_context(message_doc) + display_role = _get_collaboration_display_role(message_doc) + sender_summary = normalize_collaboration_user(merged_metadata.get('sender') or {}) or { + 'user_id': '', + 'display_name': 'Unknown User', + 'email': '', + } + generation_details = _build_collaboration_generation_details(message_doc, source_message_doc=source_message_doc) + collaboration_section = { + 'conversation_kind': conversation_doc.get('conversation_kind'), + 'conversation_title': conversation_doc.get('title'), + 'chat_type': conversation_doc.get('chat_type'), + 'participant_count': int(conversation_doc.get('participant_count', 0) or 0), + 'message_kind': message_doc.get('message_kind'), + 'display_role': display_role, + } + + merged_metadata['chat_context'] = { + **chat_context, + **dict(merged_metadata.get('chat_context', {}) or {}), + } + merged_metadata['collaboration'] = { + **dict(merged_metadata.get('collaboration', {}) or {}), + **collaboration_section, + } + merged_metadata['user_info'] = { + **dict(merged_metadata.get('user_info', {}) or {}), + 'user_id': sender_summary.get('user_id'), + 'display_name': sender_summary.get('display_name'), + 'email': sender_summary.get('email'), + 'username': sender_summary.get('user_id'), + 'timestamp': message_doc.get('timestamp'), + } + merged_metadata['message_details'] = { + 'message_id': message_doc.get('id'), + 'conversation_id': message_doc.get('conversation_id'), + 'role': message_doc.get('role'), + 'display_role': display_role, + 'message_kind': message_doc.get('message_kind'), + 'timestamp': message_doc.get('timestamp'), + 'source_role': merged_metadata.get('source_role'), + 'explicit_ai_invocation': bool(merged_metadata.get('explicit_ai_invocation', False)), + } + + if mentions: + merged_metadata['mentions'] = mentions + if reply_context: + merged_metadata['reply_context'] = reply_context + if generation_details: + merged_metadata['generation_details'] = generation_details + + if display_role == 'file': + merged_metadata['file_details'] = { + 'filename': message_doc.get('filename') or (source_message_doc or {}).get('filename') or merged_metadata.get('legacy_filename'), + 'source_message_id': merged_metadata.get('source_message_id'), + 'is_table': (source_message_doc or {}).get('is_table'), + } + if display_role == 'image': + collaboration_image_url = build_collaboration_image_url( + message_doc.get('conversation_id'), + message_doc.get('id'), + ) + merged_metadata['image_details'] = { + 'filename': message_doc.get('filename') or (source_message_doc or {}).get('filename') or merged_metadata.get('legacy_filename'), + 'image_url': collaboration_image_url or merged_metadata.get('legacy_image_url') or (source_message_doc or {}).get('content'), + 'is_user_upload': bool(merged_metadata.get('is_user_upload', False)), + 'extracted_text': message_doc.get('extracted_text') or (source_message_doc or {}).get('extracted_text'), + 'vision_analysis': message_doc.get('vision_analysis') or (source_message_doc or {}).get('vision_analysis'), + } + + if str(message_doc.get('role') or '').strip().lower() != 'assistant': + return merged_metadata + + payload = deepcopy(source_message_doc or {}) + payload.update({ + 'id': message_doc.get('id'), + 'conversation_id': message_doc.get('conversation_id'), + 'role': display_role, + 'message_kind': message_doc.get('message_kind'), + 'content': build_collaboration_image_url(message_doc.get('conversation_id'), message_doc.get('id')) if display_role == 'image' else message_doc.get('content'), + 'timestamp': message_doc.get('timestamp'), + 'model_deployment_name': message_doc.get('model_deployment_name') or payload.get('model_deployment_name'), + 'augmented': message_doc.get('augmented') if 'augmented' in message_doc else payload.get('augmented'), + 'hybrid_citations': deepcopy(message_doc.get('hybrid_citations', []) or payload.get('hybrid_citations', []) or []), + 'web_search_citations': deepcopy(message_doc.get('web_search_citations', []) or payload.get('web_search_citations', []) or []), + 'agent_citations': deepcopy(message_doc.get('agent_citations', []) or payload.get('agent_citations', []) or []), + 'agent_display_name': message_doc.get('agent_display_name') or payload.get('agent_display_name'), + 'agent_name': message_doc.get('agent_name') or payload.get('agent_name'), + 'filename': message_doc.get('filename') or payload.get('filename') or merged_metadata.get('legacy_filename'), + 'prompt': message_doc.get('prompt') or payload.get('prompt'), + 'is_table': message_doc.get('is_table') if 'is_table' in message_doc else payload.get('is_table'), + 'extracted_text': message_doc.get('extracted_text') or payload.get('extracted_text'), + 'vision_analysis': message_doc.get('vision_analysis') or payload.get('vision_analysis'), + }) + payload['metadata'] = merged_metadata + return payload + + +def serialize_collaboration_conversation(conversation_doc, current_user_id, user_state=None): + conversation_doc = conversation_doc or {} + participants = list(conversation_doc.get('participants', []) or []) + visibility_mode = get_collaboration_visibility_mode(conversation_doc) + membership_status = None + if user_state: + membership_status = user_state.get('membership_status') + elif current_user_id in set(conversation_doc.get('accepted_participant_ids', []) or []): + membership_status = MEMBERSHIP_STATUS_ACCEPTED + elif is_group_collaboration_conversation(conversation_doc) and visibility_mode == 'group_membership': + membership_status = 'group_member' + + owner_user_ids = list(conversation_doc.get('owner_user_ids', []) or []) + admin_user_ids = list(conversation_doc.get('admin_user_ids', []) or []) + scope = conversation_doc.get('scope', {}) if isinstance(conversation_doc.get('scope'), dict) else {} + current_user_role = get_personal_collaboration_role( + conversation_doc, + current_user_id, + user_state=user_state, + ) + can_manage_members = bool( + is_explicit_membership_collaboration(conversation_doc) + and membership_status == MEMBERSHIP_STATUS_ACCEPTED + and current_user_role in PERSONAL_COLLABORATION_MANAGER_ROLES + ) + can_manage_roles = bool( + is_explicit_membership_collaboration(conversation_doc) + and membership_status == MEMBERSHIP_STATUS_ACCEPTED + and current_user_role == MEMBERSHIP_ROLE_OWNER + ) + can_accept_invite = membership_status == MEMBERSHIP_STATUS_PENDING + can_post_messages = bool( + ( + is_group_collaboration_conversation(conversation_doc) + and visibility_mode == 'group_membership' + ) + or membership_status == MEMBERSHIP_STATUS_ACCEPTED + ) + can_delete_conversation = bool( + is_explicit_membership_collaboration(conversation_doc) + and membership_status == MEMBERSHIP_STATUS_ACCEPTED + and current_user_role == MEMBERSHIP_ROLE_OWNER + ) + can_leave_conversation = bool( + is_explicit_membership_collaboration(conversation_doc) + and membership_status == MEMBERSHIP_STATUS_ACCEPTED + ) + + return { + 'id': conversation_doc.get('id'), + 'title': conversation_doc.get('title', ''), + 'conversation_kind': conversation_doc.get('conversation_kind'), + 'chat_type': conversation_doc.get('chat_type'), + 'status': conversation_doc.get('status', 'active'), + 'created_at': conversation_doc.get('created_at'), + 'updated_at': conversation_doc.get('updated_at'), + 'last_message_at': conversation_doc.get('last_message_at'), + 'last_message_preview': conversation_doc.get('last_message_preview', ''), + 'message_count': conversation_doc.get('message_count', 0), + 'participant_count': conversation_doc.get('participant_count', 0), + 'pending_invite_count': conversation_doc.get('pending_invite_count', 0), + 'participants': participants, + 'accepted_participant_ids': list(conversation_doc.get('accepted_participant_ids', []) or []), + 'pending_participant_ids': list(conversation_doc.get('pending_participant_ids', []) or []), + 'owner_user_ids': owner_user_ids, + 'admin_user_ids': admin_user_ids, + 'current_user_role': current_user_role, + 'membership_status': membership_status, + 'can_manage_members': can_manage_members, + 'can_manage_roles': can_manage_roles, + 'can_accept_invite': can_accept_invite, + 'can_post_messages': can_post_messages, + 'can_delete_conversation': can_delete_conversation, + 'can_leave_conversation': can_leave_conversation, + 'scope': scope, + 'visibility_mode': visibility_mode, + 'context': list(conversation_doc.get('context', []) or []), + 'scope_locked': conversation_doc.get('scope_locked', True), + 'locked_contexts': list(conversation_doc.get('locked_contexts', []) or []), + 'conversation_settings': dict(conversation_doc.get('conversation_settings', {}) or {}), + 'group_id': scope.get('group_id'), + 'group_name': scope.get('group_name'), + 'last_updated': conversation_doc.get('updated_at'), + 'is_pinned': bool((user_state or {}).get('is_pinned', False)), + 'is_hidden': bool((user_state or {}).get('is_hidden', False)), + 'classification': list(conversation_doc.get('classification', []) or []), + 'tags': list(conversation_doc.get('tags', []) or []), + 'strict': bool(conversation_doc.get('strict', False)), + 'summary': conversation_doc.get('summary'), + 'has_unread_assistant_response': False, + 'last_unread_assistant_message_id': None, + 'last_unread_assistant_at': None, + 'user_id': conversation_doc.get('created_by_user_id'), + 'source_conversation_id': conversation_doc.get('source_conversation_id'), + } + + +def get_personal_collaboration_conversation_by_source_conversation(source_conversation_id): + query = ( + 'SELECT TOP 1 * FROM c WHERE c.conversation_kind = @conversation_kind ' + 'AND c.chat_type = @chat_type AND c.source_conversation_id = @source_conversation_id' + ) + items = list(cosmos_collaboration_conversations_container.query_items( + query=query, + parameters=[ + {'name': '@conversation_kind', 'value': COLLABORATION_KIND}, + {'name': '@chat_type', 'value': PERSONAL_MULTI_USER_CHAT_TYPE}, + {'name': '@source_conversation_id', 'value': source_conversation_id}, + ], + enable_cross_partition_query=True, + )) + return items[0] if items else None + + +def _is_eligible_legacy_personal_conversation(source_conversation_doc): + chat_type = str(source_conversation_doc.get('chat_type') or '').strip().lower() + if chat_type.startswith('group') or chat_type.startswith('public'): + return False + + primary_context = next( + ( + context_item + for context_item in list(source_conversation_doc.get('context', []) or []) + if context_item.get('type') == 'primary' + ), + None, + ) + if primary_context and str(primary_context.get('scope') or '').strip().lower() in ('group', 'public'): + return False + + return True + + +def _copy_legacy_personal_messages_to_collaboration(source_conversation_id, collaboration_conversation_id, owner_user): + query = 'SELECT * FROM c WHERE c.conversation_id = @conversation_id ORDER BY c.timestamp ASC' + raw_messages = list(cosmos_messages_container.query_items( + query=query, + parameters=[{'name': '@conversation_id', 'value': source_conversation_id}], + partition_key=source_conversation_id, + )) + raw_messages = filter_assistant_artifact_items(raw_messages) + + copied_messages = [] + for raw_message in raw_messages: + collaboration_message = build_collaboration_message_doc_from_legacy( + collaboration_conversation_id, + raw_message, + owner_user, + ) + if not collaboration_message: + continue + + metadata = collaboration_message.setdefault('metadata', {}) + metadata.setdefault('source_message_id', raw_message.get('id')) + metadata.setdefault('source_conversation_id', source_conversation_id) + metadata.setdefault('source_thought_user_id', str((owner_user or {}).get('user_id') or '').strip()) + + cosmos_collaboration_messages_container.upsert_item(collaboration_message) + copied_messages.append(collaboration_message) + + return copied_messages + + +def ensure_personal_collaboration_for_legacy_conversation(source_conversation_id, owner_user, invited_participants=None): + source_conversation_doc = cosmos_conversations_container.read_item( + item=source_conversation_id, + partition_key=source_conversation_id, + ) + owner_summary = owner_user or {} + owner_user_id = str(owner_summary.get('user_id') or '').strip() + if not owner_user_id: + raise PermissionError('User not authenticated') + + if str(source_conversation_doc.get('user_id') or '').strip() != owner_user_id: + raise PermissionError('Only the conversation owner can convert this conversation') + + if not _is_eligible_legacy_personal_conversation(source_conversation_doc): + raise PermissionError('Only personal single-user conversations can be converted into personal collaborative conversations') + + collaboration_conversation_doc = None + linked_collaboration_id = str(source_conversation_doc.get('collaboration_conversation_id') or '').strip() + if linked_collaboration_id: + try: + collaboration_conversation_doc = get_collaboration_conversation(linked_collaboration_id) + except CosmosResourceNotFoundError: + collaboration_conversation_doc = None + + if collaboration_conversation_doc is None: + collaboration_conversation_doc = get_personal_collaboration_conversation_by_source_conversation( + source_conversation_id, + ) + + if collaboration_conversation_doc is not None: + invited_state_docs = [] + if invited_participants: + collaboration_conversation_doc, invited_state_docs = invite_personal_collaboration_participants( + collaboration_conversation_doc.get('id'), + owner_user_id, + invited_participants, + ) + return collaboration_conversation_doc, invited_state_docs, False, source_conversation_doc + + collaboration_conversation_doc, user_state_docs = create_personal_collaboration_conversation_record( + title=source_conversation_doc.get('title') or '', + creator_user=owner_summary, + invited_participants=invited_participants, + ) + invited_state_docs = [ + state_doc + for state_doc in user_state_docs + if state_doc.get('user_id') != owner_user_id + ] + + collaboration_conversation_doc['source_conversation_id'] = source_conversation_id + collaboration_conversation_doc['classification'] = list(source_conversation_doc.get('classification', []) or []) + collaboration_conversation_doc['tags'] = list(source_conversation_doc.get('tags', []) or []) + collaboration_conversation_doc['strict'] = bool(source_conversation_doc.get('strict', False)) + collaboration_conversation_doc['summary'] = source_conversation_doc.get('summary') + + source_context = list(source_conversation_doc.get('context', []) or []) + if source_context: + collaboration_conversation_doc['context'] = source_context + source_scope_locked = source_conversation_doc.get('scope_locked') + if source_scope_locked is not None: + collaboration_conversation_doc['scope_locked'] = bool(source_scope_locked) + source_locked_contexts = list(source_conversation_doc.get('locked_contexts', []) or []) + if source_locked_contexts: + collaboration_conversation_doc['locked_contexts'] = source_locked_contexts + + copied_messages = _copy_legacy_personal_messages_to_collaboration( + source_conversation_id, + collaboration_conversation_doc.get('id'), + owner_summary, + ) + if copied_messages: + last_copied_message = copied_messages[-1] + collaboration_conversation_doc['last_message_at'] = last_copied_message.get('timestamp') + collaboration_conversation_doc['last_message_preview'] = ( + (last_copied_message.get('metadata') or {}).get('last_message_preview') or '' + ) + collaboration_conversation_doc['updated_at'] = last_copied_message.get('timestamp') + collaboration_conversation_doc['message_count'] = len(copied_messages) + + cosmos_collaboration_conversations_container.upsert_item(collaboration_conversation_doc) + + conversion_timestamp = utc_now_iso() + source_conversation_doc['collaboration_conversation_id'] = collaboration_conversation_doc.get('id') + source_conversation_doc['converted_to_collaboration_at'] = conversion_timestamp + source_conversation_doc['is_hidden'] = True + source_conversation_doc['last_updated'] = conversion_timestamp + cosmos_conversations_container.upsert_item(source_conversation_doc) + + log_event( + '[Collaboration] Converted personal conversation into collaborative conversation', + extra={ + 'source_conversation_id': source_conversation_id, + 'conversation_id': collaboration_conversation_doc.get('id'), + 'created_by_user_id': owner_user_id, + 'copied_message_count': len(copied_messages), + }, + level=logging.INFO, + ) + return collaboration_conversation_doc, invited_state_docs, True, source_conversation_doc + + +def _is_eligible_legacy_group_conversation(source_conversation_doc): + if str((source_conversation_doc or {}).get('conversation_kind') or '').strip().lower() == COLLABORATION_KIND: + return False + + chat_type = str((source_conversation_doc or {}).get('chat_type') or '').strip().lower() + if chat_type in ('group-single-user', 'group_single_user', 'group'): + return True + + primary_context = next( + ( + context_item + for context_item in list((source_conversation_doc or {}).get('context', []) or []) + if context_item.get('type') == 'primary' + ), + None, + ) + return bool(primary_context and str(primary_context.get('scope') or '').strip().lower() == 'group') + + +def _copy_legacy_group_messages_to_collaboration(source_conversation_id, collaboration_conversation_id, owner_user): + query = 'SELECT * FROM c WHERE c.conversation_id = @conversation_id ORDER BY c.timestamp ASC' + raw_messages = list(cosmos_group_messages_container.query_items( + query=query, + parameters=[{'name': '@conversation_id', 'value': source_conversation_id}], + partition_key=source_conversation_id, + )) + raw_messages = filter_assistant_artifact_items(raw_messages) + + copied_messages = [] + for raw_message in raw_messages: + collaboration_message = build_collaboration_message_doc_from_legacy( + collaboration_conversation_id, + raw_message, + owner_user, + ) + if not collaboration_message: + continue + + metadata = collaboration_message.setdefault('metadata', {}) + metadata.setdefault('source_message_id', raw_message.get('id')) + metadata.setdefault('source_conversation_id', source_conversation_id) + metadata.setdefault('source_conversation_scope', 'group') + metadata.setdefault('source_thought_user_id', str((owner_user or {}).get('user_id') or '').strip()) + + cosmos_collaboration_messages_container.upsert_item(collaboration_message) + copied_messages.append(collaboration_message) + + return copied_messages + + +def ensure_group_collaboration_for_legacy_conversation(source_conversation_id, owner_user, invited_participants=None): + source_conversation_doc = cosmos_group_conversations_container.read_item( + item=source_conversation_id, + partition_key=source_conversation_id, + ) + owner_summary = owner_user or {} + owner_user_id = str(owner_summary.get('user_id') or '').strip() + if not owner_user_id: + raise PermissionError('User not authenticated') + + if str(source_conversation_doc.get('user_id') or '').strip() != owner_user_id: + raise PermissionError('Only the conversation owner can convert this group conversation') + + if not _is_eligible_legacy_group_conversation(source_conversation_doc): + raise PermissionError('Only group single-user conversations can be converted into group multi-user conversations') + + primary_group_context = next( + ( + context_item + for context_item in list(source_conversation_doc.get('context', []) or []) + if context_item.get('type') == 'primary' and str(context_item.get('scope') or '').strip().lower() == 'group' + ), + None, + ) + group_id = str( + source_conversation_doc.get('group_id') + or (primary_group_context or {}).get('id') + or '' + ).strip() + if not group_id: + raise LookupError('Group conversation is missing group context') + + group_doc = find_group_by_id(group_id) + if not group_doc: + raise LookupError('Group not found') + + assert_group_role( + owner_user_id, + group_id, + allowed_roles=('Owner', 'Admin', 'DocumentManager', 'User'), + ) + allowed, reason = check_group_status_allows_operation(group_doc, 'chat') + if not allowed: + raise PermissionError(reason) + + collaboration_conversation_doc = None + linked_collaboration_id = str(source_conversation_doc.get('collaboration_conversation_id') or '').strip() + if linked_collaboration_id: + try: + collaboration_conversation_doc = get_collaboration_conversation(linked_collaboration_id) + except CosmosResourceNotFoundError: + collaboration_conversation_doc = None + + if collaboration_conversation_doc is not None: + invited_state_docs = [] + if invited_participants: + collaboration_conversation_doc, invited_state_docs = invite_personal_collaboration_participants( + collaboration_conversation_doc.get('id'), + owner_user_id, + invited_participants, + ) + return collaboration_conversation_doc, invited_state_docs, False, source_conversation_doc + + collaboration_conversation_doc, user_states = create_group_collaboration_conversation_record( + title=source_conversation_doc.get('title') or '', + creator_user=owner_summary, + group_doc=group_doc, + invited_participants=invited_participants, + ) + invited_state_docs = [ + state_doc + for state_doc in user_states + if state_doc.get('user_id') != owner_user_id + ] + + collaboration_conversation_doc['classification'] = list(source_conversation_doc.get('classification', []) or []) + collaboration_conversation_doc['tags'] = list(source_conversation_doc.get('tags', []) or []) + collaboration_conversation_doc['strict'] = bool(source_conversation_doc.get('strict', False)) + collaboration_conversation_doc['summary'] = source_conversation_doc.get('summary') + collaboration_conversation_doc['legacy_source_conversation_id'] = source_conversation_id + collaboration_conversation_doc['legacy_source_scope'] = 'group' + + source_context = list(source_conversation_doc.get('context', []) or []) + if source_context: + collaboration_conversation_doc['context'] = source_context + source_scope_locked = source_conversation_doc.get('scope_locked') + if source_scope_locked is not None: + collaboration_conversation_doc['scope_locked'] = bool(source_scope_locked) + source_locked_contexts = list(source_conversation_doc.get('locked_contexts', []) or []) + if source_locked_contexts: + collaboration_conversation_doc['locked_contexts'] = source_locked_contexts + + copied_messages = _copy_legacy_group_messages_to_collaboration( + source_conversation_id, + collaboration_conversation_doc.get('id'), + owner_summary, + ) + if copied_messages: + last_copied_message = copied_messages[-1] + collaboration_conversation_doc['last_message_at'] = last_copied_message.get('timestamp') + collaboration_conversation_doc['last_message_preview'] = ( + (last_copied_message.get('metadata') or {}).get('last_message_preview') or '' + ) + collaboration_conversation_doc['updated_at'] = last_copied_message.get('timestamp') + collaboration_conversation_doc['message_count'] = len(copied_messages) + + cosmos_collaboration_conversations_container.upsert_item(collaboration_conversation_doc) + + conversion_timestamp = utc_now_iso() + source_conversation_doc['collaboration_conversation_id'] = collaboration_conversation_doc.get('id') + source_conversation_doc['converted_to_collaboration_at'] = conversion_timestamp + source_conversation_doc['is_hidden'] = True + source_conversation_doc['last_updated'] = conversion_timestamp + cosmos_group_conversations_container.upsert_item(source_conversation_doc) + + log_event( + '[Collaboration] Converted group conversation into collaborative conversation', + extra={ + 'source_conversation_id': source_conversation_id, + 'conversation_id': collaboration_conversation_doc.get('id'), + 'group_id': group_id, + 'created_by_user_id': owner_user_id, + 'copied_message_count': len(copied_messages), + }, + level=logging.INFO, + ) + return collaboration_conversation_doc, invited_state_docs, True, source_conversation_doc + + +def create_personal_collaboration_conversation_record(title, creator_user, invited_participants=None): + conversation_doc = build_personal_collaboration_conversation( + title=title, + creator_user=creator_user, + invited_participants=invited_participants, + ) + cosmos_collaboration_conversations_container.upsert_item(conversation_doc) + + user_states = [] + for participant in conversation_doc.get('participants', []): + membership_status = participant.get('status') + role = participant.get('role') + invited_by_user_id = '' + if participant.get('user_id') != conversation_doc.get('created_by_user_id'): + invited_by_user_id = conversation_doc.get('created_by_user_id') + state_doc = build_collaboration_user_state( + conversation_doc=conversation_doc, + user_summary=participant, + role=role, + membership_status=membership_status, + invited_by_user_id=invited_by_user_id, + created_at=participant.get('invited_at') or conversation_doc.get('created_at'), + ) + cosmos_collaboration_user_state_container.upsert_item(state_doc) + user_states.append(state_doc) + + log_event( + '[Collaboration] Created personal collaborative conversation', + extra={ + 'conversation_id': conversation_doc.get('id'), + 'created_by_user_id': conversation_doc.get('created_by_user_id'), + 'participant_count': conversation_doc.get('participant_count', 0), + 'pending_invite_count': conversation_doc.get('pending_invite_count', 0), + }, + level=logging.INFO, + ) + return conversation_doc, user_states + + +def create_group_collaboration_conversation_record(title, creator_user, group_doc, invited_participants=None): + conversation_doc = build_group_collaboration_conversation( + title=title, + creator_user=creator_user, + group_id=group_doc.get('id'), + group_name=group_doc.get('name', 'Group Workspace'), + invited_participants=_normalize_group_conversation_participants( + group_doc, + invited_participants, + ) if invited_participants else None, + ) + cosmos_collaboration_conversations_container.upsert_item(conversation_doc) + + user_states = [] + for participant in conversation_doc.get('participants', []): + invited_by_user_id = '' + if participant.get('user_id') != conversation_doc.get('created_by_user_id'): + invited_by_user_id = conversation_doc.get('created_by_user_id') + state_doc = ensure_collaboration_user_state_for_participant( + conversation_doc, + participant, + role=participant.get('role') or MEMBERSHIP_ROLE_MEMBER, + membership_status=participant.get('status') or MEMBERSHIP_STATUS_PENDING, + invited_by_user_id=invited_by_user_id, + created_at=participant.get('joined_at') or participant.get('invited_at') or conversation_doc.get('created_at'), + ) + user_states.append(state_doc) + + log_event( + '[Collaboration] Created group collaborative conversation', + extra={ + 'conversation_id': conversation_doc.get('id'), + 'group_id': group_doc.get('id'), + 'created_by_user_id': conversation_doc.get('created_by_user_id'), + 'participant_count': conversation_doc.get('participant_count', 0), + 'pending_invite_count': conversation_doc.get('pending_invite_count', 0), + }, + level=logging.INFO, + ) + return conversation_doc, user_states + + +def list_personal_collaboration_conversations_for_user(user_id): + query = ( + 'SELECT * FROM c WHERE c.user_id = @user_id ' + 'AND c.conversation_kind = @conversation_kind' + ) + states = list(cosmos_collaboration_user_state_container.query_items( + query=query, + parameters=[ + {'name': '@user_id', 'value': user_id}, + {'name': '@conversation_kind', 'value': COLLABORATION_KIND}, + ], + partition_key=user_id, + )) + + conversations = [] + for state_doc in states: + membership_status = state_doc.get('membership_status') + if membership_status not in (MEMBERSHIP_STATUS_ACCEPTED, MEMBERSHIP_STATUS_PENDING): + continue + + if state_doc.get('chat_type') != PERSONAL_MULTI_USER_CHAT_TYPE: + continue + + conversation_id = state_doc.get('conversation_id') + if not conversation_id: + continue + + try: + conversation_doc = get_collaboration_conversation(conversation_id) + except CosmosResourceNotFoundError: + continue + + conversations.append((conversation_doc, state_doc)) + + conversations.sort( + key=lambda item: item[0].get('updated_at') or item[0].get('created_at') or '', + reverse=True, + ) + return conversations + + +def list_group_collaboration_conversations_for_user(user_id): + user_groups = get_user_groups(user_id) + group_map = { + str(group_doc.get('id')): group_doc + for group_doc in user_groups + if group_doc.get('id') + } + if not group_map: + return [] + + state_query = ( + 'SELECT * FROM c WHERE c.user_id = @user_id ' + 'AND c.conversation_kind = @conversation_kind' + ) + state_docs = list(cosmos_collaboration_user_state_container.query_items( + query=state_query, + parameters=[ + {'name': '@user_id', 'value': user_id}, + {'name': '@conversation_kind', 'value': COLLABORATION_KIND}, + ], + partition_key=user_id, + )) + + conversations = [] + seen_conversation_ids = set() + for state_doc in state_docs: + membership_status = state_doc.get('membership_status') + if membership_status not in (MEMBERSHIP_STATUS_ACCEPTED, MEMBERSHIP_STATUS_PENDING): + continue + if state_doc.get('chat_type') != GROUP_MULTI_USER_CHAT_TYPE: + continue + + conversation_id = str(state_doc.get('conversation_id') or '').strip() + if not conversation_id or conversation_id in seen_conversation_ids: + continue + + try: + conversation_doc = get_collaboration_conversation(conversation_id) + except CosmosResourceNotFoundError: + continue + + if not is_group_collaboration_conversation(conversation_doc): + continue + + group_id = str((conversation_doc.get('scope') or {}).get('group_id') or '') + group_doc = group_map.get(group_id) + if not group_doc: + continue + + allowed, _ = check_group_status_allows_operation(group_doc, 'view') + if not allowed: + continue + + seen_conversation_ids.add(conversation_id) + conversations.append((conversation_doc, state_doc)) + + query = ( + 'SELECT * FROM c WHERE c.conversation_kind = @conversation_kind ' + 'AND c.chat_type = @chat_type AND c.status = @status' + ) + items = list(cosmos_collaboration_conversations_container.query_items( + query=query, + parameters=[ + {'name': '@conversation_kind', 'value': COLLABORATION_KIND}, + {'name': '@chat_type', 'value': GROUP_MULTI_USER_CHAT_TYPE}, + {'name': '@status', 'value': 'active'}, + ], + enable_cross_partition_query=True, + )) + + for conversation_doc in items: + if get_collaboration_visibility_mode(conversation_doc) != 'group_membership': + continue + + conversation_id = str(conversation_doc.get('id') or '').strip() + if not conversation_id or conversation_id in seen_conversation_ids: + continue + + group_id = str((conversation_doc.get('scope') or {}).get('group_id') or '') + group_doc = group_map.get(group_id) + if not group_doc: + continue + + allowed, _ = check_group_status_allows_operation(group_doc, 'view') + if allowed: + user_state = get_collaboration_user_state_or_none(user_id, conversation_id) + conversations.append((conversation_doc, user_state)) + seen_conversation_ids.add(conversation_id) + + conversations.sort( + key=lambda item: item[0].get('updated_at') or item[0].get('created_at') or '', + reverse=True, + ) + return conversations + + +def assert_user_can_view_collaboration_conversation(user_id, conversation_doc, allow_pending=False): + if not is_collaboration_conversation(conversation_doc): + raise LookupError('Collaboration conversation not found') + + if is_personal_collaboration_conversation(conversation_doc): + try: + user_state = get_collaboration_user_state(user_id, conversation_doc.get('id')) + except CosmosResourceNotFoundError as exc: + raise PermissionError('You are not a participant in this collaborative conversation') from exc + + membership_status = user_state.get('membership_status') + if membership_status == MEMBERSHIP_STATUS_ACCEPTED: + return { + 'user_state': user_state, + 'membership_status': membership_status, + } + if allow_pending and membership_status == MEMBERSHIP_STATUS_PENDING: + return { + 'user_state': user_state, + 'membership_status': membership_status, + } + raise PermissionError('You do not have access to this collaborative conversation') + + if is_group_collaboration_conversation(conversation_doc): + group_id = str((conversation_doc.get('scope') or {}).get('group_id') or '').strip() + if not group_id: + raise LookupError('Group collaborative conversation is missing group context') + + group_doc = find_group_by_id(group_id) + if not group_doc: + raise LookupError('Group not found') + + group_role = assert_group_role( + user_id, + group_id, + allowed_roles=('Owner', 'Admin', 'DocumentManager', 'User'), + ) + allowed, reason = check_group_status_allows_operation(group_doc, 'view') + if not allowed: + raise PermissionError(reason) + + if get_collaboration_visibility_mode(conversation_doc) == 'group_membership': + return { + 'group_doc': group_doc, + 'group_role': group_role, + 'membership_status': 'group_member', + 'user_state': get_collaboration_user_state_or_none(user_id, conversation_doc.get('id')), + } + + user_state = get_collaboration_user_state_or_none(user_id, conversation_doc.get('id')) + if user_state is None: + participant = get_personal_collaboration_participant(conversation_doc, user_id) + user_state = _bootstrap_collaboration_user_state_from_participant( + conversation_doc, + participant, + invited_by_user_id=conversation_doc.get('created_by_user_id'), + ) + + if user_state is None: + raise PermissionError('You are not a participant in this shared group conversation') + + membership_status = user_state.get('membership_status') + if membership_status == MEMBERSHIP_STATUS_ACCEPTED: + return { + 'group_doc': group_doc, + 'group_role': group_role, + 'user_state': user_state, + 'membership_status': membership_status, + } + if allow_pending and membership_status == MEMBERSHIP_STATUS_PENDING: + return { + 'group_doc': group_doc, + 'group_role': group_role, + 'user_state': user_state, + 'membership_status': membership_status, + } + raise PermissionError('You do not have access to this shared group conversation') + + return { + 'group_doc': group_doc, + 'group_role': group_role, + 'membership_status': 'group_member', + } + + raise PermissionError('Unsupported collaboration conversation type') + + +def assert_user_can_participate_in_collaboration_conversation(user_id, conversation_doc): + access_context = assert_user_can_view_collaboration_conversation( + user_id, + conversation_doc, + allow_pending=False, + ) + + if is_group_collaboration_conversation(conversation_doc): + group_doc = access_context.get('group_doc') + allowed, reason = check_group_status_allows_operation(group_doc, 'chat') + if not allowed: + raise PermissionError(reason) + + return access_context + + +def record_personal_invite_response(conversation_id, user_id, action): + conversation_doc = get_collaboration_conversation(conversation_id) + if not is_explicit_membership_collaboration(conversation_doc): + raise PermissionError('Invite responses are only supported for invite-managed collaborative conversations') + + if is_group_collaboration_conversation(conversation_doc): + group_id = str(((conversation_doc.get('scope') or {}).get('group_id')) or '').strip() + assert_group_role( + user_id, + group_id, + allowed_roles=('Owner', 'Admin', 'DocumentManager', 'User'), + ) + + user_state = get_collaboration_user_state(user_id, conversation_id) + participant_record = apply_personal_invite_response( + conversation_doc, + invited_user_id=user_id, + action=action, + responded_at=utc_now_iso(), + ) + + membership_status = MEMBERSHIP_STATUS_ACCEPTED if str(action).lower() == 'accept' else MEMBERSHIP_STATUS_DECLINED + user_state['membership_status'] = membership_status + user_state['updated_at'] = participant_record.get('responded_at') + user_state['responded_at'] = participant_record.get('responded_at') + if membership_status == MEMBERSHIP_STATUS_ACCEPTED: + user_state['joined_at'] = participant_record.get('joined_at') + + cosmos_collaboration_conversations_container.upsert_item(conversation_doc) + cosmos_collaboration_user_state_container.upsert_item(user_state) + return conversation_doc, user_state, participant_record + + +def invite_personal_collaboration_participants(conversation_id, owner_user_id, participants_to_add): + conversation_doc = get_collaboration_conversation(conversation_id) + if not is_explicit_membership_collaboration(conversation_doc): + raise PermissionError('Member invites are only supported for invite-managed collaborative conversations') + + actor_user_state = get_collaboration_user_state_or_none(owner_user_id, conversation_id) + + if is_group_collaboration_conversation(conversation_doc): + group_id = str(((conversation_doc.get('scope') or {}).get('group_id')) or '').strip() + group_role = assert_group_role( + owner_user_id, + group_id, + allowed_roles=('Owner', 'Admin', 'DocumentManager', 'User'), + ) + group_doc = find_group_by_id(group_id) + if not group_doc: + raise LookupError('Group not found') + allowed, reason = check_group_status_allows_operation(group_doc, 'chat') + if not allowed: + raise PermissionError(reason) + participants_to_add = _normalize_group_conversation_participants(group_doc, participants_to_add) + + actor_role = get_personal_collaboration_role( + conversation_doc, + owner_user_id, + user_state=actor_user_state, + ) + if actor_role not in PERSONAL_COLLABORATION_MANAGER_ROLES: + raise PermissionError('Only conversation owners or admins can invite members') + + invite_timestamp = utc_now_iso() + added_participants = add_personal_pending_participants( + conversation_doc, + participants_to_add, + invited_at=invite_timestamp, + ) + if not added_participants: + return conversation_doc, [] + + cosmos_collaboration_conversations_container.upsert_item(conversation_doc) + + created_state_docs = [] + for participant in added_participants: + state_doc = build_collaboration_user_state( + conversation_doc=conversation_doc, + user_summary=participant, + role=participant.get('role', MEMBERSHIP_ROLE_MEMBER), + membership_status=participant.get('status', MEMBERSHIP_STATUS_PENDING), + invited_by_user_id=owner_user_id, + created_at=invite_timestamp, + ) + cosmos_collaboration_user_state_container.upsert_item(state_doc) + created_state_docs.append(state_doc) + + return conversation_doc, created_state_docs + + +def remove_personal_collaboration_member(conversation_id, owner_user_id, member_user_id): + conversation_doc = get_collaboration_conversation(conversation_id) + if not is_explicit_membership_collaboration(conversation_doc): + raise PermissionError('Member removal is only supported for invite-managed collaborative conversations') + + actor_user_state = get_collaboration_user_state_or_none(owner_user_id, conversation_id) + + if is_group_collaboration_conversation(conversation_doc): + group_id = str(((conversation_doc.get('scope') or {}).get('group_id')) or '').strip() + group_role = assert_group_role( + owner_user_id, + group_id, + allowed_roles=('Owner', 'Admin', 'DocumentManager', 'User'), + ) + group_doc = find_group_by_id(group_id) + if not group_doc: + raise LookupError('Group not found') + allowed, reason = check_group_status_allows_operation(group_doc, 'chat') + if not allowed: + raise PermissionError(reason) + + actor_role = get_personal_collaboration_role( + conversation_doc, + owner_user_id, + user_state=actor_user_state, + ) + if actor_role not in PERSONAL_COLLABORATION_MANAGER_ROLES: + raise PermissionError('Only conversation owners or admins can remove members') + + member_participant = get_personal_collaboration_participant(conversation_doc, member_user_id) + if member_participant is None: + raise LookupError('participant not found') + member_role = str(member_participant.get('role') or '').strip() + if actor_role != MEMBERSHIP_ROLE_OWNER and member_role != MEMBERSHIP_ROLE_MEMBER: + raise PermissionError('Only conversation owners can remove admins') + + removed_participant = remove_personal_participant( + conversation_doc, + participant_user_id=member_user_id, + removed_at=utc_now_iso(), + ) + cosmos_collaboration_conversations_container.upsert_item(conversation_doc) + + try: + user_state = get_collaboration_user_state(member_user_id, conversation_id) + except CosmosResourceNotFoundError: + user_state = None + + if user_state: + user_state['membership_status'] = MEMBERSHIP_STATUS_REMOVED + user_state['updated_at'] = removed_participant.get('removed_at') + user_state['removed_at'] = removed_participant.get('removed_at') + cosmos_collaboration_user_state_container.upsert_item(user_state) + + return conversation_doc, removed_participant + + +def list_collaboration_messages(conversation_id): + query = 'SELECT * FROM c WHERE c.conversation_id = @conversation_id ORDER BY c.timestamp ASC' + return list(cosmos_collaboration_messages_container.query_items( + query=query, + parameters=[{'name': '@conversation_id', 'value': conversation_id}], + partition_key=conversation_id, + )) + + +def persist_collaboration_message( + conversation_doc, + sender_user, + content, + reply_to_message_id=None, + mentioned_participants=None, + message_kind=MESSAGE_KIND_HUMAN, + extra_metadata=None, +): + conversation_id = conversation_doc.get('id') + message_doc = build_collaboration_message_doc( + conversation_id=conversation_id, + sender_user=sender_user, + content=content, + reply_to_message_id=reply_to_message_id, + mentioned_participants=mentioned_participants, + message_kind=message_kind, + timestamp=utc_now_iso(), + ) + + if isinstance(extra_metadata, dict) and extra_metadata: + message_doc['metadata'] = { + **dict(message_doc.get('metadata', {}) or {}), + **extra_metadata, + } + + return _save_collaboration_message_doc(conversation_doc, message_doc) + + +def _save_collaboration_message_doc(conversation_doc, message_doc): + sender_summary = normalize_collaboration_user( + ((message_doc or {}).get('metadata', {}) or {}).get('sender') or {}, + ) + + if is_group_collaboration_conversation(conversation_doc): + sender_user_id = str((sender_summary or {}).get('user_id') or '').strip() + if sender_user_id and sender_user_id != 'assistant' and str(message_doc.get('role') or '').strip().lower() == 'user': + if get_collaboration_visibility_mode(conversation_doc) == 'group_membership': + ensure_group_participant_record( + conversation_doc, + sender_summary, + joined_at=message_doc.get('timestamp'), + ) + else: + participant_record = get_personal_collaboration_participant( + conversation_doc, + sender_user_id, + ) + if participant_record is not None: + participant_record['display_name'] = sender_summary.get('display_name') or participant_record.get('display_name') + participant_record['email'] = sender_summary.get('email') or participant_record.get('email') + + cosmos_collaboration_messages_container.upsert_item(message_doc) + + conversation_doc['last_message_at'] = message_doc.get('timestamp') + conversation_doc['last_message_preview'] = ( + message_doc.get('metadata', {}).get('last_message_preview', '') + ) + conversation_doc['updated_at'] = message_doc.get('timestamp') + conversation_doc['message_count'] = int(conversation_doc.get('message_count', 0) or 0) + 1 + + if is_personal_collaboration_conversation(conversation_doc): + refresh_personal_participant_indexes(conversation_doc) + + cosmos_collaboration_conversations_container.upsert_item(conversation_doc) + return message_doc, conversation_doc + + +def sync_collaboration_conversation_metadata_from_source(conversation_doc, source_conversation_doc): + if not isinstance(conversation_doc, dict) or not isinstance(source_conversation_doc, dict): + return conversation_doc, False + + metadata_fields = { + 'context': deepcopy(list(source_conversation_doc.get('context', []) or [])), + 'tags': deepcopy(list(source_conversation_doc.get('tags', []) or [])), + 'strict': bool(source_conversation_doc.get('strict', False)), + 'scope_locked': bool(source_conversation_doc.get('scope_locked', conversation_doc.get('scope_locked', False))), + 'locked_contexts': deepcopy(list(source_conversation_doc.get('locked_contexts', []) or [])), + 'classification': deepcopy(list(source_conversation_doc.get('classification', []) or [])), + 'summary': deepcopy(source_conversation_doc.get('summary')), + } + + updated = False + for field_name, field_value in metadata_fields.items(): + if conversation_doc.get(field_name) != field_value: + conversation_doc[field_name] = field_value + updated = True + + if updated: + cosmos_collaboration_conversations_container.upsert_item(conversation_doc) + + return conversation_doc, updated + + +def ensure_collaboration_source_conversation(conversation_doc, current_user): + normalized_current_user = normalize_collaboration_user(current_user) + if not normalized_current_user: + raise PermissionError('User not authenticated') + + source_conversation_id = str((conversation_doc or {}).get('source_conversation_id') or '').strip() + source_chat_type = 'group' if is_group_collaboration_conversation(conversation_doc) else 'personal_single_user' + source_conversation_doc = None + source_updated = False + + if source_conversation_id: + try: + source_conversation_doc = cosmos_conversations_container.read_item( + item=source_conversation_id, + partition_key=source_conversation_id, + ) + except CosmosResourceNotFoundError: + source_conversation_doc = None + source_conversation_id = '' + + timestamp = utc_now_iso() + if source_conversation_doc is None: + source_conversation_id = str(uuid.uuid4()) + source_conversation_doc = { + 'id': source_conversation_id, + 'user_id': str((conversation_doc or {}).get('created_by_user_id') or normalized_current_user.get('user_id') or '').strip(), + 'last_updated': timestamp, + 'title': str((conversation_doc or {}).get('title') or 'Collaborative Conversation').strip() or 'Collaborative Conversation', + 'context': list((conversation_doc or {}).get('context', []) or []), + 'tags': list((conversation_doc or {}).get('tags', []) or []), + 'strict': bool((conversation_doc or {}).get('strict', False)), + 'chat_type': source_chat_type, + 'scope_locked': bool((conversation_doc or {}).get('scope_locked', False)), + 'locked_contexts': list((conversation_doc or {}).get('locked_contexts', []) or []), + 'classification': list((conversation_doc or {}).get('classification', []) or []), + 'summary': (conversation_doc or {}).get('summary'), + 'conversation_kind': 'collaboration_source', + 'collaboration_conversation_id': (conversation_doc or {}).get('id'), + 'is_hidden': True, + } + source_updated = True + else: + synchronized_values = { + 'title': str((conversation_doc or {}).get('title') or source_conversation_doc.get('title') or 'Collaborative Conversation').strip() or 'Collaborative Conversation', + 'context': list((conversation_doc or {}).get('context', []) or source_conversation_doc.get('context', []) or []), + 'tags': list((conversation_doc or {}).get('tags', []) or source_conversation_doc.get('tags', []) or []), + 'strict': bool((conversation_doc or {}).get('strict', source_conversation_doc.get('strict', False))), + 'chat_type': source_chat_type, + 'scope_locked': bool((conversation_doc or {}).get('scope_locked', source_conversation_doc.get('scope_locked', False))), + 'locked_contexts': list((conversation_doc or {}).get('locked_contexts', []) or source_conversation_doc.get('locked_contexts', []) or []), + 'classification': list((conversation_doc or {}).get('classification', []) or source_conversation_doc.get('classification', []) or []), + 'summary': (conversation_doc or {}).get('summary', source_conversation_doc.get('summary')), + 'conversation_kind': 'collaboration_source', + 'collaboration_conversation_id': (conversation_doc or {}).get('id'), + 'is_hidden': True, + } + for field_name, field_value in synchronized_values.items(): + if source_conversation_doc.get(field_name) != field_value: + source_conversation_doc[field_name] = field_value + source_updated = True + + if source_updated: + source_conversation_doc['last_updated'] = timestamp + cosmos_conversations_container.upsert_item(source_conversation_doc) + + if str((conversation_doc or {}).get('source_conversation_id') or '').strip() != source_conversation_id: + conversation_doc['source_conversation_id'] = source_conversation_id + cosmos_collaboration_conversations_container.upsert_item(conversation_doc) + + return source_conversation_doc, conversation_doc + + +def mirror_source_message_to_collaboration( + conversation_doc, + source_message_doc, + default_sender_user, + reply_to_message_id=None, + extra_metadata=None, +): + source_message_id = str((source_message_doc or {}).get('id') or '').strip() + if not source_message_id: + raise ValueError('source_message_doc.id is required') + + existing_message = get_collaboration_message_by_source_message( + (conversation_doc or {}).get('id'), + source_message_id, + ) + if existing_message: + return existing_message, conversation_doc, False + + collaboration_message = build_collaboration_message_doc_from_legacy( + (conversation_doc or {}).get('id'), + source_message_doc, + default_sender_user, + ) + if not collaboration_message: + return None, conversation_doc, False + + source_role = str((source_message_doc or {}).get('role') or '').strip().lower() + source_metadata = (source_message_doc or {}).get('metadata', {}) if isinstance((source_message_doc or {}).get('metadata'), dict) else {} + message_metadata = collaboration_message.setdefault('metadata', {}) + message_metadata.setdefault('source_message_id', source_message_id) + message_metadata.setdefault('source_conversation_id', str((conversation_doc or {}).get('source_conversation_id') or '').strip() or None) + message_metadata.setdefault('source_thought_user_id', str((default_sender_user or {}).get('user_id') or (conversation_doc or {}).get('created_by_user_id') or '').strip()) + + if isinstance(extra_metadata, dict) and extra_metadata: + message_metadata.update(extra_metadata) + + if reply_to_message_id: + collaboration_message['reply_to_message_id'] = str(reply_to_message_id or '').strip() or None + + if source_role == 'image': + message_metadata['last_message_preview'] = '[Uploaded image]' if bool(source_metadata.get('is_user_upload')) else '[Generated image]' + + return (*_save_collaboration_message_doc(conversation_doc, collaboration_message), True) + + +def _refresh_collaboration_conversation_message_summary(conversation_doc): + conversation_id = str((conversation_doc or {}).get('id') or '').strip() + if not conversation_id: + raise ValueError('conversation_id is required') + + remaining_messages = list_collaboration_messages(conversation_id) + conversation_doc['message_count'] = len(remaining_messages) + + if remaining_messages: + last_message_doc = remaining_messages[-1] + last_message_timestamp = last_message_doc.get('timestamp') or utc_now_iso() + conversation_doc['last_message_at'] = last_message_timestamp + conversation_doc['last_message_preview'] = ( + (last_message_doc.get('metadata') or {}).get('last_message_preview') or '' + ) + conversation_doc['updated_at'] = last_message_timestamp + else: + conversation_doc['last_message_at'] = None + conversation_doc['last_message_preview'] = '' + conversation_doc['updated_at'] = utc_now_iso() + + cosmos_collaboration_conversations_container.upsert_item(conversation_doc) + return conversation_doc + + +def delete_collaboration_message(conversation_id, message_id, current_user_id): + conversation_doc = get_collaboration_conversation(conversation_id) + access_context = assert_user_can_participate_in_collaboration_conversation( + current_user_id, + conversation_doc, + ) + message_doc = get_collaboration_message(message_id) + + if str(message_doc.get('conversation_id') or '').strip() != str(conversation_id or '').strip(): + raise LookupError('Collaborative message not found in this conversation') + + metadata = message_doc.get('metadata', {}) if isinstance(message_doc, dict) else {} + sender_user_id = str( + ((metadata.get('sender') or {}).get('user_id')) + or ((metadata.get('user_info') or {}).get('user_id')) + or '' + ).strip() + normalized_current_user_id = str(current_user_id or '').strip() + + can_delete_message = sender_user_id == normalized_current_user_id + if not can_delete_message and is_personal_collaboration_conversation(conversation_doc): + actor_role = get_personal_collaboration_role( + conversation_doc, + normalized_current_user_id, + user_state=access_context.get('user_state'), + ) + can_delete_message = actor_role in PERSONAL_COLLABORATION_MANAGER_ROLES + elif not can_delete_message and is_group_collaboration_conversation(conversation_doc): + can_delete_message = access_context.get('group_role') in ('Owner', 'Admin', 'DocumentManager') + + if not can_delete_message: + raise PermissionError('You can only delete your own shared messages') + + cosmos_collaboration_messages_container.delete_item( + item=message_id, + partition_key=conversation_id, + ) + updated_conversation_doc = _refresh_collaboration_conversation_message_summary(conversation_doc) + return message_doc, updated_conversation_doc + + +def update_personal_collaboration_title(conversation_id, current_user_id, new_title): + conversation_doc = get_collaboration_conversation(conversation_id) + if not is_collaboration_conversation(conversation_doc): + raise PermissionError('Title updates are only supported for collaborative conversations') + if is_group_collaboration_conversation(conversation_doc): + group_id = str(((conversation_doc.get('scope') or {}).get('group_id')) or '').strip() + assert_group_role( + current_user_id, + group_id, + allowed_roles=('Owner', 'Admin', 'DocumentManager', 'User'), + ) + if current_user_id not in set(conversation_doc.get('owner_user_ids', []) or []): + raise PermissionError('Only conversation owners can rename collaborative conversations') + + normalized_title = str(new_title or '').strip() + if not normalized_title: + raise ValueError('Title is required') + + conversation_doc['title'] = normalized_title + conversation_doc['updated_at'] = utc_now_iso() + cosmos_collaboration_conversations_container.upsert_item(conversation_doc) + return conversation_doc + + +def toggle_personal_collaboration_pin(conversation_id, current_user_id): + conversation_doc = get_collaboration_conversation(conversation_id) + if not is_collaboration_conversation(conversation_doc): + raise PermissionError('Pin is only supported for collaborative conversations') + + access_context = assert_user_can_view_collaboration_conversation( + current_user_id, + conversation_doc, + allow_pending=True, + ) + user_state = access_context.get('user_state') + if user_state is None: + if is_group_collaboration_conversation(conversation_doc): + participant_summary = _resolve_group_member_summary(access_context.get('group_doc'), current_user_id) or { + 'user_id': current_user_id, + 'display_name': 'Group Member', + 'email': '', + } + user_state = ensure_collaboration_user_state_for_participant( + conversation_doc, + participant_summary, + role=get_personal_collaboration_role(conversation_doc, current_user_id) or MEMBERSHIP_ROLE_MEMBER, + membership_status=MEMBERSHIP_STATUS_ACCEPTED, + invited_by_user_id='', + created_at=conversation_doc.get('created_at'), + ) + else: + user_state = get_collaboration_user_state(current_user_id, conversation_id) + + user_state['is_pinned'] = not bool(user_state.get('is_pinned', False)) + user_state['updated_at'] = utc_now_iso() + cosmos_collaboration_user_state_container.upsert_item(user_state) + return conversation_doc, user_state + + +def toggle_personal_collaboration_hide(conversation_id, current_user_id): + conversation_doc = get_collaboration_conversation(conversation_id) + if not is_collaboration_conversation(conversation_doc): + raise PermissionError('Hide is only supported for collaborative conversations') + + access_context = assert_user_can_view_collaboration_conversation( + current_user_id, + conversation_doc, + allow_pending=True, + ) + user_state = access_context.get('user_state') + if user_state is None: + if is_group_collaboration_conversation(conversation_doc): + participant_summary = _resolve_group_member_summary(access_context.get('group_doc'), current_user_id) or { + 'user_id': current_user_id, + 'display_name': 'Group Member', + 'email': '', + } + user_state = ensure_collaboration_user_state_for_participant( + conversation_doc, + participant_summary, + role=get_personal_collaboration_role(conversation_doc, current_user_id) or MEMBERSHIP_ROLE_MEMBER, + membership_status=MEMBERSHIP_STATUS_ACCEPTED, + invited_by_user_id='', + created_at=conversation_doc.get('created_at'), + ) + else: + user_state = get_collaboration_user_state(current_user_id, conversation_id) + + user_state['is_hidden'] = not bool(user_state.get('is_hidden', False)) + user_state['updated_at'] = utc_now_iso() + cosmos_collaboration_user_state_container.upsert_item(user_state) + return conversation_doc, user_state + + +def update_personal_collaboration_member_role(conversation_id, current_user_id, member_user_id, new_role): + conversation_doc = get_collaboration_conversation(conversation_id) + if not is_explicit_membership_collaboration(conversation_doc): + raise PermissionError('Role updates are only supported for invite-managed collaborative conversations') + if is_group_collaboration_conversation(conversation_doc): + group_id = str(((conversation_doc.get('scope') or {}).get('group_id')) or '').strip() + group_doc = find_group_by_id(group_id) + if not group_doc: + raise LookupError('Group not found') + assert_group_role( + current_user_id, + group_id, + allowed_roles=('Owner', 'Admin', 'DocumentManager', 'User'), + ) + if current_user_id not in set(conversation_doc.get('owner_user_ids', []) or []): + raise PermissionError('Only conversation owners can change participant roles') + + normalized_role = str(new_role or '').strip().lower() + if normalized_role not in (MEMBERSHIP_ROLE_ADMIN, MEMBERSHIP_ROLE_MEMBER): + raise ValueError('role must be admin or member') + + participant = get_personal_collaboration_participant(conversation_doc, member_user_id) + if participant is None: + raise LookupError('participant not found') + if str(participant.get('status') or '').strip() != MEMBERSHIP_STATUS_ACCEPTED: + raise ValueError('Only active participants can have admin access') + if str(participant.get('role') or '').strip() == MEMBERSHIP_ROLE_OWNER: + raise ValueError('Use owner transfer to change owner access') + + if str(participant.get('role') or '').strip() == normalized_role: + return conversation_doc, participant + + timestamp = utc_now_iso() + participant['role'] = normalized_role + conversation_doc['updated_at'] = timestamp + refresh_personal_participant_indexes(conversation_doc) + cosmos_collaboration_conversations_container.upsert_item(conversation_doc) + + user_state = get_collaboration_user_state_or_none(member_user_id, conversation_id) + + if user_state: + user_state['role'] = normalized_role + user_state['updated_at'] = timestamp + cosmos_collaboration_user_state_container.upsert_item(user_state) + + return conversation_doc, participant + + +def leave_personal_collaboration_conversation(conversation_id, current_user_id, new_owner_user_id=None): + conversation_doc = get_collaboration_conversation(conversation_id) + if not is_explicit_membership_collaboration(conversation_doc): + raise PermissionError('Leave is only supported for invite-managed collaborative conversations') + if is_group_collaboration_conversation(conversation_doc): + group_id = str(((conversation_doc.get('scope') or {}).get('group_id')) or '').strip() + group_doc = find_group_by_id(group_id) + if not group_doc: + raise LookupError('Group not found') + assert_group_role( + current_user_id, + group_id, + allowed_roles=('Owner', 'Admin', 'DocumentManager', 'User'), + ) + allowed, reason = check_group_status_allows_operation(group_doc, 'chat') + if not allowed: + raise PermissionError(reason) + + participant = get_personal_collaboration_participant(conversation_doc, current_user_id) + if participant is None: + raise PermissionError('You are not a participant in this collaborative conversation') + if str(participant.get('status') or '').strip() != MEMBERSHIP_STATUS_ACCEPTED: + raise PermissionError('Only active participants can leave this collaborative conversation') + + normalized_new_owner_user_id = str(new_owner_user_id or '').strip() + current_role = str(participant.get('role') or '').strip() + promoted_participant = None + timestamp = utc_now_iso() + + if current_role == MEMBERSHIP_ROLE_OWNER: + owner_user_ids = list(conversation_doc.get('owner_user_ids', []) or []) + other_owner_ids = [owner_user_id for owner_user_id in owner_user_ids if owner_user_id != current_user_id] + if not other_owner_ids and not normalized_new_owner_user_id: + raise ValueError('Assign a new owner before leaving this shared conversation') + + if normalized_new_owner_user_id: + if normalized_new_owner_user_id == current_user_id: + raise ValueError('Choose another participant as the new owner') + + promoted_participant = get_personal_collaboration_participant( + conversation_doc, + normalized_new_owner_user_id, + ) + if promoted_participant is None: + raise LookupError('The selected new owner is not a participant in this conversation') + if str(promoted_participant.get('status') or '').strip() != MEMBERSHIP_STATUS_ACCEPTED: + raise ValueError('The selected new owner must already be an active participant') + promoted_participant['role'] = MEMBERSHIP_ROLE_OWNER + + try: + new_owner_state = get_collaboration_user_state(normalized_new_owner_user_id, conversation_id) + except CosmosResourceNotFoundError: + new_owner_state = None + + if new_owner_state: + new_owner_state['role'] = MEMBERSHIP_ROLE_OWNER + new_owner_state['updated_at'] = timestamp + cosmos_collaboration_user_state_container.upsert_item(new_owner_state) + + participant['status'] = MEMBERSHIP_STATUS_REMOVED + participant['removed_at'] = timestamp + conversation_doc['updated_at'] = timestamp + refresh_personal_participant_indexes(conversation_doc) + cosmos_collaboration_conversations_container.upsert_item(conversation_doc) + + try: + user_state = get_collaboration_user_state(current_user_id, conversation_id) + except CosmosResourceNotFoundError: + user_state = None + + if user_state: + user_state['membership_status'] = MEMBERSHIP_STATUS_REMOVED + user_state['role'] = MEMBERSHIP_ROLE_MEMBER + user_state['removed_at'] = timestamp + user_state['updated_at'] = timestamp + cosmos_collaboration_user_state_container.upsert_item(user_state) + + return conversation_doc, participant, promoted_participant + + +def _delete_source_personal_conversation(conversation_doc, current_user_id): + source_conversation_id = str((conversation_doc or {}).get('source_conversation_id') or '').strip() + if not source_conversation_id: + return + + try: + source_conversation = cosmos_conversations_container.read_item( + item=source_conversation_id, + partition_key=source_conversation_id, + ) + except CosmosResourceNotFoundError: + return + + if str(source_conversation.get('user_id') or '').strip() != str(current_user_id or '').strip(): + return + if str(source_conversation.get('collaboration_conversation_id') or '').strip() != str(conversation_doc.get('id') or '').strip(): + return + + message_query = 'SELECT * FROM c WHERE c.conversation_id = @conversation_id' + source_messages = list(cosmos_messages_container.query_items( + query=message_query, + parameters=[{'name': '@conversation_id', 'value': source_conversation_id}], + partition_key=source_conversation_id, + )) + + for message_doc in source_messages: + cosmos_messages_container.delete_item( + item=message_doc.get('id'), + partition_key=source_conversation_id, + ) + + delete_thoughts_for_conversation(source_conversation_id, current_user_id) + cosmos_conversations_container.delete_item( + item=source_conversation_id, + partition_key=source_conversation_id, + ) + + +def _delete_source_group_conversation(conversation_doc, current_user_id): + source_conversation_id = str((conversation_doc or {}).get('legacy_source_conversation_id') or '').strip() + if not source_conversation_id: + return + + try: + source_conversation = cosmos_group_conversations_container.read_item( + item=source_conversation_id, + partition_key=source_conversation_id, + ) + except CosmosResourceNotFoundError: + return + + if str(source_conversation.get('user_id') or '').strip() != str(current_user_id or '').strip(): + return + if str(source_conversation.get('collaboration_conversation_id') or '').strip() != str(conversation_doc.get('id') or '').strip(): + return + + message_query = 'SELECT * FROM c WHERE c.conversation_id = @conversation_id' + source_messages = list(cosmos_group_messages_container.query_items( + query=message_query, + parameters=[{'name': '@conversation_id', 'value': source_conversation_id}], + partition_key=source_conversation_id, + )) + + for message_doc in source_messages: + cosmos_group_messages_container.delete_item( + item=message_doc.get('id'), + partition_key=source_conversation_id, + ) + + delete_thoughts_for_conversation(source_conversation_id, current_user_id) + cosmos_group_conversations_container.delete_item( + item=source_conversation_id, + partition_key=source_conversation_id, + ) + + +def delete_personal_collaboration_conversation(conversation_id, current_user_id): + conversation_doc = get_collaboration_conversation(conversation_id) + if not is_explicit_membership_collaboration(conversation_doc): + raise PermissionError('Delete is only supported for invite-managed collaborative conversations') + if is_group_collaboration_conversation(conversation_doc): + group_id = str(((conversation_doc.get('scope') or {}).get('group_id')) or '').strip() + group_doc = find_group_by_id(group_id) + if not group_doc: + raise LookupError('Group not found') + assert_group_role( + current_user_id, + group_id, + allowed_roles=('Owner', 'Admin', 'DocumentManager', 'User'), + ) + if current_user_id not in set(conversation_doc.get('owner_user_ids', []) or []): + raise PermissionError('Only conversation owners can delete this shared conversation') + + message_query = 'SELECT * FROM c WHERE c.conversation_id = @conversation_id' + messages = list(cosmos_collaboration_messages_container.query_items( + query=message_query, + parameters=[{'name': '@conversation_id', 'value': conversation_id}], + partition_key=conversation_id, + )) + for message_doc in messages: + cosmos_collaboration_messages_container.delete_item( + item=message_doc.get('id'), + partition_key=conversation_id, + ) + + state_query = 'SELECT * FROM c WHERE c.conversation_id = @conversation_id' + state_docs = list(cosmos_collaboration_user_state_container.query_items( + query=state_query, + parameters=[{'name': '@conversation_id', 'value': conversation_id}], + enable_cross_partition_query=True, + )) + for state_doc in state_docs: + cosmos_collaboration_user_state_container.delete_item( + item=state_doc.get('id'), + partition_key=state_doc.get('user_id'), + ) + + _delete_source_personal_conversation(conversation_doc, current_user_id) + if is_group_collaboration_conversation(conversation_doc): + _delete_source_group_conversation(conversation_doc, current_user_id) + cosmos_collaboration_conversations_container.delete_item( + item=conversation_id, + partition_key=conversation_id, + ) + return conversation_doc + + +def get_accessible_collaboration_message_thoughts(conversation_doc, message_doc, viewer_user_id): + conversation_id = str((conversation_doc or {}).get('id') or '').strip() + message_id = str((message_doc or {}).get('id') or '').strip() + metadata = (message_doc or {}).get('metadata', {}) if isinstance(message_doc, dict) else {} + + if not conversation_id or not message_id: + return [] + + direct_thoughts = get_thoughts_for_message(conversation_id, message_id, viewer_user_id) + if direct_thoughts: + return direct_thoughts + + fallback_user_ids = [] + for candidate_user_id in ( + metadata.get('source_thought_user_id'), + (conversation_doc or {}).get('created_by_user_id'), + ): + normalized_candidate = str(candidate_user_id or '').strip() + if not normalized_candidate or normalized_candidate in fallback_user_ids: + continue + fallback_user_ids.append(normalized_candidate) + + fallback_conversation_id = str( + metadata.get('source_conversation_id') + or (conversation_doc or {}).get('source_conversation_id') + or '' + ).strip() + fallback_message_id = str(metadata.get('source_message_id') or '').strip() + + for candidate_user_id in fallback_user_ids: + candidate_thoughts = get_thoughts_for_message(conversation_id, message_id, candidate_user_id) + if candidate_thoughts: + return candidate_thoughts + + if fallback_conversation_id and fallback_message_id: + candidate_thoughts = get_thoughts_for_message( + fallback_conversation_id, + fallback_message_id, + candidate_user_id, + ) + if candidate_thoughts: + return candidate_thoughts + + return [] \ No newline at end of file diff --git a/application/single_app/functions_conversation_metadata.py b/application/single_app/functions_conversation_metadata.py index 0a993ee9..abb266ee 100644 --- a/application/single_app/functions_conversation_metadata.py +++ b/application/single_app/functions_conversation_metadata.py @@ -7,6 +7,7 @@ from functions_group import find_group_by_id from functions_public_workspaces import find_public_workspace_by_id from functions_documents import get_document_metadata +from functions_collaboration import get_collaboration_conversation from functions_debug import debug_print def get_user_info_by_id(user_id): @@ -61,6 +62,23 @@ def _normalize_scope_id_list(raw_ids): return normalized_ids +def _get_conversation_item_with_source(conversation_id): + """Load a conversation from the legacy or collaboration store.""" + normalized_conversation_id = str(conversation_id or '').strip() + if not normalized_conversation_id: + raise CosmosResourceNotFoundError(message='Conversation not found') + + try: + conversation_item = cosmos_conversations_container.read_item( + item=normalized_conversation_id, + partition_key=normalized_conversation_id + ) + return conversation_item, 'legacy' + except CosmosResourceNotFoundError: + conversation_item = get_collaboration_conversation(normalized_conversation_id) + return conversation_item, 'collaboration' + + def _build_primary_context_from_scope_selection( user_id, document_scope=None, @@ -390,7 +408,11 @@ def collect_conversation_metadata(user_message, conversation_id, user_id, active if existing_primary: # Documents were used - set chat_type based on primary context scope if existing_primary.get('scope') == 'group': - conversation_item['chat_type'] = 'group-single-user' # Default to single-user for now + conversation_item['chat_type'] = ( + 'group' + if conversation_item.get('conversation_kind') == 'collaboration_source' + else 'group-single-user' + ) elif existing_primary.get('scope') == 'public': conversation_item['chat_type'] = 'public' elif existing_primary.get('scope') == 'personal': @@ -696,23 +718,23 @@ def update_conversation_with_metadata(conversation_id, metadata_updates): bool: True if successful, False otherwise """ try: - # Read the existing conversation - conversation_item = cosmos_conversations_container.read_item( - item=conversation_id, - partition_key=conversation_id - ) + conversation_item, conversation_source = _get_conversation_item_with_source(conversation_id) # Update with new metadata conversation_item.update(metadata_updates) - conversation_item['last_updated'] = datetime.utcnow().isoformat() + updated_at = datetime.utcnow().isoformat() - # Upsert back to Cosmos - cosmos_conversations_container.upsert_item(conversation_item) + if conversation_source == 'collaboration': + conversation_item['updated_at'] = updated_at + cosmos_collaboration_conversations_container.upsert_item(conversation_item) + else: + conversation_item['last_updated'] = updated_at + cosmos_conversations_container.upsert_item(conversation_item) return True except Exception as e: - print(f"Error updating conversation metadata for {conversation_id}: {e}") + debug_print(f"Error updating conversation metadata for {conversation_id}: {e}") return False @@ -727,12 +749,9 @@ def get_conversation_metadata(conversation_id): dict: Conversation metadata or None if not found """ try: - conversation_item = cosmos_conversations_container.read_item( - item=conversation_id, - partition_key=conversation_id - ) + conversation_item, _ = _get_conversation_item_with_source(conversation_id) return conversation_item except Exception as e: - print(f"Error retrieving conversation metadata for {conversation_id}: {e}") + debug_print(f"Error retrieving conversation metadata for {conversation_id}: {e}") return None diff --git a/application/single_app/functions_document_actions.py b/application/single_app/functions_document_actions.py new file mode 100644 index 00000000..ae37dbe1 --- /dev/null +++ b/application/single_app/functions_document_actions.py @@ -0,0 +1,153 @@ +# functions_document_actions.py +"""Shared helpers for backend document actions.""" + +from functions_exhaustive_document_review import normalize_exhaustive_review_targets +from functions_search import normalize_search_id_list + + +DOCUMENT_ACTION_TYPE_NONE = 'none' +DOCUMENT_ACTION_TYPE_EXHAUSTIVE_REVIEW = 'exhaustive_review' +DOCUMENT_ACTION_TYPE_COMPARISON = 'comparison' +VALID_DOCUMENT_ACTION_TYPES = { + DOCUMENT_ACTION_TYPE_NONE, + DOCUMENT_ACTION_TYPE_EXHAUSTIVE_REVIEW, + DOCUMENT_ACTION_TYPE_COMPARISON, +} + + +def normalize_document_action_type(action_type): + normalized_type = str(action_type or DOCUMENT_ACTION_TYPE_NONE).strip().lower() + if normalized_type not in VALID_DOCUMENT_ACTION_TYPES: + return DOCUMENT_ACTION_TYPE_NONE + return normalized_type + + +def _build_legacy_exhaustive_action(legacy_exhaustive_review=None): + legacy_exhaustive_review = legacy_exhaustive_review if isinstance(legacy_exhaustive_review, dict) else {} + if not legacy_exhaustive_review.get('enabled'): + return {} + + return { + 'type': DOCUMENT_ACTION_TYPE_EXHAUSTIVE_REVIEW, + 'doc_scope': legacy_exhaustive_review.get('doc_scope', 'all'), + 'active_group_ids': legacy_exhaustive_review.get('active_group_ids'), + 'active_public_workspace_id': legacy_exhaustive_review.get('active_public_workspace_id'), + 'window_unit': legacy_exhaustive_review.get('window_unit'), + 'window_size': legacy_exhaustive_review.get('window_size'), + 'window_percent': legacy_exhaustive_review.get('window_percent'), + 'max_retries_per_window': legacy_exhaustive_review.get('max_retries_per_window'), + 'document_ids': legacy_exhaustive_review.get('document_ids'), + } + + +def normalize_document_action_config( + action_payload=None, + existing_action=None, + legacy_exhaustive_review=None, + max_documents=None, +): + action_payload = action_payload if isinstance(action_payload, dict) else {} + existing_action = existing_action if isinstance(existing_action, dict) else {} + source_action = action_payload or existing_action or _build_legacy_exhaustive_action(legacy_exhaustive_review) + action_type = normalize_document_action_type(source_action.get('type')) + + normalized_action = { + 'type': action_type, + 'doc_scope': 'all', + 'active_group_ids': [], + 'active_public_workspace_id': [], + 'window_unit': 'pages', + 'window_size': None, + 'window_percent': None, + 'max_retries_per_window': 1, + 'document_ids': [], + 'left_document_id': '', + 'right_document_ids': [], + } + if action_type == DOCUMENT_ACTION_TYPE_NONE: + return normalized_action + + if action_type == DOCUMENT_ACTION_TYPE_EXHAUSTIVE_REVIEW: + normalized_targets = normalize_exhaustive_review_targets( + document_ids=source_action.get('document_ids'), + doc_scope=source_action.get('doc_scope', 'all'), + active_group_ids=source_action.get('active_group_ids'), + active_public_workspace_id=source_action.get('active_public_workspace_id'), + window_unit=source_action.get('window_unit'), + window_size=source_action.get('window_size'), + window_percent=source_action.get('window_percent'), + max_retries_per_window=source_action.get('max_retries_per_window'), + max_documents=max_documents, + ) + normalized_action.update(normalized_targets) + return normalized_action + + left_candidates = normalize_search_id_list([source_action.get('left_document_id')]) + if not left_candidates: + raise ValueError('Select one left-side document for comparison.') + + left_document_id = left_candidates[0] + right_document_ids = [ + document_id for document_id in normalize_search_id_list(source_action.get('right_document_ids')) + if document_id != left_document_id + ] + if not right_document_ids: + raise ValueError('Select one or more right-side documents for comparison.') + + normalized_targets = normalize_exhaustive_review_targets( + document_ids=[left_document_id, *right_document_ids], + doc_scope=source_action.get('doc_scope', 'all'), + active_group_ids=source_action.get('active_group_ids'), + active_public_workspace_id=source_action.get('active_public_workspace_id'), + window_unit=source_action.get('window_unit'), + window_size=source_action.get('window_size'), + window_percent=source_action.get('window_percent'), + max_retries_per_window=source_action.get('max_retries_per_window'), + max_documents=max_documents, + ) + + normalized_action.update(normalized_targets) + normalized_action['left_document_id'] = left_document_id + normalized_action['right_document_ids'] = [ + document_id for document_id in normalized_action.get('document_ids', []) + if document_id != left_document_id + ] + return normalized_action + + +def get_document_action_config(document_source, max_documents=None): + document_source = document_source if isinstance(document_source, dict) else {} + return normalize_document_action_config( + action_payload=document_source.get('document_action'), + existing_action=document_source.get('document_action'), + legacy_exhaustive_review=document_source.get('exhaustive_review'), + max_documents=max_documents, + ) + + +def build_legacy_exhaustive_review_config(action_config=None): + action_config = action_config if isinstance(action_config, dict) else {} + if action_config.get('type') != DOCUMENT_ACTION_TYPE_EXHAUSTIVE_REVIEW: + return { + 'enabled': False, + 'document_ids': [], + 'doc_scope': 'all', + 'active_group_ids': [], + 'active_public_workspace_id': [], + 'window_unit': 'pages', + 'window_size': None, + 'window_percent': None, + 'max_retries_per_window': 1, + } + + return { + 'enabled': True, + 'document_ids': list(action_config.get('document_ids', [])), + 'doc_scope': action_config.get('doc_scope', 'all'), + 'active_group_ids': list(action_config.get('active_group_ids', [])), + 'active_public_workspace_id': list(action_config.get('active_public_workspace_id', [])), + 'window_unit': action_config.get('window_unit', 'pages'), + 'window_size': action_config.get('window_size'), + 'window_percent': action_config.get('window_percent'), + 'max_retries_per_window': action_config.get('max_retries_per_window', 1), + } \ No newline at end of file diff --git a/application/single_app/functions_document_comparison.py b/application/single_app/functions_document_comparison.py new file mode 100644 index 00000000..ce0816db --- /dev/null +++ b/application/single_app/functions_document_comparison.py @@ -0,0 +1,371 @@ +# functions_document_comparison.py +"""Shared deterministic document comparison services.""" + +import logging + +from functions_appinsights import log_event +from functions_debug import debug_print +from functions_document_actions import DOCUMENT_ACTION_TYPE_COMPARISON +from functions_exhaustive_document_review import ( + build_document_review_progress_snapshot, + run_exhaustive_document_review, +) + + +def _create_document_state(document_id, role_label): + return { + 'document_id': document_id, + 'document_name': document_id, + 'role_label': role_label, + 'scope': None, + 'scope_id': None, + 'total_windows': 0, + 'processed_windows': 0, + 'failed_windows': 0, + 'total_chunks': 0, + 'processed_chunks': 0, + 'failed_chunks': 0, + 'total_pages': 0, + 'status': 'pending', + 'status_text': 'Queued', + 'active_window_number': None, + 'active_attempt_number': None, + 'failed_ranges': [], + 'ranges': [], + 'retries': 0, + } + + +def _refresh_comparison_coverage(coverage, document_order, document_states): + ordered_documents = [document_states[document_id] for document_id in document_order if document_id in document_states] + coverage['documents'] = ordered_documents + coverage['document_count'] = len(ordered_documents) + coverage['total_windows'] = sum(document.get('total_windows', 0) for document in ordered_documents) + coverage['processed_windows'] = sum(document.get('processed_windows', 0) for document in ordered_documents) + coverage['failed_windows'] = sum(document.get('failed_windows', 0) for document in ordered_documents) + coverage['total_chunks'] = sum(document.get('total_chunks', 0) for document in ordered_documents) + coverage['processed_chunks'] = sum(document.get('processed_chunks', 0) for document in ordered_documents) + coverage['failed_chunks'] = sum(document.get('failed_chunks', 0) for document in ordered_documents) + coverage['retries'] = sum(document.get('retries', 0) for document in ordered_documents) + return coverage + + +def _apply_summary_progress_event(document_state, event): + event = event if isinstance(event, dict) else {} + progress = event.get('progress') if isinstance(event.get('progress'), dict) else {} + progress_documents = progress.get('documents') if isinstance(progress.get('documents'), list) else [] + progress_document = progress_documents[0] if progress_documents else {} + progress_overall = progress.get('overall') if isinstance(progress.get('overall'), dict) else {} + + document_state['document_name'] = str(event.get('document_name') or document_state.get('document_name') or document_state.get('document_id')).strip() or document_state.get('document_id') + document_state['scope'] = progress_document.get('scope', document_state.get('scope')) + document_state['scope_id'] = progress_document.get('scope_id', document_state.get('scope_id')) + document_state['total_windows'] = progress_document.get('total_windows', document_state.get('total_windows', 0)) + document_state['processed_windows'] = progress_document.get('processed_windows', document_state.get('processed_windows', 0)) + document_state['failed_windows'] = progress_document.get('failed_windows', document_state.get('failed_windows', 0)) + document_state['total_chunks'] = progress_document.get('total_chunks', document_state.get('total_chunks', 0)) + document_state['processed_chunks'] = progress_document.get('processed_chunks', document_state.get('processed_chunks', 0)) + document_state['failed_chunks'] = progress_document.get('failed_chunks', document_state.get('failed_chunks', 0)) + document_state['total_pages'] = progress_document.get('total_pages', document_state.get('total_pages', 0)) + document_state['active_window_number'] = progress_document.get('active_window_number', document_state.get('active_window_number')) + document_state['active_attempt_number'] = progress_document.get('active_attempt_number', document_state.get('active_attempt_number')) + document_state['status'] = progress_document.get('status', document_state.get('status', 'pending')) + document_state['status_text'] = progress_document.get('status_text', document_state.get('status_text', 'Queued')) + document_state['retries'] = progress_overall.get('retries', document_state.get('retries', 0)) + + if event.get('type') == 'document_completed': + document_state['active_window_number'] = None + document_state['active_attempt_number'] = None + + +def _build_document_summary_prompt(comparison_prompt, role_label, document_name): + return ( + 'You are preparing a deterministic document summary that will be used for a later comparison. ' + 'Focus on facts, obligations, definitions, decisions, changes, dates, and caveats that matter to the ' + 'comparison request below. Preserve uncertainty instead of guessing.\n\n' + f'Comparison request:\n{comparison_prompt}\n\n' + f'Document role: {role_label}\n' + f'Document name: {document_name}\n\n' + 'Write a comparison-ready summary of this document. Include the points that would matter when this ' + 'document is compared against one or more other documents for differences, impact, alignment, conflicts, ' + 'or version changes.' + ) + + +def _build_pairwise_comparison_prompt(comparison_prompt, left_name, right_name, left_summary, right_summary): + return ( + 'You are comparing two documents that were summarized from exhaustive review. ' + 'Treat the left document as the primary baseline and compare the right document against it.\n\n' + f'Comparison request:\n{comparison_prompt}\n\n' + f'Left document: {left_name}\n' + f'Right document: {right_name}\n\n' + 'Explain what matches, what differs, what the right document changes or impacts relative to the left, ' + 'and any conflicts, missing items, risks, or open questions that matter to the user request.\n\n' + f'\n{left_summary}\n\n\n' + f'\n{right_summary}\n' + ) + + +def _build_comparison_reduction_prompt(comparison_prompt, left_name, comparison_items): + combined_sections = [] + for comparison_item in comparison_items: + combined_sections.append( + f"[{comparison_item.get('right_document_name')}]\n{comparison_item.get('text', '')}" + ) + + return ( + 'You are consolidating pairwise document comparisons into one final answer. ' + 'Keep the left document as the anchor and organize the response clearly by right-side document.\n\n' + f'Comparison request:\n{comparison_prompt}\n\n' + f'Left document: {left_name}\n\n' + 'Preserve material differences, impact analysis, conflicts, and unresolved questions.\n\n' + f"\n{'\n\n'.join(combined_sections)}\n" + ) + + +def _format_comparison_coverage_summary(coverage, left_document_name, right_document_names): + lines = [ + '## Comparison Coverage', + f'- Left document: {left_document_name}', + f"- Right documents compared: {len(right_document_names)}", + f"- Total windows: {coverage.get('total_windows', 0)}", + f"- Processed windows: {coverage.get('processed_windows', 0)}", + f"- Failed windows: {coverage.get('failed_windows', 0)}", + f"- Total chunks: {coverage.get('total_chunks', 0)}", + f"- Processed chunks: {coverage.get('processed_chunks', 0)}", + f"- Failed chunks: {coverage.get('failed_chunks', 0)}", + f"- Retries used: {coverage.get('retries', 0)}", + '', + '### Documents Summarized', + ] + + for document in coverage.get('documents', []): + lines.append( + '- ' + f"{document.get('document_name')}: " + f"{document.get('processed_windows', 0)}/{document.get('total_windows', 0)} windows processed, " + f"{document.get('processed_chunks', 0)}/{document.get('total_chunks', 0)} chunks completed" + ) + + return '\n'.join(lines) + + +def run_document_comparison( + user_id, + comparison_prompt, + action_config, + invoke_prompt, + activity_callback=None, +): + normalized_prompt = str(comparison_prompt or '').strip() + if not normalized_prompt: + raise ValueError('A comparison prompt is required for document comparison.') + if not callable(invoke_prompt): + raise ValueError('A callable invoke_prompt handler is required for document comparison.') + action_config = action_config if isinstance(action_config, dict) else {} + if action_config.get('type') != DOCUMENT_ACTION_TYPE_COMPARISON: + raise ValueError('Document comparison requires a comparison action configuration.') + + left_document_id = str(action_config.get('left_document_id') or '').strip() + right_document_ids = list(action_config.get('right_document_ids') or []) + if not left_document_id or not right_document_ids: + raise ValueError('Document comparison requires one left document and at least one right document.') + + coverage = { + 'document_count': 0, + 'total_windows': 0, + 'processed_windows': 0, + 'failed_windows': 0, + 'total_chunks': 0, + 'processed_chunks': 0, + 'failed_chunks': 0, + 'retries': 0, + 'window_unit': action_config.get('window_unit', 'pages'), + 'documents': [], + } + document_order = [left_document_id, *right_document_ids] + document_states = { + left_document_id: _create_document_state(left_document_id, 'left'), + } + for document_id in right_document_ids: + document_states[document_id] = _create_document_state(document_id, 'right') + _refresh_comparison_coverage(coverage, document_order, document_states) + + document_summaries = {} + for document_index, document_id in enumerate(document_order, start=1): + document_state = document_states[document_id] + role_label = document_state.get('role_label', 'right') + document_state['status'] = 'running' + document_state['status_text'] = f"Preparing {role_label}-side document {document_index} of {len(document_order)}" + _refresh_comparison_coverage(coverage, document_order, document_states) + + def summary_activity_callback(event, current_document_id=document_id): + current_document_state = document_states[current_document_id] + _apply_summary_progress_event(current_document_state, event) + _refresh_comparison_coverage(coverage, document_order, document_states) + if callable(activity_callback): + forwarded_event = dict(event or {}) + forwarded_event['comparison_role'] = current_document_state.get('role_label') + forwarded_event['progress'] = build_document_review_progress_snapshot(coverage) + activity_callback(forwarded_event) + + summary_result = run_exhaustive_document_review( + user_id=user_id, + review_prompt=_build_document_summary_prompt( + normalized_prompt, + role_label, + document_state.get('document_name'), + ), + document_ids=[document_id], + invoke_prompt=invoke_prompt, + doc_scope=action_config.get('doc_scope'), + active_group_ids=action_config.get('active_group_ids'), + active_public_workspace_id=action_config.get('active_public_workspace_id'), + window_unit=action_config.get('window_unit'), + window_size=action_config.get('window_size'), + window_percent=action_config.get('window_percent'), + max_retries_per_window=action_config.get('max_retries_per_window'), + activity_callback=summary_activity_callback, + max_documents=1, + include_coverage_summary=False, + ) + document_summaries[document_id] = summary_result + document_state['document_name'] = ( + (summary_result.get('documents') or [{}])[0].get('document_name') + or document_state.get('document_name') + ) + document_state['status'] = 'completed_with_failures' if document_state.get('failed_windows', 0) else 'completed' + document_state['status_text'] = 'Summary ready' + document_state['active_window_number'] = None + document_state['active_attempt_number'] = None + _refresh_comparison_coverage(coverage, document_order, document_states) + + left_document_name = document_states[left_document_id].get('document_name') or left_document_id + comparison_items = [] + for comparison_index, right_document_id in enumerate(right_document_ids, start=1): + right_document_name = document_states[right_document_id].get('document_name') or right_document_id + if callable(activity_callback): + activity_callback({ + 'type': 'comparison_started', + 'left_document_id': left_document_id, + 'left_document_name': left_document_name, + 'right_document_id': right_document_id, + 'right_document_name': right_document_name, + 'comparison_index': comparison_index, + 'comparison_count': len(right_document_ids), + 'progress': build_document_review_progress_snapshot(coverage), + }) + + pairwise_text = str(invoke_prompt( + _build_pairwise_comparison_prompt( + normalized_prompt, + left_document_name, + right_document_name, + document_summaries[left_document_id].get('analysis_reply', ''), + document_summaries[right_document_id].get('analysis_reply', ''), + ), + stage='comparison', + metadata={ + 'comparison_index': comparison_index, + 'comparison_count': len(right_document_ids), + 'left_document_id': left_document_id, + 'right_document_id': right_document_id, + }, + ) or '').strip() + if not pairwise_text: + raise RuntimeError( + f'Document comparison returned an empty response for {left_document_name} and {right_document_name}.' + ) + + comparison_items.append({ + 'right_document_id': right_document_id, + 'right_document_name': right_document_name, + 'text': pairwise_text, + }) + if callable(activity_callback): + activity_callback({ + 'type': 'comparison_completed', + 'left_document_id': left_document_id, + 'left_document_name': left_document_name, + 'right_document_id': right_document_id, + 'right_document_name': right_document_name, + 'comparison_index': comparison_index, + 'comparison_count': len(right_document_ids), + 'progress': build_document_review_progress_snapshot(coverage), + }) + + if len(comparison_items) == 1: + final_reply = comparison_items[0].get('text', '').strip() + else: + if callable(activity_callback): + activity_callback({ + 'type': 'comparison_reduction_started', + 'left_document_id': left_document_id, + 'left_document_name': left_document_name, + 'comparison_count': len(comparison_items), + 'progress': build_document_review_progress_snapshot(coverage), + }) + final_reply = str(invoke_prompt( + _build_comparison_reduction_prompt( + normalized_prompt, + left_document_name, + comparison_items, + ), + stage='comparison_reduction', + metadata={ + 'comparison_count': len(comparison_items), + 'left_document_id': left_document_id, + }, + ) or '').strip() + if not final_reply: + raise RuntimeError('Document comparison reduction returned an empty response.') + + coverage_summary = _format_comparison_coverage_summary( + coverage, + left_document_name, + [document_states[document_id].get('document_name') or document_id for document_id in right_document_ids], + ) + if coverage_summary: + final_reply = f"{final_reply}\n\n{coverage_summary}".strip() + + log_event( + '[DocumentComparison] Completed deterministic document comparison', + extra={ + 'user_id': user_id, + 'left_document_id': left_document_id, + 'right_document_count': len(right_document_ids), + 'document_count': coverage.get('document_count', 0), + 'total_windows': coverage.get('total_windows', 0), + 'processed_windows': coverage.get('processed_windows', 0), + 'failed_windows': coverage.get('failed_windows', 0), + 'retries': coverage.get('retries', 0), + }, + level=logging.INFO, + ) + debug_print( + '[DocumentComparison] Completed comparison | ' + f'left={left_document_id} | ' + f'right_count={len(right_document_ids)} | ' + f"documents={coverage.get('document_count', 0)} | " + f"windows={coverage.get('total_windows', 0)} | " + f"processed={coverage.get('processed_windows', 0)} | " + f"failed={coverage.get('failed_windows', 0)}" + ) + + return { + 'reply': final_reply, + 'coverage': coverage, + 'documents': coverage.get('documents', []), + 'left_document': { + 'document_id': left_document_id, + 'document_name': left_document_name, + }, + 'right_documents': [ + { + 'document_id': document_id, + 'document_name': document_states[document_id].get('document_name') or document_id, + } + for document_id in right_document_ids + ], + 'comparison_items': comparison_items, + } \ No newline at end of file diff --git a/application/single_app/functions_documents.py b/application/single_app/functions_documents.py index 7c6e4a27..31a2067e 100644 --- a/application/single_app/functions_documents.py +++ b/application/single_app/functions_documents.py @@ -2573,49 +2573,13 @@ def get_document_metadata_for_citations(document_id, user_id=None, group_id=None return None def get_all_chunks(document_id, user_id, group_id=None, public_workspace_id=None): - is_group = group_id is not None - is_public_workspace = public_workspace_id is not None - - # For personal documents, first check if user has access (owner or shared) - if not is_group and not is_public_workspace: - # Check if user has access to this document - if not is_document_shared_with_user(document_id, user_id): - print(f"User {user_id} does not have access to document {document_id}") - return [] - elif is_group: - # For group documents, check if group has access (owner or shared) - if not is_document_shared_with_group(document_id, group_id): - print(f"Group {group_id} does not have access to document {document_id}") - return [] - - search_client = CLIENTS["search_client_public"] if is_public_workspace else CLIENTS["search_client_group"] if is_group else CLIENTS["search_client_user"] - filter_expr = ( - f"document_id eq '{document_id}' and public_workspace_id eq '{public_workspace_id}'" - if is_public_workspace else - f"document_id eq '{document_id}' and (group_id eq '{group_id}' or shared_group_ids/any(g: g eq '{group_id}'))" - if is_group else - f"document_id eq '{document_id}'" # For personal documents, just filter by document_id since access is already verified - ) - - select_fields = [ - "id", - "chunk_text", - "chunk_id", - "file_name", - "public_workspace_id" if is_public_workspace else ("group_id" if is_group else "user_id"), - "version", - "chunk_sequence", - "upload_date" - ] - try: - results = search_client.search( - search_text="*", - filter=filter_expr, - select=",".join(select_fields) + return get_ordered_document_chunks( + document_id=document_id, + user_id=user_id, + group_id=group_id, + public_workspace_id=public_workspace_id, ) - return results - except Exception as e: print(f"Error retrieving chunks for document {document_id}: {e}") raise @@ -2723,6 +2687,133 @@ def chunk_pdf(input_pdf_path: str, max_pages: int = 500) -> list: return chunks + +def get_document_record(user_id, document_id, group_id=None, public_workspace_id=None): + """Return a document record when the caller has access to it, otherwise None.""" + is_group = group_id is not None + is_public_workspace = public_workspace_id is not None + + cosmos_container = _get_documents_container( + group_id=group_id, + public_workspace_id=public_workspace_id, + ) + + try: + document_item = cosmos_container.read_item( + item=document_id, + partition_key=document_id, + ) + except CosmosResourceNotFoundError: + return None + except Exception as e: + print(f"Error retrieving document record {document_id}: {e}") + return None + + if is_public_workspace: + if document_item.get('public_workspace_id') != public_workspace_id: + return None + return _normalize_document_enhanced_citations(document_item) + + if is_group: + shared_group_ids = document_item.get('shared_group_ids', []) + if ( + document_item.get('group_id') != group_id + and not any(str(entry).startswith(f"{group_id},") for entry in shared_group_ids) + ): + return None + return _normalize_document_enhanced_citations(document_item) + + shared_user_ids = document_item.get('shared_user_ids', []) + if ( + document_item.get('user_id') != user_id + and not any(str(entry).startswith(f"{user_id},") for entry in shared_user_ids) + ): + return None + + return _normalize_document_enhanced_citations(document_item) + + +def get_ordered_document_chunks(document_id, user_id, group_id=None, public_workspace_id=None, max_chunks=None): + """Return ordered chunk records for a document after access has been verified.""" + document_item = get_document_record( + user_id=user_id, + document_id=document_id, + group_id=group_id, + public_workspace_id=public_workspace_id, + ) + + if not document_item: + return [] + + search_client = _get_search_client( + group_id=group_id, + public_workspace_id=public_workspace_id, + ) + scope_field = 'public_workspace_id' if public_workspace_id is not None else ('group_id' if group_id is not None else 'user_id') + select_fields = [ + 'id', + 'document_id', + 'chunk_text', + 'chunk_id', + 'file_name', + scope_field, + 'version', + 'chunk_sequence', + 'page_number', + 'upload_date', + 'document_classification', + 'document_tags', + 'author', + 'chunk_keywords', + 'title', + 'chunk_summary', + ] + search_kwargs = { + 'search_text': '*', + 'filter': f"document_id eq '{document_id}'", + 'select': ','.join(select_fields), + } + if max_chunks is not None: + search_kwargs['top'] = max(1, int(max_chunks)) + + try: + results = list(search_client.search(**search_kwargs)) + except Exception as e: + print(f"Error retrieving chunks for document {document_id}: {e}") + raise + + ordered_chunks = [] + for result in results: + ordered_chunks.append({ + 'id': result.get('id'), + 'document_id': result.get('document_id'), + 'chunk_text': result.get('chunk_text', ''), + 'chunk_id': result.get('chunk_id'), + 'file_name': result.get('file_name'), + 'user_id': result.get('user_id') if scope_field == 'user_id' else document_item.get('user_id'), + 'group_id': result.get('group_id') if scope_field == 'group_id' else document_item.get('group_id'), + 'public_workspace_id': result.get('public_workspace_id') if scope_field == 'public_workspace_id' else document_item.get('public_workspace_id'), + 'version': result.get('version'), + 'chunk_sequence': result.get('chunk_sequence', 0), + 'page_number': result.get('page_number'), + 'upload_date': result.get('upload_date'), + 'document_classification': result.get('document_classification'), + 'document_tags': result.get('document_tags', []), + 'author': result.get('author'), + 'chunk_keywords': result.get('chunk_keywords'), + 'title': result.get('title'), + 'chunk_summary': result.get('chunk_summary'), + }) + + ordered_chunks.sort( + key=lambda chunk: ( + _safe_int(chunk.get('page_number')) if chunk.get('page_number') is not None else 10**9, + _safe_int(chunk.get('chunk_sequence')), + str(chunk.get('id') or ''), + ) + ) + return ordered_chunks + def get_documents(user_id, group_id=None, public_workspace_id=None): try: documents = _query_accessible_documents( @@ -2736,72 +2827,18 @@ def get_documents(user_id, group_id=None, public_workspace_id=None): return jsonify({'error': f'Error retrieving documents: {str(e)}'}), 500 def get_document(user_id, document_id, group_id=None, public_workspace_id=None): - is_group = group_id is not None - is_public_workspace = public_workspace_id is not None - - # Choose the correct cosmos_container and query parameters - if is_public_workspace: - cosmos_container = cosmos_public_documents_container - elif is_group: - cosmos_container = cosmos_group_documents_container - else: - cosmos_container = cosmos_user_documents_container - - if is_public_workspace: - query = """ - SELECT TOP 1 * - FROM c - WHERE c.id = @document_id - AND c.public_workspace_id = @public_workspace_id - ORDER BY c.version DESC - """ - parameters = [ - {"name": "@document_id", "value": document_id}, - {"name": "@public_workspace_id", "value": public_workspace_id} - ] - elif is_group: - query = """ - SELECT TOP 1 * - FROM c - WHERE c.id = @document_id - AND (c.group_id = @group_id OR ARRAY_CONTAINS(c.shared_group_ids, @group_id)) - ORDER BY c.version DESC - """ - parameters = [ - {"name": "@document_id", "value": document_id}, - {"name": "@group_id", "value": group_id} - ] - else: - query = """ - SELECT TOP 1 * - FROM c - WHERE c.id = @document_id - AND ( - c.user_id = @user_id - OR ARRAY_CONTAINS(c.shared_user_ids, @user_id) - OR EXISTS(SELECT VALUE s FROM s IN c.shared_user_ids WHERE STARTSWITH(s, @user_id_prefix)) - ) - ORDER BY c.version DESC - """ - parameters = [ - {"name": "@document_id", "value": document_id}, - {"name": "@user_id", "value": user_id}, - {"name": "@user_id_prefix", "value": f"{user_id},"} - ] - try: - document_results = list( - cosmos_container.query_items( - query=query, - parameters=parameters, - enable_cross_partition_query=True - ) + document_record = get_document_record( + user_id=user_id, + document_id=document_id, + group_id=group_id, + public_workspace_id=public_workspace_id, ) - if not document_results: + if not document_record: return jsonify({'error': 'Document not found or access denied'}), 404 - return jsonify(_normalize_document_enhanced_citations(document_results[0])), 200 + return jsonify(document_record), 200 except Exception as e: return jsonify({'error': f'Error retrieving document: {str(e)}'}), 500 diff --git a/application/single_app/functions_exhaustive_document_review.py b/application/single_app/functions_exhaustive_document_review.py new file mode 100644 index 00000000..e011b497 --- /dev/null +++ b/application/single_app/functions_exhaustive_document_review.py @@ -0,0 +1,662 @@ +# functions_exhaustive_document_review.py +"""Shared exhaustive document review services.""" + +import logging +from typing import Any, Callable, Dict, List, Optional + +from functions_appinsights import log_event +from functions_debug import debug_print +from functions_search import normalize_search_id_list, normalize_search_scope +from functions_search_service import build_document_chunk_windows, get_document_chunks_payload + + +DEFAULT_WINDOW_UNIT = 'pages' +DEFAULT_MAX_RETRIES_PER_WINDOW = 1 +DEFAULT_REDUCTION_BATCH_SIZE = 5 +DEFAULT_MAX_REDUCTION_ROUNDS = 4 +CHAT_EXHAUSTIVE_REVIEW_MAX_DOCUMENTS = 3 +WORKFLOW_EXHAUSTIVE_REVIEW_MAX_DOCUMENTS = 10 + + +def _coerce_int(value, default_value, min_value=None, max_value=None): + try: + normalized_value = int(value) + except (TypeError, ValueError): + normalized_value = default_value + + if normalized_value is None: + return None + + if min_value is not None and normalized_value < min_value: + if default_value is None: + normalized_value = min_value + else: + normalized_value = min_value if default_value < min_value else default_value + if max_value is not None and normalized_value > max_value: + normalized_value = max_value + return normalized_value + + +def _count_chunk_pages(chunks): + return len({chunk.get('page_number') for chunk in chunks if chunk.get('page_number') is not None}) + + +def _calculate_progress_percent(completed_value, total_value, fallback_complete=False): + try: + resolved_total = int(total_value or 0) + resolved_completed = int(completed_value or 0) + except (TypeError, ValueError): + resolved_total = 0 + resolved_completed = 0 + + if resolved_total > 0: + return max(0, min(100, int(round((resolved_completed / resolved_total) * 100)))) + return 100 if fallback_complete else 0 + + +def _build_progress_snapshot(coverage): + coverage = coverage if isinstance(coverage, dict) else {} + document_summaries = coverage.get('documents', []) if isinstance(coverage.get('documents'), list) else [] + completed_documents = 0 + running_documents = 0 + pending_documents = 0 + documents = [] + + for document_summary in document_summaries: + status = str(document_summary.get('status') or 'pending').strip().lower() or 'pending' + if status in {'completed', 'completed_with_failures'}: + completed_documents += 1 + elif status == 'running': + running_documents += 1 + else: + pending_documents += 1 + + completed_windows = document_summary.get('processed_windows', 0) + document_summary.get('failed_windows', 0) + completed_chunks = document_summary.get('processed_chunks', 0) + document_summary.get('failed_chunks', 0) + total_chunks = document_summary.get('total_chunks', 0) + total_windows = document_summary.get('total_windows', 0) + progress_total = total_chunks or total_windows + progress_completed = completed_chunks if total_chunks else completed_windows + + documents.append({ + 'document_id': document_summary.get('document_id'), + 'document_name': document_summary.get('document_name'), + 'scope': document_summary.get('scope'), + 'scope_id': document_summary.get('scope_id'), + 'status': status, + 'status_text': document_summary.get('status_text'), + 'total_windows': total_windows, + 'processed_windows': document_summary.get('processed_windows', 0), + 'failed_windows': document_summary.get('failed_windows', 0), + 'completed_windows': completed_windows, + 'total_chunks': total_chunks, + 'processed_chunks': document_summary.get('processed_chunks', 0), + 'failed_chunks': document_summary.get('failed_chunks', 0), + 'completed_chunks': completed_chunks, + 'total_pages': document_summary.get('total_pages', 0), + 'active_window_number': document_summary.get('active_window_number'), + 'active_attempt_number': document_summary.get('active_attempt_number'), + 'percent': _calculate_progress_percent( + progress_completed, + progress_total, + fallback_complete=status in {'completed', 'completed_with_failures'}, + ), + }) + + completed_windows = coverage.get('processed_windows', 0) + coverage.get('failed_windows', 0) + completed_chunks = coverage.get('processed_chunks', 0) + coverage.get('failed_chunks', 0) + overall_total = coverage.get('total_chunks', 0) or coverage.get('total_windows', 0) + overall_completed = completed_chunks if coverage.get('total_chunks', 0) else completed_windows + + return { + 'overall': { + 'document_count': coverage.get('document_count', 0), + 'completed_documents': completed_documents, + 'running_documents': running_documents, + 'pending_documents': pending_documents, + 'total_windows': coverage.get('total_windows', 0), + 'processed_windows': coverage.get('processed_windows', 0), + 'failed_windows': coverage.get('failed_windows', 0), + 'completed_windows': completed_windows, + 'total_chunks': coverage.get('total_chunks', 0), + 'processed_chunks': coverage.get('processed_chunks', 0), + 'failed_chunks': coverage.get('failed_chunks', 0), + 'completed_chunks': completed_chunks, + 'retries': coverage.get('retries', 0), + 'window_unit': coverage.get('window_unit'), + 'percent': _calculate_progress_percent( + overall_completed, + overall_total, + fallback_complete=bool(coverage.get('document_count')) and completed_documents >= coverage.get('document_count', 0), + ), + }, + 'documents': documents, + } + + +def build_document_review_progress_snapshot(coverage): + return _build_progress_snapshot(coverage) + + +def normalize_exhaustive_review_targets( + document_ids, + doc_scope='all', + active_group_ids=None, + active_public_workspace_id=None, + window_unit=DEFAULT_WINDOW_UNIT, + window_size=None, + window_percent=None, + max_retries_per_window=DEFAULT_MAX_RETRIES_PER_WINDOW, + max_documents=None, +): + normalized_document_ids = normalize_search_id_list(document_ids) + if not normalized_document_ids: + raise ValueError('At least one document id is required for exhaustive review.') + if max_documents is not None and len(normalized_document_ids) > max_documents: + raise ValueError( + f'Exhaustive review supports up to {max_documents} ' + f"document{'s' if max_documents != 1 else ''} at a time." + ) + + normalized_scope = normalize_search_scope(doc_scope) + normalized_window_unit = str(window_unit or DEFAULT_WINDOW_UNIT).strip().lower() + if normalized_window_unit not in ('pages', 'chunks'): + normalized_window_unit = DEFAULT_WINDOW_UNIT + + normalized_window_size = None + if window_size not in (None, ''): + normalized_window_size = _coerce_int(window_size, None, min_value=1, max_value=100) + + normalized_window_percent = None + if window_percent not in (None, ''): + normalized_window_percent = _coerce_int(window_percent, None, min_value=1, max_value=100) + + normalized_max_retries = _coerce_int( + max_retries_per_window, + DEFAULT_MAX_RETRIES_PER_WINDOW, + min_value=0, + max_value=5, + ) + + return { + 'document_ids': normalized_document_ids, + 'doc_scope': normalized_scope, + 'active_group_ids': normalize_search_id_list(active_group_ids), + 'active_public_workspace_id': normalize_search_id_list(active_public_workspace_id), + 'window_unit': normalized_window_unit, + 'window_size': normalized_window_size, + 'window_percent': normalized_window_percent, + 'max_retries_per_window': normalized_max_retries, + } + + +def _render_window_source_text(window_payload): + source_parts = [] + for chunk in window_payload.get('chunks', []): + chunk_text = str(chunk.get('chunk_text') or '').strip() + if not chunk_text: + continue + + chunk_labels = [] + if chunk.get('page_number') is not None: + chunk_labels.append(f"Page {chunk.get('page_number')}") + if chunk.get('chunk_sequence') is not None: + chunk_labels.append(f"Chunk {chunk.get('chunk_sequence')}") + prefix = f"[{', '.join(chunk_labels)}] " if chunk_labels else '' + source_parts.append(f"{prefix}{chunk_text}") + + return '\n\n'.join(source_parts) + + +def _serialize_window_range(window_payload): + return { + 'window_number': window_payload.get('window_number'), + 'window_unit': window_payload.get('window_unit'), + 'start_page': window_payload.get('start_page'), + 'end_page': window_payload.get('end_page'), + 'start_chunk_sequence': window_payload.get('start_chunk_sequence'), + 'end_chunk_sequence': window_payload.get('end_chunk_sequence'), + 'page_count': window_payload.get('page_count', 0), + 'chunk_count': window_payload.get('chunk_count', 0), + } + + +def _build_window_label(document_name, window_range): + if window_range.get('start_page') is not None and window_range.get('end_page') is not None: + range_label = f"pages {window_range.get('start_page')} to {window_range.get('end_page')}" + else: + range_label = ( + f"chunks {window_range.get('start_chunk_sequence')} to {window_range.get('end_chunk_sequence')}" + ) + return f"{document_name} - window {window_range.get('window_number')} ({range_label})" + + +def _build_window_review_prompt(review_prompt, document_payload, window_payload, window_range): + document_name = document_payload.get('title') or document_payload.get('file_name') or document_payload.get('id') + range_label = _build_window_label(document_name, window_range) + return ( + 'You are completing a deterministic document review. Review only the supplied document excerpt. ' + 'Do not assume that missing details appear elsewhere in the document. If the excerpt is insufficient ' + 'for a conclusion, say so explicitly.\n\n' + f'Document: {document_name}\n' + f'Document ID: {document_payload.get("id")}\n' + f'Scope: {document_payload.get("scope")}\n' + f'Coverage slice: {range_label}\n' + f'Chunk count in slice: {window_range.get("chunk_count", 0)}\n' + f'Page count in slice: {window_range.get("page_count", 0)}\n\n' + 'Task instructions:\n' + f'{review_prompt}\n\n' + 'Write a focused review of this slice. Preserve concrete facts, decisions, comments, action items, ' + 'and open questions. Call out anything that still needs follow-up.\n\n' + f'\n{_render_window_source_text(window_payload)}\n' + ) + + +def _build_reduction_prompt(review_prompt, items, stage_label, failed_range_labels): + combined_sections = [] + for item in items: + combined_sections.append( + f"[{item.get('label')}]\n{item.get('text', '')}" + ) + combined_text = '\n\n'.join(combined_sections) + + failed_note = '' + if failed_range_labels: + failed_note = ( + 'Some windows failed during earlier processing. Treat those slices as uncovered gaps and mention ' + 'them explicitly in the final answer if they matter. Failed slices: ' + f"{'; '.join(failed_range_labels)}\n\n" + ) + + return ( + 'You are consolidating exhaustive document review outputs. Preserve material findings, unresolved ' + 'questions, and any coverage caveats. Do not drop important issues just to make the answer shorter.\n\n' + f'Stage: {stage_label}\n' + f'Task instructions:\n{review_prompt}\n\n' + f'{failed_note}' + 'Combine the review notes below into one coherent answer.\n\n' + f'\n{combined_text}\n' + ) + + +def _build_reduction_batches(items, batch_size): + reduction_batches = [] + for start_index in range(0, len(items), batch_size): + reduction_batches.append(items[start_index:start_index + batch_size]) + return reduction_batches + + +def _format_coverage_summary(coverage): + lines = [ + '## Coverage', + f"- Documents reviewed: {coverage.get('document_count', 0)}", + f"- Total windows: {coverage.get('total_windows', 0)}", + f"- Processed windows: {coverage.get('processed_windows', 0)}", + f"- Failed windows: {coverage.get('failed_windows', 0)}", + f"- Total chunks: {coverage.get('total_chunks', 0)}", + f"- Processed chunks: {coverage.get('processed_chunks', 0)}", + f"- Failed chunks: {coverage.get('failed_chunks', 0)}", + f"- Retries used: {coverage.get('retries', 0)}", + f"- Window unit: {coverage.get('window_unit')}", + ] + + document_summaries = coverage.get('documents', []) + if document_summaries: + lines.append('') + lines.append('### Document Coverage') + for document_summary in document_summaries: + lines.append( + '- ' + f"{document_summary.get('document_name')}: " + f"{document_summary.get('processed_windows', 0)}/{document_summary.get('total_windows', 0)} windows processed, " + f"{document_summary.get('processed_chunks', 0)}/{document_summary.get('total_chunks', 0)} chunks completed" + ) + failed_ranges = document_summary.get('failed_ranges', []) + if failed_ranges: + lines.append(f" Failed ranges: {', '.join(failed_ranges)}") + + return '\n'.join(lines) + + +def run_exhaustive_document_review( + user_id, + review_prompt, + document_ids, + invoke_prompt, + doc_scope='all', + active_group_ids=None, + active_public_workspace_id=None, + window_unit=DEFAULT_WINDOW_UNIT, + window_size=None, + window_percent=None, + max_retries_per_window=DEFAULT_MAX_RETRIES_PER_WINDOW, + reduction_batch_size=DEFAULT_REDUCTION_BATCH_SIZE, + max_reduction_rounds=DEFAULT_MAX_REDUCTION_ROUNDS, + activity_callback=None, + max_documents=None, + include_coverage_summary=True, +): + normalized_review_prompt = str(review_prompt or '').strip() + if not normalized_review_prompt: + raise ValueError('A review prompt is required for exhaustive document review.') + if not callable(invoke_prompt): + raise ValueError('A callable invoke_prompt handler is required for exhaustive document review.') + + targets = normalize_exhaustive_review_targets( + document_ids=document_ids, + doc_scope=doc_scope, + active_group_ids=active_group_ids, + active_public_workspace_id=active_public_workspace_id, + window_unit=window_unit, + window_size=window_size, + window_percent=window_percent, + max_retries_per_window=max_retries_per_window, + max_documents=max_documents, + ) + + reduction_batch_size = _coerce_int( + reduction_batch_size, + DEFAULT_REDUCTION_BATCH_SIZE, + min_value=2, + max_value=8, + ) + max_reduction_rounds = _coerce_int( + max_reduction_rounds, + DEFAULT_MAX_REDUCTION_ROUNDS, + min_value=1, + max_value=8, + ) + + coverage = { + 'document_count': 0, + 'total_windows': 0, + 'processed_windows': 0, + 'failed_windows': 0, + 'total_chunks': 0, + 'processed_chunks': 0, + 'failed_chunks': 0, + 'retries': 0, + 'window_unit': targets.get('window_unit'), + 'documents': [], + } + document_runs = [] + reduction_items = [] + failed_range_labels = [] + + for document_index, document_id in enumerate(targets.get('document_ids', []), start=1): + document_payload = get_document_chunks_payload( + document_id=document_id, + user_id=user_id, + doc_scope=targets.get('doc_scope'), + active_group_ids=targets.get('active_group_ids'), + active_public_workspace_id=targets.get('active_public_workspace_id'), + window_unit=targets.get('window_unit'), + window_size=targets.get('window_size'), + window_percent=targets.get('window_percent'), + ) + windows = build_document_chunk_windows( + document_payload.get('chunks', []), + window_unit=targets.get('window_unit'), + window_size=targets.get('window_size'), + window_percent=targets.get('window_percent'), + ) + + document_name = ( + document_payload.get('document', {}).get('title') + or document_payload.get('document', {}).get('file_name') + or document_id + ) + document_summary = { + 'document_id': document_id, + 'document_name': document_name, + 'scope': document_payload.get('scope'), + 'scope_id': document_payload.get('scope_id'), + 'total_windows': len(windows), + 'processed_windows': 0, + 'failed_windows': 0, + 'total_chunks': int(document_payload.get('chunk_count') or len(document_payload.get('chunks', [])) or 0), + 'processed_chunks': 0, + 'failed_chunks': 0, + 'total_pages': _count_chunk_pages(document_payload.get('chunks', [])), + 'status': 'pending', + 'status_text': 'Queued', + 'active_window_number': None, + 'active_attempt_number': None, + 'failed_ranges': [], + 'ranges': [], + } + coverage['documents'].append(document_summary) + coverage['document_count'] += 1 + coverage['total_windows'] += len(windows) + coverage['total_chunks'] += document_summary.get('total_chunks', 0) + document_runs.append({ + 'document_id': document_id, + 'document_index': document_index, + 'document_payload': document_payload, + 'document_name': document_name, + 'document_summary': document_summary, + 'windows': windows, + }) + + for document_run in document_runs: + document_id = document_run.get('document_id') + document_payload = document_run.get('document_payload') or {} + document_name = document_run.get('document_name') + document_summary = document_run.get('document_summary') or {} + windows = document_run.get('windows') or [] + document_index = document_run.get('document_index') or 1 + document_summary['status'] = 'running' + document_summary['status_text'] = f"Starting document {document_index} of {coverage.get('document_count', 0)}" + if callable(activity_callback): + activity_callback({ + 'type': 'document_started', + 'document_id': document_id, + 'document_index': document_index, + 'document_count': coverage.get('document_count', 0), + 'document_name': document_name, + 'window_count': len(windows), + 'chunk_count': document_summary.get('total_chunks', 0), + 'page_count': document_summary.get('total_pages', 0), + 'progress': _build_progress_snapshot(coverage), + }) + + for window_payload in windows: + window_range = _serialize_window_range(window_payload) + document_summary['ranges'].append(window_range) + window_label = _build_window_label(document_name, window_range) + document_summary['active_window_number'] = window_range.get('window_number') + document_summary['active_attempt_number'] = 1 + document_summary['status_text'] = ( + f"Reviewing window {window_range.get('window_number')} of {document_summary.get('total_windows', 0)}" + ) + + if callable(activity_callback): + activity_callback({ + 'type': 'window_started', + 'document_id': document_id, + 'document_name': document_name, + 'window_range': window_range, + 'progress': _build_progress_snapshot(coverage), + }) + + review_text = '' + last_error = '' + max_attempts = targets.get('max_retries_per_window', DEFAULT_MAX_RETRIES_PER_WINDOW) + 1 + for attempt_number in range(1, max_attempts + 1): + if attempt_number > 1: + coverage['retries'] += 1 + + try: + prompt_text = _build_window_review_prompt( + normalized_review_prompt, + document_payload.get('document', {}), + window_payload, + window_range, + ) + review_text = str(invoke_prompt( + prompt_text, + stage='window_review', + metadata={ + 'document_id': document_id, + 'document_name': document_name, + 'window_range': window_range, + 'attempt_number': attempt_number, + }, + ) or '').strip() + if not review_text: + raise ValueError('The review runner returned an empty response.') + break + except Exception as exc: + last_error = str(exc) + document_summary['active_window_number'] = window_range.get('window_number') + document_summary['active_attempt_number'] = attempt_number + document_summary['status_text'] = ( + f"Retrying window {window_range.get('window_number')} after attempt {attempt_number}" + if attempt_number < max_attempts + else f"Window {window_range.get('window_number')} failed" + ) + if callable(activity_callback): + activity_callback({ + 'type': 'window_retry' if attempt_number < max_attempts else 'window_failed', + 'document_id': document_id, + 'document_name': document_name, + 'window_range': window_range, + 'attempt_number': attempt_number, + 'error': last_error, + 'progress': _build_progress_snapshot(coverage), + }) + if attempt_number >= max_attempts: + break + + if review_text: + coverage['processed_windows'] += 1 + coverage['processed_chunks'] += window_range.get('chunk_count', 0) or 0 + document_summary['processed_windows'] += 1 + document_summary['processed_chunks'] += window_range.get('chunk_count', 0) or 0 + document_summary['status_text'] = ( + f"Completed window {window_range.get('window_number')} of {document_summary.get('total_windows', 0)}" + ) + document_summary['active_attempt_number'] = None + reduction_items.append({ + 'label': window_label, + 'text': review_text, + 'document_id': document_id, + 'document_name': document_name, + 'window_range': window_range, + }) + if callable(activity_callback): + activity_callback({ + 'type': 'window_completed', + 'document_id': document_id, + 'document_name': document_name, + 'window_range': window_range, + 'progress': _build_progress_snapshot(coverage), + }) + else: + coverage['failed_windows'] += 1 + coverage['failed_chunks'] += window_range.get('chunk_count', 0) or 0 + document_summary['failed_windows'] += 1 + document_summary['failed_chunks'] += window_range.get('chunk_count', 0) or 0 + document_summary['failed_ranges'].append(window_label) + document_summary['status_text'] = ( + f"Failed window {window_range.get('window_number')} of {document_summary.get('total_windows', 0)}" + ) + document_summary['active_attempt_number'] = None + failed_range_labels.append(window_label) + + document_summary['active_window_number'] = None + document_summary['active_attempt_number'] = None + document_summary['status'] = 'completed_with_failures' if document_summary.get('failed_windows', 0) else 'completed' + document_summary['status_text'] = ( + 'Completed with some failed windows' + if document_summary.get('failed_windows', 0) + else 'Completed' + ) + if callable(activity_callback): + activity_callback({ + 'type': 'document_completed', + 'document_id': document_id, + 'document_name': document_name, + 'processed_windows': document_summary.get('processed_windows', 0), + 'failed_windows': document_summary.get('failed_windows', 0), + 'processed_chunks': document_summary.get('processed_chunks', 0), + 'failed_chunks': document_summary.get('failed_chunks', 0), + 'progress': _build_progress_snapshot(coverage), + }) + + if not reduction_items: + raise RuntimeError('No document windows were reviewed successfully.') + + current_items = reduction_items + reduction_round = 1 + while len(current_items) > 1 and reduction_round <= max_reduction_rounds: + next_items = [] + batches = _build_reduction_batches(current_items, reduction_batch_size) + for batch_index, batch_items in enumerate(batches, start=1): + reduction_prompt = _build_reduction_prompt( + normalized_review_prompt, + batch_items, + stage_label=f'reduction-{reduction_round}.{batch_index}', + failed_range_labels=failed_range_labels, + ) + reduced_text = str(invoke_prompt( + reduction_prompt, + stage='reduction', + metadata={ + 'reduction_round': reduction_round, + 'batch_index': batch_index, + 'item_count': len(batch_items), + }, + ) or '').strip() + if not reduced_text: + raise RuntimeError( + f'Exhaustive review reduction returned an empty response at round {reduction_round}, batch {batch_index}.' + ) + + source_labels = [item.get('label') for item in batch_items] + next_items.append({ + 'label': f'Reduction {reduction_round}.{batch_index}', + 'text': reduced_text, + 'source_labels': source_labels, + }) + current_items = next_items + reduction_round += 1 + + final_reply = current_items[0].get('text', '').strip() + coverage_summary = _format_coverage_summary(coverage) + if include_coverage_summary and coverage_summary: + final_reply = f"{final_reply}\n\n{coverage_summary}".strip() + + log_event( + '[ExhaustiveReview] Completed exhaustive document review', + extra={ + 'user_id': user_id, + 'document_count': coverage.get('document_count', 0), + 'total_windows': coverage.get('total_windows', 0), + 'processed_windows': coverage.get('processed_windows', 0), + 'failed_windows': coverage.get('failed_windows', 0), + 'retries': coverage.get('retries', 0), + }, + level=logging.INFO, + ) + debug_print( + '[ExhaustiveReview] Completed review | ' + f"documents={coverage.get('document_count', 0)} | " + f"windows={coverage.get('total_windows', 0)} | " + f"processed={coverage.get('processed_windows', 0)} | " + f"failed={coverage.get('failed_windows', 0)} | " + f"retries={coverage.get('retries', 0)}" + ) + + return { + 'reply': final_reply, + 'analysis_reply': current_items[0].get('text', '').strip(), + 'coverage': coverage, + 'documents': coverage.get('documents', []), + 'document_ids': targets.get('document_ids', []), + 'doc_scope': targets.get('doc_scope'), + 'window_unit': targets.get('window_unit'), + 'window_size': targets.get('window_size'), + 'window_percent': targets.get('window_percent'), + 'max_retries_per_window': targets.get('max_retries_per_window'), + } \ No newline at end of file diff --git a/application/single_app/functions_global_actions.py b/application/single_app/functions_global_actions.py index 122ea9e8..be4c04a2 100644 --- a/application/single_app/functions_global_actions.py +++ b/application/single_app/functions_global_actions.py @@ -14,20 +14,30 @@ from functions_authentication import get_current_user_id from functions_keyvault import keyvault_plugin_save_helper, keyvault_plugin_get_helper, keyvault_plugin_delete_helper, SecretReturnType -def get_global_actions(return_type=SecretReturnType.TRIGGER): +def get_global_actions(return_type=SecretReturnType.TRIGGER, include_disabled=False): """ Get all global actions. + + Args: + return_type: Secret resolution mode for Key Vault-backed values. + include_disabled (bool): When True, include disabled actions for admin management. Returns: list: List of global action dictionaries """ try: + query = "SELECT * FROM c" + if not include_disabled: + query = "SELECT * FROM c WHERE NOT IS_DEFINED(c.is_enabled) OR c.is_enabled = true" + actions = list(cosmos_global_actions_container.query_items( - query="SELECT * FROM c", + query=query, enable_cross_partition_query=True )) # Resolve Key Vault references for each action actions = [keyvault_plugin_get_helper(a, scope_value=a.get('id'), scope="global", return_type=return_type) for a in actions] + for action in actions: + action.setdefault('is_enabled', True) return actions except Exception as e: @@ -101,6 +111,12 @@ def save_global_action(action_data, user_id=None): else: action_data['created_by'] = user_id action_data['created_at'] = now + if 'is_enabled' in action_data: + action_data['is_enabled'] = bool(action_data.get('is_enabled')) + elif existing_action is not None: + action_data['is_enabled'] = bool(existing_action.get('is_enabled', True)) + else: + action_data['is_enabled'] = True action_data['modified_by'] = user_id action_data['modified_at'] = now action_data['updated_at'] = now @@ -151,3 +167,38 @@ def delete_global_action(action_id): return False +def update_global_action_enabled(action_id, is_enabled, user_id=None): + """ + Enable or disable a global action without mutating its stored secret references. + + Args: + action_id (str): The action ID to update. + is_enabled (bool): The desired enabled state. + user_id (str, optional): The user performing the change. + + Returns: + dict: Updated action document or None if the operation fails. + """ + try: + if user_id is None: + user_id = get_current_user_id() + if not user_id: + user_id = "system" + + action = cosmos_global_actions_container.read_item( + item=action_id, + partition_key=action_id + ) + now = datetime.utcnow().isoformat() + action['is_enabled'] = bool(is_enabled) + action['modified_by'] = user_id + action['modified_at'] = now + action['updated_at'] = now + result = cosmos_global_actions_container.upsert_item(body=action) + return result + except Exception as e: + print(f"❌ Error updating enabled state for global action {action_id}: {str(e)}") + traceback.print_exc() + return None + + diff --git a/application/single_app/functions_global_agents.py b/application/single_app/functions_global_agents.py index 51870b9c..105c8555 100644 --- a/application/single_app/functions_global_agents.py +++ b/application/single_app/functions_global_agents.py @@ -25,7 +25,8 @@ def ensure_default_global_agent_exists(): If none exist, create a default global agent (using the researcher agent template). """ try: - agents = get_global_agents() or [] + agents = get_global_agents(include_disabled=True) or [] + default_agent = None if not agents: default_agent = { "name": "researcher", @@ -42,6 +43,7 @@ def ensure_default_global_agent_exists(): "enable_agent_gpt_apim": False, "is_global": True, "is_group": False, + "is_enabled": True, "agent_type": "local", "instructions": ( "You are a highly capable research assistant. Your role is to help the user investigate academic, technical, and real-world topics by finding relevant information, summarizing key points, identifying knowledge gaps, and suggesting credible sources for further study.\n\n" @@ -70,14 +72,16 @@ def ensure_default_global_agent_exists(): settings = get_settings() needs_default = False + enabled_agents = [agent for agent in agents if agent.get('is_enabled', True)] global_selected = settings.get("global_selected_agent") if settings else None if not isinstance(global_selected, dict): needs_default = True elif global_selected.get("name", "") == "": needs_default = True if settings and needs_default: + selected_agent = default_agent or (enabled_agents[0] if enabled_agents else agents[0]) settings["global_selected_agent"] = { - "name": default_agent["name"], + "name": selected_agent["name"], "is_global": True } save_settings(settings) @@ -91,16 +95,23 @@ def ensure_default_global_agent_exists(): print(f"Error ensuring default global agent exists: {e}") traceback.print_exc() -def get_global_agents(): +def get_global_agents(include_disabled=False): """ Get all global agents. + + Args: + include_disabled (bool): When True, include disabled agents for admin management. Returns: list: List of global agent dictionaries """ try: + query = "SELECT * FROM c" + if not include_disabled: + query = "SELECT * FROM c WHERE NOT IS_DEFINED(c.is_enabled) OR c.is_enabled = true" + agents = list(cosmos_global_agents_container.query_items( - query="SELECT * FROM c", + query=query, enable_cross_partition_query=True )) # Mask or replace sensitive keys for UI display @@ -110,6 +121,7 @@ def get_global_agents(): agent['max_completion_tokens'] = -1 agent.setdefault('is_global', True) agent.setdefault('is_group', False) + agent.setdefault('is_enabled', True) agent.setdefault('agent_type', 'local') agent.setdefault('model_endpoint_id', '') agent.setdefault('model_id', '') @@ -206,6 +218,12 @@ def save_global_agent(agent_data, user_id=None): else: cleaned_agent['created_by'] = user_id cleaned_agent['created_at'] = now + if 'is_enabled' in cleaned_agent: + cleaned_agent['is_enabled'] = bool(cleaned_agent.get('is_enabled')) + elif existing_agent is not None: + cleaned_agent['is_enabled'] = bool(existing_agent.get('is_enabled', True)) + else: + cleaned_agent['is_enabled'] = True cleaned_agent['modified_by'] = user_id cleaned_agent['modified_at'] = now cleaned_agent['updated_at'] = now @@ -281,3 +299,44 @@ def delete_global_agent(agent_id): print(f"Error deleting global agent {agent_id}: {str(e)}") traceback.print_exc() return False + + +def update_global_agent_enabled(agent_id, is_enabled, user_id=None): + """ + Enable or disable a global agent without rewriting stored secret references. + + Args: + agent_id (str): The agent ID to update. + is_enabled (bool): The desired enabled state. + user_id (str, optional): The user performing the change. + + Returns: + dict: Updated agent document or None if the operation fails. + """ + try: + if user_id is None: + user_id = get_current_user_id() + if not user_id: + user_id = "system" + + agent = cosmos_global_agents_container.read_item( + item=agent_id, + partition_key=agent_id + ) + now = datetime.utcnow().isoformat() + agent['is_enabled'] = bool(is_enabled) + agent['modified_by'] = user_id + agent['modified_at'] = now + agent['updated_at'] = now + result = cosmos_global_agents_container.upsert_item(body=agent) + return result + except Exception as e: + log_event( + f"Error updating enabled state for global agent {agent_id}: {e}", + extra={"agent_id": agent_id, "exception": str(e), "is_enabled": bool(is_enabled)}, + level=logging.ERROR, + exceptionTraceback=True + ) + print(f"Error updating enabled state for global agent {agent_id}: {str(e)}") + traceback.print_exc() + return None diff --git a/application/single_app/functions_image_messages.py b/application/single_app/functions_image_messages.py new file mode 100644 index 00000000..175c7af2 --- /dev/null +++ b/application/single_app/functions_image_messages.py @@ -0,0 +1,195 @@ +# functions_image_messages.py + +"""Helpers for storing, hydrating, and serving image chat messages.""" + +import base64 +from copy import deepcopy +from datetime import UTC, datetime + + +IMAGE_MESSAGE_SAFE_CONTENT_LIMIT = 1500000 +IMAGE_MESSAGE_INLINE_RESPONSE_LIMIT = 1024 * 1024 +IMAGE_MESSAGE_CHUNK_OVERHEAD_BUFFER = 200 + + +def _normalize_timestamp(timestamp=None): + return str(timestamp or '').strip() or datetime.now(UTC).isoformat() + + +def _split_image_content(image_content, max_content_size=IMAGE_MESSAGE_SAFE_CONTENT_LIMIT): + normalized_content = str(image_content or '') + data_url_prefix = '' + content_body = normalized_content + + if normalized_content.startswith('data:image/') and ',' in normalized_content: + header, content_body = normalized_content.split(',', 1) + data_url_prefix = f'{header},' + + chunk_size = int(max_content_size or IMAGE_MESSAGE_SAFE_CONTENT_LIMIT) - len(data_url_prefix) - IMAGE_MESSAGE_CHUNK_OVERHEAD_BUFFER + if chunk_size <= 0: + raise ValueError('max_content_size is too small to store image content safely') + + chunks = [ + content_body[index:index + chunk_size] + for index in range(0, len(content_body), chunk_size) + ] or [''] + + return data_url_prefix, chunks + + +def build_image_message_documents(base_document, max_content_size=IMAGE_MESSAGE_SAFE_CONTENT_LIMIT): + main_document = deepcopy(base_document or {}) + image_content = str(main_document.get('content') or '') + if not image_content: + raise ValueError('Image content is required') + + if not str(main_document.get('id') or '').strip(): + raise ValueError('Image document id is required') + if not str(main_document.get('conversation_id') or '').strip(): + raise ValueError('Image document conversation_id is required') + + data_url_prefix, chunks = _split_image_content( + image_content, + max_content_size=max_content_size, + ) + total_chunks = len(chunks) + timestamp = _normalize_timestamp(main_document.get('timestamp') or main_document.get('created_at')) + + main_document['role'] = 'image' + main_document['timestamp'] = timestamp + main_document['created_at'] = str(main_document.get('created_at') or timestamp) + main_document['content'] = f'{data_url_prefix}{chunks[0]}' if data_url_prefix else chunks[0] + + metadata = dict(main_document.get('metadata', {}) or {}) + metadata['is_chunked'] = total_chunks > 1 + metadata['original_size'] = len(image_content) + + if total_chunks > 1: + metadata['total_chunks'] = total_chunks + metadata['chunk_index'] = 0 + else: + metadata.pop('total_chunks', None) + metadata.pop('chunk_index', None) + + main_document['metadata'] = metadata + + documents = [main_document] + for chunk_index in range(1, total_chunks): + documents.append({ + 'id': f"{main_document['id']}_chunk_{chunk_index}", + 'conversation_id': main_document['conversation_id'], + 'role': 'image_chunk', + 'content': chunks[chunk_index], + 'parent_message_id': main_document['id'], + 'created_at': timestamp, + 'timestamp': timestamp, + 'metadata': { + 'is_chunk': True, + 'chunk_index': chunk_index, + 'total_chunks': total_chunks, + 'parent_message_id': main_document['id'], + }, + }) + + return documents + + +def reassemble_image_message_content(message_doc, chunk_documents=None): + complete_content = str((message_doc or {}).get('content') or '') + metadata = (message_doc or {}).get('metadata', {}) if isinstance((message_doc or {}).get('metadata'), dict) else {} + total_chunks = int(metadata.get('total_chunks', 1) or 1) + + if total_chunks <= 1: + return complete_content + + chunk_lookup = {} + for chunk_document in chunk_documents or []: + chunk_metadata = chunk_document.get('metadata', {}) if isinstance(chunk_document.get('metadata'), dict) else {} + chunk_index = int(chunk_metadata.get('chunk_index', 0) or 0) + if chunk_index <= 0: + continue + chunk_lookup[chunk_index] = str(chunk_document.get('content') or '') + + for chunk_index in range(1, total_chunks): + complete_content += chunk_lookup.get(chunk_index, '') + + return complete_content + + +def hydrate_image_messages(items, image_url_builder=None, inline_content_limit=IMAGE_MESSAGE_INLINE_RESPONSE_LIMIT): + hydrated_messages = [] + chunk_lookup = {} + + for item in items or []: + if item.get('role') == 'image_chunk': + parent_message_id = str(item.get('parent_message_id') or '').strip() + if not parent_message_id: + continue + chunk_lookup.setdefault(parent_message_id, []).append(deepcopy(item)) + continue + + hydrated_messages.append(deepcopy(item)) + + for message in hydrated_messages: + if message.get('role') != 'image': + continue + + message_id = str(message.get('id') or '').strip() + metadata = message.get('metadata', {}) if isinstance(message.get('metadata'), dict) else {} + complete_content = reassemble_image_message_content( + message, + chunk_lookup.get(message_id, []), + ) + + if image_url_builder and len(complete_content) > int(inline_content_limit or IMAGE_MESSAGE_INLINE_RESPONSE_LIMIT): + message['content'] = image_url_builder(message_id) + metadata['is_large_image'] = True + metadata['image_size'] = len(complete_content) + else: + message['content'] = complete_content + metadata.pop('is_large_image', None) + metadata.pop('image_size', None) + + message['metadata'] = metadata + + return hydrated_messages + + +def get_complete_image_content(message_container, conversation_id, image_message_id): + main_document = message_container.read_item( + item=image_message_id, + partition_key=conversation_id, + ) + + chunk_documents = [] + metadata = main_document.get('metadata', {}) if isinstance(main_document.get('metadata'), dict) else {} + if metadata.get('is_chunked'): + chunk_documents = list(message_container.query_items( + query=( + 'SELECT * FROM c ' + 'WHERE c.conversation_id = @conversation_id ' + 'AND c.parent_message_id = @parent_message_id' + ), + parameters=[ + {'name': '@conversation_id', 'value': conversation_id}, + {'name': '@parent_message_id', 'value': image_message_id}, + ], + partition_key=conversation_id, + )) + + return main_document, reassemble_image_message_content(main_document, chunk_documents) + + +def is_external_image_url(image_content): + normalized_content = str(image_content or '').strip().lower() + return normalized_content.startswith('http://') or normalized_content.startswith('https://') + + +def decode_image_content(image_content): + normalized_content = str(image_content or '') + if not normalized_content.startswith('data:image/') or ',' not in normalized_content: + raise ValueError('Image content is not a supported data URL') + + header, base64_data = normalized_content.split(',', 1) + mime_type = header.split(':', 1)[1].split(';', 1)[0] + return mime_type, base64.b64decode(base64_data) \ No newline at end of file diff --git a/application/single_app/functions_message_artifacts.py b/application/single_app/functions_message_artifacts.py index 25be2b2b..5fd6b79a 100644 --- a/application/single_app/functions_message_artifacts.py +++ b/application/single_app/functions_message_artifacts.py @@ -7,6 +7,8 @@ from copy import deepcopy from typing import Any, Dict, List, Optional, Tuple +from functions_azure_maps import refresh_azure_maps_citation_payload + ASSISTANT_ARTIFACT_ROLE = 'assistant_artifact' ASSISTANT_ARTIFACT_CHUNK_ROLE = 'assistant_artifact_chunk' @@ -83,6 +85,298 @@ def make_json_serializable(value: Any) -> Any: return str(value) +def build_agent_citation_tool_label( + plugin_name: str, + function_name: str, + function_arguments: Any = None, + function_result: Any = None, +) -> str: + """Return a user-facing label for agent tool citations.""" + normalized_plugin_name = str(plugin_name or '').strip() + normalized_function_name = str(function_name or '').strip() + fallback_label = '.'.join(part for part in [normalized_plugin_name, normalized_function_name] if part) + if not fallback_label: + return 'Tool invocation' + + parsed_arguments = _parse_json_if_possible(function_arguments) + parsed_result = _parse_json_if_possible(function_result) + + if normalized_plugin_name == 'AzureMapsOpenLayersPlugin' and normalized_function_name == 'create_map_visualization': + title = _first_non_empty( + _get_mapping_value(parsed_arguments, 'title'), + _get_mapping_value(_get_mapping_value(parsed_result, 'map_payload'), 'title'), + ) + return _format_tool_label('Map', title, fallback_label=fallback_label) + + image_gallery_payload = _get_image_gallery_payload(parsed_result) + if image_gallery_payload: + title = _first_non_empty( + _get_mapping_value(image_gallery_payload, 'title'), + _get_mapping_value(parsed_arguments, 'title'), + _get_mapping_value(parsed_result, 'title'), + ) + return _format_tool_label('Image gallery', title, fallback_label=fallback_label) + + video_gallery_payload = _get_video_gallery_payload(parsed_result) + if video_gallery_payload: + title = _first_non_empty( + _get_mapping_value(video_gallery_payload, 'title'), + _get_mapping_value(parsed_arguments, 'title'), + _get_mapping_value(parsed_result, 'title'), + ) + return _format_tool_label('Video gallery', title, fallback_label=fallback_label) + + if _has_image_result(parsed_result): + title = _first_non_empty( + _get_mapping_value(parsed_result, 'title'), + _get_mapping_value(parsed_arguments, 'title'), + _get_mapping_value(parsed_result, 'summary'), + ) + return _format_tool_label('Image', title, fallback_label=fallback_label) + + if _has_video_result(parsed_result): + title = _first_non_empty( + _get_mapping_value(parsed_result, 'title'), + _get_mapping_value(parsed_arguments, 'title'), + _get_mapping_value(parsed_result, 'summary'), + ) + return _format_tool_label('Video', title, fallback_label=fallback_label) + + if normalized_plugin_name == 'SimpleChatPlugin': + simplechat_label = _build_simplechat_tool_label( + normalized_function_name, + parsed_arguments, + parsed_result, + ) + if simplechat_label: + return simplechat_label + + if normalized_plugin_name == 'MSGraphPlugin': + msgraph_label = _build_msgraph_tool_label( + normalized_function_name, + parsed_arguments, + parsed_result, + ) + if msgraph_label: + return msgraph_label + + return fallback_label + + +def _build_simplechat_tool_label(function_name: str, arguments: Any, result: Any) -> str: + conversation_payload = _get_mapping_value(result, 'conversation') + group_payload = _get_mapping_value(result, 'group') + detail = '' + + if function_name == 'create_group': + detail = _first_non_empty( + _get_mapping_value(arguments, 'name'), + _get_mapping_value(group_payload, 'name'), + ) + return _format_tool_label('Group workspace', detail, fallback_label='SimpleChatPlugin.create_group') + + if function_name == 'add_user_to_group': + detail = _first_non_empty( + _get_mapping_value(arguments, 'display_name'), + _get_mapping_value(arguments, 'email'), + _get_mapping_value(arguments, 'user_identifier'), + ) + return _format_tool_label('Group member', detail, fallback_label='SimpleChatPlugin.add_user_to_group') + + if function_name == 'create_group_conversation': + detail = _first_non_empty( + _get_mapping_value(arguments, 'title'), + _get_mapping_value(conversation_payload, 'title'), + ) + return _format_tool_label('Group conversation', detail, fallback_label='SimpleChatPlugin.create_group_conversation') + + if function_name == 'create_personal_conversation': + detail = _first_non_empty( + _get_mapping_value(arguments, 'title'), + _get_mapping_value(conversation_payload, 'title'), + ) + return _format_tool_label('Personal conversation', detail, fallback_label='SimpleChatPlugin.create_personal_conversation') + + if function_name == 'create_personal_collaboration_conversation': + detail = _first_non_empty( + _get_mapping_value(arguments, 'title'), + _get_mapping_value(conversation_payload, 'title'), + ) + return _format_tool_label('Personal collaboration', detail, fallback_label='SimpleChatPlugin.create_personal_collaboration_conversation') + + if function_name == 'invite_group_conversation_members': + detail = _first_non_empty(_get_mapping_value(conversation_payload, 'title')) + return _format_tool_label('Conversation participants', detail, fallback_label='SimpleChatPlugin.invite_group_conversation_members') + + if function_name == 'upload_markdown_document': + detail = _first_non_empty( + _get_mapping_value(arguments, 'file_name'), + _get_mapping_value(result, 'file_name'), + _get_mapping_value(_get_mapping_value(result, 'document'), 'file_name'), + ) + return _format_tool_label('Markdown file', detail, fallback_label='SimpleChatPlugin.upload_markdown_document') + + if function_name == 'create_personal_workflow': + detail = _first_non_empty( + _get_mapping_value(arguments, 'name'), + _get_mapping_value(_get_mapping_value(result, 'workflow'), 'name'), + ) + return _format_tool_label('Workflow', detail, fallback_label='SimpleChatPlugin.create_personal_workflow') + + if function_name == 'add_conversation_message': + detail = _first_non_empty(_get_mapping_value(conversation_payload, 'title')) + return _format_tool_label('Conversation message', detail, fallback_label='SimpleChatPlugin.add_conversation_message') + + if function_name == 'make_group_inactive': + detail = _first_non_empty(_get_mapping_value(group_payload, 'name')) + return _format_tool_label('Group status update', detail, fallback_label='SimpleChatPlugin.make_group_inactive') + + return '' + + +def _build_msgraph_tool_label(function_name: str, arguments: Any, result: Any) -> str: + if function_name == 'create_calendar_invite': + teams_meeting_requested = bool( + _get_mapping_value(arguments, 'make_teams_meeting') + or _get_mapping_value(result, 'teams_meeting_requested') + or _get_mapping_value(result, 'join_url') + or _get_mapping_value(_get_mapping_value(result, 'onlineMeeting'), 'joinUrl') + ) + detail = _first_non_empty( + _get_mapping_value(arguments, 'subject'), + _get_mapping_value(result, 'subject'), + ) + base_label = 'Teams meeting' if teams_meeting_requested else 'Calendar invite' + return _format_tool_label(base_label, detail, fallback_label='MSGraphPlugin.create_calendar_invite') + + if function_name == 'get_my_messages': + return 'Mail messages' + + if function_name == 'mark_message_as_read': + return 'Mail status update' + + if function_name in {'search_users', 'get_user_by_email'}: + detail = _first_non_empty( + _get_mapping_value(arguments, 'query'), + _get_mapping_value(arguments, 'email'), + ) + return _format_tool_label('User lookup', detail, fallback_label=f'MSGraphPlugin.{function_name}') + + return '' + + +def _format_tool_label(base_label: str, detail: str = '', fallback_label: str = '') -> str: + normalized_base = str(base_label or '').strip() + normalized_detail = str(detail or '').strip() + if normalized_base and normalized_detail: + return f'{normalized_base}: {normalized_detail}' + if normalized_base: + return normalized_base + return str(fallback_label or 'Tool invocation').strip() or 'Tool invocation' + + +def _first_non_empty(*values: Any) -> str: + for value in values: + normalized = str(value or '').strip() + if normalized: + return normalized + return '' + + +def _get_mapping_value(candidate: Any, key: str) -> Any: + if isinstance(candidate, dict): + return candidate.get(key) + return None + + +def _get_image_gallery_payload(candidate: Any) -> Any: + if not isinstance(candidate, dict): + return None + + image_gallery_payload = _get_mapping_value(candidate, 'image_gallery') + if isinstance(image_gallery_payload, dict): + items = _get_mapping_value(image_gallery_payload, 'items') + if isinstance(items, list) and items: + return image_gallery_payload + + candidate_items = _get_mapping_value(candidate, 'items') + if isinstance(candidate_items, list) and candidate_items: + return candidate + + candidate_images = _get_mapping_value(candidate, 'images') + if isinstance(candidate_images, list) and candidate_images: + return candidate + + candidate_image_urls = _get_mapping_value(candidate, 'image_urls') + if isinstance(candidate_image_urls, list) and candidate_image_urls: + return candidate + + return None + + +def _get_video_gallery_payload(candidate: Any) -> Any: + if not isinstance(candidate, dict): + return None + + video_gallery_payload = _get_mapping_value(candidate, 'video_gallery') + if isinstance(video_gallery_payload, dict): + items = _get_mapping_value(video_gallery_payload, 'items') + if isinstance(items, list) and items: + return video_gallery_payload + + candidate_items = _get_mapping_value(candidate, 'items') + if isinstance(candidate_items, list) and candidate_items: + return candidate + + candidate_videos = _get_mapping_value(candidate, 'videos') + if isinstance(candidate_videos, list) and candidate_videos: + return candidate + + candidate_video_urls = _get_mapping_value(candidate, 'video_urls') + if isinstance(candidate_video_urls, list) and candidate_video_urls: + return candidate + + return None + + +def _has_image_result(candidate: Any) -> bool: + if not isinstance(candidate, dict): + return False + + image_url = _get_mapping_value(candidate, 'image_url') + if isinstance(image_url, str) and image_url.strip(): + return True + + if isinstance(image_url, dict) and str(image_url.get('url') or '').strip(): + return True + + mime_type = str(_get_mapping_value(candidate, 'mime') or '').strip().lower() + if mime_type.startswith('image/'): + return True + + result_type = str(_get_mapping_value(candidate, 'type') or '').strip().lower() + return result_type == 'image_url' + + +def _has_video_result(candidate: Any) -> bool: + if not isinstance(candidate, dict): + return False + + video_url = _get_mapping_value(candidate, 'video_url') + if isinstance(video_url, str) and video_url.strip(): + return True + + if isinstance(video_url, dict) and str(video_url.get('url') or '').strip(): + return True + + mime_type = str(_get_mapping_value(candidate, 'mime') or '').strip().lower() + if mime_type.startswith('video/'): + return True + + result_type = str(_get_mapping_value(candidate, 'type') or '').strip().lower() + return result_type == 'video_url' + + def build_agent_citation_artifact_documents( conversation_id: str, assistant_message_id: str, @@ -187,7 +481,7 @@ def hydrate_agent_citations_from_artifacts( artifact_payload = artifact_payload_map.get(str(artifact_id or '')) raw_citation = artifact_payload.get('citation') if isinstance(artifact_payload, dict) else None if isinstance(raw_citation, dict): - merged_citation = deepcopy(raw_citation) + merged_citation = refresh_azure_maps_citation_payload(deepcopy(raw_citation)) merged_citation.setdefault('artifact_id', artifact_id) merged_citation.setdefault('raw_payload_externalized', True) hydrated_citations.append(merged_citation) diff --git a/application/single_app/functions_msgraph_operations.py b/application/single_app/functions_msgraph_operations.py new file mode 100644 index 00000000..8f8b1768 --- /dev/null +++ b/application/single_app/functions_msgraph_operations.py @@ -0,0 +1,125 @@ +# functions_msgraph_operations.py +"""Shared Microsoft Graph action capability metadata and helpers.""" + +from typing import Any, Dict, List, Optional + + +MSGRAPH_PLUGIN_TYPE = "msgraph" +MSGRAPH_DEFAULT_ENDPOINT = "https://graph.microsoft.com" +MSGRAPH_CAPABILITY_DEFINITIONS = [ + { + "key": "get_my_profile", + "function_name": "get_my_profile", + "label": "Read my profile", + "description": "Read the signed-in user's Microsoft 365 profile details.", + }, + { + "key": "get_my_timezone", + "function_name": "get_my_timezone", + "label": "Read my mailbox timezone", + "description": "Read the signed-in user's mailbox time zone and time formatting settings.", + }, + { + "key": "get_my_events", + "function_name": "get_my_events", + "label": "Read my calendar events", + "description": "Read upcoming calendar events for the signed-in user.", + }, + { + "key": "create_calendar_invite", + "function_name": "create_calendar_invite", + "label": "Create calendar invites", + "description": "Create calendar invites, optionally add current group members, and turn meetings into Microsoft Teams meetings.", + }, + { + "key": "get_my_messages", + "function_name": "get_my_messages", + "label": "Read my mail", + "description": "Read recent mail messages for the signed-in user.", + }, + { + "key": "mark_message_as_read", + "function_name": "mark_message_as_read", + "label": "Update message read state", + "description": "Mark a message as read or unread for the signed-in user.", + }, + { + "key": "search_users", + "function_name": "search_users", + "label": "Search directory users", + "description": "Search Microsoft 365 directory users by name or email prefix.", + }, + { + "key": "get_user_by_email", + "function_name": "get_user_by_email", + "label": "Lookup user by email", + "description": "Get a directory user by exact email address or UPN.", + }, + { + "key": "list_drive_items", + "function_name": "list_drive_items", + "label": "List OneDrive items", + "description": "List OneDrive items from the signed-in user's drive.", + }, + { + "key": "get_my_security_alerts", + "function_name": "get_my_security_alerts", + "label": "Read my security alerts", + "description": "Read recent security alerts available to the signed-in user.", + }, +] + + +def get_default_msgraph_capabilities() -> Dict[str, bool]: + return {definition["key"]: True for definition in MSGRAPH_CAPABILITY_DEFINITIONS} + + +def normalize_msgraph_capabilities(raw_capabilities: Any = None) -> Dict[str, bool]: + normalized = get_default_msgraph_capabilities() + + if raw_capabilities is None: + return normalized + + if isinstance(raw_capabilities, dict): + for capability_key in normalized: + if capability_key in raw_capabilities: + normalized[capability_key] = bool(raw_capabilities[capability_key]) + return normalized + + if isinstance(raw_capabilities, (list, tuple, set)): + enabled_items = {str(item or "").strip() for item in raw_capabilities if str(item or "").strip()} + return { + definition["key"]: ( + definition["key"] in enabled_items or definition["function_name"] in enabled_items + ) + for definition in MSGRAPH_CAPABILITY_DEFINITIONS + } + + return normalized + + +def get_msgraph_enabled_function_names(raw_capabilities: Any = None) -> List[str]: + normalized = normalize_msgraph_capabilities(raw_capabilities) + return [ + definition["function_name"] + for definition in MSGRAPH_CAPABILITY_DEFINITIONS + if normalized.get(definition["key"], False) + ] + + +def resolve_msgraph_action_capabilities( + action_capability_map: Any, + action_defaults: Any = None, + action_id: Optional[str] = None, + action_name: Optional[str] = None, +) -> Dict[str, bool]: + resolved_defaults = normalize_msgraph_capabilities(action_defaults) + + if not isinstance(action_capability_map, dict): + return resolved_defaults + + for candidate_key in (str(action_id or "").strip(), str(action_name or "").strip()): + if candidate_key and candidate_key in action_capability_map: + return normalize_msgraph_capabilities(action_capability_map.get(candidate_key)) + + return resolved_defaults \ No newline at end of file diff --git a/application/single_app/functions_notifications.py b/application/single_app/functions_notifications.py index 80574160..2a24f5f7 100644 --- a/application/single_app/functions_notifications.py +++ b/application/single_app/functions_notifications.py @@ -25,6 +25,21 @@ # Constants TTL_60_DAYS = 60 * 24 * 60 * 60 # 60 days in seconds (5184000) ASSIGNMENT_NOTIFICATIONS_PARTITION_KEY = 'assignment-notifications' +WORKFLOW_ALERT_NOTIFICATION_TYPE = 'workflow_priority_alert' +WORKFLOW_ALERT_PRIORITY_CONFIG = { + 'low': { + 'icon': 'bi-bell', + 'color': 'info', + }, + 'medium': { + 'icon': 'bi-exclamation-circle', + 'color': 'warning', + }, + 'high': { + 'icon': 'bi-exclamation-triangle', + 'color': 'danger', + }, +} # Notification type registry for extensibility NOTIFICATION_TYPES = { @@ -32,6 +47,22 @@ 'icon': 'bi-file-earmark-check', 'color': 'success' }, + 'group_created': { + 'icon': 'bi-people-fill', + 'color': 'success' + }, + 'group_member_added': { + 'icon': 'bi-person-plus', + 'color': 'info' + }, + 'conversation_created': { + 'icon': 'bi-chat-square-text', + 'color': 'info' + }, + 'collaboration_message_received': { + 'icon': 'bi-people-fill', + 'color': 'info' + }, 'chat_response_complete': { 'icon': 'bi-chat-dots', 'color': 'success' @@ -91,6 +122,10 @@ 'agent_template_deleted': { 'icon': 'bi-trash', 'color': 'secondary' + }, + WORKFLOW_ALERT_NOTIFICATION_TYPE: { + 'icon': 'bi-bell', + 'color': 'secondary' } } @@ -127,6 +162,28 @@ def _get_notification_display_message(notification): return message +def _get_workflow_alert_priority(notification): + metadata = notification.get('metadata') or {} + priority = str(metadata.get('priority') or 'medium').strip().lower() + if priority not in WORKFLOW_ALERT_PRIORITY_CONFIG: + return 'medium' + return priority + + +def _get_notification_type_config(notification): + notification_type = notification.get('notification_type') + if notification_type == WORKFLOW_ALERT_NOTIFICATION_TYPE: + return WORKFLOW_ALERT_PRIORITY_CONFIG.get( + _get_workflow_alert_priority(notification), + NOTIFICATION_TYPES[WORKFLOW_ALERT_NOTIFICATION_TYPE], + ) + + return NOTIFICATION_TYPES.get( + notification_type, + NOTIFICATION_TYPES['system_announcement'], + ) + + def get_notifications_by_metadata(metadata_filters=None, notification_types=None): """Fetch notifications matching metadata values and optional types.""" try: @@ -395,6 +452,86 @@ def create_chat_response_notification( ) +def create_collaboration_message_notification( + user_id, + conversation_id, + message_id, + conversation_title='', + sender_display_name='', + message_preview='', + chat_type='', + group_id=None, + mentioned_user=False, +): + """Create a personal notification when another participant posts in a shared conversation.""" + normalized_title = str(conversation_title or '').strip() or 'Shared Conversation' + normalized_sender = str(sender_display_name or '').strip() or 'A participant' + normalized_preview = str(message_preview or '').strip() + if len(normalized_preview) > 160: + normalized_preview = f"{normalized_preview[:157]}..." + + notification_title = f"New shared message in {normalized_title}" + if mentioned_user: + notification_title = f"{normalized_sender} tagged you in {normalized_title}" + + notification_message = normalized_preview or f"{normalized_sender} posted in {normalized_title}." + + return create_notification( + user_id=user_id, + notification_type='collaboration_message_received', + title=notification_title, + message=notification_message, + link_url=f'/chats?conversationId={conversation_id}', + link_context={ + 'workspace_type': 'group' if str(chat_type or '').strip().lower().startswith('group') else 'personal', + 'conversation_id': conversation_id, + 'group_id': group_id, + 'conversation_kind': 'collaborative', + }, + metadata={ + 'conversation_id': conversation_id, + 'message_id': message_id, + 'sender_display_name': normalized_sender, + 'mentioned_user': bool(mentioned_user), + 'conversation_kind': 'collaborative', + 'chat_type': chat_type, + 'group_id': group_id, + } + ) + + +def create_workflow_priority_notification( + user_id, + workflow_id, + workflow_name, + priority, + title, + message, + link_url='', + link_context=None, + metadata=None, +): + """Create a personal workflow alert notification with a priority-aware display.""" + normalized_priority = str(priority or 'medium').strip().lower() + if normalized_priority not in WORKFLOW_ALERT_PRIORITY_CONFIG: + normalized_priority = 'medium' + + alert_metadata = dict(metadata or {}) + alert_metadata.setdefault('priority', normalized_priority) + alert_metadata.setdefault('workflow_id', workflow_id) + alert_metadata.setdefault('workflow_name', workflow_name) + + return create_notification( + user_id=user_id, + notification_type=WORKFLOW_ALERT_NOTIFICATION_TYPE, + title=title, + message=message, + link_url=link_url, + link_context=link_context or {}, + metadata=alert_metadata, + ) + + def get_user_notifications(user_id, page=1, per_page=20, include_read=True, include_dismissed=False, user_roles=None): """ Fetch notifications visible to a user from personal, group, and public workspace scopes. @@ -518,10 +655,9 @@ def get_user_notifications(user_id, page=1, per_page=20, include_read=True, incl notif['message'] = _get_notification_display_message(notif) notif['is_read'] = user_id in read_by notif['is_dismissed'] = user_id in dismissed_by - notif['type_config'] = NOTIFICATION_TYPES.get( - notif.get('notification_type'), - NOTIFICATION_TYPES['system_announcement'] - ) + notif['type_config'] = _get_notification_type_config(notif) + if notif.get('notification_type') == WORKFLOW_ALERT_NOTIFICATION_TYPE: + notif['priority'] = _get_workflow_alert_priority(notif) filtered_notifications.append(notif) @@ -583,6 +719,51 @@ def get_unread_notification_count(user_id): return 0 +def get_unread_workflow_priority_notifications(user_id, limit=5): + """Return the most recent unread workflow alert notifications for a user.""" + try: + normalized_limit = max(1, min(int(limit or 5), 10)) + except (TypeError, ValueError): + normalized_limit = 5 + + try: + notifications = list(cosmos_notifications_container.query_items( + query=( + 'SELECT * FROM c ' + 'WHERE c.user_id = @user_id ' + 'AND c.notification_type = @notification_type ' + 'ORDER BY c.created_at DESC' + ), + parameters=[ + {'name': '@user_id', 'value': user_id}, + {'name': '@notification_type', 'value': WORKFLOW_ALERT_NOTIFICATION_TYPE}, + ], + partition_key=user_id, + )) + + unread_notifications = [] + for notification in notifications: + if user_id in notification.get('dismissed_by', []): + continue + if user_id in notification.get('read_by', []): + continue + + notification['message'] = _get_notification_display_message(notification) + notification['is_read'] = False + notification['is_dismissed'] = False + notification['priority'] = _get_workflow_alert_priority(notification) + notification['type_config'] = _get_notification_type_config(notification) + unread_notifications.append(notification) + + if len(unread_notifications) >= normalized_limit: + break + + return unread_notifications + except Exception as e: + debug_print(f"Error fetching unread workflow alerts for {user_id}: {e}") + return [] + + def mark_notification_read(notification_id, user_id): """ Mark a notification as read by a specific user. @@ -674,6 +855,46 @@ def mark_chat_response_notifications_read_for_conversation(user_id, conversation return 0 +def mark_collaboration_message_notifications_read_for_conversation(user_id, conversation_id): + """Mark personal collaboration-message notifications read for a conversation.""" + try: + query = """ + SELECT * FROM c + WHERE c.user_id = @user_id + AND c.notification_type = @notification_type + AND c.metadata.conversation_id = @conversation_id + """ + params = [ + {'name': '@user_id', 'value': user_id}, + {'name': '@notification_type', 'value': 'collaboration_message_received'}, + {'name': '@conversation_id', 'value': conversation_id}, + ] + + notifications = list(cosmos_notifications_container.query_items( + query=query, + parameters=params, + partition_key=user_id + )) + + marked_count = 0 + for notification in notifications: + read_by = notification.get('read_by', []) + if user_id in read_by: + continue + + read_by.append(user_id) + notification['read_by'] = read_by + cosmos_notifications_container.upsert_item(notification) + marked_count += 1 + + return marked_count + except Exception as e: + debug_print( + f"Error marking collaboration notifications as read for conversation {conversation_id}: {e}" + ) + return 0 + + def dismiss_notification(notification_id, user_id): """ Dismiss a notification for a specific user (adds to dismissed_by). diff --git a/application/single_app/functions_personal_workflows.py b/application/single_app/functions_personal_workflows.py new file mode 100644 index 00000000..a8d93169 --- /dev/null +++ b/application/single_app/functions_personal_workflows.py @@ -0,0 +1,604 @@ +# functions_personal_workflows.py + +""" +Personal workflow CRUD helpers and schedule validation. +""" + +import logging +import uuid +from datetime import datetime, timedelta, timezone + +from azure.cosmos import exceptions + +from config import ( + cosmos_personal_workflow_runs_container, + cosmos_personal_workflows_container, +) +from functions_appinsights import log_event +from functions_debug import debug_print +from functions_document_actions import ( + build_legacy_exhaustive_review_config, + normalize_document_action_config, +) +from functions_exhaustive_document_review import WORKFLOW_EXHAUSTIVE_REVIEW_MAX_DOCUMENTS +from functions_global_agents import get_global_agents +from functions_personal_agents import get_personal_agents +from functions_settings import get_settings, get_user_settings, normalize_model_endpoints + + +WORKFLOW_TRIGGER_TYPES = {'manual', 'interval'} +WORKFLOW_RUNNER_TYPES = {'agent', 'model'} +WORKFLOW_SCHEDULE_UNITS = {'seconds', 'minutes', 'hours'} +WORKFLOW_ALERT_PRIORITIES = {'none', 'low', 'medium', 'high'} + + +def _utc_now(): + return datetime.now(timezone.utc) + + +def _utc_now_iso(): + return _utc_now().isoformat() + + +def _strip_cosmos_metadata(document): + if not isinstance(document, dict): + return {} + return {key: value for key, value in document.items() if not str(key).startswith('_')} + + +def _normalize_text(value, field_name, required=False): + normalized = str(value or '').strip() + if required and not normalized: + raise ValueError(f'{field_name} is required.') + return normalized + + +def _normalize_schedule(schedule_payload): + schedule_payload = schedule_payload if isinstance(schedule_payload, dict) else {} + unit = str(schedule_payload.get('unit') or '').strip().lower() + if unit not in WORKFLOW_SCHEDULE_UNITS: + raise ValueError('Schedule unit must be seconds, minutes, or hours.') + + try: + value = int(schedule_payload.get('value')) + except (TypeError, ValueError): + raise ValueError('Schedule value must be an integer.') + + max_value = 59 if unit in ('seconds', 'minutes') else 24 + if value < 1 or value > max_value: + raise ValueError(f'Schedule value for {unit} must be between 1 and {max_value}.') + + return { + 'unit': unit, + 'value': value, + } + + +def _normalize_alert_priority(value): + normalized = str(value or 'none').strip().lower() or 'none' + if normalized not in WORKFLOW_ALERT_PRIORITIES: + raise ValueError('Alert priority must be none, low, medium, or high.') + return normalized + + +def _normalize_document_action_config(workflow_data, existing_workflow=None): + workflow_data = workflow_data if isinstance(workflow_data, dict) else {} + existing_workflow = existing_workflow if isinstance(existing_workflow, dict) else {} + return normalize_document_action_config( + action_payload=workflow_data.get('document_action'), + existing_action=existing_workflow.get('document_action'), + legacy_exhaustive_review=workflow_data.get('exhaustive_review') or existing_workflow.get('exhaustive_review'), + max_documents=WORKFLOW_EXHAUSTIVE_REVIEW_MAX_DOCUMENTS, + ) + + +def _build_schedule_delta(schedule_payload): + unit = schedule_payload.get('unit') + value = schedule_payload.get('value') + if unit == 'seconds': + return timedelta(seconds=value) + if unit == 'minutes': + return timedelta(minutes=value) + return timedelta(hours=value) + + +def _build_selectable_agents(user_id, settings, requested_agent=None): + requested_agent = requested_agent if isinstance(requested_agent, dict) else {} + candidates = [] + + for agent in get_personal_agents(user_id): + candidate = dict(agent) + candidate['is_global'] = False + candidate['is_group'] = False + candidates.append(candidate) + + merge_global = ( + settings.get('per_user_semantic_kernel', False) + and settings.get('merge_global_semantic_kernel_with_workspace', False) + ) + if merge_global or requested_agent.get('is_global'): + for agent in get_global_agents(): + candidate = dict(agent) + candidate['is_global'] = True + candidate['is_group'] = False + candidates.append(candidate) + + return candidates + + +def _find_matching_agent(candidates, requested_agent): + if not isinstance(requested_agent, dict): + return None + + requested_id = str(requested_agent.get('id') or '').strip() + requested_name = str(requested_agent.get('name') or '').strip() + requested_is_global = bool(requested_agent.get('is_global', False)) + + def scope_matches(candidate): + return bool(candidate.get('is_global', False)) == requested_is_global + + if requested_id: + for candidate in candidates: + if str(candidate.get('id') or '').strip() == requested_id and scope_matches(candidate): + return candidate + + if requested_name: + for candidate in candidates: + if str(candidate.get('name') or '').strip() == requested_name and scope_matches(candidate): + return candidate + + return None + + +def _normalize_selected_agent(user_id, settings, requested_agent): + candidates = _build_selectable_agents(user_id, settings, requested_agent=requested_agent) + matched_agent = _find_matching_agent(candidates, requested_agent) + if not matched_agent: + raise ValueError('Select a valid personal or merged global agent.') + + return { + 'id': str(matched_agent.get('id') or '').strip(), + 'name': str(matched_agent.get('name') or '').strip(), + 'display_name': str(matched_agent.get('display_name') or matched_agent.get('name') or '').strip(), + 'description': str(matched_agent.get('description') or '').strip(), + 'is_global': bool(matched_agent.get('is_global', False)), + 'is_group': False, + } + + +def _build_default_model_summary(settings): + default_selection = settings.get('default_model_selection', {}) if isinstance(settings, dict) else {} + endpoint_id = str(default_selection.get('endpoint_id') or '').strip() + model_id = str(default_selection.get('model_id') or '').strip() + provider = str(default_selection.get('provider') or '').strip().lower() + + if endpoint_id and model_id: + return { + 'mode': 'default_selection', + 'valid': True, + 'endpoint_id': endpoint_id, + 'model_id': model_id, + 'provider': provider, + 'label': 'Default app model selection', + } + + selected_models = (settings.get('gpt_model') or {}).get('selected') or [] + default_model = selected_models[0] if selected_models else {} + default_label = ( + default_model.get('displayName') + or default_model.get('deploymentName') + or default_model.get('modelName') + or 'Default app model' + ) + + return { + 'mode': 'legacy_default', + 'valid': bool( + settings.get('enable_gpt_apim', False) + or settings.get('azure_openai_gpt_endpoint') + or first_if_comma(settings.get('azure_openai_gpt_deployment')) + ), + 'endpoint_id': '', + 'model_id': '', + 'provider': 'aoai', + 'label': default_label, + } + + +def _build_model_endpoint_candidates(user_id, settings): + candidates = [] + user_settings = get_user_settings(user_id) + + if settings.get('allow_user_custom_endpoints', False): + personal_endpoints, _ = normalize_model_endpoints( + user_settings.get('settings', {}).get('personal_model_endpoints', []) or [] + ) + for endpoint in personal_endpoints: + candidate = dict(endpoint) + candidate['scope'] = 'user' + candidates.append(candidate) + + global_endpoints, _ = normalize_model_endpoints(settings.get('model_endpoints', []) or []) + for endpoint in global_endpoints: + candidate = dict(endpoint) + candidate['scope'] = 'global' + candidates.append(candidate) + + return candidates + + +def _summarize_model_binding(candidates, endpoint_id, model_id): + endpoint_id = str(endpoint_id or '').strip() + model_id = str(model_id or '').strip() + if not endpoint_id and not model_id: + return None + if not endpoint_id or not model_id: + raise ValueError('Select both an endpoint and model, or choose the default app model.') + + endpoint_cfg = next((candidate for candidate in candidates if candidate.get('id') == endpoint_id), None) + if not endpoint_cfg: + raise ValueError('The selected model endpoint is no longer available.') + if not endpoint_cfg.get('enabled', True): + raise ValueError('The selected model endpoint is disabled.') + + model_cfg = next( + (model for model in endpoint_cfg.get('models', []) if model.get('id') == model_id), + None, + ) + if not model_cfg: + raise ValueError('The selected model is no longer available on that endpoint.') + if not model_cfg.get('enabled', True): + raise ValueError('The selected model is disabled.') + + endpoint_name = endpoint_cfg.get('name') or endpoint_id + model_name = ( + model_cfg.get('displayName') + or model_cfg.get('deploymentName') + or model_cfg.get('modelName') + or model_id + ) + provider = str(endpoint_cfg.get('provider') or '').strip().lower() + scope = str(endpoint_cfg.get('scope') or 'global').strip().lower() + scope_prefix = 'Workspace' if scope == 'user' else 'Global' + + return { + 'mode': 'custom', + 'valid': True, + 'endpoint_id': endpoint_id, + 'model_id': model_id, + 'provider': provider, + 'scope': scope, + 'label': f'{scope_prefix}: {endpoint_name} / {model_name}', + } + + +def compute_next_run_at(workflow, from_time=None): + """Return the next scheduled run timestamp for an interval workflow.""" + workflow = workflow if isinstance(workflow, dict) else {} + if workflow.get('trigger_type') != 'interval' or not workflow.get('is_enabled', False): + return None + + schedule = workflow.get('schedule') if isinstance(workflow.get('schedule'), dict) else {} + if not schedule: + return None + + reference_time = from_time or _utc_now() + if isinstance(reference_time, str): + reference_time = datetime.fromisoformat(reference_time) + if reference_time.tzinfo is None: + reference_time = reference_time.replace(tzinfo=timezone.utc) + + return (reference_time + _build_schedule_delta(schedule)).isoformat() + + +def get_personal_workflows(user_id): + """Fetch all workflows for a user.""" + try: + items = list(cosmos_personal_workflows_container.query_items( + query='SELECT * FROM c WHERE c.user_id = @user_id', + parameters=[{'name': '@user_id', 'value': user_id}], + partition_key=user_id, + )) + cleaned = [_strip_cosmos_metadata(item) for item in items] + cleaned.sort(key=lambda item: item.get('updated_at') or item.get('created_at') or '', reverse=True) + return cleaned + except exceptions.CosmosResourceNotFoundError: + return [] + except Exception as exc: + log_event( + f'[WorkflowStore] Error fetching workflows for user {user_id}: {exc}', + extra={'user_id': user_id}, + level=logging.ERROR, + exceptionTraceback=True, + ) + return [] + + +def get_personal_workflow(user_id, workflow_id): + """Fetch a specific personal workflow.""" + try: + workflow = cosmos_personal_workflows_container.read_item(item=workflow_id, partition_key=user_id) + return _strip_cosmos_metadata(workflow) + except exceptions.CosmosResourceNotFoundError: + return None + except Exception as exc: + log_event( + f'[WorkflowStore] Error fetching workflow {workflow_id}: {exc}', + extra={'user_id': user_id, 'workflow_id': workflow_id}, + level=logging.ERROR, + exceptionTraceback=True, + ) + return None + + +def get_due_personal_workflows(limit=20): + """Return interval workflows whose next run timestamp is due.""" + now_iso = _utc_now_iso() + try: + items = list(cosmos_personal_workflows_container.query_items( + query=( + 'SELECT * FROM c ' + 'WHERE c.trigger_type = @trigger_type ' + 'AND c.is_enabled = true ' + 'AND IS_DEFINED(c.next_run_at) ' + 'AND c.next_run_at != null ' + 'AND c.next_run_at <= @now_iso' + ), + parameters=[ + {'name': '@trigger_type', 'value': 'interval'}, + {'name': '@now_iso', 'value': now_iso}, + ], + enable_cross_partition_query=True, + )) + cleaned = [_strip_cosmos_metadata(item) for item in items] + cleaned.sort(key=lambda item: item.get('next_run_at') or '') + return cleaned[:limit] + except Exception as exc: + log_event( + f'[WorkflowStore] Error fetching due workflows: {exc}', + level=logging.ERROR, + exceptionTraceback=True, + ) + return [] + + +def save_personal_workflow(user_id, workflow_data, actor_user_id=None): + """Create or update a personal workflow.""" + workflow_data = workflow_data if isinstance(workflow_data, dict) else {} + settings = get_settings() + now_iso = _utc_now_iso() + modifying_user_id = actor_user_id or user_id + + workflow_id = str(workflow_data.get('id') or '').strip() + existing_workflow = get_personal_workflow(user_id, workflow_id) if workflow_id else None + + workflow_name = _normalize_text(workflow_data.get('name'), 'Workflow name', required=True) + description = _normalize_text(workflow_data.get('description'), 'Description') + task_prompt = _normalize_text(workflow_data.get('task_prompt'), 'Task prompt', required=True) + runner_type = _normalize_text(workflow_data.get('runner_type'), 'Runner type', required=True).lower() + if runner_type not in WORKFLOW_RUNNER_TYPES: + raise ValueError('Runner type must be agent or model.') + + trigger_type = _normalize_text(workflow_data.get('trigger_type'), 'Trigger type', required=True).lower() + if trigger_type not in WORKFLOW_TRIGGER_TYPES: + raise ValueError('Trigger type must be manual or interval.') + + is_enabled = bool(workflow_data.get('is_enabled', existing_workflow.get('is_enabled', True) if existing_workflow else True)) + alert_priority = _normalize_alert_priority( + workflow_data.get('alert_priority', (existing_workflow or {}).get('alert_priority', 'none')) + ) + document_action = _normalize_document_action_config(workflow_data, existing_workflow=existing_workflow) + exhaustive_review = build_legacy_exhaustive_review_config(document_action) + selected_agent = {} + model_binding_summary = None + model_endpoint_id = '' + model_id = '' + model_provider = '' + + if runner_type == 'agent': + if not settings.get('enable_semantic_kernel', False): + raise ValueError('Agents must be enabled before creating agent-based workflows.') + if not settings.get('allow_user_agents', False): + raise ValueError('User agents must be enabled before creating agent-based workflows.') + selected_agent = _normalize_selected_agent(user_id, settings, workflow_data.get('selected_agent')) + else: + model_candidates = _build_model_endpoint_candidates(user_id, settings) + model_endpoint_id = _normalize_text(workflow_data.get('model_endpoint_id'), 'Model endpoint') + model_id = _normalize_text(workflow_data.get('model_id'), 'Model') + if model_endpoint_id or model_id: + model_binding_summary = _summarize_model_binding(model_candidates, model_endpoint_id, model_id) + model_provider = str(model_binding_summary.get('provider') or '').strip().lower() + else: + model_binding_summary = _build_default_model_summary(settings) + model_provider = str(model_binding_summary.get('provider') or '').strip().lower() + + schedule = {} + if trigger_type == 'interval': + schedule = _normalize_schedule(workflow_data.get('schedule')) + + workflow = { + 'id': workflow_id or str(uuid.uuid4()), + 'user_id': user_id, + 'name': workflow_name, + 'description': description, + 'task_prompt': task_prompt, + 'runner_type': runner_type, + 'trigger_type': trigger_type, + 'is_enabled': is_enabled, + 'alert_priority': alert_priority, + 'schedule': schedule, + 'document_action': document_action, + 'exhaustive_review': exhaustive_review, + 'selected_agent': selected_agent, + 'model_endpoint_id': model_endpoint_id, + 'model_id': model_id, + 'model_provider': model_provider, + 'model_binding_summary': model_binding_summary, + 'conversation_id': _normalize_text( + workflow_data.get('conversation_id') or (existing_workflow or {}).get('conversation_id'), + 'Conversation id', + ), + 'created_at': (existing_workflow or {}).get('created_at') or now_iso, + 'created_by': (existing_workflow or {}).get('created_by') or user_id, + 'modified_at': now_iso, + 'modified_by': modifying_user_id, + 'updated_at': now_iso, + 'status': (existing_workflow or {}).get('status') or 'idle', + 'last_run_started_at': (existing_workflow or {}).get('last_run_started_at'), + 'last_run_at': (existing_workflow or {}).get('last_run_at'), + 'last_run_status': (existing_workflow or {}).get('last_run_status'), + 'last_run_error': (existing_workflow or {}).get('last_run_error', ''), + 'last_run_response_preview': (existing_workflow or {}).get('last_run_response_preview', ''), + 'last_run_trigger_source': (existing_workflow or {}).get('last_run_trigger_source', ''), + 'run_count': int((existing_workflow or {}).get('run_count') or 0), + } + + if trigger_type == 'interval' and is_enabled: + schedule_changed = ( + not existing_workflow + or existing_workflow.get('trigger_type') != 'interval' + or not existing_workflow.get('is_enabled', False) + or existing_workflow.get('schedule') != schedule + ) + workflow['next_run_at'] = (existing_workflow or {}).get('next_run_at') + if schedule_changed or not workflow.get('next_run_at'): + workflow['next_run_at'] = compute_next_run_at(workflow) + else: + workflow['next_run_at'] = None + + result = cosmos_personal_workflows_container.upsert_item(body=workflow) + cleaned_result = _strip_cosmos_metadata(result) + debug_print(f"[WorkflowStore] Saved workflow {cleaned_result.get('id')} for user {user_id}") + return cleaned_result + + +def update_personal_workflow_runtime_fields(user_id, workflow_id, updates): + """Apply runtime fields such as status and last-run metadata.""" + updates = updates if isinstance(updates, dict) else {} + workflow = get_personal_workflow(user_id, workflow_id) + if not workflow: + raise ValueError('Workflow not found.') + + workflow.update(updates) + workflow['updated_at'] = _utc_now_iso() + result = cosmos_personal_workflows_container.upsert_item(body=workflow) + return _strip_cosmos_metadata(result) + + +def list_personal_workflow_runs(user_id, workflow_id, limit=25): + """List recent workflow runs for a workflow.""" + try: + items = list(cosmos_personal_workflow_runs_container.query_items( + query=( + 'SELECT * FROM c ' + 'WHERE c.user_id = @user_id AND c.workflow_id = @workflow_id ' + 'ORDER BY c.started_at DESC' + ), + parameters=[ + {'name': '@user_id', 'value': user_id}, + {'name': '@workflow_id', 'value': workflow_id}, + ], + partition_key=user_id, + )) + return [_strip_cosmos_metadata(item) for item in items[:limit]] + except Exception as exc: + log_event( + f'[WorkflowStore] Error fetching workflow runs for {workflow_id}: {exc}', + extra={'user_id': user_id, 'workflow_id': workflow_id}, + level=logging.ERROR, + exceptionTraceback=True, + ) + return [] + + +def get_personal_workflow_run(user_id, run_id): + """Fetch a workflow run record by id.""" + try: + item = cosmos_personal_workflow_runs_container.read_item(item=run_id, partition_key=user_id) + return _strip_cosmos_metadata(item) + except exceptions.CosmosResourceNotFoundError: + return None + except Exception as exc: + log_event( + f'[WorkflowStore] Error fetching workflow run {run_id}: {exc}', + extra={'user_id': user_id, 'run_id': run_id}, + level=logging.ERROR, + exceptionTraceback=True, + ) + return None + + +def get_latest_personal_workflow_run_for_conversation(user_id, conversation_id, workflow_id=None): + """Return the latest run for a workflow conversation.""" + try: + query = ( + 'SELECT TOP 1 * FROM c ' + 'WHERE c.user_id = @user_id AND c.conversation_id = @conversation_id ' + ) + parameters = [ + {'name': '@user_id', 'value': user_id}, + {'name': '@conversation_id', 'value': conversation_id}, + ] + + if str(workflow_id or '').strip(): + query += 'AND c.workflow_id = @workflow_id ' + parameters.append({'name': '@workflow_id', 'value': workflow_id}) + + query += 'ORDER BY c.started_at DESC' + + items = list(cosmos_personal_workflow_runs_container.query_items( + query=query, + parameters=parameters, + partition_key=user_id, + )) + if not items: + return None + return _strip_cosmos_metadata(items[0]) + except Exception as exc: + log_event( + f'[WorkflowStore] Error fetching latest run for conversation {conversation_id}: {exc}', + extra={'user_id': user_id, 'conversation_id': conversation_id, 'workflow_id': workflow_id}, + level=logging.ERROR, + exceptionTraceback=True, + ) + return None + + +def save_personal_workflow_run(user_id, run_record): + """Create or update a workflow run record.""" + run_record = run_record if isinstance(run_record, dict) else {} + run_record['user_id'] = user_id + run_record.setdefault('id', str(uuid.uuid4())) + result = cosmos_personal_workflow_runs_container.upsert_item(body=run_record) + return _strip_cosmos_metadata(result) + + +def delete_personal_workflow(user_id, workflow_id): + """Delete a workflow and its run history.""" + workflow = get_personal_workflow(user_id, workflow_id) + if not workflow: + return False + + cosmos_personal_workflows_container.delete_item(item=workflow_id, partition_key=user_id) + + runs = list_personal_workflow_runs(user_id, workflow_id, limit=500) + for run in runs: + try: + cosmos_personal_workflow_runs_container.delete_item(item=run.get('id'), partition_key=user_id) + except exceptions.CosmosResourceNotFoundError: + continue + except Exception as exc: + log_event( + f"[WorkflowStore] Error deleting workflow run {run.get('id')}: {exc}", + extra={'user_id': user_id, 'workflow_id': workflow_id}, + level=logging.WARNING, + ) + + return True + + +def first_if_comma(val): + """Return the first item from a comma-separated string.""" + if isinstance(val, str) and ',' in val: + return val.split(',')[0].strip() + return val \ No newline at end of file diff --git a/application/single_app/functions_search.py b/application/single_app/functions_search.py index 6851778f..e7d117a6 100644 --- a/application/single_app/functions_search.py +++ b/application/single_app/functions_search.py @@ -16,6 +16,97 @@ logger = logging.getLogger(__name__) +SEARCH_DEFAULT_TOP_N = 12 +SEARCH_MAX_TOP_N = 500 +VALID_SEARCH_SCOPES = {"all", "personal", "group", "public"} +BASE_SEARCH_SELECT_FIELDS = [ + "id", + "document_id", + "chunk_text", + "chunk_id", + "file_name", + "version", + "chunk_sequence", + "upload_date", + "document_classification", + "document_tags", + "page_number", + "author", + "chunk_keywords", + "title", + "chunk_summary", +] +SEARCH_SELECT_FIELDS_BY_SCOPE = { + "personal": BASE_SEARCH_SELECT_FIELDS + ["user_id"], + "group": BASE_SEARCH_SELECT_FIELDS + ["group_id"], + "public": BASE_SEARCH_SELECT_FIELDS + ["public_workspace_id"], +} + + +def normalize_search_top_n(top_n, default_top_n=SEARCH_DEFAULT_TOP_N, max_top_n=SEARCH_MAX_TOP_N): + """Return a bounded integer top-N value for search-style operations.""" + try: + normalized_top_n = int(top_n) + except (TypeError, ValueError): + return default_top_n + + if normalized_top_n < 1: + return default_top_n + + return min(normalized_top_n, max_top_n) + + +def normalize_search_scope(doc_scope, default_scope="all"): + """Normalize search scope values to the supported set.""" + normalized_scope = str(doc_scope or default_scope).strip().lower() + if normalized_scope not in VALID_SEARCH_SCOPES: + return default_scope + return normalized_scope + + +def normalize_search_id_list(raw_ids): + """Normalize an optional list or comma-separated string of ids.""" + if raw_ids is None: + return [] + + if isinstance(raw_ids, str): + candidate_ids = [value.strip() for value in raw_ids.split(",") if value.strip()] + elif isinstance(raw_ids, list): + candidate_ids = [str(value).strip() for value in raw_ids if str(value).strip()] + else: + candidate_ids = [str(raw_ids).strip()] if str(raw_ids).strip() else [] + + normalized_ids = [] + seen_ids = set() + for candidate_id in candidate_ids: + if candidate_id in seen_ids: + continue + seen_ids.add(candidate_id) + normalized_ids.append(candidate_id) + + return normalized_ids + + +def get_search_select_fields(scope_name): + return SEARCH_SELECT_FIELDS_BY_SCOPE.get(scope_name, SEARCH_SELECT_FIELDS_BY_SCOPE["personal"]) + + +def get_search_result_scope(result_item): + if result_item.get("public_workspace_id"): + return "public" + if result_item.get("group_id"): + return "group" + return "personal" + + +def get_search_result_scope_id(result_item): + return ( + result_item.get("public_workspace_id") + or result_item.get("group_id") + or result_item.get("user_id") + ) + + def normalize_scores(results: List[Dict[str, Any]], index_name: str = "unknown") -> List[Dict[str, Any]]: """ Normalize search scores to [0, 1] range using min-max normalization. @@ -109,6 +200,10 @@ def hybrid_search(query, user_id, document_id=None, document_ids=None, top_n=12, across identical queries against the same document set. """ + top_n = normalize_search_top_n(top_n) + doc_scope = normalize_search_scope(doc_scope) + document_ids = normalize_search_id_list(document_ids) + # Backwards compat: wrap single group ID into list if not active_group_ids and active_group_id: active_group_ids = [active_group_id] @@ -253,7 +348,7 @@ def hybrid_search(query, user_id, document_id=None, document_ids=None, top_n=12, semantic_configuration_name="nexus-user-index-semantic-configuration", query_caption="extractive", query_answer="extractive", - select=["id", "chunk_text", "chunk_id", "file_name", "user_id", "version", "chunk_sequence", "upload_date", "document_classification", "document_tags", "page_number", "author", "chunk_keywords", "title", "chunk_summary"] + select=get_search_select_fields("personal") ) # Only search group index if active_group_ids is provided @@ -271,7 +366,7 @@ def hybrid_search(query, user_id, document_id=None, document_ids=None, top_n=12, semantic_configuration_name="nexus-group-index-semantic-configuration", query_caption="extractive", query_answer="extractive", - select=["id", "chunk_text", "chunk_id", "file_name", "group_id", "version", "chunk_sequence", "upload_date", "document_classification", "document_tags", "page_number", "author", "chunk_keywords", "title", "chunk_summary"] + select=get_search_select_fields("group") ) else: group_results = [] @@ -298,7 +393,7 @@ def hybrid_search(query, user_id, document_id=None, document_ids=None, top_n=12, semantic_configuration_name="nexus-public-index-semantic-configuration", query_caption="extractive", query_answer="extractive", - select=["id", "chunk_text", "chunk_id", "file_name", "public_workspace_id", "version", "chunk_sequence", "upload_date", "document_classification", "document_tags", "page_number", "author", "chunk_keywords", "title", "chunk_summary"] + select=get_search_select_fields("public") ) else: # Build user filter with optional tags @@ -317,7 +412,7 @@ def hybrid_search(query, user_id, document_id=None, document_ids=None, top_n=12, semantic_configuration_name="nexus-user-index-semantic-configuration", query_caption="extractive", query_answer="extractive", - select=["id", "chunk_text", "chunk_id", "file_name", "user_id", "version", "chunk_sequence", "upload_date", "document_classification", "document_tags", "page_number", "author", "chunk_keywords", "title", "chunk_summary"] + select=get_search_select_fields("personal") ) # Only search group index if active_group_ids is provided @@ -335,7 +430,7 @@ def hybrid_search(query, user_id, document_id=None, document_ids=None, top_n=12, semantic_configuration_name="nexus-group-index-semantic-configuration", query_caption="extractive", query_answer="extractive", - select=["id", "chunk_text", "chunk_id", "file_name", "group_id", "version", "chunk_sequence", "upload_date", "document_classification", "document_tags", "page_number", "author", "chunk_keywords", "title", "chunk_summary"] + select=get_search_select_fields("group") ) else: group_results = [] @@ -362,7 +457,7 @@ def hybrid_search(query, user_id, document_id=None, document_ids=None, top_n=12, semantic_configuration_name="nexus-public-index-semantic-configuration", query_caption="extractive", query_answer="extractive", - select=["id", "chunk_text", "chunk_id", "file_name", "public_workspace_id", "version", "chunk_sequence", "upload_date", "document_classification", "document_tags", "page_number", "author", "chunk_keywords", "title", "chunk_summary"] + select=get_search_select_fields("public") ) # Extract results from each index @@ -412,7 +507,7 @@ def hybrid_search(query, user_id, document_id=None, document_ids=None, top_n=12, semantic_configuration_name="nexus-user-index-semantic-configuration", query_caption="extractive", query_answer="extractive", - select=["id", "chunk_text", "chunk_id", "file_name", "user_id", "version", "chunk_sequence", "upload_date", "document_classification", "document_tags", "page_number", "author", "chunk_keywords", "title", "chunk_summary"] + select=get_search_select_fields("personal") ) results = extract_search_results(user_results, top_n) else: @@ -431,7 +526,7 @@ def hybrid_search(query, user_id, document_id=None, document_ids=None, top_n=12, semantic_configuration_name="nexus-user-index-semantic-configuration", query_caption="extractive", query_answer="extractive", - select=["id", "chunk_text", "chunk_id", "file_name", "user_id", "version", "chunk_sequence", "upload_date", "document_classification", "document_tags", "page_number", "author", "chunk_keywords", "title", "chunk_summary"] + select=get_search_select_fields("personal") ) results = extract_search_results(user_results, top_n) @@ -452,7 +547,7 @@ def hybrid_search(query, user_id, document_id=None, document_ids=None, top_n=12, semantic_configuration_name="nexus-group-index-semantic-configuration", query_caption="extractive", query_answer="extractive", - select=["id", "chunk_text", "chunk_id", "file_name", "group_id", "version", "chunk_sequence", "upload_date", "document_classification", "document_tags", "page_number", "author", "chunk_keywords", "title", "chunk_summary"] + select=get_search_select_fields("group") ) results = extract_search_results(group_results, top_n) else: @@ -469,7 +564,7 @@ def hybrid_search(query, user_id, document_id=None, document_ids=None, top_n=12, semantic_configuration_name="nexus-group-index-semantic-configuration", query_caption="extractive", query_answer="extractive", - select=["id", "chunk_text", "chunk_id", "file_name", "group_id", "version", "chunk_sequence", "upload_date", "document_classification", "document_tags", "page_number", "author", "chunk_keywords", "title", "chunk_summary"] + select=get_search_select_fields("group") ) results = extract_search_results(group_results, top_n) @@ -497,7 +592,7 @@ def hybrid_search(query, user_id, document_id=None, document_ids=None, top_n=12, semantic_configuration_name="nexus-public-index-semantic-configuration", query_caption="extractive", query_answer="extractive", - select=["id", "chunk_text", "chunk_id", "file_name", "public_workspace_id", "version", "chunk_sequence", "upload_date", "document_classification", "document_tags", "page_number", "author", "chunk_keywords", "title", "chunk_summary"] + select=get_search_select_fields("public") ) results = extract_search_results(public_results, top_n) else: @@ -523,7 +618,7 @@ def hybrid_search(query, user_id, document_id=None, document_ids=None, top_n=12, semantic_configuration_name="nexus-public-index-semantic-configuration", query_caption="extractive", query_answer="extractive", - select=["id", "chunk_text", "chunk_id", "file_name", "public_workspace_id", "version", "chunk_sequence", "upload_date", "document_classification", "document_tags", "page_number", "author", "chunk_keywords", "title", "chunk_summary"] + select=get_search_select_fields("public") ) results = extract_search_results(public_results, top_n) @@ -613,13 +708,18 @@ def extract_search_results(paged_results, top_n): for i, r in enumerate(paged_results): if i >= top_n: break + result_scope = get_search_result_scope(r) extracted.append({ "id": r["id"], + "document_id": r.get("document_id"), "chunk_text": r["chunk_text"], "chunk_id": r["chunk_id"], "file_name": r["file_name"], + "user_id": r.get("user_id"), "group_id": r.get("group_id"), "public_workspace_id": r.get("public_workspace_id"), + "scope": result_scope, + "scope_id": get_search_result_scope_id(r), "version": r["version"], "chunk_sequence": r["chunk_sequence"], "upload_date": r["upload_date"], diff --git a/application/single_app/functions_search_service.py b/application/single_app/functions_search_service.py new file mode 100644 index 00000000..f00fe0ca --- /dev/null +++ b/application/single_app/functions_search_service.py @@ -0,0 +1,741 @@ +# functions_search_service.py +"""Shared search, retrieval, and summarization services for documents.""" + +import logging +import math +from typing import Any, Dict, List, Optional + +from azure.identity import DefaultAzureCredential, get_bearer_token_provider +from openai import AzureOpenAI + +from config import cognitive_services_scope +from functions_appinsights import log_event +from functions_debug import debug_print +from functions_documents import get_document_record, get_ordered_document_chunks +from functions_group import get_user_groups +from functions_public_workspaces import get_user_visible_public_workspace_ids_from_settings +from functions_search import ( + SEARCH_DEFAULT_TOP_N, + SEARCH_MAX_TOP_N, + hybrid_search, + normalize_search_id_list, + normalize_search_scope, + normalize_search_top_n, +) +from functions_settings import get_settings, get_user_settings + + +SUMMARY_DEFAULT_WINDOW_UNIT = "pages" +SUMMARY_DEFAULT_WINDOW_SUMMARY_TARGET = "2 pages" +SUMMARY_DEFAULT_FINAL_TARGET = "2 pages" +SUMMARY_DEFAULT_REDUCTION_BATCH_SIZE = 4 +SUMMARY_DEFAULT_MAX_REDUCTION_ROUNDS = 4 +SUMMARY_DEFAULT_MIN_PAGE_WINDOW = 5 +SUMMARY_DEFAULT_MAX_PAGE_WINDOW = 25 +SUMMARY_DEFAULT_CHUNK_WINDOW = 20 +SUMMARY_MAX_WINDOW_SIZE = 50 + + +def _coerce_positive_int(value, default_value, min_value=1, max_value=None): + try: + normalized_value = int(value) + except (TypeError, ValueError): + normalized_value = default_value + + if normalized_value < min_value: + normalized_value = default_value + if max_value is not None: + normalized_value = min(normalized_value, max_value) + return normalized_value + + +def _normalize_window_unit(window_unit, chunks): + normalized_window_unit = str(window_unit or SUMMARY_DEFAULT_WINDOW_UNIT).strip().lower() + if normalized_window_unit == "pages": + has_page_numbers = any(chunk.get("page_number") is not None for chunk in chunks or []) + if has_page_numbers: + return "pages" + return "chunks" + + +def _resolve_active_group_ids(user_id, active_group_ids=None, fallback_to_memberships=False): + normalized_group_ids = normalize_search_id_list(active_group_ids) + if normalized_group_ids: + return normalized_group_ids + + user_settings = get_user_settings(user_id) + active_group_id = str(user_settings.get("settings", {}).get("activeGroupOid") or "").strip() + if active_group_id: + return [active_group_id] + + if not fallback_to_memberships: + return [] + + try: + user_groups = get_user_groups(user_id) + except Exception: + return [] + + return normalize_search_id_list([group.get("id") for group in user_groups if group.get("id")]) + + +def _resolve_public_workspace_ids(user_id, active_public_workspace_id=None): + normalized_workspace_ids = normalize_search_id_list(active_public_workspace_id) + if normalized_workspace_ids: + return normalized_workspace_ids + + try: + return normalize_search_id_list(get_user_visible_public_workspace_ids_from_settings(user_id)) + except Exception: + return [] + + +def _serialize_document(document_item, scope_name): + return { + "id": document_item.get("id"), + "file_name": document_item.get("file_name"), + "title": document_item.get("title"), + "abstract": document_item.get("abstract"), + "version": document_item.get("version"), + "revision_family_id": document_item.get("revision_family_id"), + "document_classification": document_item.get("document_classification"), + "tags": document_item.get("tags", []), + "scope": scope_name, + "scope_id": ( + document_item.get("public_workspace_id") + or document_item.get("group_id") + or document_item.get("user_id") + ), + "group_id": document_item.get("group_id"), + "public_workspace_id": document_item.get("public_workspace_id"), + "user_id": document_item.get("user_id"), + } + + +def resolve_document_context( + document_id, + user_id, + doc_scope="all", + active_group_ids=None, + active_public_workspace_id=None, +): + normalized_scope = normalize_search_scope(doc_scope) + + if normalized_scope in ("all", "personal"): + personal_document = get_document_record(user_id=user_id, document_id=document_id) + if personal_document: + return { + "scope": "personal", + "group_id": None, + "public_workspace_id": None, + "document": personal_document, + } + + if normalized_scope in ("all", "group"): + for group_id in _resolve_active_group_ids( + user_id, + active_group_ids=active_group_ids, + fallback_to_memberships=True, + ): + group_document = get_document_record( + user_id=user_id, + document_id=document_id, + group_id=group_id, + ) + if group_document: + return { + "scope": "group", + "group_id": group_id, + "public_workspace_id": None, + "document": group_document, + } + + if normalized_scope in ("all", "public"): + for public_workspace_id in _resolve_public_workspace_ids( + user_id, + active_public_workspace_id=active_public_workspace_id, + ): + public_document = get_document_record( + user_id=user_id, + document_id=document_id, + public_workspace_id=public_workspace_id, + ) + if public_document: + return { + "scope": "public", + "group_id": None, + "public_workspace_id": public_workspace_id, + "document": public_document, + } + + return None + + +def build_search_request( + query, + user_id, + top_n=None, + doc_scope="all", + document_id=None, + document_ids=None, + tags_filter=None, + active_group_ids=None, + active_public_workspace_id=None, + enable_file_sharing=True, +): + normalized_query = str(query or "").strip() + if not normalized_query: + raise ValueError("Query is required") + + normalized_scope = normalize_search_scope(doc_scope) + normalized_top_n = normalize_search_top_n(top_n, SEARCH_DEFAULT_TOP_N, SEARCH_MAX_TOP_N) + normalized_document_ids = normalize_search_id_list(document_ids) + if document_id and not normalized_document_ids: + normalized_document_ids = [str(document_id).strip()] + + search_request = { + "query": normalized_query, + "user_id": user_id, + "top_n": normalized_top_n, + "doc_scope": normalized_scope, + "enable_file_sharing": bool(enable_file_sharing), + } + + if normalized_document_ids: + search_request["document_ids"] = normalized_document_ids + + normalized_tags = normalize_search_id_list(tags_filter) + if normalized_tags: + search_request["tags_filter"] = normalized_tags + + resolved_group_ids = _resolve_active_group_ids( + user_id, + active_group_ids=active_group_ids, + fallback_to_memberships=False, + ) + if resolved_group_ids and normalized_scope in ("all", "group"): + search_request["active_group_ids"] = resolved_group_ids + + resolved_public_workspace_ids = _resolve_public_workspace_ids( + user_id, + active_public_workspace_id=active_public_workspace_id, + ) + if resolved_public_workspace_ids and normalized_scope in ("all", "public"): + search_request["active_public_workspace_id"] = resolved_public_workspace_ids[0] + + return search_request + + +def search_documents( + query, + user_id, + top_n=None, + doc_scope="all", + document_id=None, + document_ids=None, + tags_filter=None, + active_group_ids=None, + active_public_workspace_id=None, + enable_file_sharing=True, +): + search_request = build_search_request( + query=query, + user_id=user_id, + top_n=top_n, + doc_scope=doc_scope, + document_id=document_id, + document_ids=document_ids, + tags_filter=tags_filter, + active_group_ids=active_group_ids, + active_public_workspace_id=active_public_workspace_id, + enable_file_sharing=enable_file_sharing, + ) + results = hybrid_search(**search_request) or [] + unique_document_ids = { + result.get("document_id") + for result in results + if result.get("document_id") + } + + return { + "query": search_request.get("query"), + "scope": search_request.get("doc_scope"), + "top_n": search_request.get("top_n"), + "document_ids": search_request.get("document_ids", []), + "tags_filter": search_request.get("tags_filter", []), + "group_ids": search_request.get("active_group_ids", []), + "active_public_workspace_id": search_request.get("active_public_workspace_id"), + "result_count": len(results), + "document_count": len(unique_document_ids), + "results": results, + } + + +def _derive_window_size(chunks, window_unit, window_size=None, window_percent=None): + if not chunks: + return 0 + + if window_unit == "pages": + total_units = len({chunk.get("page_number") for chunk in chunks if chunk.get("page_number") is not None}) + if total_units <= 0: + return 0 + + if window_size is not None and str(window_size).strip() != "": + return _coerce_positive_int( + window_size, + default_value=min(total_units, SUMMARY_DEFAULT_MAX_PAGE_WINDOW), + min_value=1, + max_value=min(total_units, SUMMARY_MAX_WINDOW_SIZE), + ) + + if window_percent: + computed_size = int(math.ceil(total_units * (float(window_percent) / 100.0))) + else: + computed_size = int(math.ceil(total_units / 4.0)) + + computed_size = max(SUMMARY_DEFAULT_MIN_PAGE_WINDOW, computed_size) + computed_size = min(SUMMARY_DEFAULT_MAX_PAGE_WINDOW, computed_size) + return min(total_units, computed_size) + + total_units = len(chunks) + if total_units <= 0: + return 0 + + default_chunk_window = min(total_units, SUMMARY_DEFAULT_CHUNK_WINDOW) + if window_size is not None and str(window_size).strip() != "": + return _coerce_positive_int( + window_size, + default_value=default_chunk_window, + min_value=1, + max_value=min(total_units, SUMMARY_MAX_WINDOW_SIZE), + ) + + if window_percent: + computed_size = int(math.ceil(total_units * (float(window_percent) / 100.0))) + return min(total_units, max(1, computed_size)) + + return default_chunk_window + + +def build_document_chunk_windows(chunks, window_unit="pages", window_size=None, window_percent=None): + if not chunks: + return [] + + normalized_window_unit = _normalize_window_unit(window_unit, chunks) + resolved_window_size = _derive_window_size( + chunks, + normalized_window_unit, + window_size=window_size, + window_percent=window_percent, + ) + if resolved_window_size <= 0: + return [] + + windows = [] + if normalized_window_unit == "pages": + ordered_pages = sorted({chunk.get("page_number") for chunk in chunks if chunk.get("page_number") is not None}) + for window_index, page_offset in enumerate(range(0, len(ordered_pages), resolved_window_size), start=1): + window_pages = ordered_pages[page_offset:page_offset + resolved_window_size] + window_chunks = [ + chunk for chunk in chunks + if chunk.get("page_number") in window_pages + ] + windows.append({ + "window_number": window_index, + "window_unit": normalized_window_unit, + "window_size": resolved_window_size, + "chunk_count": len(window_chunks), + "page_count": len(window_pages), + "start_page": window_pages[0], + "end_page": window_pages[-1], + "start_chunk_sequence": window_chunks[0].get("chunk_sequence") if window_chunks else None, + "end_chunk_sequence": window_chunks[-1].get("chunk_sequence") if window_chunks else None, + "chunks": window_chunks, + }) + else: + for window_index, chunk_offset in enumerate(range(0, len(chunks), resolved_window_size), start=1): + window_chunks = chunks[chunk_offset:chunk_offset + resolved_window_size] + page_numbers = [chunk.get("page_number") for chunk in window_chunks if chunk.get("page_number") is not None] + windows.append({ + "window_number": window_index, + "window_unit": normalized_window_unit, + "window_size": resolved_window_size, + "chunk_count": len(window_chunks), + "page_count": len(set(page_numbers)) if page_numbers else 0, + "start_page": min(page_numbers) if page_numbers else None, + "end_page": max(page_numbers) if page_numbers else None, + "start_chunk_sequence": window_chunks[0].get("chunk_sequence") if window_chunks else None, + "end_chunk_sequence": window_chunks[-1].get("chunk_sequence") if window_chunks else None, + "chunks": window_chunks, + }) + + return windows + + +def get_document_chunks_payload( + document_id, + user_id, + doc_scope="all", + active_group_ids=None, + active_public_workspace_id=None, + window_unit="pages", + window_size=None, + window_percent=None, + window_number=None, +): + document_context = resolve_document_context( + document_id=document_id, + user_id=user_id, + doc_scope=doc_scope, + active_group_ids=active_group_ids, + active_public_workspace_id=active_public_workspace_id, + ) + if not document_context: + raise LookupError("Document not found or access denied") + + chunks = get_ordered_document_chunks( + document_id=document_id, + user_id=user_id, + group_id=document_context.get("group_id"), + public_workspace_id=document_context.get("public_workspace_id"), + ) + windows = build_document_chunk_windows( + chunks, + window_unit=window_unit, + window_size=window_size, + window_percent=window_percent, + ) + selected_window = None + selected_chunks = chunks + + if window_number not in (None, ""): + resolved_window_number = _coerce_positive_int(window_number, default_value=1) + selected_window = next( + (window for window in windows if window.get("window_number") == resolved_window_number), + None, + ) + if not selected_window: + raise LookupError(f"Window {resolved_window_number} was not found for this document") + selected_chunks = selected_window.get("chunks", []) + + return { + "document": _serialize_document(document_context.get("document"), document_context.get("scope")), + "scope": document_context.get("scope"), + "scope_id": ( + document_context.get("public_workspace_id") + or document_context.get("group_id") + or document_context.get("document", {}).get("user_id") + ), + "chunk_count": len(chunks), + "returned_chunk_count": len(selected_chunks), + "window_count": len(windows), + "windowing": { + "window_unit": windows[0].get("window_unit") if windows else _normalize_window_unit(window_unit, chunks), + "window_size": windows[0].get("window_size") if windows else None, + "window_percent": window_percent, + "selected_window_number": selected_window.get("window_number") if selected_window else None, + }, + "windows": [ + { + "window_number": window.get("window_number"), + "window_unit": window.get("window_unit"), + "window_size": window.get("window_size"), + "chunk_count": window.get("chunk_count"), + "page_count": window.get("page_count"), + "start_page": window.get("start_page"), + "end_page": window.get("end_page"), + "start_chunk_sequence": window.get("start_chunk_sequence"), + "end_chunk_sequence": window.get("end_chunk_sequence"), + } + for window in windows + ], + "chunks": selected_chunks, + } + + +def _render_window_source_text(window_payload): + source_parts = [] + for chunk in window_payload.get("chunks", []): + chunk_text = str(chunk.get("chunk_text") or "").strip() + if not chunk_text: + continue + + chunk_labels = [] + if chunk.get("page_number") is not None: + chunk_labels.append(f"Page {chunk.get('page_number')}") + if chunk.get("chunk_sequence") is not None: + chunk_labels.append(f"Chunk {chunk.get('chunk_sequence')}") + prefix = f"[{', '.join(chunk_labels)}] " if chunk_labels else "" + source_parts.append(f"{prefix}{chunk_text}") + + return "\n\n".join(source_parts) + + +def _create_summary_client(settings): + if settings.get('enable_gpt_apim', False): + return AzureOpenAI( + api_version=settings.get('azure_apim_gpt_api_version'), + azure_endpoint=settings.get('azure_apim_gpt_endpoint'), + api_key=settings.get('azure_apim_gpt_subscription_key'), + ) + + auth_type = settings.get('azure_openai_gpt_authentication_type', 'key') + if auth_type == 'managed_identity': + token_provider = get_bearer_token_provider( + DefaultAzureCredential(), + cognitive_services_scope, + ) + return AzureOpenAI( + api_version=settings.get('azure_openai_gpt_api_version'), + azure_endpoint=settings.get('azure_openai_gpt_endpoint'), + azure_ad_token_provider=token_provider, + ) + + return AzureOpenAI( + api_version=settings.get('azure_openai_gpt_api_version'), + azure_endpoint=settings.get('azure_openai_gpt_endpoint'), + api_key=settings.get('azure_openai_gpt_key'), + ) + + +def _resolve_summary_model(settings): + selected_model = settings.get('gpt_model', {}).get('selected', [{}]) + selected_model = selected_model[0] if selected_model else {} + model_name = ( + settings.get('metadata_extraction_model') + or settings.get('azure_openai_gpt_deployment') + or selected_model.get('deploymentName') + ) + if not model_name: + raise RuntimeError('No GPT deployment is configured for document summarization') + return model_name + + +def _build_summary_api_params(model_name, messages, max_output_tokens=1600): + uses_completion_tokens = any( + marker in model_name.lower() + for marker in ('o1', 'o3', 'gpt-5') + ) + api_params = { + 'model': model_name, + 'messages': messages, + } + if uses_completion_tokens: + api_params['max_completion_tokens'] = max_output_tokens + else: + api_params['temperature'] = 0.2 + api_params['max_tokens'] = max_output_tokens + return api_params + + +def _summarize_text_block( + gpt_client, + model_name, + file_name, + stage_label, + target_length, + focus_instructions, + coverage_note, + source_text, +): + messages = [ + { + 'role': 'system', + 'content': ( + 'You summarize document content accurately and conservatively. ' + 'Do not invent details. Preserve factual meaning, decisions, risks, dates, and action items when present.' + ), + }, + { + 'role': 'user', + 'content': ( + f'Document: {file_name}\n' + f'Stage: {stage_label}\n' + f'Coverage: {coverage_note}\n' + f'Target length: {target_length}\n' + f'Focus instructions: {focus_instructions or "Summarize the most important facts, decisions, risks, dependencies, and open questions."}\n\n' + 'Write a clear summary with short section headers when useful. ' + 'Call out important caveats or ambiguities explicitly.\n\n' + f'\n{source_text}\n' + ), + }, + ] + response = gpt_client.chat.completions.create( + **_build_summary_api_params(model_name, messages) + ) + return str(response.choices[0].message.content or '').strip() + + +def _build_reduction_windows(summary_items, batch_size): + reduction_windows = [] + for window_number, index in enumerate(range(0, len(summary_items), batch_size), start=1): + batch_items = summary_items[index:index + batch_size] + source_text = [] + for batch_item in batch_items: + source_text.append( + f"[Section {batch_item.get('source_window_numbers')}]\n{batch_item.get('summary', '')}" + ) + reduction_windows.append({ + 'window_number': window_number, + 'window_unit': 'summaries', + 'window_size': batch_size, + 'chunk_count': sum(item.get('chunk_count', 0) for item in batch_items), + 'page_count': sum(item.get('page_count', 0) for item in batch_items), + 'start_page': batch_items[0].get('start_page') if batch_items else None, + 'end_page': batch_items[-1].get('end_page') if batch_items else None, + 'source_text': '\n\n'.join(source_text), + 'source_window_numbers': [item.get('source_window_numbers') for item in batch_items], + }) + return reduction_windows + + +def summarize_document_content( + document_id, + user_id, + doc_scope='all', + active_group_ids=None, + active_public_workspace_id=None, + focus_instructions='', + final_target_length=SUMMARY_DEFAULT_FINAL_TARGET, + window_target_length=SUMMARY_DEFAULT_WINDOW_SUMMARY_TARGET, + window_unit=SUMMARY_DEFAULT_WINDOW_UNIT, + window_size=None, + window_percent=None, + reduction_batch_size=SUMMARY_DEFAULT_REDUCTION_BATCH_SIZE, + max_reduction_rounds=SUMMARY_DEFAULT_MAX_REDUCTION_ROUNDS, +): + chunk_payload = get_document_chunks_payload( + document_id=document_id, + user_id=user_id, + doc_scope=doc_scope, + active_group_ids=active_group_ids, + active_public_workspace_id=active_public_workspace_id, + window_unit=window_unit, + window_size=window_size, + window_percent=window_percent, + ) + windows = build_document_chunk_windows( + chunk_payload.get('chunks', []), + window_unit=window_unit, + window_size=window_size, + window_percent=window_percent, + ) + if not windows: + raise LookupError('No document chunks were available for summarization') + + settings = get_settings() + model_name = _resolve_summary_model(settings) + gpt_client = _create_summary_client(settings) + reduction_batch_size = _coerce_positive_int( + reduction_batch_size, + SUMMARY_DEFAULT_REDUCTION_BATCH_SIZE, + min_value=1, + max_value=8, + ) + max_reduction_rounds = _coerce_positive_int( + max_reduction_rounds, + SUMMARY_DEFAULT_MAX_REDUCTION_ROUNDS, + min_value=1, + max_value=8, + ) + file_name = chunk_payload.get('document', {}).get('file_name') or document_id + + stage_records = [] + current_stage_inputs = windows + stage_number = 1 + final_summary = '' + + while current_stage_inputs and stage_number <= max_reduction_rounds: + debug_print( + f"[SearchService] Summarization stage {stage_number} for {file_name} with {len(current_stage_inputs)} input windows" + ) + output_items = [] + + for stage_input in current_stage_inputs: + if stage_number == 1: + coverage_note = ( + f"pages {stage_input.get('start_page')} to {stage_input.get('end_page')}" + if stage_input.get('start_page') is not None else + f"chunks {stage_input.get('start_chunk_sequence')} to {stage_input.get('end_chunk_sequence')}" + ) + source_text = _render_window_source_text(stage_input) + source_window_numbers = [stage_input.get('window_number')] + target_length = window_target_length + page_count = stage_input.get('page_count', 0) + chunk_count = stage_input.get('chunk_count', 0) + start_page = stage_input.get('start_page') + end_page = stage_input.get('end_page') + else: + coverage_note = f"summary windows {stage_input.get('source_window_numbers')}" + source_text = stage_input.get('source_text', '') + source_window_numbers = stage_input.get('source_window_numbers', []) + target_length = final_target_length + page_count = stage_input.get('page_count', 0) + chunk_count = stage_input.get('chunk_count', 0) + start_page = stage_input.get('start_page') + end_page = stage_input.get('end_page') + + if not source_text.strip(): + continue + + summary_text = _summarize_text_block( + gpt_client=gpt_client, + model_name=model_name, + file_name=file_name, + stage_label=f'stage-{stage_number}', + target_length=target_length, + focus_instructions=focus_instructions, + coverage_note=coverage_note, + source_text=source_text, + ) + output_items.append({ + 'window_number': stage_input.get('window_number'), + 'source_window_numbers': source_window_numbers, + 'chunk_count': chunk_count, + 'page_count': page_count, + 'start_page': start_page, + 'end_page': end_page, + 'summary': summary_text, + }) + + stage_records.append({ + 'stage_number': stage_number, + 'input_count': len(current_stage_inputs), + 'output_count': len(output_items), + 'target_length': window_target_length if stage_number == 1 else final_target_length, + 'outputs': output_items, + }) + + if len(output_items) <= 1: + final_summary = output_items[0].get('summary', '') if output_items else '' + break + + current_stage_inputs = _build_reduction_windows(output_items, reduction_batch_size) + stage_number += 1 + + log_event( + '[SearchService] Document summarization completed', + extra={ + 'document_id': document_id, + 'file_name': file_name, + 'stage_count': len(stage_records), + 'window_count': len(windows), + 'scope': chunk_payload.get('scope'), + }, + level=logging.INFO, + ) + + return { + 'document': chunk_payload.get('document'), + 'scope': chunk_payload.get('scope'), + 'scope_id': chunk_payload.get('scope_id'), + 'chunk_count': chunk_payload.get('chunk_count'), + 'window_count': len(windows), + 'windowing': chunk_payload.get('windowing'), + 'focus_instructions': focus_instructions, + 'window_target_length': window_target_length, + 'final_target_length': final_target_length, + 'stage_count': len(stage_records), + 'stages': stage_records, + 'summary': final_summary, + } \ No newline at end of file diff --git a/application/single_app/functions_settings.py b/application/single_app/functions_settings.py index 8d09ee61..281e8fff 100644 --- a/application/single_app/functions_settings.py +++ b/application/single_app/functions_settings.py @@ -62,6 +62,7 @@ def get_settings(use_cosmos=False, include_source=False): 'allow_user_custom_endpoints': False, 'allow_user_custom_agent_endpoints': False, 'allow_user_plugins': False, + 'allow_user_workflows': True, 'allow_group_agents': False, 'allow_group_custom_endpoints': False, 'allow_group_custom_agent_endpoints': False, @@ -83,6 +84,7 @@ def get_settings(use_cosmos=False, include_source=False): 'landing_page_text': 'You can add text here and it supports Markdown. ' 'You agree to our [acceptable user policy](acceptable_use_policy.html) by using this service.', 'landing_page_alignment': 'left', + 'landing_page_logo_scale_percent': 100, 'show_logo': False, 'hide_app_title': False, 'custom_logo_base64': '', @@ -264,6 +266,9 @@ def get_settings(use_cosmos=False, include_source=False): # Processing Thoughts 'enable_thoughts': True, + # Collaborative Conversations + 'enable_collaborative_conversations': True, + # Search and Extract 'azure_ai_search_endpoint': '', 'azure_ai_search_key': '', diff --git a/application/single_app/functions_simplechat_operations.py b/application/single_app/functions_simplechat_operations.py new file mode 100644 index 00000000..cb3d6527 --- /dev/null +++ b/application/single_app/functions_simplechat_operations.py @@ -0,0 +1,1590 @@ +# functions_simplechat_operations.py +"""Shared SimpleChat-native operations for routes and Semantic Kernel plugins.""" + +import logging +import os +import re +import tempfile +import uuid +from datetime import datetime, timezone +from typing import Any, Dict, Iterable, List, Optional, Tuple +from urllib.parse import quote + +import requests +from azure.cosmos.exceptions import CosmosResourceNotFoundError +from flask import current_app, has_app_context, session + +from collaboration_models import normalize_collaboration_user +from config import ( + cosmos_activity_logs_container, + cosmos_conversations_container, + cosmos_groups_container, + cosmos_messages_container, +) +from functions_activity_logging import ( + log_chat_activity, + log_conversation_creation, + log_document_upload, + log_group_status_change, + log_workflow_creation, +) +from functions_appinsights import log_event +from functions_authentication import ( + get_current_user_info, + get_graph_endpoint, + get_valid_access_token, +) +from functions_collaboration import ( + assert_user_can_participate_in_collaboration_conversation, + create_collaboration_message_notifications, + create_group_collaboration_conversation_record, + create_personal_collaboration_conversation_record, + get_collaboration_conversation, + invite_personal_collaboration_participants, + is_group_collaboration_conversation, + persist_collaboration_message, +) +from functions_documents import allowed_file, create_document, process_document_upload_background, update_document +from functions_group import ( + assert_group_role, + check_group_status_allows_operation, + create_group, + find_group_by_id, + get_user_role_in_group, + require_active_group, +) +from functions_notifications import create_notification +from functions_personal_workflows import save_personal_workflow +from functions_settings import get_settings, get_user_settings +from utils_cache import invalidate_group_search_cache, invalidate_personal_search_cache + + +SIMPLECHAT_PLUGIN_TYPE = "simplechat" +SIMPLECHAT_DEFAULT_ENDPOINT = "simplechat://internal" +SIMPLECHAT_CAPABILITY_TO_FUNCTION = { + "create_group": "create_group", + "add_group_member": "add_user_to_group", + "make_group_inactive": "make_group_inactive", + "create_group_conversation": "create_group_conversation", + "invite_group_conversation_members": "invite_group_conversation_members", + "add_conversation_message": "add_conversation_message", + "upload_markdown_document": "upload_markdown_document", + "create_personal_conversation": "create_personal_conversation", + "create_personal_workflow": "create_personal_workflow", + "create_personal_collaboration_conversation": "create_personal_collaboration_conversation", +} +SIMPLECHAT_CAPABILITY_DEFINITIONS = [ + { + "key": "create_group", + "function_name": SIMPLECHAT_CAPABILITY_TO_FUNCTION["create_group"], + "label": "Create Groups", + "description": "Create a new group workspace as the current user.", + }, + { + "key": "add_group_member", + "function_name": SIMPLECHAT_CAPABILITY_TO_FUNCTION["add_group_member"], + "label": "Add Group Members", + "description": "Add a user directly to a group as a member, admin, or document manager.", + }, + { + "key": "make_group_inactive", + "function_name": SIMPLECHAT_CAPABILITY_TO_FUNCTION["make_group_inactive"], + "label": "Make Groups Inactive", + "description": "Mark a group inactive using the current user's Control Center admin permissions.", + }, + { + "key": "create_group_conversation", + "function_name": SIMPLECHAT_CAPABILITY_TO_FUNCTION["create_group_conversation"], + "label": "Create Group Multi-User Conversations", + "description": "Create an invite-managed multi-user conversation in a group the current user can access, then add current group members as participants to grant access.", + }, + { + "key": "invite_group_conversation_members", + "function_name": SIMPLECHAT_CAPABILITY_TO_FUNCTION["invite_group_conversation_members"], + "label": "Invite Group Conversation Members", + "description": "Invite current group members into an existing invite-managed group multi-user conversation the current user manages.", + }, + { + "key": "add_conversation_message", + "function_name": SIMPLECHAT_CAPABILITY_TO_FUNCTION["add_conversation_message"], + "label": "Add Conversation Messages", + "description": "Add a user-authored message to a personal or collaborative conversation the current user can access.", + }, + { + "key": "upload_markdown_document", + "function_name": SIMPLECHAT_CAPABILITY_TO_FUNCTION["upload_markdown_document"], + "label": "Upload Markdown Documents", + "description": "Create and upload a Markdown document into the current user's personal workspace or an allowed group workspace.", + }, + { + "key": "create_personal_conversation", + "function_name": SIMPLECHAT_CAPABILITY_TO_FUNCTION["create_personal_conversation"], + "label": "Create Personal Conversations", + "description": "Create a standard one-user personal conversation.", + }, + { + "key": "create_personal_workflow", + "function_name": SIMPLECHAT_CAPABILITY_TO_FUNCTION["create_personal_workflow"], + "label": "Create Personal Workflows", + "description": "Create a personal workflow for the current user using the existing workflow engine and permissions.", + }, + { + "key": "create_personal_collaboration_conversation", + "function_name": SIMPLECHAT_CAPABILITY_TO_FUNCTION["create_personal_collaboration_conversation"], + "label": "Create Personal Collaborative Conversations", + "description": "Create a personal collaborative conversation and invite other users.", + }, +] + + +def get_default_simplechat_capabilities() -> Dict[str, bool]: + return {definition["key"]: True for definition in SIMPLECHAT_CAPABILITY_DEFINITIONS} + + +def normalize_simplechat_capabilities(raw_capabilities: Any = None) -> Dict[str, bool]: + normalized = get_default_simplechat_capabilities() + + if raw_capabilities is None: + return normalized + + if isinstance(raw_capabilities, dict): + for capability_key in normalized: + if capability_key in raw_capabilities: + normalized[capability_key] = bool(raw_capabilities[capability_key]) + return normalized + + if isinstance(raw_capabilities, (list, tuple, set)): + enabled_items = {str(item or "").strip() for item in raw_capabilities if str(item or "").strip()} + return { + definition["key"]: ( + definition["key"] in enabled_items or definition["function_name"] in enabled_items + ) + for definition in SIMPLECHAT_CAPABILITY_DEFINITIONS + } + + return normalized + + +def get_simplechat_enabled_function_names(raw_capabilities: Any = None) -> List[str]: + normalized = normalize_simplechat_capabilities(raw_capabilities) + return [ + definition["function_name"] + for definition in SIMPLECHAT_CAPABILITY_DEFINITIONS + if normalized.get(definition["key"], False) + ] + + +def resolve_simplechat_action_capabilities( + action_capability_map: Any, + action_defaults: Any = None, + action_id: Optional[str] = None, + action_name: Optional[str] = None, +) -> Dict[str, bool]: + resolved_defaults = normalize_simplechat_capabilities(action_defaults) + + if not isinstance(action_capability_map, dict): + return resolved_defaults + + for candidate_key in (str(action_id or "").strip(), str(action_name or "").strip()): + if candidate_key and candidate_key in action_capability_map: + return normalize_simplechat_capabilities(action_capability_map.get(candidate_key)) + + return resolved_defaults + + +def create_personal_conversation_for_current_user( + title: str = "New Conversation", + notify_creation: bool = False, +) -> Dict[str, Any]: + current_user = _require_current_user_info() + normalized_title = str(title or "").strip() or "New Conversation" + conversation_id = str(uuid.uuid4()) + conversation_item = { + "id": conversation_id, + "user_id": current_user["userId"], + "last_updated": datetime.utcnow().isoformat(), + "title": normalized_title, + "context": [], + "tags": [], + "strict": False, + "is_pinned": False, + "is_hidden": False, + "chat_type": "new", + "has_unread_assistant_response": False, + "last_unread_assistant_message_id": None, + "last_unread_assistant_at": None, + } + cosmos_conversations_container.upsert_item(conversation_item) + + log_conversation_creation( + user_id=current_user["userId"], + conversation_id=conversation_id, + title=normalized_title, + workspace_type="personal", + ) + + conversation_item["added_to_activity_log"] = True + cosmos_conversations_container.upsert_item(conversation_item) + + if notify_creation: + _notify_personal_conversation_created( + conversation_item=conversation_item, + current_user=current_user, + ) + + return conversation_item + + +def create_personal_workflow_for_current_user( + name: str, + task_prompt: str, + description: str = "", + runner_type: str = "model", + trigger_type: str = "manual", + selected_agent_name: str = "", + selected_agent_id: str = "", + selected_agent_is_global: bool = False, + model_endpoint_id: str = "", + model_id: str = "", + alert_priority: str = "none", + is_enabled: bool = True, + schedule_value: int = 1, + schedule_unit: str = "hours", + conversation_id: str = "", +) -> Dict[str, Any]: + _require_user_workflows_enabled() + current_user_info = _require_current_user_info() + + normalized_runner_type = str(runner_type or "model").strip().lower() or "model" + normalized_trigger_type = str(trigger_type or "manual").strip().lower() or "manual" + workflow_payload = { + "name": str(name or "").strip(), + "description": str(description or "").strip(), + "task_prompt": str(task_prompt or "").strip(), + "runner_type": normalized_runner_type, + "trigger_type": normalized_trigger_type, + "alert_priority": str(alert_priority or "none").strip().lower() or "none", + "is_enabled": bool(is_enabled) if normalized_trigger_type == "interval" else True, + "conversation_id": str(conversation_id or "").strip(), + } + + if normalized_runner_type == "agent": + workflow_payload["selected_agent"] = { + "id": str(selected_agent_id or "").strip(), + "name": str(selected_agent_name or "").strip(), + "is_global": bool(selected_agent_is_global), + } + else: + workflow_payload["model_endpoint_id"] = str(model_endpoint_id or "").strip() + workflow_payload["model_id"] = str(model_id or "").strip() + + if normalized_trigger_type == "interval": + workflow_payload["schedule"] = { + "value": int(schedule_value), + "unit": str(schedule_unit or "hours").strip().lower() or "hours", + } + + workflow = save_personal_workflow( + current_user_info["userId"], + workflow_payload, + actor_user_id=current_user_info["userId"], + ) + log_workflow_creation( + user_id=current_user_info["userId"], + workflow_id=str(workflow.get("id") or "").strip(), + workflow_name=str(workflow.get("name") or "").strip(), + runner_type=workflow.get("runner_type"), + trigger_type=workflow.get("trigger_type"), + ) + return { + "workflow": workflow, + "message": f"Created workflow '{workflow.get('name', 'Workflow')}'.", + } + + +def add_conversation_message_for_current_user( + conversation_id: str, + content: str, + reply_to_message_id: str = "", +) -> Dict[str, Any]: + current_user_info = _require_current_user_info() + normalized_conversation_id = str(conversation_id or "").strip() + normalized_content = str(content or "").strip() + normalized_reply_to_message_id = str(reply_to_message_id or "").strip() or None + + if not normalized_conversation_id: + raise ValueError("conversation_id is required") + if not normalized_content: + raise ValueError("content is required") + + try: + conversation_item = cosmos_conversations_container.read_item( + item=normalized_conversation_id, + partition_key=normalized_conversation_id, + ) + except CosmosResourceNotFoundError: + conversation_item = None + + if conversation_item is not None: + if str(conversation_item.get("user_id") or "").strip() != current_user_info["userId"]: + raise PermissionError("Conversation not found or not accessible for the current user") + + message_doc, updated_conversation = _persist_personal_conversation_message( + conversation_item=conversation_item, + current_user_info=current_user_info, + content=normalized_content, + reply_to_message_id=normalized_reply_to_message_id, + ) + return { + "conversation": updated_conversation, + "message": message_doc, + "conversation_kind": "personal", + } + + current_user = normalize_collaboration_user(current_user_info) + if not current_user: + raise PermissionError("User not authenticated") + + try: + collaboration_conversation = get_collaboration_conversation(normalized_conversation_id) + except CosmosResourceNotFoundError as exc: + raise LookupError("Conversation was not found") from exc + + assert_user_can_participate_in_collaboration_conversation( + current_user["user_id"], + collaboration_conversation, + ) + message_doc, updated_conversation = persist_collaboration_message( + collaboration_conversation, + current_user, + normalized_content, + reply_to_message_id=normalized_reply_to_message_id, + ) + create_collaboration_message_notifications(updated_conversation, message_doc) + return { + "conversation": updated_conversation, + "message": message_doc, + "conversation_kind": "collaboration", + } + + +def upload_markdown_document_for_current_user( + file_name: str, + markdown_content: str, + workspace_scope: str = "personal", + group_id: str = "", + default_group_id: str = "", +) -> Dict[str, Any]: + current_user_info = _require_current_user_info() + current_user_id = current_user_info["userId"] + normalized_workspace_scope = _normalize_document_workspace_scope(workspace_scope) + normalized_file_name = _normalize_markdown_file_name(file_name) + raw_markdown_content = str(markdown_content or "") + + if not raw_markdown_content.strip(): + raise ValueError("markdown_content is required") + if not allowed_file(normalized_file_name, allowed_extensions={"md"}): + raise ValueError("Only Markdown files are supported") + + document_id = str(uuid.uuid4()) + encoded_markdown_content = raw_markdown_content.encode("utf-8") + temp_file_path = _write_temp_markdown_file(raw_markdown_content) + resolved_group_id = None + + try: + if normalized_workspace_scope == "group": + resolved_group_id = _resolve_group_upload_target_for_current_user( + current_user_id, + group_id=group_id, + default_group_id=default_group_id, + ) + create_document( + file_name=normalized_file_name, + group_id=resolved_group_id, + user_id=current_user_id, + document_id=document_id, + num_file_chunks=0, + status="Queued for processing", + ) + update_document( + document_id=document_id, + user_id=current_user_id, + group_id=resolved_group_id, + percentage_complete=0, + ) + else: + create_document( + file_name=normalized_file_name, + user_id=current_user_id, + document_id=document_id, + num_file_chunks=0, + status="Queued for processing", + ) + update_document( + document_id=document_id, + user_id=current_user_id, + percentage_complete=0, + ) + + _queue_document_upload_background_task( + document_id=document_id, + user_id=current_user_id, + temp_file_path=temp_file_path, + original_filename=normalized_file_name, + group_id=resolved_group_id, + ) + + if normalized_workspace_scope == "group": + invalidate_group_search_cache(resolved_group_id) + log_document_upload( + user_id=current_user_id, + container_type="group", + document_id=document_id, + file_size=len(encoded_markdown_content), + file_type=".md", + ) + else: + invalidate_personal_search_cache(current_user_id) + log_document_upload( + user_id=current_user_id, + container_type="personal", + document_id=document_id, + file_size=len(encoded_markdown_content), + file_type=".md", + ) + + return { + "document": { + "id": document_id, + "file_name": normalized_file_name, + "status": "Queued for processing", + "workspace_scope": normalized_workspace_scope, + "group_id": resolved_group_id, + }, + "message": f"Queued Markdown document '{normalized_file_name}' for processing.", + } + except Exception: + if temp_file_path and os.path.exists(temp_file_path): + os.remove(temp_file_path) + raise + + +def create_group_for_current_user(name: str, description: str = "") -> Dict[str, Any]: + settings = _require_group_workspaces_enabled() + _require_group_creation_enabled(settings) + normalized_name = str(name or "").strip() or "Untitled Group" + normalized_description = str(description or "").strip() + current_user = _require_current_user_info() + group_doc = create_group(normalized_name, normalized_description) + _notify_group_created(group_doc=group_doc, actor_user=current_user) + return group_doc + + +def make_group_inactive_for_current_user( + group_id: str = "", + reason: str = "", + default_group_id: str = "", +) -> Dict[str, Any]: + current_user_info = _require_current_user_info() + admin_session_user = _require_control_center_admin_access() + + resolved_group_id = str(group_id or default_group_id or "").strip() + if not resolved_group_id: + resolved_group_id = require_active_group(current_user_info["userId"]) + + group_doc = find_group_by_id(resolved_group_id) + if not group_doc: + raise LookupError("Group not found") + + old_status = str(group_doc.get("status") or "active").strip() or "active" + if old_status == "inactive": + return { + "group": group_doc, + "old_status": old_status, + "new_status": old_status, + "message": f"Group '{group_doc.get('name', 'Unknown')}' is already inactive.", + } + + changed_at = datetime.utcnow().isoformat() + changed_by_user_id = str(admin_session_user.get("oid") or current_user_info.get("userId") or "").strip() or "unknown" + changed_by_email = str( + admin_session_user.get("preferred_username") + or current_user_info.get("email") + or current_user_info.get("userPrincipalName") + or "" + ).strip() or "unknown" + normalized_reason = str(reason or "").strip() + + group_doc["status"] = "inactive" + group_doc["modifiedDate"] = changed_at + group_doc.setdefault("statusHistory", []).append( + { + "old_status": old_status, + "new_status": "inactive", + "changed_by_user_id": changed_by_user_id, + "changed_by_email": changed_by_email, + "changed_at": changed_at, + "reason": normalized_reason, + } + ) + updated_group_doc = cosmos_groups_container.upsert_item(group_doc) + + log_group_status_change( + group_id=resolved_group_id, + group_name=str(group_doc.get("name") or "Unknown").strip() or "Unknown", + old_status=old_status, + new_status="inactive", + changed_by_user_id=changed_by_user_id, + changed_by_email=changed_by_email, + reason=normalized_reason or None, + ) + log_event( + "[SimpleChat] Group marked inactive", + { + "group_id": resolved_group_id, + "group_name": group_doc.get("name"), + "old_status": old_status, + "new_status": "inactive", + "changed_by_user_id": changed_by_user_id, + "changed_by_email": changed_by_email, + "reason": normalized_reason, + }, + ) + + return { + "group": updated_group_doc, + "old_status": old_status, + "new_status": "inactive", + "message": f"Marked group '{group_doc.get('name', 'Unknown')}' as inactive.", + } + + +def create_group_collaboration_conversation_for_current_user( + title: str = "", + group_id: str = "", + default_group_id: str = "", +) -> Tuple[Dict[str, Any], Dict[str, Any], Dict[str, Any]]: + _require_collaboration_feature_enabled() + current_user_info = _require_current_user_info() + current_user = normalize_collaboration_user(current_user_info) + if not current_user: + raise PermissionError("User not authenticated") + + group_doc = _resolve_group_doc_for_current_user( + current_user_info["userId"], + group_id=group_id, + default_group_id=default_group_id, + allowed_roles=("Owner", "Admin", "DocumentManager", "User"), + missing_group_message="group_id is required for group collaborative conversations", + ) + allowed, reason = check_group_status_allows_operation(group_doc, "chat") + if not allowed: + raise PermissionError(reason) + + conversation_doc, _ = create_group_collaboration_conversation_record( + title=str(title or "").strip(), + creator_user=current_user, + group_doc=group_doc, + ) + _notify_group_conversation_created( + conversation_doc=conversation_doc, + group_doc=group_doc, + creator_user=current_user, + ) + return conversation_doc, current_user, group_doc + + +def invite_group_conversation_members_for_current_user( + conversation_id: str, + participants: Optional[Iterable[Dict[str, Any]]] = None, + participant_identifiers: Any = None, +) -> Dict[str, Any]: + _require_collaboration_feature_enabled() + current_user_info = _require_current_user_info() + current_user = normalize_collaboration_user(current_user_info) + if not current_user: + raise PermissionError("User not authenticated") + + normalized_conversation_id = str(conversation_id or "").strip() + if not normalized_conversation_id: + raise ValueError("conversation_id is required") + + conversation_doc = get_collaboration_conversation(normalized_conversation_id) + if not is_group_collaboration_conversation(conversation_doc): + raise ValueError("conversation_id must reference a group multi-user conversation") + + participants_to_add = _build_invited_participants( + creator_user=current_user, + participants=participants, + participant_identifiers=participant_identifiers, + ) + if not participants_to_add: + raise ValueError("At least one participant identifier is required") + + updated_conversation_doc, invited_state_docs = invite_personal_collaboration_participants( + normalized_conversation_id, + current_user["user_id"], + participants_to_add, + ) + + conversation_title = str((updated_conversation_doc or {}).get("title") or "Group Conversation").strip() or "Group Conversation" + scope = (updated_conversation_doc or {}).get("scope") if isinstance((updated_conversation_doc or {}).get("scope"), dict) else {} + group_id = str(scope.get("group_id") or "").strip() + group_name = str(scope.get("group_name") or "Group Workspace").strip() or "Group Workspace" + invited_participants = [ + { + "user_id": state_doc.get("user_id"), + "display_name": state_doc.get("user_display_name"), + "email": state_doc.get("user_email"), + "membership_status": state_doc.get("membership_status"), + } + for state_doc in invited_state_docs + ] + + if invited_participants: + message = ( + f"Invited {len(invited_participants)} current group member(s) to " + f"'{conversation_title}' in '{group_name}'." + ) + else: + message = ( + f"No new group members were invited to '{conversation_title}' in '{group_name}'." + ) + + return { + "conversation": updated_conversation_doc, + "group": { + "id": group_id, + "name": group_name, + }, + "invited_participants": invited_participants, + "message": message, + } + + +def create_personal_collaboration_conversation_for_current_user( + title: str = "", + participants: Optional[Iterable[Dict[str, Any]]] = None, + participant_identifiers: Any = None, +) -> Tuple[Dict[str, Any], List[Dict[str, Any]], Dict[str, Any]]: + _require_collaboration_feature_enabled() + current_user_info = _require_current_user_info() + creator_user = normalize_collaboration_user(current_user_info) + if not creator_user: + raise PermissionError("User not authenticated") + + invited_participants = _build_invited_participants( + creator_user=creator_user, + participants=participants, + participant_identifiers=participant_identifiers, + ) + conversation_doc, user_states = create_personal_collaboration_conversation_record( + title=str(title or "").strip(), + creator_user=creator_user, + invited_participants=invited_participants, + ) + _notify_personal_collaboration_conversation_created( + conversation_doc=conversation_doc, + creator_user=creator_user, + invited_participants=invited_participants, + ) + return conversation_doc, user_states, creator_user + + +def add_group_member_for_current_user( + group_id: str = "", + user_id: str = "", + user_identifier: str = "", + email: str = "", + display_name: str = "", + role: str = "user", + default_group_id: str = "", +) -> Dict[str, Any]: + current_user = _require_current_user_info() + group_doc = _resolve_group_doc_for_current_user( + current_user["userId"], + group_id=group_id, + default_group_id=default_group_id, + allowed_roles=("Owner", "Admin"), + missing_group_message="group_id is required when adding a user to a group", + ) + actor_role = get_user_role_in_group(group_doc, current_user["userId"]) + if actor_role not in ["Owner", "Admin"]: + raise PermissionError("Only the owner or admin can add members") + + member_role = str(role or "user").strip().lower() + valid_roles = ["admin", "document_manager", "user"] + if member_role not in valid_roles: + raise ValueError(f"Invalid role. Must be: {', '.join(valid_roles)}") + + resolved_user = resolve_directory_user( + user_id=user_id, + user_identifier=user_identifier, + email=email, + display_name=display_name, + ) + target_user_id = resolved_user["id"] + if get_user_role_in_group(group_doc, target_user_id): + raise ValueError("User is already a member") + + new_member_doc = { + "userId": target_user_id, + "email": resolved_user.get("email", ""), + "displayName": resolved_user.get("displayName") or resolved_user.get("email") or target_user_id, + } + group_doc.setdefault("users", []).append(new_member_doc) + + if member_role == "admin": + if target_user_id not in group_doc.get("admins", []): + group_doc.setdefault("admins", []).append(target_user_id) + elif member_role == "document_manager": + if target_user_id not in group_doc.get("documentManagers", []): + group_doc.setdefault("documentManagers", []).append(target_user_id) + + group_doc["modifiedDate"] = datetime.utcnow().isoformat() + updated_group_doc = cosmos_groups_container.upsert_item(group_doc) + + _log_group_member_addition( + actor_user=current_user, + actor_role=actor_role, + group_doc=group_doc, + member_doc=new_member_doc, + member_role=member_role, + ) + _notify_group_member_addition( + group_doc=group_doc, + member_doc=new_member_doc, + member_role=member_role, + added_by_email=current_user.get("email", "unknown"), + actor_user=current_user, + ) + + return { + "success": True, + "message": "Member added", + "group_id": group_doc.get("id"), + "group_name": group_doc.get("name", "Unknown"), + "member": new_member_doc, + "member_role": member_role, + "group": updated_group_doc, + } + + +def resolve_directory_user( + user_id: str = "", + user_identifier: str = "", + email: str = "", + display_name: str = "", +) -> Dict[str, str]: + normalized_user_id = str(user_id or "").strip() + normalized_identifier = str(user_identifier or "").strip() + normalized_email = str(email or "").strip() + normalized_display_name = str(display_name or "").strip() + + if normalized_user_id and (normalized_email or normalized_display_name): + return { + "id": normalized_user_id, + "displayName": normalized_display_name or normalized_email or normalized_user_id, + "email": normalized_email, + } + + if normalized_user_id: + try: + direct_match = _get_directory_user_by_id(normalized_user_id) + except PermissionError: + direct_match = None + if direct_match: + return direct_match + if not (normalized_identifier or normalized_email or normalized_display_name): + return { + "id": normalized_user_id, + "displayName": normalized_user_id, + "email": "", + } + + if normalized_email or "@" in normalized_identifier: + lookup_value = normalized_email or normalized_identifier + exact_matches = _find_directory_users_by_email(lookup_value) + if len(exact_matches) == 1: + return exact_matches[0] + if len(exact_matches) > 1: + raise ValueError(f"Multiple directory users matched '{lookup_value}'") + + lookup_query = normalized_identifier or normalized_display_name or normalized_email or normalized_user_id + if not lookup_query: + raise ValueError("Missing userId or user identifier") + + search_results = search_directory_users(lookup_query, limit=10) + if not search_results: + raise LookupError(f"User '{lookup_query}' was not found in the directory") + + exact_matches = [] + lowered_lookup = lookup_query.lower() + for candidate in search_results: + candidate_email = str(candidate.get("email") or "").strip().lower() + candidate_name = str(candidate.get("displayName") or "").strip().lower() + candidate_id = str(candidate.get("id") or "").strip().lower() + if lowered_lookup in {candidate_email, candidate_name, candidate_id}: + exact_matches.append(candidate) + + if len(exact_matches) == 1: + return exact_matches[0] + if len(exact_matches) > 1: + raise ValueError(f"Multiple directory users matched '{lookup_query}'") + if len(search_results) == 1: + return search_results[0] + + raise ValueError( + f"Multiple directory users matched '{lookup_query}'. Provide a more specific email or user ID." + ) + + +def search_directory_users(query: str, limit: int = 10) -> List[Dict[str, str]]: + normalized_query = str(query or "").strip() + if not normalized_query: + return [] + + escaped_query = _escape_odata_value(normalized_query) + payload = _graph_get_json( + "/users", + params={ + "$filter": ( + f"startswith(displayName, '{escaped_query}') " + f"or startswith(mail, '{escaped_query}') " + f"or startswith(userPrincipalName, '{escaped_query}')" + ), + "$top": max(1, min(int(limit or 10), 25)), + "$select": "id,displayName,mail,userPrincipalName", + }, + ) + return _normalize_directory_users(payload.get("value", [])) + + +def _build_invited_participants( + creator_user: Dict[str, str], + participants: Optional[Iterable[Dict[str, Any]]] = None, + participant_identifiers: Any = None, +) -> List[Dict[str, str]]: + invited_participants: List[Dict[str, str]] = [] + seen_user_ids = {creator_user.get("user_id")} + + for raw_participant in participants or []: + normalized_participant = normalize_collaboration_user(raw_participant) + if not normalized_participant: + continue + participant_user_id = normalized_participant.get("user_id") + if participant_user_id in seen_user_ids: + continue + seen_user_ids.add(participant_user_id) + invited_participants.append(normalized_participant) + + for raw_identifier in _split_participant_identifiers(participant_identifiers): + resolved_user = resolve_directory_user(user_identifier=raw_identifier) + normalized_participant = normalize_collaboration_user(resolved_user) + if not normalized_participant: + continue + participant_user_id = normalized_participant.get("user_id") + if participant_user_id in seen_user_ids: + continue + seen_user_ids.add(participant_user_id) + invited_participants.append(normalized_participant) + + return invited_participants + + +def _split_participant_identifiers(raw_identifiers: Any) -> List[str]: + if raw_identifiers is None: + return [] + if isinstance(raw_identifiers, str): + values = re.split(r"[,;\n]+", raw_identifiers) + elif isinstance(raw_identifiers, (list, tuple, set)): + values = [] + for item in raw_identifiers: + if isinstance(item, str): + values.extend(re.split(r"[,;\n]+", item)) + else: + values = [str(raw_identifiers)] + + return [str(value or "").strip() for value in values if str(value or "").strip()] + + +def _persist_personal_conversation_message( + conversation_item: Dict[str, Any], + current_user_info: Dict[str, str], + content: str, + reply_to_message_id: Optional[str] = None, +) -> Tuple[Dict[str, Any], Dict[str, Any]]: + conversation_id = str(conversation_item.get("id") or "").strip() + if not conversation_id: + raise ValueError("Conversation is missing an id") + + timestamp = datetime.now(timezone.utc).isoformat() + current_thread_id = str(uuid.uuid4()) + previous_thread_id = _get_latest_personal_thread_id(conversation_id) + normalized_chat_type = str(conversation_item.get("chat_type") or "personal_single_user").strip() or "personal_single_user" + + message_doc = { + "id": f"{conversation_id}_user_{uuid.uuid4().hex}", + "conversation_id": conversation_id, + "role": "user", + "content": str(content or "").strip(), + "reply_to_message_id": reply_to_message_id, + "timestamp": timestamp, + "model_deployment_name": None, + "metadata": { + "user_info": { + "user_id": current_user_info.get("userId"), + "username": current_user_info.get("userPrincipalName"), + "display_name": current_user_info.get("displayName"), + "email": current_user_info.get("email"), + "timestamp": timestamp, + }, + "button_states": { + "image_generation": False, + "document_search": False, + "web_search": False, + }, + "workspace_search": { + "search_enabled": False, + }, + "chat_context": { + "conversation_id": conversation_id, + "chat_type": normalized_chat_type, + }, + "thread_info": { + "thread_id": current_thread_id, + "previous_thread_id": previous_thread_id, + "active_thread": True, + "thread_attempt": 1, + }, + }, + } + + cosmos_messages_container.upsert_item(message_doc) + + conversation_item["chat_type"] = normalized_chat_type + if str(conversation_item.get("title") or "").strip() in {"", "New Conversation"}: + conversation_item["title"] = _derive_personal_conversation_title(message_doc["content"]) + conversation_item["last_updated"] = timestamp + cosmos_conversations_container.upsert_item(conversation_item) + + log_chat_activity( + user_id=current_user_info["userId"], + conversation_id=conversation_id, + message_type="user_message", + message_length=len(message_doc["content"]), + has_document_search=False, + has_image_generation=False, + chat_context=normalized_chat_type, + ) + + return message_doc, conversation_item + + +def _get_latest_personal_thread_id(conversation_id: str) -> Optional[str]: + query = ( + "SELECT TOP 1 c.metadata.thread_info.thread_id AS thread_id " + "FROM c WHERE c.conversation_id = @conversation_id ORDER BY c.timestamp DESC" + ) + items = list(cosmos_messages_container.query_items( + query=query, + parameters=[{"name": "@conversation_id", "value": conversation_id}], + partition_key=conversation_id, + )) + if not items: + return None + return str(items[0].get("thread_id") or "").strip() or None + + +def _normalize_document_workspace_scope(workspace_scope: str = "personal") -> str: + normalized_workspace_scope = str(workspace_scope or "personal").strip().lower() + if normalized_workspace_scope not in {"personal", "group"}: + raise ValueError("workspace_scope must be 'personal' or 'group'") + return normalized_workspace_scope + + +def _normalize_markdown_file_name(file_name: str) -> str: + normalized_file_name = str(file_name or "").replace("\\", "/").split("/")[-1].strip() + if not normalized_file_name: + normalized_file_name = "generated_markdown_document" + + base_name, extension = os.path.splitext(normalized_file_name) + if extension.lower() == ".md" and base_name.strip(): + return normalized_file_name + + normalized_base_name = base_name.strip() or normalized_file_name.strip() or "generated_markdown_document" + return f"{normalized_base_name}.md" + + +def _write_temp_markdown_file(markdown_content: str) -> str: + sc_temp_files_dir = "/sc-temp-files" if os.path.exists("/sc-temp-files") else None + with tempfile.NamedTemporaryFile(delete=False, suffix=".md", dir=sc_temp_files_dir) as temp_file: + temp_file.write(str(markdown_content or "").encode("utf-8")) + return temp_file.name + + +def _queue_document_upload_background_task( + document_id: str, + user_id: str, + temp_file_path: str, + original_filename: str, + group_id: Optional[str] = None, +) -> None: + task_kwargs = { + "document_id": document_id, + "user_id": user_id, + "temp_file_path": temp_file_path, + "original_filename": original_filename, + } + if group_id: + task_kwargs["group_id"] = group_id + + if not has_app_context(): + raise RuntimeError("SimpleChat document uploads require an active app context") + + executor = current_app.extensions.get("executor") + if executor and hasattr(executor, "submit_stored"): + executor.submit_stored( + document_id, + process_document_upload_background, + **task_kwargs, + ) + return + + if executor and hasattr(executor, "submit"): + executor.submit(process_document_upload_background, **task_kwargs) + return + + process_document_upload_background(**task_kwargs) + + +def _resolve_group_upload_target_for_current_user( + current_user_id: str, + group_id: str = "", + default_group_id: str = "", +) -> str: + normalized_group_id = str(group_id or default_group_id or "").strip() + if not normalized_group_id: + normalized_group_id = require_active_group(current_user_id) + + group_doc = find_group_by_id(normalized_group_id) + if not group_doc: + raise LookupError("Group not found") + + allowed, reason = check_group_status_allows_operation(group_doc, "upload") + if not allowed: + raise PermissionError(reason) + + assert_group_role( + current_user_id, + normalized_group_id, + allowed_roles=("Owner", "Admin", "DocumentManager"), + ) + return normalized_group_id + + +def _derive_personal_conversation_title(content: str) -> str: + normalized_content = str(content or "").strip() + if not normalized_content: + return "New Conversation" + return f"{normalized_content[:30]}..." if len(normalized_content) > 30 else normalized_content + + +def _resolve_group_doc_for_current_user( + current_user_id: str, + group_id: str = "", + default_group_id: str = "", + allowed_roles: Tuple[str, ...] = ("Owner", "Admin", "DocumentManager", "User"), + missing_group_message: str = "group_id is required", +) -> Dict[str, Any]: + _require_group_workspaces_enabled() + resolved_group_id = str(group_id or default_group_id or "").strip() + if not resolved_group_id: + user_settings = get_user_settings(current_user_id) or {} + resolved_group_id = str(((user_settings.get("settings") or {}).get("activeGroupOid") or "")).strip() + + if not resolved_group_id: + raise ValueError(missing_group_message) + + group_doc = find_group_by_id(resolved_group_id) + if not group_doc: + raise LookupError("Group not found") + + assert_group_role(current_user_id, resolved_group_id, allowed_roles=allowed_roles) + return group_doc + + +def _require_current_user_info() -> Dict[str, str]: + current_user = get_current_user_info() + if not current_user or not current_user.get("userId"): + raise PermissionError("User not authenticated") + return current_user + + +def _require_group_workspaces_enabled() -> Dict[str, Any]: + settings = get_settings() or {} + if not settings.get("enable_group_workspaces", False): + raise PermissionError("Group workspaces are disabled by configuration") + return settings + + +def _require_group_creation_enabled(settings: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + settings = settings or get_settings() or {} + if not settings.get("enable_group_creation", False): + raise PermissionError("Group creation is disabled by configuration") + + if settings.get("require_member_of_create_group", False): + user_roles = (session.get("user") or {}).get("roles") or [] + if "CreateGroups" not in user_roles: + raise PermissionError("Insufficient permissions (CreateGroups role required)") + return settings + + +def _require_user_workflows_enabled() -> Dict[str, Any]: + settings = get_settings() or {} + if not settings.get("allow_user_workflows", True): + raise PermissionError("Personal workflows are disabled by configuration") + return settings + + +def _require_control_center_admin_access(settings: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + settings = settings or get_settings() or {} + session_user = (session.get("user") or {}) + user_roles = session_user.get("roles") or [] + require_member_of_control_center_admin = settings.get("require_member_of_control_center_admin", False) + + has_control_center_admin_role = "ControlCenterAdmin" in user_roles + has_regular_admin_role = "Admin" in user_roles + + if require_member_of_control_center_admin: + if not has_control_center_admin_role: + raise PermissionError("Insufficient permissions (ControlCenterAdmin role required)") + return session_user + + if not has_regular_admin_role: + raise PermissionError("Insufficient permissions (Admin role required)") + return session_user + + +def _require_collaboration_feature_enabled() -> Dict[str, Any]: + settings = get_settings() or {} + if not settings.get("enable_collaborative_conversations", False): + raise PermissionError("Collaborative conversations are disabled by configuration") + return settings + + +def _graph_get_json(path: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + token = get_valid_access_token() + if not token: + raise PermissionError("Could not acquire access token") + + response = requests.get( + get_graph_endpoint(path), + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + }, + params=params, + timeout=20, + ) + response.raise_for_status() + return response.json() + + +def _get_directory_user_by_id(user_id: str) -> Optional[Dict[str, str]]: + normalized_user_id = str(user_id or "").strip() + if not normalized_user_id: + return None + + token = get_valid_access_token() + if not token: + raise PermissionError("Could not acquire access token") + + response = requests.get( + get_graph_endpoint(f"/users/{quote(normalized_user_id)}"), + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + }, + params={"$select": "id,displayName,mail,userPrincipalName"}, + timeout=20, + ) + if response.status_code == 404: + return None + response.raise_for_status() + return _normalize_directory_user(response.json()) + + +def _find_directory_users_by_email(email: str) -> List[Dict[str, str]]: + normalized_email = str(email or "").strip() + if not normalized_email: + return [] + + escaped_email = _escape_odata_value(normalized_email) + payload = _graph_get_json( + "/users", + params={ + "$filter": f"mail eq '{escaped_email}' or userPrincipalName eq '{escaped_email}'", + "$top": 5, + "$select": "id,displayName,mail,userPrincipalName", + }, + ) + return _normalize_directory_users(payload.get("value", [])) + + +def _normalize_directory_users(raw_users: Iterable[Dict[str, Any]]) -> List[Dict[str, str]]: + normalized_users = [] + for raw_user in raw_users or []: + normalized_user = _normalize_directory_user(raw_user) + if normalized_user: + normalized_users.append(normalized_user) + return normalized_users + + +def _normalize_directory_user(raw_user: Dict[str, Any]) -> Optional[Dict[str, str]]: + if not isinstance(raw_user, dict): + return None + + user_id = str(raw_user.get("id") or "").strip() + if not user_id: + return None + + email = str(raw_user.get("mail") or raw_user.get("userPrincipalName") or "").strip() + display_name = str(raw_user.get("displayName") or email or user_id).strip() + return { + "id": user_id, + "displayName": display_name, + "email": email, + } + + +def _escape_odata_value(value: str) -> str: + return str(value or "").replace("'", "''").strip() + + +def _log_group_member_addition( + actor_user: Dict[str, str], + actor_role: str, + group_doc: Dict[str, Any], + member_doc: Dict[str, str], + member_role: str, +) -> None: + try: + activity_record = { + "id": str(uuid.uuid4()), + "activity_type": "add_member_directly", + "timestamp": datetime.utcnow().isoformat(), + "added_by_user_id": actor_user.get("userId"), + "added_by_email": actor_user.get("email", "unknown"), + "added_by_role": actor_role, + "group_id": group_doc.get("id"), + "group_name": group_doc.get("name", "Unknown"), + "member_user_id": member_doc.get("userId", ""), + "member_email": member_doc.get("email", ""), + "member_name": member_doc.get("displayName", ""), + "member_role": member_role, + "description": ( + f"{actor_role} {actor_user.get('email', 'unknown')} added member " + f"{member_doc.get('displayName', '')} ({member_doc.get('email', '')}) to group " + f"{group_doc.get('name', group_doc.get('id', 'Unknown'))} as {member_role}" + ), + } + cosmos_activity_logs_container.create_item(body=activity_record) + except Exception as exc: + log_event( + f"[SimpleChat] Failed to log group member addition: {exc}", + level=logging.WARNING, + exceptionTraceback=True, + ) + + +def _notify_group_member_addition( + group_doc: Dict[str, Any], + member_doc: Dict[str, str], + member_role: str, + added_by_email: str, + actor_user: Optional[Dict[str, str]] = None, +) -> None: + role_display = { + "admin": "Admin", + "document_manager": "Document Manager", + "user": "Member", + }.get(member_role, "Member") + + try: + create_notification( + user_id=member_doc.get("userId", ""), + notification_type="group_member_added", + title="Added to Group", + message=( + f"You have been added to the group '{group_doc.get('name', 'Unknown')}' " + f"as {role_display} by {added_by_email}." + ), + link_url=f"/manage_group/{group_doc.get('id', '')}", + link_context={ + "workspace_type": "group", + "group_id": group_doc.get("id", ""), + }, + metadata={ + "group_id": group_doc.get("id", ""), + "group_name": group_doc.get("name", "Unknown"), + "added_by": added_by_email, + "role": member_role, + "audience": "member", + }, + ) + except Exception as exc: + log_event( + f"[SimpleChat] Failed to notify group member addition: {exc}", + level=logging.WARNING, + exceptionTraceback=True, + ) + + actor_user_id = str((actor_user or {}).get("userId") or "").strip() + if not actor_user_id or actor_user_id == str(member_doc.get("userId") or "").strip(): + return + + try: + create_notification( + user_id=actor_user_id, + notification_type="group_member_added", + title="Group member added", + message=( + f"Added {member_doc.get('displayName', 'a new member')} to '{group_doc.get('name', 'Unknown')}' " + f"as {role_display}." + ), + link_url=f"/manage_group/{group_doc.get('id', '')}", + link_context={ + "workspace_type": "group", + "group_id": group_doc.get("id", ""), + }, + metadata={ + "group_id": group_doc.get("id", ""), + "group_name": group_doc.get("name", "Unknown"), + "member_user_id": member_doc.get("userId", ""), + "member_email": member_doc.get("email", ""), + "member_display_name": member_doc.get("displayName", ""), + "role": member_role, + "audience": "actor", + }, + ) + except Exception as exc: + log_event( + f"[SimpleChat] Failed to notify actor about group member addition: {exc}", + level=logging.WARNING, + exceptionTraceback=True, + ) + + +def _create_personal_notification( + user_id: str, + notification_type: str, + title: str, + message: str, + link_url: str = "", + link_context: Optional[Dict[str, Any]] = None, + metadata: Optional[Dict[str, Any]] = None, +) -> Optional[Dict[str, Any]]: + normalized_user_id = str(user_id or "").strip() + if not normalized_user_id: + return None + + try: + return create_notification( + user_id=normalized_user_id, + notification_type=notification_type, + title=title, + message=message, + link_url=link_url, + link_context=link_context or {}, + metadata=metadata or {}, + ) + except Exception as exc: + log_event( + f"[SimpleChat] Failed to create notification '{notification_type}': {exc}", + level=logging.WARNING, + exceptionTraceback=True, + ) + return None + + +def _build_group_link_context(group_doc: Dict[str, Any]) -> Dict[str, Any]: + return { + "workspace_type": "group", + "group_id": str((group_doc or {}).get("id") or "").strip(), + } + + +def _build_conversation_link_context(conversation_doc: Dict[str, Any]) -> Dict[str, Any]: + conversation_doc = conversation_doc if isinstance(conversation_doc, dict) else {} + scope = conversation_doc.get("scope") if isinstance(conversation_doc.get("scope"), dict) else {} + group_id = str(scope.get("group_id") or conversation_doc.get("group_id") or "").strip() + chat_type = str(conversation_doc.get("chat_type") or "").strip().lower() + + link_context = { + "conversation_id": str(conversation_doc.get("id") or "").strip(), + "workspace_type": "group" if group_id or chat_type.startswith("group") else "personal", + } + if group_id: + link_context["group_id"] = group_id + if conversation_doc.get("conversation_kind"): + link_context["conversation_kind"] = conversation_doc.get("conversation_kind") + return link_context + + +def _build_conversation_link_url(conversation_doc: Dict[str, Any]) -> str: + conversation_id = str((conversation_doc or {}).get("id") or "").strip() + if not conversation_id: + return "" + return f"/chats?conversationId={conversation_id}" + + +def _get_group_notification_recipient_ids(group_doc: Dict[str, Any]) -> List[str]: + recipient_ids = set() + owner_user_id = str(((group_doc or {}).get("owner") or {}).get("id") or "").strip() + if owner_user_id: + recipient_ids.add(owner_user_id) + + for member in list((group_doc or {}).get("users", []) or []): + member_user_id = str(member.get("userId") or "").strip() + if member_user_id: + recipient_ids.add(member_user_id) + + return sorted(recipient_ids) + + +def _notify_group_created(group_doc: Dict[str, Any], actor_user: Dict[str, str]) -> None: + group_id = str((group_doc or {}).get("id") or "").strip() + group_name = str((group_doc or {}).get("name") or "Untitled Group").strip() or "Untitled Group" + actor_user_id = str((actor_user or {}).get("userId") or "").strip() + if not group_id or not actor_user_id: + return + + _create_personal_notification( + user_id=actor_user_id, + notification_type="group_created", + title=f"Group created: {group_name}", + message=f"You created the group '{group_name}'.", + link_url=f"/manage_group/{group_id}", + link_context=_build_group_link_context(group_doc), + metadata={ + "group_id": group_id, + "group_name": group_name, + }, + ) + + +def _notify_personal_conversation_created( + conversation_item: Dict[str, Any], + current_user: Dict[str, str], +) -> None: + conversation_title = str((conversation_item or {}).get("title") or "New Conversation").strip() or "New Conversation" + _create_personal_notification( + user_id=str((current_user or {}).get("userId") or "").strip(), + notification_type="conversation_created", + title=f"Conversation created: {conversation_title}", + message=f"Created a new personal conversation named '{conversation_title}'.", + link_url=_build_conversation_link_url(conversation_item), + link_context=_build_conversation_link_context(conversation_item), + metadata={ + "conversation_id": str((conversation_item or {}).get("id") or "").strip(), + "conversation_title": conversation_title, + "chat_type": str((conversation_item or {}).get("chat_type") or "").strip(), + "audience": "actor", + }, + ) + + +def _notify_group_conversation_created( + conversation_doc: Dict[str, Any], + group_doc: Dict[str, Any], + creator_user: Dict[str, str], +) -> None: + conversation_title = str((conversation_doc or {}).get("title") or "New group conversation").strip() or "New group conversation" + group_name = str((group_doc or {}).get("name") or "Group Workspace").strip() or "Group Workspace" + creator_display_name = str((creator_user or {}).get("display_name") or (creator_user or {}).get("displayName") or "A teammate").strip() or "A teammate" + link_url = _build_conversation_link_url(conversation_doc) + link_context = _build_conversation_link_context(conversation_doc) + metadata = { + "group_id": str((group_doc or {}).get("id") or "").strip(), + "group_name": group_name, + "conversation_id": str((conversation_doc or {}).get("id") or "").strip(), + "conversation_title": conversation_title, + "chat_type": str((conversation_doc or {}).get("chat_type") or "").strip(), + } + + for recipient_user_id in _get_group_notification_recipient_ids(group_doc): + audience = "actor" if recipient_user_id == str((creator_user or {}).get("user_id") or "").strip() else "member" + if audience == "actor": + title = f"Group conversation created: {conversation_title}" + message = f"You created '{conversation_title}' in '{group_name}'." + else: + title = f"New group conversation in {group_name}" + message = f"{creator_display_name} created '{conversation_title}' in '{group_name}'." + + _create_personal_notification( + user_id=recipient_user_id, + notification_type="conversation_created", + title=title, + message=message, + link_url=link_url, + link_context=link_context, + metadata={ + **metadata, + "audience": audience, + }, + ) + + +def _notify_personal_collaboration_conversation_created( + conversation_doc: Dict[str, Any], + creator_user: Dict[str, str], + invited_participants: Optional[Iterable[Dict[str, Any]]] = None, +) -> None: + conversation_title = str((conversation_doc or {}).get("title") or "Collaborative conversation").strip() or "Collaborative conversation" + creator_user_id = str((creator_user or {}).get("user_id") or "").strip() + creator_display_name = str((creator_user or {}).get("display_name") or "You").strip() or "You" + link_url = _build_conversation_link_url(conversation_doc) + link_context = _build_conversation_link_context(conversation_doc) + base_metadata = { + "conversation_id": str((conversation_doc or {}).get("id") or "").strip(), + "conversation_title": conversation_title, + "chat_type": str((conversation_doc or {}).get("chat_type") or "").strip(), + "participant_count": len(list(invited_participants or [])) + 1, + } + + _create_personal_notification( + user_id=creator_user_id, + notification_type="conversation_created", + title=f"Collaborative conversation created: {conversation_title}", + message=( + f"Created '{conversation_title}'" + f" with {len(list(invited_participants or []))} invited participant(s)." + ), + link_url=link_url, + link_context=link_context, + metadata={ + **base_metadata, + "audience": "actor", + }, + ) + + for participant in invited_participants or []: + participant_user_id = str((participant or {}).get("user_id") or "").strip() + if not participant_user_id or participant_user_id == creator_user_id: + continue + + _create_personal_notification( + user_id=participant_user_id, + notification_type="conversation_created", + title=f"Added to collaborative conversation: {conversation_title}", + message=f"{creator_display_name} added you to '{conversation_title}'.", + link_url=link_url, + link_context=link_context, + metadata={ + **base_metadata, + "created_by_user_id": creator_user_id, + "created_by_display_name": creator_display_name, + "audience": "participant", + }, + ) \ No newline at end of file diff --git a/application/single_app/functions_thoughts.py b/application/single_app/functions_thoughts.py index 7d20441b..91df9b98 100644 --- a/application/single_app/functions_thoughts.py +++ b/application/single_app/functions_thoughts.py @@ -18,16 +18,16 @@ class ThoughtTracker: interrupt the chat processing flow. """ - def __init__(self, conversation_id, message_id, thread_id, user_id): + def __init__(self, conversation_id, message_id, thread_id, user_id, force_enabled=False): self.conversation_id = conversation_id self.message_id = message_id self.thread_id = thread_id self.user_id = user_id self.current_index = 0 settings = get_settings() - self.enabled = settings.get('enable_thoughts', True) + self.enabled = force_enabled or settings.get('enable_thoughts', True) - def add_thought(self, step_type, content, detail=None): + def add_thought(self, step_type, content, detail=None, activity=None): """Write a thought step to Cosmos immediately. Args: @@ -56,6 +56,8 @@ def add_thought(self, step_type, content, detail=None): 'duration_ms': None, 'timestamp': datetime.now(timezone.utc).isoformat() } + if isinstance(activity, dict) and activity: + thought_doc['activity'] = dict(activity) self.current_index += 1 try: diff --git a/application/single_app/functions_workflow_activity.py b/application/single_app/functions_workflow_activity.py new file mode 100644 index 00000000..0caeadbf --- /dev/null +++ b/application/single_app/functions_workflow_activity.py @@ -0,0 +1,262 @@ +# functions_workflow_activity.py + +"""Helpers for building workflow activity timeline snapshots.""" + +from datetime import datetime, timezone + + +def _normalize_text(value): + return str(value or '').strip() + + +def _coerce_datetime(value): + normalized_value = _normalize_text(value) + if not normalized_value: + return None + + try: + return datetime.fromisoformat(normalized_value.replace('Z', '+00:00')) + except ValueError: + return None + + +def _normalize_duration_ms(value): + if value in (None, ''): + return None + + try: + return int(round(float(value))) + except (TypeError, ValueError): + return None + + +def _normalize_status(value): + normalized_value = _normalize_text(value).lower() + if normalized_value in {'running', 'pending', 'in_progress', 'in-progress'}: + return 'running' + if normalized_value in {'failed', 'error', 'cancelled', 'canceled'}: + return 'failed' + if normalized_value in {'completed', 'complete', 'succeeded', 'success', 'done'}: + return 'completed' + if normalized_value: + return normalized_value + return 'completed' + + +def _serialize_workflow(workflow): + if not isinstance(workflow, dict): + return None + + return { + 'id': workflow.get('id'), + 'name': workflow.get('name'), + 'description': workflow.get('description'), + 'runner_type': workflow.get('runner_type'), + 'trigger_type': workflow.get('trigger_type'), + 'alert_priority': workflow.get('alert_priority'), + 'conversation_id': workflow.get('conversation_id'), + } + + +def _serialize_conversation(conversation): + if not isinstance(conversation, dict): + return None + + return { + 'id': conversation.get('id'), + 'title': conversation.get('title'), + 'chat_type': conversation.get('chat_type'), + 'workflow_id': conversation.get('workflow_id'), + 'last_updated': conversation.get('last_updated'), + } + + +def _serialize_run(run_record): + if not isinstance(run_record, dict): + return None + + return { + 'id': run_record.get('id'), + 'workflow_id': run_record.get('workflow_id'), + 'workflow_name': run_record.get('workflow_name'), + 'runner_type': run_record.get('runner_type'), + 'trigger_source': run_record.get('trigger_source'), + 'status': run_record.get('status'), + 'success': bool(run_record.get('success')), + 'started_at': run_record.get('started_at'), + 'completed_at': run_record.get('completed_at'), + 'conversation_id': run_record.get('conversation_id'), + 'user_message_id': run_record.get('user_message_id'), + 'assistant_message_id': run_record.get('assistant_message_id'), + 'model_deployment_name': run_record.get('model_deployment_name'), + 'agent_name': run_record.get('agent_name'), + 'agent_display_name': run_record.get('agent_display_name'), + 'response_preview': run_record.get('response_preview'), + 'error': run_record.get('error'), + } + + +def _build_activity_event(thought, activity_payload): + return { + 'thought_id': thought.get('id'), + 'step_index': thought.get('step_index'), + 'step_type': thought.get('step_type'), + 'state': _normalize_status(activity_payload.get('status') or activity_payload.get('state')), + 'content': thought.get('content'), + 'detail': thought.get('detail'), + 'timestamp': thought.get('timestamp'), + 'duration_ms': _normalize_duration_ms(thought.get('duration_ms')), + } + + +def _initialize_activity_record(activity_key, thought, activity_payload, order_index): + lane_key = _normalize_text(activity_payload.get('lane_key')) or 'main' + lane_label = _normalize_text(activity_payload.get('lane_label')) or lane_key.replace('_', ' ').title() + title = _normalize_text(activity_payload.get('title')) or _normalize_text(thought.get('content')) or 'Workflow activity' + summary = _normalize_text(activity_payload.get('summary')) or _normalize_text(thought.get('content')) or title + detail = _normalize_text(thought.get('detail')) or _normalize_text(activity_payload.get('detail')) + status = _normalize_status(activity_payload.get('status') or activity_payload.get('state')) + + record = { + 'id': activity_key, + 'title': title, + 'summary': summary, + 'detail': detail, + 'kind': _normalize_text(activity_payload.get('kind')) or 'thought', + 'status': status, + 'lane_key': lane_key, + 'lane_label': lane_label, + 'plugin_name': _normalize_text(activity_payload.get('plugin_name')) or None, + 'function_name': _normalize_text(activity_payload.get('function_name')) or None, + 'run_id': _normalize_text(activity_payload.get('run_id')) or None, + 'workflow_id': _normalize_text(activity_payload.get('workflow_id')) or None, + 'started_at': thought.get('timestamp'), + 'completed_at': thought.get('timestamp') if status in {'completed', 'failed'} else None, + 'duration_ms': _normalize_duration_ms(thought.get('duration_ms')), + 'timestamp': thought.get('timestamp'), + 'step_index': thought.get('step_index'), + 'events': [], + 'order_index': order_index, + } + record['events'].append(_build_activity_event(thought, activity_payload)) + return record + + +def _merge_activity_record(record, thought, activity_payload): + thought_timestamp = thought.get('timestamp') + thought_datetime = _coerce_datetime(thought_timestamp) + record_started_at = _coerce_datetime(record.get('started_at')) + record_completed_at = _coerce_datetime(record.get('completed_at')) + + summary = _normalize_text(activity_payload.get('summary')) or _normalize_text(thought.get('content')) + detail = _normalize_text(thought.get('detail')) or _normalize_text(activity_payload.get('detail')) + status = _normalize_status(activity_payload.get('status') or activity_payload.get('state')) + duration_ms = _normalize_duration_ms(thought.get('duration_ms')) + + if summary: + record['summary'] = summary + if detail: + record['detail'] = detail + if status: + record['status'] = status + if duration_ms is not None: + record['duration_ms'] = duration_ms + + if thought_datetime and (record_started_at is None or thought_datetime < record_started_at): + record['started_at'] = thought_timestamp + if thought_datetime and status in {'completed', 'failed'}: + if record_completed_at is None or thought_datetime >= record_completed_at: + record['completed_at'] = thought_timestamp + + if thought.get('step_index') is not None: + existing_index = record.get('step_index') + if existing_index is None or thought.get('step_index') < existing_index: + record['step_index'] = thought.get('step_index') + + record['timestamp'] = thought_timestamp or record.get('timestamp') + record['events'].append(_build_activity_event(thought, activity_payload)) + + +def _build_fallback_activity(run_record, workflow): + workflow_name = _normalize_text((workflow or {}).get('name') or (run_record or {}).get('workflow_name')) or 'Workflow' + run_status = _normalize_status((run_record or {}).get('status')) + error_text = _normalize_text((run_record or {}).get('error')) + response_preview = _normalize_text((run_record or {}).get('response_preview')) + detail = error_text or response_preview or 'No structured activity was captured for this run.' + + return { + 'id': f"run:{_normalize_text((run_record or {}).get('id')) or 'unknown'}:fallback", + 'title': workflow_name, + 'summary': 'Workflow run summary', + 'detail': detail, + 'kind': 'workflow_run', + 'status': run_status or 'completed', + 'lane_key': 'main', + 'lane_label': 'Main', + 'plugin_name': None, + 'function_name': None, + 'run_id': _normalize_text((run_record or {}).get('id')) or None, + 'workflow_id': _normalize_text((run_record or {}).get('workflow_id')) or _normalize_text((workflow or {}).get('id')) or None, + 'started_at': (run_record or {}).get('started_at'), + 'completed_at': (run_record or {}).get('completed_at'), + 'duration_ms': None, + 'timestamp': (run_record or {}).get('completed_at') or (run_record or {}).get('started_at'), + 'step_index': 0, + 'lane_index': 0, + 'events': [], + } + + +def build_workflow_activity_snapshot(run_record=None, workflow=None, conversation=None, thoughts=None): + """Build a frontend-friendly workflow activity snapshot.""" + thoughts = thoughts if isinstance(thoughts, list) else [] + + sorted_thoughts = sorted( + thoughts, + key=lambda thought: ( + _coerce_datetime(thought.get('timestamp')) or datetime.min.replace(tzinfo=timezone.utc), + thought.get('step_index') if thought.get('step_index') is not None else 0, + ), + ) + + activity_records = {} + lane_order = [] + + for order_index, thought in enumerate(sorted_thoughts): + activity_payload = thought.get('activity') if isinstance(thought.get('activity'), dict) else {} + activity_key = _normalize_text(activity_payload.get('activity_key')) or _normalize_text(thought.get('id')) or f'thought:{order_index}' + lane_key = _normalize_text(activity_payload.get('lane_key')) or 'main' + + if lane_key not in lane_order: + lane_order.append(lane_key) + + if activity_key not in activity_records: + activity_records[activity_key] = _initialize_activity_record(activity_key, thought, activity_payload, order_index) + continue + + _merge_activity_record(activity_records[activity_key], thought, activity_payload) + + activities = sorted( + activity_records.values(), + key=lambda activity: ( + activity.get('order_index', 0), + _coerce_datetime(activity.get('started_at')) or datetime.max.replace(tzinfo=timezone.utc), + ), + ) + + if not activities and isinstance(run_record, dict): + activities = [_build_fallback_activity(run_record, workflow)] + lane_order = ['main'] + + for activity in activities: + activity['lane_index'] = lane_order.index(activity.get('lane_key')) if activity.get('lane_key') in lane_order else 0 + activity.pop('order_index', None) + + return { + 'workflow': _serialize_workflow(workflow), + 'conversation': _serialize_conversation(conversation), + 'run': _serialize_run(run_record), + 'activities': activities, + 'lane_count': max(1, len(lane_order) or 1), + 'live': _normalize_status((run_record or {}).get('status')) == 'running', + } \ No newline at end of file diff --git a/application/single_app/functions_workflow_runner.py b/application/single_app/functions_workflow_runner.py new file mode 100644 index 00000000..e16ec460 --- /dev/null +++ b/application/single_app/functions_workflow_runner.py @@ -0,0 +1,2391 @@ +# functions_workflow_runner.py +""" +Workflow execution helpers for personal workflows. +""" + +import asyncio +import logging +import re +import uuid +from contextlib import contextmanager +from datetime import datetime, timezone + +from azure.identity import ( + AzureAuthorityHosts, + ClientSecretCredential, + DefaultAzureCredential, + get_bearer_token_provider, +) +from flask import Flask, g, has_request_context, session +from openai import AzureOpenAI +from semantic_kernel import Kernel +from semantic_kernel.contents.chat_message_content import ChatMessageContent + +from collaboration_models import ( + COLLABORATION_KIND, + GROUP_MULTI_USER_CHAT_TYPE, + PERSONAL_MULTI_USER_CHAT_TYPE, + normalize_collaboration_user, +) +from config import ( + SECRET_KEY, + cognitive_services_scope, + cosmos_conversations_container, + cosmos_messages_container, +) +from functions_activity_logging import log_conversation_creation, log_workflow_run +from functions_appinsights import log_event +from functions_collaboration import ( + create_collaboration_message_notifications, + get_collaboration_conversation, + mirror_source_message_to_collaboration, +) +from functions_document_actions import ( + DOCUMENT_ACTION_TYPE_COMPARISON, + DOCUMENT_ACTION_TYPE_EXHAUSTIVE_REVIEW, + DOCUMENT_ACTION_TYPE_NONE, + get_document_action_config, +) +from functions_document_comparison import run_document_comparison +from functions_exhaustive_document_review import ( + WORKFLOW_EXHAUSTIVE_REVIEW_MAX_DOCUMENTS, + run_exhaustive_document_review, +) +from functions_keyvault import SecretReturnType, keyvault_model_endpoint_get_helper +from functions_message_artifacts import ( + build_agent_citation_tool_label, + build_agent_citation_artifact_documents, + make_json_serializable, +) +from functions_notifications import create_workflow_priority_notification +from functions_personal_workflows import save_personal_workflow_run +from functions_settings import get_settings, get_user_settings, normalize_model_endpoints +from functions_thoughts import ThoughtTracker +from semantic_kernel_loader import load_user_semantic_kernel +from semantic_kernel_plugins.plugin_invocation_logger import get_plugin_logger +from semantic_kernel_plugins.plugin_invocation_thoughts import register_plugin_invocation_thought_callback + + +_workflow_runner_app = None + + +def _utc_now(): + return datetime.now(timezone.utc) + + +def _utc_now_iso(): + return _utc_now().isoformat() + + +def _strip_agent_citation_artifact_refs(agent_citations): + compact_citations = [] + for citation in agent_citations or []: + if not isinstance(citation, dict): + compact_citations.append(citation) + continue + + compact_citation = dict(citation) + compact_citation.pop('artifact_id', None) + compact_citation.pop('raw_payload_externalized', None) + compact_citations.append(compact_citation) + + return compact_citations + + +def _persist_agent_citation_artifacts( + conversation_id, + assistant_message_id, + agent_citations, + created_timestamp, + user_info=None, +): + if not agent_citations: + return [] + + compact_citations, artifact_docs = build_agent_citation_artifact_documents( + conversation_id=conversation_id, + assistant_message_id=assistant_message_id, + agent_citations=agent_citations, + created_timestamp=created_timestamp, + user_info=user_info, + ) + + try: + for artifact_doc in artifact_docs: + cosmos_messages_container.upsert_item(artifact_doc) + return compact_citations + except Exception as exc: + log_event( + f'[WorkflowRunner] Failed to persist workflow assistant artifacts: {exc}', + extra={ + 'conversation_id': conversation_id, + 'assistant_message_id': assistant_message_id, + 'artifact_count': len(artifact_docs), + 'citation_count': len(agent_citations), + }, + level=logging.WARNING, + exceptionTraceback=True, + ) + return _strip_agent_citation_artifact_refs(compact_citations) + + +def _normalize_invocation_timestamp(raw_timestamp): + if not raw_timestamp: + return None + if hasattr(raw_timestamp, 'isoformat'): + return raw_timestamp.isoformat() + return str(raw_timestamp) + + +def _build_agent_citations_from_invocations(user_id, conversation_id): + if not user_id or not conversation_id: + return [] + + plugin_logger = get_plugin_logger() + plugin_invocations = plugin_logger.get_invocations_for_conversation(user_id, conversation_id, limit=1000) + detailed_citations = [] + + for invocation in plugin_invocations: + tool_name = build_agent_citation_tool_label( + invocation.plugin_name, + invocation.function_name, + invocation.parameters, + invocation.result, + ) + detailed_citations.append({ + 'tool_name': tool_name, + 'function_name': invocation.function_name, + 'plugin_name': invocation.plugin_name, + 'function_arguments': make_json_serializable(invocation.parameters), + 'function_result': make_json_serializable(invocation.result), + 'duration_ms': invocation.duration_ms, + 'timestamp': _normalize_invocation_timestamp(invocation.timestamp), + 'success': invocation.success, + 'error_message': make_json_serializable(invocation.error_message), + 'user_id': invocation.user_id, + }) + + return detailed_citations + + +def _build_response_preview(text, max_length=220): + normalized = str(text or '').strip() + if len(normalized) <= max_length: + return normalized + return f'{normalized[:max_length].rstrip()}...' + + +def _normalize_workflow_alert_text(text): + return re.sub(r'\s+', ' ', str(text or '')).strip() + + +def _summarize_workflow_alert_text(text, max_length=140): + normalized = _normalize_workflow_alert_text(text) + if not normalized: + return '' + + sentence_match = re.search(r'(.+?[.!?])(?:\s|$)', normalized) + if sentence_match: + sentence = sentence_match.group(1).strip() + if 24 <= len(sentence) <= max_length: + return sentence + + numbered_split = re.split(r'\s+\d+\.\s+', normalized, maxsplit=1)[0].strip() + if 24 <= len(numbered_split) <= max_length: + return numbered_split + + dash_split = re.split(r'\s+-\s+', normalized, maxsplit=1)[0].strip() + if 24 <= len(dash_split) <= max_length: + return dash_split + + if len(normalized) <= max_length: + return normalized + + return f'{normalized[:max_length - 3].rstrip()}...' + + +def _extract_message_text(message_content): + if isinstance(message_content, str): + return message_content + if isinstance(message_content, list): + parts = [] + for item in message_content: + if isinstance(item, dict): + text_value = item.get('text') or item.get('content') or '' + if text_value: + parts.append(str(text_value)) + elif item: + parts.append(str(item)) + return ''.join(parts) + return str(message_content or '') + + +def _extract_created_conversation_docs_from_citations(agent_citations): + created_function_names = { + 'create_group_conversation', + 'create_personal_collaboration_conversation', + 'create_personal_conversation', + } + created_conversations = [] + seen_conversation_ids = set() + + for citation in agent_citations or []: + if not isinstance(citation, dict): + continue + if citation.get('plugin_name') != 'SimpleChatPlugin': + continue + if citation.get('function_name') not in created_function_names: + continue + + invocation_result = citation.get('function_result') if isinstance(citation.get('function_result'), dict) else {} + conversation_doc = invocation_result.get('conversation') if isinstance(invocation_result.get('conversation'), dict) else {} + conversation_id = str(conversation_doc.get('id') or '').strip() + if not conversation_id or conversation_id in seen_conversation_ids: + continue + + seen_conversation_ids.add(conversation_id) + created_conversations.append(dict(conversation_doc)) + + return created_conversations + + +def _is_visualization_citation(citation): + if not isinstance(citation, dict): + return False + + function_result = citation.get('function_result') if isinstance(citation.get('function_result'), dict) else {} + if function_result.get('success') is False: + return False + + return bool( + function_result.get('render_type') + or function_result.get('chart_markdown') + or function_result.get('chart_payload') + or _contains_inline_image_gallery_result(function_result) + or _contains_inline_video_result(function_result) + ) + + +def _contains_inline_image_gallery_result(function_result): + if not isinstance(function_result, dict): + return False + + image_gallery = function_result.get('image_gallery') + if isinstance(image_gallery, dict) and list(image_gallery.get('items') or []): + return True + + for field_name in ('items', 'images', 'image_urls'): + field_value = function_result.get(field_name) + if isinstance(field_value, list) and field_value: + return True + + image_url = function_result.get('image_url') + if isinstance(image_url, str) and image_url.strip(): + return True + if isinstance(image_url, dict) and str(image_url.get('url') or '').strip(): + return True + + mime_type = str(function_result.get('mime') or '').strip().lower() + if mime_type.startswith('image/'): + return True + + result_type = str(function_result.get('type') or '').strip().lower() + return result_type == 'image_url' + + +def _contains_inline_video_result(function_result): + if not isinstance(function_result, dict): + return False + + video_gallery = function_result.get('video_gallery') + if isinstance(video_gallery, dict) and list(video_gallery.get('items') or []): + return True + + for field_name in ('items', 'videos', 'video_urls'): + field_value = function_result.get(field_name) + if isinstance(field_value, list) and field_value: + return True + + video_url = function_result.get('video_url') + if isinstance(video_url, str) and video_url.strip(): + return True + if isinstance(video_url, dict) and str(video_url.get('url') or '').strip(): + return True + + mime_type = str(function_result.get('mime') or '').strip().lower() + if mime_type.startswith('video/'): + return True + + result_type = str(function_result.get('type') or '').strip().lower() + return result_type == 'video_url' + + +def _filter_visualization_agent_citations(agent_citations): + return [citation for citation in agent_citations or [] if _is_visualization_citation(citation)] + + +def _is_collaboration_target_conversation(conversation_doc): + chat_type = str((conversation_doc or {}).get('chat_type') or '').strip() + conversation_kind = str((conversation_doc or {}).get('conversation_kind') or '').strip() + return conversation_kind == COLLABORATION_KIND or chat_type in { + GROUP_MULTI_USER_CHAT_TYPE, + PERSONAL_MULTI_USER_CHAT_TYPE, + } + + +def _build_workflow_mirror_metadata(workflow, source_assistant_doc, previous_thread_id): + source_metadata = source_assistant_doc.get('metadata') if isinstance(source_assistant_doc.get('metadata'), dict) else {} + workflow_metadata = source_metadata.get('workflow') if isinstance(source_metadata.get('workflow'), dict) else {} + return { + 'source': 'workflow_mirror', + 'workflow': { + 'workflow_id': workflow.get('id'), + 'workflow_name': workflow.get('name'), + 'runner_type': workflow.get('runner_type'), + 'trigger_source': workflow_metadata.get('trigger_source'), + 'run_id': workflow_metadata.get('run_id'), + }, + 'mirrored_from': { + 'conversation_id': source_assistant_doc.get('conversation_id'), + 'message_id': source_assistant_doc.get('id'), + }, + 'thread_info': { + 'thread_id': str(uuid.uuid4()), + 'previous_thread_id': previous_thread_id, + 'active_thread': True, + 'thread_attempt': 1, + }, + } + + +def _mirror_assistant_message_to_personal_conversation( + workflow, + source_assistant_doc, + target_conversation_doc, + mirrored_agent_citations, +): + conversation_id = str((target_conversation_doc or {}).get('id') or '').strip() + if not conversation_id: + return None + + try: + conversation_doc = cosmos_conversations_container.read_item( + item=conversation_id, + partition_key=conversation_id, + ) + except Exception: + conversation_doc = dict(target_conversation_doc or {}) + + mirrored_message_id = str(uuid.uuid4()) + timestamp = _utc_now_iso() + previous_thread_id = _get_latest_thread_id(conversation_id) + prepared_agent_citations = _persist_agent_citation_artifacts( + conversation_id=conversation_id, + assistant_message_id=mirrored_message_id, + agent_citations=mirrored_agent_citations, + created_timestamp=timestamp, + user_info={ + 'user_id': str(workflow.get('user_id') or '').strip(), + }, + ) + + mirrored_assistant_doc = { + 'id': mirrored_message_id, + 'conversation_id': conversation_id, + 'role': 'assistant', + 'content': source_assistant_doc.get('content', ''), + 'timestamp': timestamp, + 'model_deployment_name': source_assistant_doc.get('model_deployment_name'), + 'augmented': bool(source_assistant_doc.get('augmented', False)), + 'hybrid_citations': list(source_assistant_doc.get('hybrid_citations') or []), + 'web_search_citations': list(source_assistant_doc.get('web_search_citations') or []), + 'agent_citations': prepared_agent_citations, + 'agent_display_name': source_assistant_doc.get('agent_display_name'), + 'agent_name': source_assistant_doc.get('agent_name'), + 'metadata': _build_workflow_mirror_metadata( + workflow, + source_assistant_doc, + previous_thread_id, + ), + } + cosmos_messages_container.upsert_item(mirrored_assistant_doc) + + conversation_doc['last_updated'] = timestamp + conversation_doc['has_unread_assistant_response'] = True + conversation_doc['last_unread_assistant_message_id'] = mirrored_message_id + conversation_doc['last_unread_assistant_at'] = timestamp + cosmos_conversations_container.upsert_item(conversation_doc) + + return mirrored_assistant_doc + + +def _mirror_workflow_visualizations_to_created_conversations(workflow, source_assistant_doc, execution_result): + source_assistant_doc = source_assistant_doc if isinstance(source_assistant_doc, dict) else {} + execution_result = execution_result if isinstance(execution_result, dict) else {} + raw_agent_citations = list(execution_result.get('agent_citations') or []) + mirrored_agent_citations = raw_agent_citations or list(source_assistant_doc.get('agent_citations') or []) + hybrid_citations = list(source_assistant_doc.get('hybrid_citations') or []) + web_search_citations = list(source_assistant_doc.get('web_search_citations') or []) + if not source_assistant_doc or not (mirrored_agent_citations or hybrid_citations or web_search_citations): + return [] + + created_conversations = _extract_created_conversation_docs_from_citations(raw_agent_citations) + if not created_conversations: + return [] + + source_conversation_id = str(source_assistant_doc.get('conversation_id') or '').strip() + default_sender_user = normalize_collaboration_user({ + 'user_id': str(workflow.get('user_id') or '').strip(), + 'display_name': str(workflow.get('user_id') or '').strip(), + }) or { + 'user_id': str(workflow.get('user_id') or '').strip(), + 'display_name': str(workflow.get('user_id') or '').strip() or 'Workflow user', + 'email': '', + } + collaboration_source_doc = { + **source_assistant_doc, + 'agent_citations': mirrored_agent_citations, + 'hybrid_citations': hybrid_citations, + 'web_search_citations': web_search_citations, + } + mirrored_message_ids = [] + + for created_conversation in created_conversations: + conversation_id = str(created_conversation.get('id') or '').strip() + if not conversation_id or conversation_id == source_conversation_id: + continue + + try: + if _is_collaboration_target_conversation(created_conversation): + collaboration_conversation = get_collaboration_conversation(conversation_id) + mirrored_message_doc, updated_conversation, created = mirror_source_message_to_collaboration( + collaboration_conversation, + collaboration_source_doc, + default_sender_user, + extra_metadata={ + 'source_conversation_id': source_conversation_id, + 'source_thought_user_id': str(workflow.get('user_id') or '').strip(), + 'workflow_mirror': True, + }, + ) + if created and mirrored_message_doc: + create_collaboration_message_notifications(updated_conversation, mirrored_message_doc) + mirrored_message_ids.append(mirrored_message_doc.get('id')) + else: + mirrored_message_doc = _mirror_assistant_message_to_personal_conversation( + workflow, + source_assistant_doc, + created_conversation, + mirrored_agent_citations, + ) + if mirrored_message_doc: + mirrored_message_ids.append(mirrored_message_doc.get('id')) + except Exception as exc: + log_event( + f'[WorkflowRunner] Failed to mirror workflow visualizations into conversation {conversation_id}: {exc}', + extra={ + 'workflow_id': str(workflow.get('id') or '').strip(), + 'source_message_id': str(source_assistant_doc.get('id') or '').strip(), + 'target_conversation_id': conversation_id, + }, + level=logging.WARNING, + exceptionTraceback=True, + ) + + return mirrored_message_ids + + +WORKFLOW_ALERT_PRIORITIES = {'low', 'medium', 'high'} + + +def _normalize_workflow_alert_priority(priority): + normalized = str(priority or '').strip().lower() + if normalized not in WORKFLOW_ALERT_PRIORITIES: + return 'none' + return normalized + + +def _dedupe_workflow_alert_targets(targets): + deduped_targets = [] + seen_keys = set() + + for target in targets or []: + if not isinstance(target, dict): + continue + + link_context = target.get('link_context') if isinstance(target.get('link_context'), dict) else {} + conversation_id = str(target.get('conversation_id') or link_context.get('conversation_id') or '').strip() + link_url = str(target.get('link_url') or '').strip() + dedupe_key = conversation_id or link_url + if not dedupe_key or dedupe_key in seen_keys: + continue + + seen_keys.add(dedupe_key) + deduped_targets.append(target) + + return deduped_targets + + +def _normalize_workflow_alert_target_label(label): + normalized_label = str(label or '').strip() + lowered_label = normalized_label.lower() + if lowered_label.startswith('open workflow'): + return 'Open workflow' + if lowered_label.startswith('open created'): + return 'Open created conversation' + if lowered_label.startswith('open updated'): + return 'Open conversation' + return normalized_label or 'Open conversation' + + +def _is_workflow_alert_workflow_target(target): + return str((target or {}).get('label') or '').strip().lower() == 'open workflow' + + +def _get_workflow_alert_target_priority(target): + target = target if isinstance(target, dict) else {} + label = str(target.get('label') or '').strip().lower() + link_context = target.get('link_context') if isinstance(target.get('link_context'), dict) else {} + workspace_type = str(link_context.get('workspace_type') or '').strip().lower() + chat_type = str(link_context.get('chat_type') or '').strip().lower() + conversation_kind = str(link_context.get('conversation_kind') or '').strip().lower() + + priority = 0 + if label.startswith('open created'): + priority += 100 + elif label.startswith('open conversation'): + priority += 60 + else: + priority += 20 + + if workspace_type == 'group' or chat_type.startswith('group'): + priority += 40 + elif workspace_type == 'personal' and (chat_type == 'personal_multi_user' or conversation_kind == 'collaboration'): + priority += 20 + elif workspace_type == 'personal': + priority += 10 + + return priority + + +def _select_preferred_workflow_alert_targets(targets): + normalized_targets = [] + for raw_target in _dedupe_workflow_alert_targets(targets): + normalized_target = dict(raw_target) + normalized_target['label'] = _normalize_workflow_alert_target_label(normalized_target.get('label')) + normalized_targets.append(normalized_target) + + workflow_target = next( + (target for target in normalized_targets if _is_workflow_alert_workflow_target(target)), + None, + ) + non_workflow_targets = [ + target for target in normalized_targets + if not _is_workflow_alert_workflow_target(target) + ] + + selected_targets = [] + if non_workflow_targets: + selected_targets.append(max(non_workflow_targets, key=_get_workflow_alert_target_priority)) + + if workflow_target: + if not selected_targets or selected_targets[0].get('conversation_id') != workflow_target.get('conversation_id'): + selected_targets.append(workflow_target) + + if not selected_targets and normalized_targets: + selected_targets.append(normalized_targets[0]) + + return selected_targets + + +def _strip_workflow_alert_markdown(text): + normalized_text = str(text or '').strip() + if not normalized_text: + return '' + + normalized_text = re.sub(r'\[([^\]]+)\]\([^\)]*\)', r'\1', normalized_text) + normalized_text = re.sub(r'[*_`#>~]+', '', normalized_text) + normalized_text = re.sub(r'\s+', ' ', normalized_text) + return normalized_text.strip(' \t-:;,') + + +def _normalize_workflow_alert_title_text(text, max_length=110): + normalized_text = _strip_workflow_alert_markdown(text) + if not normalized_text: + return '' + + normalized_text = re.sub( + r'^\s*eguardian\s*alert\s*[:,-]?\s*', + 'eGuardian Alert, ', + normalized_text, + flags=re.IGNORECASE, + ) + normalized_text = re.sub( + r'^\s*eguardian\s*[:,-]\s*', + 'eGuardian Alert, ', + normalized_text, + flags=re.IGNORECASE, + ) + normalized_text = re.sub(r'\s+', ' ', normalized_text).strip(' ,;:-') + if len(normalized_text) > max_length: + normalized_text = f"{normalized_text[:max_length - 3].rstrip(' ,;:-')}..." + return normalized_text + + +def _extract_workflow_alert_event_title(text, max_length=90): + normalized_text = _strip_workflow_alert_markdown(text) + if not normalized_text: + return '' + + numbered_match = re.search(r'(?:^|\s)\d+\.\s*([^\-:.]{3,90}?)(?=\s+-|\.|:|$)', normalized_text) + if numbered_match: + return _normalize_workflow_alert_title_text(numbered_match.group(1), max_length=max_length) + + heading_match = re.search(r"^([A-Z][A-Za-z0-9/&()'\s]{5,90}?)(?=\s+-|:|\.)", normalized_text) + if heading_match: + return _normalize_workflow_alert_title_text(heading_match.group(1), max_length=max_length) + + return '' + + +def _build_workflow_alert_citation_label(citation): + if not isinstance(citation, dict): + return '' + + explicit_label = str(citation.get('tool_name') or '').strip() + if explicit_label: + return explicit_label + + return build_agent_citation_tool_label( + citation.get('plugin_name'), + citation.get('function_name'), + citation.get('function_arguments'), + citation.get('function_result'), + ) + + +def _get_workflow_alert_enrichment_priority(citation): + function_name = str((citation or {}).get('function_name') or '').strip() + priority_map = { + 'create_group_conversation': 100, + 'create_personal_collaboration_conversation': 100, + 'create_personal_conversation': 100, + 'create_calendar_invite': 95, + 'create_map_visualization': 90, + 'upload_markdown_document': 85, + 'create_group': 80, + 'invite_group_conversation_members': 75, + 'mark_message_as_read': 70, + 'get_my_messages': 40, + 'search_users': 30, + 'get_user_by_email': 30, + } + return priority_map.get(function_name, 10) + + +def _build_workflow_alert_enrichment_labels(agent_citations): + ranked_labels = [] + seen_labels = set() + + for index, citation in enumerate(agent_citations or []): + if not isinstance(citation, dict): + continue + if citation.get('success') is False: + continue + + function_name = str(citation.get('function_name') or '').strip() + if function_name == 'add_conversation_message': + continue + + label = _normalize_workflow_alert_text(_build_workflow_alert_citation_label(citation)) + if not label: + continue + + dedupe_key = label.lower() + if dedupe_key in seen_labels: + continue + + seen_labels.add(dedupe_key) + ranked_labels.append(( + _get_workflow_alert_enrichment_priority(citation), + index, + label, + )) + + ranked_labels.sort(key=lambda item: (-item[0], item[1])) + return [item[2] for item in ranked_labels] + + +def _extract_workflow_alert_subject(alert_title): + normalized_title = _normalize_workflow_alert_title_text(alert_title) + if not normalized_title: + return '' + + normalized_title = re.sub( + r'^\s*eguardian\s*alert,\s*', + '', + normalized_title, + flags=re.IGNORECASE, + ) + return normalized_title.strip(' ,;:-') + + +def _build_workflow_alert_action_plan(agent_citations): + action_plan = { + 'summary_labels': [], + 'ready_lines': [], + 'support_lines': [], + } + seen_values = { + 'summary_labels': set(), + 'ready_lines': set(), + 'support_lines': set(), + } + + for citation in agent_citations or []: + if not isinstance(citation, dict) or citation.get('success') is False: + continue + + function_name = str(citation.get('function_name') or '').strip() + function_result = citation.get('function_result') if isinstance(citation.get('function_result'), dict) else {} + citation_label = _normalize_workflow_alert_text(_build_workflow_alert_citation_label(citation)) + summary_label = '' + ready_line = '' + support_line = '' + + if function_name in { + 'create_group_conversation', + 'create_personal_collaboration_conversation', + 'create_personal_conversation', + }: + summary_label = 'coordination conversation' + ready_line = 'Coordination conversation created' + elif function_name == 'create_calendar_invite': + is_teams_briefing = ( + str(function_result.get('meeting_type') or '').strip().lower() == 'teams' + or 'teams' in citation_label.lower() + ) + summary_label = 'Teams briefing' if is_teams_briefing else 'briefing invite' + ready_line = 'Teams briefing prepared' if is_teams_briefing else 'Briefing invite prepared' + elif function_name == 'create_map_visualization': + summary_label = 'travel map' + ready_line = 'Travel map generated' + elif function_name == 'upload_markdown_document': + support_line = 'Briefing document saved' + elif function_name == 'invite_group_conversation_members': + support_line = 'Participants invited' + else: + continue + + if summary_label: + summary_key = summary_label.lower() + if summary_key not in seen_values['summary_labels']: + seen_values['summary_labels'].add(summary_key) + action_plan['summary_labels'].append(summary_label) + if ready_line: + ready_key = ready_line.lower() + if ready_key not in seen_values['ready_lines']: + seen_values['ready_lines'].add(ready_key) + action_plan['ready_lines'].append(ready_line) + if support_line: + support_key = support_line.lower() + if support_key not in seen_values['support_lines']: + seen_values['support_lines'].add(support_key) + action_plan['support_lines'].append(support_line) + + return action_plan + + +def _join_workflow_alert_labels(labels): + normalized_labels = [str(label or '').strip() for label in labels or [] if str(label or '').strip()] + if not normalized_labels: + return '' + if len(normalized_labels) == 1: + return normalized_labels[0] + if len(normalized_labels) == 2: + return f'{normalized_labels[0]} and {normalized_labels[1]}' + return f"{', '.join(normalized_labels[:-1])}, and {normalized_labels[-1]}" + + +def _looks_like_workflow_alert_failure_text(text): + normalized_text = _normalize_workflow_alert_text(text).lower() + if not normalized_text: + return False + + failure_markers = [ + "i can't", + 'i cannot', + "couldn't", + 'could not', + 'failed to', + 'unable to', + 'not able to', + 'do not have access', + 'permission', + 'not supported', + 'not reliably', + ] + return any(marker in normalized_text for marker in failure_markers) + + +def _extract_workflow_alert_title_from_citations(agent_citations): + for conversation_doc in _extract_created_conversation_docs_from_citations(agent_citations): + conversation_title = str(conversation_doc.get('title') or '').strip() + if conversation_title: + return _normalize_workflow_alert_title_text(conversation_title) + + for enrichment_label in _build_workflow_alert_enrichment_labels(agent_citations): + if ': ' not in enrichment_label: + continue + label_detail = enrichment_label.split(': ', 1)[1].strip() + if label_detail: + return _normalize_workflow_alert_title_text(label_detail) + + return '' + + +def _build_workflow_alert_action_summary(summary_labels): + normalized_labels = [str(label or '').strip() for label in summary_labels or [] if str(label or '').strip()] + if not normalized_labels: + return '' + + summary_subset = normalized_labels[:3] + joined_labels = _join_workflow_alert_labels(summary_subset) + verb = 'is' if len(summary_subset) == 1 else 'are' + return f'{joined_labels[:1].upper()}{joined_labels[1:]} {verb} ready.' + + +def _trim_workflow_alert_summary_text(text, max_length=180): + normalized_text = _normalize_workflow_alert_text(text) + if not normalized_text: + return '' + if len(normalized_text) <= max_length: + return normalized_text + return f'{normalized_text[:max_length - 3].rstrip()}...' + + +def _build_workflow_alert_success_summary(alert_title, action_plan, response_preview, workflow_name, trigger_source): + alert_subject = _extract_workflow_alert_subject(alert_title) + action_summary = _build_workflow_alert_action_summary(action_plan.get('summary_labels') or []) + + if alert_subject and action_summary: + return _trim_workflow_alert_summary_text(f'{alert_subject}. {action_summary}', max_length=180) + if alert_subject: + return _trim_workflow_alert_summary_text(alert_subject, max_length=180) + if action_summary: + return _trim_workflow_alert_summary_text(action_summary, max_length=180) + + normalized_preview = _strip_workflow_alert_markdown(response_preview) + if normalized_preview and not _looks_like_workflow_alert_failure_text(normalized_preview): + return _summarize_workflow_alert_text(normalized_preview) + + return _summarize_workflow_alert_text( + f'{workflow_name} completed from the {trigger_source} trigger.' + ) + + +def _build_workflow_alert_success_detail(alert_title, action_plan, response_preview, workflow_name, trigger_source): + alert_subject = _extract_workflow_alert_subject(alert_title) + ready_lines = list(action_plan.get('ready_lines') or []) + support_lines = list(action_plan.get('support_lines') or []) + detail_sections = [] + + if alert_subject: + detail_sections.append(f'Focus\n{alert_subject}') + + if ready_lines: + ready_text = '\n- '.join(ready_lines[:4]) + detail_sections.append(f'Ready now\n- {ready_text}') + + if support_lines: + support_text = '\n- '.join(support_lines[:2]) + detail_sections.append(f'Supporting items\n- {support_text}') + + if detail_sections: + return '\n\n'.join(detail_sections) + + normalized_preview = _strip_workflow_alert_markdown(response_preview) + if normalized_preview and not _looks_like_workflow_alert_failure_text(normalized_preview): + return normalized_preview + + return _normalize_workflow_alert_text( + f'{workflow_name} completed from the {trigger_source} trigger.' + ) + + +def _build_workflow_alert_content(workflow, run_record, execution_result, priority): + execution_result = execution_result if isinstance(execution_result, dict) else {} + workflow_name = _normalize_workflow_alert_title_text(workflow.get('name') or 'Workflow') or 'Workflow' + trigger_source = str(run_record.get('trigger_source') or 'manual').strip() or 'manual' + success = bool(run_record.get('success')) + response_preview = _strip_workflow_alert_markdown(run_record.get('response_preview') or '') + reply_text = _strip_workflow_alert_markdown(execution_result.get('reply') or '') + error_text = _strip_workflow_alert_markdown(run_record.get('error') or '') + agent_citations = list(execution_result.get('agent_citations') or []) + enrichment_labels = _build_workflow_alert_enrichment_labels(agent_citations) + action_plan = _build_workflow_alert_action_plan(agent_citations) + + alert_title = _extract_workflow_alert_title_from_citations(agent_citations) + if not alert_title: + alert_title = _extract_workflow_alert_event_title(reply_text or response_preview) + if not alert_title: + alert_title = workflow_name + + if success: + alert_summary = _build_workflow_alert_success_summary( + alert_title, + action_plan, + response_preview or reply_text, + workflow_name, + trigger_source, + ) + alert_detail = _build_workflow_alert_success_detail( + alert_title, + action_plan, + response_preview or reply_text, + workflow_name, + trigger_source, + ) + notification_title = f'{priority.capitalize()} priority workflow alert: {alert_title}' + else: + failure_text = error_text or response_preview or reply_text or ( + f'{workflow_name} failed from the {trigger_source} trigger.' + ) + alert_summary = _summarize_workflow_alert_text(failure_text) + alert_detail = _normalize_workflow_alert_text(failure_text) + notification_title = f'{priority.capitalize()} priority workflow alert: {workflow_name} failed' + + return { + 'notification_title': notification_title, + 'notification_message': alert_summary, + 'alert_title': alert_title, + 'alert_summary': alert_summary, + 'alert_detail': alert_detail, + 'event_title': alert_title, + 'enrichment_labels': enrichment_labels, + } + + +def _build_workflow_alert_target_from_conversation(conversation_doc, default_label='Open conversation'): + conversation_doc = conversation_doc if isinstance(conversation_doc, dict) else {} + conversation_id = str(conversation_doc.get('id') or '').strip() + if not conversation_id: + return None + + chat_type = str(conversation_doc.get('chat_type') or '').strip().lower() + conversation_kind = str(conversation_doc.get('conversation_kind') or '').strip() + scope = conversation_doc.get('scope') if isinstance(conversation_doc.get('scope'), dict) else {} + group_id = str(scope.get('group_id') or conversation_doc.get('group_id') or '').strip() + workspace_type = 'group' if chat_type.startswith('group') or group_id else 'personal' + label = str(default_label or conversation_doc.get('title') or 'Open conversation').strip() or 'Open conversation' + + link_context = { + 'workspace_type': workspace_type, + 'conversation_id': conversation_id, + 'chat_type': chat_type, + } + if group_id: + link_context['group_id'] = group_id + if conversation_kind: + link_context['conversation_kind'] = conversation_kind + + return { + 'label': label, + 'link_url': f'/chats?conversationId={conversation_id}', + 'link_context': link_context, + 'conversation_id': conversation_id, + } + + +def _get_simplechat_alert_target_label(function_name): + target_labels = { + 'create_group_conversation': 'Open created conversation', + 'create_personal_collaboration_conversation': 'Open created conversation', + 'create_personal_conversation': 'Open created conversation', + 'add_conversation_message': 'Open conversation', + } + return target_labels.get(str(function_name or '').strip(), 'Open related conversation') + + +def _collect_agent_alert_targets(user_id, conversation_id): + if not user_id or not conversation_id: + return [] + + plugin_logger = get_plugin_logger() + invocations = plugin_logger.get_invocations_for_conversation(user_id, conversation_id, limit=100) + alert_targets = [] + + for invocation in invocations: + if invocation.plugin_name != 'SimpleChatPlugin' or not invocation.success: + continue + + invocation_result = invocation.result + if not isinstance(invocation_result, dict): + continue + + conversation_doc = invocation_result.get('conversation') if isinstance(invocation_result.get('conversation'), dict) else {} + alert_target = _build_workflow_alert_target_from_conversation( + conversation_doc, + default_label=_get_simplechat_alert_target_label(invocation.function_name), + ) + if alert_target: + alert_targets.append(alert_target) + + return _select_preferred_workflow_alert_targets(alert_targets) + + +def _create_workflow_priority_alert(workflow, run_record, conversation, execution_result=None): + execution_result = execution_result if isinstance(execution_result, dict) else {} + priority = _normalize_workflow_alert_priority(workflow.get('alert_priority')) + if priority == 'none': + return None + + try: + user_id = str(workflow.get('user_id') or '').strip() + workflow_id = str(workflow.get('id') or '').strip() + workflow_name = _normalize_workflow_alert_title_text(workflow.get('name') or 'Workflow') or 'Workflow' + trigger_source = str(run_record.get('trigger_source') or 'manual').strip() or 'manual' + workflow_targets = list(execution_result.get('alert_targets') or []) + workflow_conversation_target = _build_workflow_alert_target_from_conversation( + conversation, + default_label='Open workflow', + ) + if workflow_conversation_target: + workflow_targets.append(workflow_conversation_target) + + workflow_targets = _select_preferred_workflow_alert_targets(workflow_targets) + primary_target = workflow_targets[0] if workflow_targets else None + response_preview = str(run_record.get('response_preview') or '').strip() + error_text = str(run_record.get('error') or '').strip() + alert_content = _build_workflow_alert_content( + workflow, + run_record, + execution_result, + priority, + ) + + metadata = { + 'workflow_id': workflow_id, + 'workflow_name': workflow_name, + 'priority': priority, + 'trigger_source': trigger_source, + 'run_id': str(run_record.get('id') or '').strip(), + 'runner_type': str(workflow.get('runner_type') or '').strip(), + 'status': str(run_record.get('status') or '').strip(), + 'conversation_id': str((conversation or {}).get('id') or run_record.get('conversation_id') or '').strip(), + 'assistant_message_id': str(run_record.get('assistant_message_id') or '').strip(), + 'response_preview': response_preview, + 'error': error_text, + 'event_title': alert_content.get('event_title'), + 'alert_title': alert_content.get('alert_title'), + 'alert_summary': alert_content.get('alert_summary'), + 'alert_detail': alert_content.get('alert_detail'), + 'alert_enrichments': alert_content.get('enrichment_labels') or [], + 'link_targets': workflow_targets, + } + if execution_result.get('agent_name'): + metadata['agent_name'] = execution_result.get('agent_name') + if execution_result.get('agent_display_name'): + metadata['agent_display_name'] = execution_result.get('agent_display_name') + + return create_workflow_priority_notification( + user_id=user_id, + workflow_id=workflow_id, + workflow_name=workflow_name, + priority=priority, + title=alert_content.get('notification_title') or f'{priority.capitalize()} priority workflow alert: {workflow_name}', + message=alert_content.get('notification_message') or _summarize_workflow_alert_text(response_preview or error_text), + link_url=primary_target.get('link_url') if primary_target else '', + link_context=primary_target.get('link_context') if primary_target else {}, + metadata=metadata, + ) + except Exception as exc: + log_event( + f'[WorkflowRunner] Failed to create workflow alert: {exc}', + extra={ + 'workflow_id': str(workflow.get('id') or '').strip(), + 'user_id': str(workflow.get('user_id') or '').strip(), + }, + level=logging.WARNING, + exceptionTraceback=True, + ) + return None + + +def _resolve_authority(auth_settings): + management_cloud = (auth_settings.get('management_cloud') or 'public').lower() + if management_cloud in ('government', 'usgovernment', 'usgov'): + return AzureAuthorityHosts.AZURE_GOVERNMENT + custom_authority = auth_settings.get('custom_authority') or '' + if custom_authority: + return custom_authority + return AzureAuthorityHosts.AZURE_PUBLIC_CLOUD + + +def _resolve_foundry_scope(auth_settings, endpoint=None): + custom_scope = (auth_settings.get('foundry_scope') or '').strip() + if custom_scope: + return custom_scope + + management_cloud = (auth_settings.get('management_cloud') or 'public').lower() + if management_cloud in ('government', 'usgovernment', 'usgov'): + return 'https://ai.azure.us/.default' + if management_cloud == 'china': + return 'https://ai.azure.cn/.default' + if management_cloud == 'germany': + return 'https://ai.azure.de/.default' + + endpoint_value = (endpoint or '').lower() + if 'azure.us' in endpoint_value: + return 'https://ai.azure.us/.default' + if 'azure.cn' in endpoint_value: + return 'https://ai.azure.cn/.default' + if 'azure.de' in endpoint_value: + return 'https://ai.azure.de/.default' + return 'https://ai.azure.com/.default' + + +def _build_token_provider(auth_settings, provider='aoai', endpoint=None): + auth_type = (auth_settings.get('type') or 'managed_identity').lower() + authority = _resolve_authority(auth_settings) + + if auth_type == 'service_principal': + credential = ClientSecretCredential( + tenant_id=auth_settings.get('tenant_id'), + client_id=auth_settings.get('client_id'), + client_secret=auth_settings.get('client_secret'), + authority=authority, + ) + else: + credential = DefaultAzureCredential( + managed_identity_client_id=auth_settings.get('managed_identity_client_id') or None, + authority=authority, + ) + + scope = cognitive_services_scope + if provider in ('aifoundry', 'new_foundry'): + scope = _resolve_foundry_scope(auth_settings, endpoint=endpoint) + + return get_bearer_token_provider(credential, scope) + + +def _get_workflow_runner_app(): + global _workflow_runner_app + if _workflow_runner_app is None: + workflow_app = Flask('simplechat_workflow_runner') + workflow_app.secret_key = SECRET_KEY + _workflow_runner_app = workflow_app + return _workflow_runner_app + + +@contextmanager +def _ensure_execution_context(user_id): + created_context = None + reuse_existing = False + + if has_request_context(): + session_user = session.get('user') if isinstance(session.get('user'), dict) else {} + session_user_id = str(session_user.get('oid') or '').strip() + reuse_existing = session_user_id == str(user_id or '').strip() + + if not reuse_existing: + created_context = _get_workflow_runner_app().test_request_context('/api/internal/workflows/run') + created_context.push() + session['user'] = { + 'oid': user_id, + 'roles': ['User'], + 'preferred_username': '', + 'name': user_id, + } + + try: + yield + finally: + if created_context is not None: + created_context.pop() + + +def _ensure_workflow_conversation(workflow): + conversation_id = str(workflow.get('conversation_id') or '').strip() + user_id = str(workflow.get('user_id') or '').strip() + title = f"Workflow: {workflow.get('name') or 'Untitled Workflow'}" + + if conversation_id: + try: + conversation = cosmos_conversations_container.read_item(item=conversation_id, partition_key=conversation_id) + cleaned = {key: value for key, value in conversation.items() if not str(key).startswith('_')} + if cleaned.get('title') != title: + cleaned['title'] = title + cleaned['last_updated'] = _utc_now_iso() + cosmos_conversations_container.upsert_item(cleaned) + return cleaned + except Exception: + pass + + conversation_id = str(uuid.uuid4()) + conversation = { + 'id': conversation_id, + 'user_id': user_id, + 'last_updated': _utc_now_iso(), + 'title': title, + 'context': [], + 'tags': ['workflow'], + 'strict': False, + 'is_pinned': False, + 'is_hidden': False, + 'chat_type': 'workflow', + 'workflow_id': workflow.get('id'), + 'has_unread_assistant_response': False, + 'last_unread_assistant_message_id': None, + 'last_unread_assistant_at': None, + } + cosmos_conversations_container.upsert_item(conversation) + log_conversation_creation( + user_id=user_id, + conversation_id=conversation_id, + title=title, + workspace_type='personal', + ) + conversation['added_to_activity_log'] = True + cosmos_conversations_container.upsert_item(conversation) + return conversation + + +def _get_latest_thread_id(conversation_id): + try: + rows = list(cosmos_messages_container.query_items( + query=( + 'SELECT TOP 1 c.metadata.thread_info.thread_id as thread_id ' + 'FROM c WHERE c.conversation_id = @conversation_id ' + 'ORDER BY c.timestamp DESC' + ), + parameters=[{'name': '@conversation_id', 'value': conversation_id}], + partition_key=conversation_id, + )) + return rows[0].get('thread_id') if rows else None + except Exception: + return None + + +def _create_user_message(conversation_id, workflow, trigger_source, run_id): + previous_thread_id = _get_latest_thread_id(conversation_id) + current_thread_id = str(uuid.uuid4()) + message_id = str(uuid.uuid4()) + document_action = _get_document_action_config(workflow) + metadata = { + 'source': 'workflow', + 'workflow': { + 'workflow_id': workflow.get('id'), + 'workflow_name': workflow.get('name'), + 'runner_type': workflow.get('runner_type'), + 'trigger_source': trigger_source, + 'run_id': run_id, + 'document_action': document_action, + 'exhaustive_review': workflow.get('exhaustive_review') or {}, + }, + 'thread_info': { + 'thread_id': current_thread_id, + 'previous_thread_id': previous_thread_id, + 'active_thread': True, + 'thread_attempt': 1, + }, + } + message_doc = { + 'id': message_id, + 'conversation_id': conversation_id, + 'role': 'user', + 'content': workflow.get('task_prompt', ''), + 'timestamp': _utc_now_iso(), + 'model_deployment_name': None, + 'metadata': metadata, + } + cosmos_messages_container.upsert_item(message_doc) + return message_doc + + +def _initialize_workflow_assistant_tracking(conversation_id, user_id, user_message_doc): + assistant_message_id = str(uuid.uuid4()) + user_thread_info = (user_message_doc.get('metadata') or {}).get('thread_info') or {} + thought_tracker = ThoughtTracker( + conversation_id=conversation_id, + message_id=assistant_message_id, + thread_id=user_thread_info.get('thread_id'), + user_id=user_id, + force_enabled=True, + ) + return assistant_message_id, thought_tracker + + +def _build_workflow_activity_payload(workflow, run_id, activity_key, kind, title, status, lane_key='main', lane_label='Main'): + return { + 'activity_key': activity_key, + 'workflow_id': workflow.get('id'), + 'run_id': run_id, + 'kind': kind, + 'title': title, + 'status': status, + 'state': status, + 'lane_key': lane_key, + 'lane_label': lane_label, + } + + +def _add_workflow_activity_thought( + thought_tracker, + workflow, + run_id, + *, + step_type, + content, + detail=None, + activity_key, + kind, + title, + status, + lane_key='main', + lane_label='Main', +): + if not thought_tracker: + return None + + return thought_tracker.add_thought( + step_type, + content, + detail=detail, + activity=_build_workflow_activity_payload( + workflow, + run_id, + activity_key, + kind, + title, + status, + lane_key=lane_key, + lane_label=lane_label, + ), + ) + + +def _create_assistant_message(conversation, workflow, result, trigger_source, run_id, user_message_doc, assistant_message_id=None): + assistant_message_id = assistant_message_id or str(uuid.uuid4()) + timestamp = _utc_now_iso() + user_thread_info = (user_message_doc.get('metadata') or {}).get('thread_info') or {} + document_action = _get_document_action_config(workflow) + raw_agent_citations = list(result.get('agent_citations') or []) + prepared_agent_citations = _persist_agent_citation_artifacts( + conversation_id=conversation.get('id'), + assistant_message_id=assistant_message_id, + agent_citations=raw_agent_citations, + created_timestamp=timestamp, + user_info={ + 'user_id': str(workflow.get('user_id') or '').strip(), + }, + ) + assistant_doc = { + 'id': assistant_message_id, + 'conversation_id': conversation.get('id'), + 'role': 'assistant', + 'content': result.get('reply', ''), + 'timestamp': timestamp, + 'model_deployment_name': result.get('model_deployment_name'), + 'agent_citations': prepared_agent_citations, + 'agent_display_name': result.get('agent_display_name'), + 'agent_name': result.get('agent_name'), + 'metadata': { + 'source': 'workflow', + 'workflow': { + 'workflow_id': workflow.get('id'), + 'workflow_name': workflow.get('name'), + 'runner_type': workflow.get('runner_type'), + 'trigger_source': trigger_source, + 'run_id': run_id, + 'selected_agent': workflow.get('selected_agent') or {}, + 'model_binding_summary': workflow.get('model_binding_summary') or {}, + 'document_action': document_action, + 'exhaustive_review': workflow.get('exhaustive_review') or {}, + 'review_coverage': result.get('review_coverage') or {}, + }, + 'thread_info': { + 'thread_id': str(uuid.uuid4()), + 'previous_thread_id': user_thread_info.get('thread_id'), + 'active_thread': True, + 'thread_attempt': 1, + }, + }, + } + cosmos_messages_container.upsert_item(assistant_doc) + + conversation['last_updated'] = timestamp + conversation['workflow_id'] = workflow.get('id') + conversation['chat_type'] = 'workflow' + conversation['has_unread_assistant_response'] = True + conversation['last_unread_assistant_message_id'] = assistant_message_id + conversation['last_unread_assistant_at'] = timestamp + cosmos_conversations_container.upsert_item(conversation) + + return assistant_doc + + +def _build_multi_endpoint_client(user_id, endpoint_id, model_id, settings): + candidates = [] + user_settings = get_user_settings(user_id) + if settings.get('allow_user_custom_endpoints', False): + personal_endpoints, _ = normalize_model_endpoints( + user_settings.get('settings', {}).get('personal_model_endpoints', []) or [] + ) + for endpoint in personal_endpoints: + item = dict(endpoint) + item['scope'] = 'user' + candidates.append(item) + + global_endpoints, _ = normalize_model_endpoints(settings.get('model_endpoints', []) or []) + for endpoint in global_endpoints: + item = dict(endpoint) + item['scope'] = 'global' + candidates.append(item) + + endpoint_cfg = next((candidate for candidate in candidates if candidate.get('id') == endpoint_id), None) + if not endpoint_cfg: + raise ValueError('Selected model endpoint was not found.') + + model_cfg = next((model for model in endpoint_cfg.get('models', []) if model.get('id') == model_id), None) + if not model_cfg: + raise ValueError('Selected model was not found on the endpoint.') + + scope = endpoint_cfg.get('scope', 'global') + resolved_endpoint = keyvault_model_endpoint_get_helper( + endpoint_cfg, + endpoint_cfg.get('id'), + scope=scope, + return_type=SecretReturnType.VALUE, + ) + connection = resolved_endpoint.get('connection', {}) if isinstance(resolved_endpoint, dict) else {} + auth = resolved_endpoint.get('auth', {}) if isinstance(resolved_endpoint, dict) else {} + provider = str(resolved_endpoint.get('provider') or endpoint_cfg.get('provider') or 'aoai').strip().lower() + deployment_name = ( + model_cfg.get('deploymentName') + or model_cfg.get('deployment') + or model_cfg.get('displayName') + or model_id + ) + api_version = connection.get('api_version') or connection.get('openai_api_version') or settings.get('azure_openai_gpt_api_version') + endpoint = connection.get('endpoint') + auth_type = str(auth.get('type') or 'api_key').strip().lower() + + if auth_type in ('key', 'api_key'): + client = AzureOpenAI( + azure_endpoint=endpoint, + api_key=auth.get('api_key'), + api_version=api_version, + ) + else: + auth_settings = { + 'type': auth_type, + 'tenant_id': auth.get('tenant_id'), + 'client_id': auth.get('client_id'), + 'client_secret': auth.get('client_secret'), + 'managed_identity_client_id': auth.get('managed_identity_client_id'), + 'management_cloud': auth.get('management_cloud') or settings.get('management_cloud') or 'public', + 'custom_authority': auth.get('custom_authority') or settings.get('custom_authority') or '', + 'foundry_scope': auth.get('foundry_scope') or '', + } + token_provider = _build_token_provider(auth_settings, provider=provider, endpoint=endpoint) + client = AzureOpenAI( + azure_endpoint=endpoint, + azure_ad_token_provider=token_provider, + api_version=api_version, + ) + + return client, deployment_name, provider + + +def _build_legacy_default_client(settings): + if settings.get('enable_gpt_apim', False): + endpoint = settings.get('azure_apim_gpt_endpoint') + deployment_name = settings.get('azure_apim_gpt_deployment') + api_key = settings.get('azure_apim_gpt_subscription_key') + api_version = settings.get('azure_apim_gpt_api_version') or settings.get('azure_openai_gpt_api_version') + client = AzureOpenAI( + azure_endpoint=endpoint, + api_key=api_key, + api_version=api_version, + ) + return client, deployment_name, 'aoai' + + endpoint = settings.get('azure_openai_gpt_endpoint') + deployment_name = settings.get('azure_openai_gpt_deployment') + api_version = settings.get('azure_openai_gpt_api_version') + api_key = settings.get('azure_openai_gpt_key') + auth_type = str(settings.get('azure_openai_gpt_authentication_type') or 'key').strip().lower() + if isinstance(deployment_name, str) and ',' in deployment_name: + deployment_name = deployment_name.split(',')[0].strip() + + if auth_type in ('key', 'api_key') or api_key: + client = AzureOpenAI( + azure_endpoint=endpoint, + api_key=api_key, + api_version=api_version, + ) + return client, deployment_name, 'aoai' + + auth_settings = { + 'type': auth_type, + 'tenant_id': settings.get('azure_openai_gpt_tenant_id') or settings.get('azure_openai_tenant_id'), + 'client_id': settings.get('azure_openai_gpt_client_id') or settings.get('azure_openai_client_id'), + 'client_secret': settings.get('azure_openai_gpt_client_secret') or settings.get('azure_openai_client_secret'), + 'managed_identity_client_id': settings.get('azure_openai_gpt_managed_identity_client_id') or settings.get('azure_openai_managed_identity_client_id'), + 'management_cloud': settings.get('management_cloud') or settings.get('azure_management_cloud') or 'public', + 'custom_authority': settings.get('custom_authority') or settings.get('azure_custom_authority') or '', + } + token_provider = _build_token_provider(auth_settings, provider='aoai', endpoint=endpoint) + client = AzureOpenAI( + azure_endpoint=endpoint, + azure_ad_token_provider=token_provider, + api_version=api_version, + ) + return client, deployment_name, 'aoai' + + +def _resolve_model_workflow_client(workflow, settings): + user_id = str(workflow.get('user_id') or '').strip() + binding_summary = workflow.get('model_binding_summary') if isinstance(workflow.get('model_binding_summary'), dict) else {} + endpoint_id = str(workflow.get('model_endpoint_id') or binding_summary.get('endpoint_id') or '').strip() + model_id = str(workflow.get('model_id') or binding_summary.get('model_id') or '').strip() + legacy_model_deployment = str(workflow.get('legacy_model_deployment') or '').strip() + + if endpoint_id and model_id: + return _build_multi_endpoint_client(user_id, endpoint_id, model_id, settings) + + if legacy_model_deployment: + client, _, provider = _build_legacy_default_client(settings) + return client, legacy_model_deployment, provider + + default_selection = settings.get('default_model_selection', {}) if isinstance(settings, dict) else {} + default_endpoint_id = str(default_selection.get('endpoint_id') or '').strip() + default_model_id = str(default_selection.get('model_id') or '').strip() + if default_endpoint_id and default_model_id: + return _build_multi_endpoint_client(user_id, default_endpoint_id, default_model_id, settings) + + return _build_legacy_default_client(settings) + + +def _chain_activity_callbacks(*callbacks): + active_callbacks = [callback for callback in callbacks if callable(callback)] + if not active_callbacks: + return None + + def callback(event): + for activity_callback in active_callbacks: + try: + activity_callback(event) + except Exception as exc: + log_event( + f'[WorkflowRunner] Exhaustive review activity callback failed: {exc}', + level=logging.WARNING, + exceptionTraceback=True, + ) + + return callback + + +def _get_document_action_config(workflow): + return get_document_action_config(workflow, max_documents=WORKFLOW_EXHAUSTIVE_REVIEW_MAX_DOCUMENTS) + + +def _build_document_action_activity_callback(workflow, run_id, thought_tracker=None): + if not thought_tracker or not run_id: + return None + + def callback(event): + event_type = str((event or {}).get('type') or '').strip().lower() + document_id = str((event or {}).get('document_id') or '').strip() + document_name = str((event or {}).get('document_name') or 'Document').strip() or 'Document' + window_range = (event or {}).get('window_range') if isinstance((event or {}).get('window_range'), dict) else {} + window_number = window_range.get('window_number') + + if event_type == 'document_started': + _add_workflow_activity_thought( + thought_tracker, + workflow, + run_id, + step_type='document', + content=f'Started exhaustive review for {document_name}', + detail=f"windows={event.get('window_count', 0)}", + activity_key=f'review:{run_id}:{document_id}:start', + kind='document_review', + title='Document review', + status='running', + ) + elif event_type == 'document_completed': + _add_workflow_activity_thought( + thought_tracker, + workflow, + run_id, + step_type='document', + content=f'Completed exhaustive review for {document_name}', + detail=( + f"processed={event.get('processed_windows', 0)} | " + f"failed={event.get('failed_windows', 0)}" + ), + activity_key=f'review:{run_id}:{document_id}:complete', + kind='document_review', + title='Document review', + status='completed', + ) + elif event_type == 'window_failed': + _add_workflow_activity_thought( + thought_tracker, + workflow, + run_id, + step_type='document', + content=f'Failed review window {window_number} for {document_name}', + detail=str(event.get('error') or 'Unknown exhaustive review failure'), + activity_key=f'review:{run_id}:{document_id}:window:{window_number}:failed', + kind='document_review', + title='Document review', + status='failed', + ) + elif event_type == 'comparison_started': + right_document_name = str((event or {}).get('right_document_name') or 'Document').strip() or 'Document' + _add_workflow_activity_thought( + thought_tracker, + workflow, + run_id, + step_type='document', + content=f'Comparing {document_name} to {right_document_name}', + detail=( + f"pair={event.get('comparison_index', 0)}/{event.get('comparison_count', 0)}" + ), + activity_key=f"compare:{run_id}:{document_id}:{event.get('right_document_id')}:start", + kind='document_review', + title='Document comparison', + status='running', + ) + elif event_type == 'comparison_completed': + right_document_name = str((event or {}).get('right_document_name') or 'Document').strip() or 'Document' + _add_workflow_activity_thought( + thought_tracker, + workflow, + run_id, + step_type='document', + content=f'Completed comparison of {document_name} to {right_document_name}', + detail=( + f"pair={event.get('comparison_index', 0)}/{event.get('comparison_count', 0)}" + ), + activity_key=f"compare:{run_id}:{document_id}:{event.get('right_document_id')}:complete", + kind='document_review', + title='Document comparison', + status='completed', + ) + elif event_type == 'comparison_reduction_started': + _add_workflow_activity_thought( + thought_tracker, + workflow, + run_id, + step_type='document', + content='Combining comparison findings across the selected documents', + detail=f"pairs={event.get('comparison_count', 0)}", + activity_key=f'compare:{run_id}:reduction', + kind='document_review', + title='Document comparison', + status='running', + ) + + return callback + + +def _execute_model_workflow(workflow, settings, run_id=None, thought_tracker=None): + if thought_tracker and run_id: + _add_workflow_activity_thought( + thought_tracker, + workflow, + run_id, + step_type='generation', + content='Starting direct model execution', + detail=None, + activity_key=f'generation:{run_id}', + kind='model_execution', + title='Model execution', + status='running', + ) + + client, deployment_name, provider = _resolve_model_workflow_client(workflow, settings) + + completion = client.chat.completions.create( + model=deployment_name, + messages=[{'role': 'user', 'content': workflow.get('task_prompt', '')}], + ) + reply = '' + if getattr(completion, 'choices', None): + reply = _extract_message_text(completion.choices[0].message.content) + + if thought_tracker and run_id: + _add_workflow_activity_thought( + thought_tracker, + workflow, + run_id, + step_type='generation', + content=f'Direct model execution completed with {deployment_name}', + detail=f'provider={provider}', + activity_key=f'generation:{run_id}', + kind='model_execution', + title='Model execution', + status='completed', + ) + + return { + 'reply': reply, + 'model_deployment_name': deployment_name, + 'provider': provider, + } + + +def _execute_exhaustive_review_workflow( + workflow, + settings, + conversation_id='', + run_id=None, + thought_tracker=None, + external_activity_callback=None, + action_config=None, +): + review_config = action_config if isinstance(action_config, dict) else _get_document_action_config(workflow) + if review_config.get('type') != DOCUMENT_ACTION_TYPE_EXHAUSTIVE_REVIEW: + raise ValueError('Exhaustive review is not enabled for this workflow.') + + activity_callback = _chain_activity_callbacks( + _build_document_action_activity_callback(workflow, run_id, thought_tracker=thought_tracker), + external_activity_callback, + ) + user_id = str(workflow.get('user_id') or '').strip() + selected_agent = workflow.get('selected_agent') if isinstance(workflow.get('selected_agent'), dict) else {} + + if workflow.get('runner_type') == 'agent': + with _ensure_execution_context(user_id): + plugin_logger = get_plugin_logger() + previous_force_enable_agents = getattr(g, 'force_enable_agents', None) if hasattr(g, 'force_enable_agents') else None + previous_request_agent_info = getattr(g, 'request_agent_info', None) if hasattr(g, 'request_agent_info') else None + previous_request_agent_name = getattr(g, 'request_agent_name', None) if hasattr(g, 'request_agent_name') else None + previous_conversation_id = getattr(g, 'conversation_id', None) if hasattr(g, 'conversation_id') else None + + g.force_enable_agents = True + g.request_agent_info = dict(selected_agent) + g.request_agent_name = selected_agent.get('name') + callback_key = None + if conversation_id: + plugin_logger.clear_invocations_for_conversation(user_id, conversation_id) + g.conversation_id = conversation_id + + try: + kernel = Kernel() + kernel, agent_objs = load_user_semantic_kernel(kernel, settings, user_id, None) + if not agent_objs: + raise ValueError('The selected agent could not be loaded for exhaustive review.') + + loaded_agent = None + requested_name = str(selected_agent.get('name') or '').strip() + if requested_name: + loaded_agent = agent_objs.get(requested_name) + if loaded_agent is None: + loaded_agent = next(iter(agent_objs.values())) + + if thought_tracker and run_id and conversation_id: + callback_key = register_plugin_invocation_thought_callback( + plugin_logger, + thought_tracker, + user_id, + conversation_id, + actor_label='Workflow agent', + ) + + def invoke_prompt(prompt_text, stage='window_review', metadata=None): + result = asyncio.run(loaded_agent.invoke([ + ChatMessageContent(role='user', content=prompt_text), + ])) + return str(result) + + review_result = run_exhaustive_document_review( + user_id=user_id, + review_prompt=workflow.get('task_prompt', ''), + document_ids=review_config.get('document_ids'), + invoke_prompt=invoke_prompt, + doc_scope=review_config.get('doc_scope'), + active_group_ids=review_config.get('active_group_ids'), + active_public_workspace_id=review_config.get('active_public_workspace_id'), + window_unit=review_config.get('window_unit'), + window_size=review_config.get('window_size'), + window_percent=review_config.get('window_percent'), + max_retries_per_window=review_config.get('max_retries_per_window'), + activity_callback=activity_callback, + max_documents=WORKFLOW_EXHAUSTIVE_REVIEW_MAX_DOCUMENTS, + ) + agent_citations = _build_agent_citations_from_invocations(user_id, conversation_id) + alert_targets = _collect_agent_alert_targets(user_id, conversation_id) + + return { + 'reply': review_result.get('reply', ''), + 'review_result': review_result, + 'review_coverage': review_result.get('coverage') or {}, + 'model_deployment_name': getattr(loaded_agent, 'deployment_name', None) or requested_name, + 'provider': 'agent', + 'agent_name': getattr(loaded_agent, 'name', None) or requested_name, + 'agent_display_name': getattr(loaded_agent, 'display_name', None) or selected_agent.get('display_name') or requested_name, + 'agent_citations': agent_citations, + 'alert_targets': alert_targets, + } + finally: + if callback_key: + plugin_logger.deregister_callbacks(callback_key) + if previous_force_enable_agents is None and hasattr(g, 'force_enable_agents'): + delattr(g, 'force_enable_agents') + else: + g.force_enable_agents = previous_force_enable_agents + + if previous_request_agent_info is None and hasattr(g, 'request_agent_info'): + delattr(g, 'request_agent_info') + else: + g.request_agent_info = previous_request_agent_info + + if previous_request_agent_name is None and hasattr(g, 'request_agent_name'): + delattr(g, 'request_agent_name') + else: + g.request_agent_name = previous_request_agent_name + + if previous_conversation_id is None and hasattr(g, 'conversation_id'): + delattr(g, 'conversation_id') + else: + g.conversation_id = previous_conversation_id + + client, deployment_name, provider = _resolve_model_workflow_client(workflow, settings) + + def invoke_model_prompt(prompt_text, stage='window_review', metadata=None): + completion = client.chat.completions.create( + model=deployment_name, + messages=[{'role': 'user', 'content': prompt_text}], + ) + if not getattr(completion, 'choices', None): + return '' + return _extract_message_text(completion.choices[0].message.content) + + review_result = run_exhaustive_document_review( + user_id=user_id, + review_prompt=workflow.get('task_prompt', ''), + document_ids=review_config.get('document_ids'), + invoke_prompt=invoke_model_prompt, + doc_scope=review_config.get('doc_scope'), + active_group_ids=review_config.get('active_group_ids'), + active_public_workspace_id=review_config.get('active_public_workspace_id'), + window_unit=review_config.get('window_unit'), + window_size=review_config.get('window_size'), + window_percent=review_config.get('window_percent'), + max_retries_per_window=review_config.get('max_retries_per_window'), + activity_callback=activity_callback, + max_documents=WORKFLOW_EXHAUSTIVE_REVIEW_MAX_DOCUMENTS, + ) + return { + 'reply': review_result.get('reply', ''), + 'review_result': review_result, + 'review_coverage': review_result.get('coverage') or {}, + 'model_deployment_name': deployment_name, + 'provider': provider, + } + + +def _execute_document_comparison_workflow( + workflow, + settings, + conversation_id='', + run_id=None, + thought_tracker=None, + external_activity_callback=None, + action_config=None, +): + comparison_config = action_config if isinstance(action_config, dict) else _get_document_action_config(workflow) + if comparison_config.get('type') != DOCUMENT_ACTION_TYPE_COMPARISON: + raise ValueError('Document comparison is not enabled for this workflow.') + + activity_callback = _chain_activity_callbacks( + _build_document_action_activity_callback(workflow, run_id, thought_tracker=thought_tracker), + external_activity_callback, + ) + user_id = str(workflow.get('user_id') or '').strip() + selected_agent = workflow.get('selected_agent') if isinstance(workflow.get('selected_agent'), dict) else {} + + if workflow.get('runner_type') == 'agent': + with _ensure_execution_context(user_id): + plugin_logger = get_plugin_logger() + previous_force_enable_agents = getattr(g, 'force_enable_agents', None) if hasattr(g, 'force_enable_agents') else None + previous_request_agent_info = getattr(g, 'request_agent_info', None) if hasattr(g, 'request_agent_info') else None + previous_request_agent_name = getattr(g, 'request_agent_name', None) if hasattr(g, 'request_agent_name') else None + previous_conversation_id = getattr(g, 'conversation_id', None) if hasattr(g, 'conversation_id') else None + + g.force_enable_agents = True + g.request_agent_info = dict(selected_agent) + g.request_agent_name = selected_agent.get('name') + callback_key = None + if conversation_id: + plugin_logger.clear_invocations_for_conversation(user_id, conversation_id) + g.conversation_id = conversation_id + + try: + kernel = Kernel() + kernel, agent_objs = load_user_semantic_kernel(kernel, settings, user_id, None) + if not agent_objs: + raise ValueError('The selected agent could not be loaded for document comparison.') + + loaded_agent = None + requested_name = str(selected_agent.get('name') or '').strip() + if requested_name: + loaded_agent = agent_objs.get(requested_name) + if loaded_agent is None: + loaded_agent = next(iter(agent_objs.values())) + + if thought_tracker and run_id and conversation_id: + callback_key = register_plugin_invocation_thought_callback( + plugin_logger, + thought_tracker, + user_id, + conversation_id, + actor_label='Workflow agent', + ) + + def invoke_prompt(prompt_text, stage='window_review', metadata=None): + result = asyncio.run(loaded_agent.invoke([ + ChatMessageContent(role='user', content=prompt_text), + ])) + return str(result) + + comparison_result = run_document_comparison( + user_id=user_id, + comparison_prompt=workflow.get('task_prompt', ''), + action_config=comparison_config, + invoke_prompt=invoke_prompt, + activity_callback=activity_callback, + ) + agent_citations = _build_agent_citations_from_invocations(user_id, conversation_id) + alert_targets = _collect_agent_alert_targets(user_id, conversation_id) + + return { + 'reply': comparison_result.get('reply', ''), + 'review_result': comparison_result, + 'review_coverage': comparison_result.get('coverage') or {}, + 'model_deployment_name': getattr(loaded_agent, 'deployment_name', None) or requested_name, + 'provider': 'agent', + 'agent_name': getattr(loaded_agent, 'name', None) or requested_name, + 'agent_display_name': getattr(loaded_agent, 'display_name', None) or selected_agent.get('display_name') or requested_name, + 'agent_citations': agent_citations, + 'alert_targets': alert_targets, + } + finally: + if callback_key: + plugin_logger.deregister_callbacks(callback_key) + if previous_force_enable_agents is None and hasattr(g, 'force_enable_agents'): + delattr(g, 'force_enable_agents') + else: + g.force_enable_agents = previous_force_enable_agents + + if previous_request_agent_info is None and hasattr(g, 'request_agent_info'): + delattr(g, 'request_agent_info') + else: + g.request_agent_info = previous_request_agent_info + + if previous_request_agent_name is None and hasattr(g, 'request_agent_name'): + delattr(g, 'request_agent_name') + else: + g.request_agent_name = previous_request_agent_name + + if previous_conversation_id is None and hasattr(g, 'conversation_id'): + delattr(g, 'conversation_id') + else: + g.conversation_id = previous_conversation_id + + client, deployment_name, provider = _resolve_model_workflow_client(workflow, settings) + + def invoke_model_prompt(prompt_text, stage='window_review', metadata=None): + completion = client.chat.completions.create( + model=deployment_name, + messages=[{'role': 'user', 'content': prompt_text}], + ) + if not getattr(completion, 'choices', None): + return '' + return _extract_message_text(completion.choices[0].message.content) + + comparison_result = run_document_comparison( + user_id=user_id, + comparison_prompt=workflow.get('task_prompt', ''), + action_config=comparison_config, + invoke_prompt=invoke_model_prompt, + activity_callback=activity_callback, + ) + return { + 'reply': comparison_result.get('reply', ''), + 'review_result': comparison_result, + 'review_coverage': comparison_result.get('coverage') or {}, + 'model_deployment_name': deployment_name, + 'provider': provider, + } + + +def _execute_document_action_workflow( + workflow, + settings, + conversation_id='', + run_id=None, + thought_tracker=None, + external_activity_callback=None, +): + action_config = _get_document_action_config(workflow) + if action_config.get('type') == DOCUMENT_ACTION_TYPE_EXHAUSTIVE_REVIEW: + return _execute_exhaustive_review_workflow( + workflow, + settings, + conversation_id=conversation_id, + run_id=run_id, + thought_tracker=thought_tracker, + external_activity_callback=external_activity_callback, + action_config=action_config, + ) + if action_config.get('type') == DOCUMENT_ACTION_TYPE_COMPARISON: + return _execute_document_comparison_workflow( + workflow, + settings, + conversation_id=conversation_id, + run_id=run_id, + thought_tracker=thought_tracker, + external_activity_callback=external_activity_callback, + action_config=action_config, + ) + raise ValueError('No document action is enabled for this workflow.') + + +def _execute_agent_workflow(workflow, settings, conversation_id='', run_id=None, thought_tracker=None): + user_id = str(workflow.get('user_id') or '').strip() + selected_agent = workflow.get('selected_agent') if isinstance(workflow.get('selected_agent'), dict) else {} + if not selected_agent: + raise ValueError('No selected agent is configured for this workflow.') + + with _ensure_execution_context(user_id): + plugin_logger = get_plugin_logger() + previous_force_enable_agents = getattr(g, 'force_enable_agents', None) if hasattr(g, 'force_enable_agents') else None + previous_request_agent_info = getattr(g, 'request_agent_info', None) if hasattr(g, 'request_agent_info') else None + previous_request_agent_name = getattr(g, 'request_agent_name', None) if hasattr(g, 'request_agent_name') else None + previous_conversation_id = getattr(g, 'conversation_id', None) if hasattr(g, 'conversation_id') else None + + g.force_enable_agents = True + g.request_agent_info = dict(selected_agent) + g.request_agent_name = selected_agent.get('name') + callback_key = None + if conversation_id: + plugin_logger.clear_invocations_for_conversation(user_id, conversation_id) + g.conversation_id = conversation_id + + if thought_tracker and run_id: + agent_label = selected_agent.get('display_name') or selected_agent.get('name') or 'Agent' + _add_workflow_activity_thought( + thought_tracker, + workflow, + run_id, + step_type='generation', + content=f'Starting agent workflow with {agent_label}', + detail=f'agent={agent_label}', + activity_key=f'agent:{run_id}', + kind='agent_execution', + title='Agent execution', + status='running', + ) + + if thought_tracker and run_id and conversation_id: + callback_key = register_plugin_invocation_thought_callback( + plugin_logger, + thought_tracker, + user_id, + conversation_id, + actor_label='Workflow agent', + ) + + try: + kernel = Kernel() + kernel, agent_objs = load_user_semantic_kernel(kernel, settings, user_id, None) + if not agent_objs: + raise ValueError('The selected agent could not be loaded for workflow execution.') + + loaded_agent = None + requested_name = str(selected_agent.get('name') or '').strip() + if requested_name: + loaded_agent = agent_objs.get(requested_name) + if loaded_agent is None: + loaded_agent = next(iter(agent_objs.values())) + + result = asyncio.run(loaded_agent.invoke([ + ChatMessageContent(role='user', content=workflow.get('task_prompt', '')), + ])) + reply = str(result) + agent_citations = _build_agent_citations_from_invocations(user_id, conversation_id) + alert_targets = _collect_agent_alert_targets(user_id, conversation_id) + + if thought_tracker and run_id: + _add_workflow_activity_thought( + thought_tracker, + workflow, + run_id, + step_type='generation', + content='Agent workflow completed', + detail=f"agent={getattr(loaded_agent, 'display_name', None) or getattr(loaded_agent, 'name', None) or requested_name}", + activity_key=f'agent:{run_id}', + kind='agent_execution', + title='Agent execution', + status='completed', + ) + + return { + 'reply': reply, + 'model_deployment_name': getattr(loaded_agent, 'deployment_name', None) or requested_name, + 'provider': 'agent', + 'agent_name': getattr(loaded_agent, 'name', None) or requested_name, + 'agent_display_name': getattr(loaded_agent, 'display_name', None) or selected_agent.get('display_name') or requested_name, + 'agent_citations': agent_citations, + 'alert_targets': alert_targets, + } + finally: + if callback_key: + plugin_logger.deregister_callbacks(callback_key) + if previous_force_enable_agents is None and hasattr(g, 'force_enable_agents'): + delattr(g, 'force_enable_agents') + else: + g.force_enable_agents = previous_force_enable_agents + + if previous_request_agent_info is None and hasattr(g, 'request_agent_info'): + delattr(g, 'request_agent_info') + else: + g.request_agent_info = previous_request_agent_info + + if previous_request_agent_name is None and hasattr(g, 'request_agent_name'): + delattr(g, 'request_agent_name') + else: + g.request_agent_name = previous_request_agent_name + + if previous_conversation_id is None and hasattr(g, 'conversation_id'): + delattr(g, 'conversation_id') + else: + g.conversation_id = previous_conversation_id + + +def run_personal_workflow(workflow, trigger_source='manual'): + """Execute a personal workflow and persist a run record.""" + workflow = workflow if isinstance(workflow, dict) else {} + user_id = str(workflow.get('user_id') or '').strip() + workflow_id = str(workflow.get('id') or '').strip() + run_id = str(uuid.uuid4()) + started_at = _utc_now_iso() + settings = get_settings() + + run_record = { + 'id': run_id, + 'workflow_id': workflow_id, + 'workflow_name': workflow.get('name'), + 'runner_type': workflow.get('runner_type'), + 'trigger_type': workflow.get('trigger_type'), + 'trigger_source': trigger_source, + 'status': 'running', + 'success': False, + 'started_at': started_at, + 'completed_at': None, + 'conversation_id': workflow.get('conversation_id'), + 'response_preview': '', + 'error': '', + } + save_personal_workflow_run(user_id, run_record) + + conversation = None + thought_tracker = None + try: + conversation = _ensure_workflow_conversation(workflow) + run_record['conversation_id'] = conversation.get('id') + user_message_doc = _create_user_message(conversation.get('id'), workflow, trigger_source, run_id) + assistant_message_id, thought_tracker = _initialize_workflow_assistant_tracking( + conversation.get('id'), + user_id, + user_message_doc, + ) + run_record['user_message_id'] = user_message_doc.get('id') + run_record['assistant_message_id'] = assistant_message_id + save_personal_workflow_run(user_id, run_record) + + _add_workflow_activity_thought( + thought_tracker, + workflow, + run_id, + step_type='workflow', + content='Workflow run started', + detail=f'trigger_source={trigger_source}', + activity_key=f'run:{run_id}', + kind='workflow_run', + title='Workflow run', + status='running', + ) + + document_action = _get_document_action_config(workflow) + if document_action.get('type') != DOCUMENT_ACTION_TYPE_NONE: + execution_result = _execute_document_action_workflow( + workflow, + settings, + conversation_id=conversation.get('id'), + run_id=run_id, + thought_tracker=thought_tracker, + ) + elif workflow.get('runner_type') == 'agent': + execution_result = _execute_agent_workflow( + workflow, + settings, + conversation_id=conversation.get('id'), + run_id=run_id, + thought_tracker=thought_tracker, + ) + else: + execution_result = _execute_model_workflow( + workflow, + settings, + run_id=run_id, + thought_tracker=thought_tracker, + ) + + assistant_doc = _create_assistant_message( + conversation, + workflow, + execution_result, + trigger_source, + run_id, + user_message_doc, + assistant_message_id=assistant_message_id, + ) + _mirror_workflow_visualizations_to_created_conversations( + workflow, + assistant_doc, + execution_result, + ) + + _add_workflow_activity_thought( + thought_tracker, + workflow, + run_id, + step_type='workflow', + content='Workflow run completed', + detail=f"message_id={assistant_doc.get('id')}", + activity_key=f'run:{run_id}', + kind='workflow_run', + title='Workflow run', + status='completed', + ) + + completed_at = _utc_now_iso() + run_record.update({ + 'status': 'completed', + 'success': True, + 'completed_at': completed_at, + 'conversation_id': conversation.get('id'), + 'user_message_id': user_message_doc.get('id'), + 'assistant_message_id': assistant_doc.get('id'), + 'model_deployment_name': execution_result.get('model_deployment_name'), + 'agent_name': execution_result.get('agent_name'), + 'agent_display_name': execution_result.get('agent_display_name'), + 'review_coverage': execution_result.get('review_coverage') or {}, + 'response_preview': _build_response_preview(execution_result.get('reply')), + 'error': '', + }) + save_personal_workflow_run(user_id, run_record) + log_workflow_run( + user_id=user_id, + workflow_id=workflow_id, + workflow_name=workflow.get('name', ''), + status='completed', + trigger_source=trigger_source, + run_id=run_id, + conversation_id=conversation.get('id'), + runner_type=workflow.get('runner_type'), + ) + alert_notification = _create_workflow_priority_alert( + workflow, + run_record, + conversation, + execution_result=execution_result, + ) + + return { + 'success': True, + 'run': run_record, + 'notification': alert_notification, + 'workflow_updates': { + 'conversation_id': conversation.get('id'), + 'last_run_started_at': started_at, + 'last_run_at': completed_at, + 'last_run_status': 'completed', + 'last_run_error': '', + 'last_run_response_preview': run_record.get('response_preview', ''), + 'last_run_trigger_source': trigger_source, + 'run_count': int(workflow.get('run_count') or 0) + 1, + }, + } + except Exception as exc: + if thought_tracker: + _add_workflow_activity_thought( + thought_tracker, + workflow, + run_id, + step_type='workflow', + content='Workflow run failed', + detail=str(exc), + activity_key=f'run:{run_id}', + kind='workflow_run', + title='Workflow run', + status='failed', + ) + completed_at = _utc_now_iso() + run_record.update({ + 'status': 'failed', + 'success': False, + 'completed_at': completed_at, + 'error': str(exc), + 'response_preview': '', + }) + save_personal_workflow_run(user_id, run_record) + log_workflow_run( + user_id=user_id, + workflow_id=workflow_id, + workflow_name=workflow.get('name', ''), + status='failed', + trigger_source=trigger_source, + run_id=run_id, + conversation_id=run_record.get('conversation_id'), + runner_type=workflow.get('runner_type'), + error=str(exc), + ) + log_event( + f'[WorkflowRunner] Workflow execution failed: {exc}', + extra={ + 'workflow_id': workflow_id, + 'workflow_name': workflow.get('name'), + 'user_id': user_id, + 'trigger_source': trigger_source, + }, + level=logging.ERROR, + exceptionTraceback=True, + ) + alert_notification = _create_workflow_priority_alert( + workflow, + run_record, + conversation, + ) + return { + 'success': False, + 'run': run_record, + 'notification': alert_notification, + 'workflow_updates': { + 'last_run_started_at': started_at, + 'last_run_at': completed_at, + 'last_run_status': 'failed', + 'last_run_error': str(exc), + 'last_run_response_preview': '', + 'last_run_trigger_source': trigger_source, + 'run_count': int(workflow.get('run_count') or 0) + 1, + 'conversation_id': run_record.get('conversation_id'), + }, + } \ No newline at end of file diff --git a/application/single_app/json_schema_validation.py b/application/single_app/json_schema_validation.py index abfbcb90..d0dfa25f 100644 --- a/application/single_app/json_schema_validation.py +++ b/application/single_app/json_schema_validation.py @@ -5,7 +5,19 @@ from functools import lru_cache from jsonschema import validate, ValidationError, Draft7Validator, Draft6Validator, RefResolver +from functions_blob_storage_operations import BLOB_STORAGE_PLUGIN_TYPE, derive_blob_endpoint_from_connection_string +from functions_chart_operations import CHART_DEFAULT_ENDPOINT + SCHEMA_DIR = os.path.join(os.path.dirname(__file__), 'static', 'json', 'schemas') +PLUGIN_ENDPOINT_DEFAULTS = { + 'sql_schema': 'sql://sql_schema', + 'sql_query': 'sql://sql_query', + 'chart': CHART_DEFAULT_ENDPOINT, + 'msgraph': 'https://graph.microsoft.com', + 'simplechat': 'simplechat://internal', + 'search': 'internal://document-search', + 'document_search': 'internal://document-search', +} PLUGIN_STORAGE_MANAGED_FIELDS = { '_attachments', @@ -45,20 +57,32 @@ def validate_agent(agent): return '; '.join([e.message for e in errors]) return None -def validate_plugin(plugin): - schema = load_schema('plugin.schema.json') - - # For SQL plugins, temporarily provide a dummy endpoint if none exists - # since SQL plugins don't use endpoints but the schema requires them - plugin_copy = plugin.copy() - plugin_type = plugin_copy.get('type', '') - + +def apply_plugin_validation_defaults(plugin): + plugin_copy = plugin.copy() if isinstance(plugin, dict) else {} + plugin_type = str(plugin_copy.get('type', '') or '').strip().lower() + # Remove storage-managed fields that appear on persisted plugin documents but are not part of the schema. for field in PLUGIN_STORAGE_MANAGED_FIELDS: plugin_copy.pop(field, None) - - if plugin_type in ['sql_schema', 'sql_query'] and not plugin_copy.get('endpoint'): - plugin_copy['endpoint'] = f'sql://{plugin_type}' + + default_endpoint = PLUGIN_ENDPOINT_DEFAULTS.get(plugin_type) + if default_endpoint and not str(plugin_copy.get('endpoint', '') or '').strip(): + plugin_copy['endpoint'] = default_endpoint + + if plugin_type == BLOB_STORAGE_PLUGIN_TYPE and not str(plugin_copy.get('endpoint', '') or '').strip(): + auth = plugin_copy.get('auth', {}) if isinstance(plugin_copy.get('auth'), dict) else {} + if str(auth.get('type') or '').strip().lower() == 'connection_string': + derived_endpoint = derive_blob_endpoint_from_connection_string(auth.get('key') or '') + if derived_endpoint: + plugin_copy['endpoint'] = derived_endpoint + + return plugin_copy + +def validate_plugin(plugin): + schema = load_schema('plugin.schema.json') + plugin_copy = apply_plugin_validation_defaults(plugin) + plugin_type = str(plugin_copy.get('type', '') or '').strip().lower() # First run schema validation if schema.get("$ref") and schema.get("definitions"): @@ -72,7 +96,7 @@ def validate_plugin(plugin): # Additional business logic validation # For non-SQL plugins, endpoint must not be empty if plugin_type not in ['sql_schema', 'sql_query']: - endpoint = plugin.get('endpoint', '') + endpoint = plugin_copy.get('endpoint', '') if not endpoint or endpoint.strip() == '': return 'Non-SQL plugins must have a valid endpoint' diff --git a/application/single_app/plugin_validation_endpoint.py b/application/single_app/plugin_validation_endpoint.py index d59a8d80..d5a76f27 100644 --- a/application/single_app/plugin_validation_endpoint.py +++ b/application/single_app/plugin_validation_endpoint.py @@ -3,61 +3,84 @@ Additional validation endpoints for plugin health checking and manifest validation. """ -from flask import Blueprint, jsonify, request, current_app -from semantic_kernel_plugins.plugin_health_checker import PluginHealthChecker, PluginErrorRecovery -from semantic_kernel_plugins.plugin_loader import discover_plugins -from functions_appinsights import log_event -from functions_authentication import login_required, admin_required -from swagger_wrapper import swagger_route, get_auth_security import logging +from flask import Blueprint, current_app, jsonify, request + +from functions_appinsights import log_event +from functions_authentication import admin_required, login_required +from json_schema_validation import apply_plugin_validation_defaults +from semantic_kernel_plugins.plugin_health_checker import PluginErrorRecovery, PluginHealthChecker +from semantic_kernel_plugins.plugin_loader import discover_plugins +from swagger_wrapper import get_auth_security, swagger_route + plugin_validation_bp = Blueprint('plugin_validation', __name__) -@plugin_validation_bp.route('/api/admin/plugins/validate', methods=['POST']) +def _validate_plugin_manifest_request(): + manifest = apply_plugin_validation_defaults(request.get_json(silent=True) or {}) + if not manifest: + return jsonify({'error': 'No manifest provided'}), 400 + + plugin_type = manifest.get('type', '') + plugin_name = manifest.get('name', 'unnamed') + + # Perform health checker validation + is_valid, validation_errors = PluginHealthChecker.validate_plugin_manifest(manifest, plugin_type) + + response = { + 'valid': is_valid, + 'plugin_name': plugin_name, + 'plugin_type': plugin_type, + 'errors': validation_errors, + 'warnings': [] + } + + # Additional checks for completeness + if not manifest.get('description'): + response['warnings'].append('Plugin description is empty') + + if plugin_type in ['azure_function', 'blob_storage', 'queue_storage'] and not manifest.get('endpoint'): + response['warnings'].append('Endpoint field is recommended for this plugin type') + + log_event( + f"[Plugin Validation] Validated manifest for {plugin_name}", + extra={'plugin_name': plugin_name, 'valid': is_valid, 'errors': validation_errors}, + ) + + return jsonify(response) + + +@plugin_validation_bp.route('/api/plugins/validate', methods=['POST']) @swagger_route( security=get_auth_security() ) @login_required -@admin_required def validate_plugin_manifest(): """ Validate a plugin manifest without saving it. Useful for frontend validation before submission. """ try: - manifest = request.json - if not manifest: - return jsonify({'error': 'No manifest provided'}), 400 - - plugin_type = manifest.get('type', '') - plugin_name = manifest.get('name', 'unnamed') - - # Perform health checker validation - is_valid, validation_errors = PluginHealthChecker.validate_plugin_manifest(manifest, plugin_type) - - response = { - 'valid': is_valid, - 'plugin_name': plugin_name, - 'plugin_type': plugin_type, - 'errors': validation_errors, - 'warnings': [] - } - - # Additional checks for completeness - if not manifest.get('description'): - response['warnings'].append('Plugin description is empty') - - if plugin_type in ['azure_function', 'blob_storage', 'queue_storage']: - if not manifest.get('endpoint'): - response['warnings'].append('Endpoint field is recommended for this plugin type') - - log_event(f"[Plugin Validation] Validated manifest for {plugin_name}", - extra={'plugin_name': plugin_name, 'valid': is_valid, 'errors': validation_errors}) - - return jsonify(response) - + return _validate_plugin_manifest_request() + except Exception as e: + log_event(f"[Plugin Validation] Error validating manifest: {str(e)}", level=logging.ERROR) + return jsonify({'error': f'Validation failed: {str(e)}'}), 500 + + +@plugin_validation_bp.route('/api/admin/plugins/validate', methods=['POST']) +@swagger_route( + security=get_auth_security() +) +@login_required +@admin_required +def validate_plugin_manifest_admin(): + """ + Backward-compatible admin route for plugin manifest validation. + """ + try: + return _validate_plugin_manifest_request() except Exception as e: log_event(f"[Plugin Validation] Error validating manifest: {str(e)}", level=logging.ERROR) return jsonify({'error': f'Validation failed: {str(e)}'}), 500 diff --git a/application/single_app/route_backend_agents.py b/application/single_app/route_backend_agents.py index 561f1d18..d25735dc 100644 --- a/application/single_app/route_backend_agents.py +++ b/application/single_app/route_backend_agents.py @@ -12,7 +12,7 @@ ) from semantic_kernel_loader import get_agent_orchestration_types from functions_settings import get_settings, update_settings, get_user_settings, update_user_settings, sanitize_model_endpoints_for_frontend -from functions_global_agents import get_global_agents, save_global_agent, delete_global_agent +from functions_global_agents import get_global_agents, save_global_agent, delete_global_agent, update_global_agent_enabled from functions_personal_agents import get_personal_agents, ensure_migration_complete, save_personal_agent, delete_personal_agent from functions_group import require_active_group, assert_group_role from functions_agent_payload import ( @@ -1014,7 +1014,7 @@ def set_selected_agent(): def list_agents(): try: # Use new global agents container - agents = get_global_agents() + agents = get_global_agents(include_disabled=True) # Ensure each agent has an actions_to_load field for agent in agents: @@ -1031,6 +1031,73 @@ def list_agents(): return jsonify({'error': 'Failed to list agents.'}), 500 +@bpa.route('/api/admin/agents//enabled', methods=['PATCH']) +@swagger_route( + security=get_auth_security() +) +@login_required +@admin_required +def set_agent_enabled(agent_name): + try: + data = request.get_json(silent=True) or {} + if 'is_enabled' not in data or not isinstance(data.get('is_enabled'), bool): + return jsonify({'error': 'Field "is_enabled" must be a boolean.'}), 400 + + is_enabled = data.get('is_enabled') + agents = get_global_agents(include_disabled=True) + target_agent = next((agent for agent in agents if agent.get('name') == agent_name), None) + if not target_agent: + return jsonify({'error': 'Agent not found.'}), 404 + + result = update_global_agent_enabled( + target_agent.get('id'), + is_enabled, + user_id=str(get_current_user_id()) + ) + if not result: + return jsonify({'error': 'Failed to update agent enabled state.'}), 500 + + fallback_agent_name = None + settings = get_settings() + selected_agent = settings.get('global_selected_agent', {}) if isinstance(settings.get('global_selected_agent', {}), dict) else {} + if not is_enabled and selected_agent.get('name') == agent_name: + enabled_agents = get_global_agents() + if enabled_agents: + fallback_agent_name = enabled_agents[0].get('name') + settings['global_selected_agent'] = { + 'name': fallback_agent_name, + 'is_global': True, + 'is_group': False, + } + else: + settings['global_selected_agent'] = {} + update_settings(settings) + + log_agent_update( + user_id=str(get_current_user_id()), + agent_id=target_agent.get('id', ''), + agent_name=agent_name, + agent_display_name=target_agent.get('display_name', ''), + scope='global' + ) + log_event( + "Global agent enabled state updated", + extra={ + 'action': 'toggle-enabled', + 'agent_name': agent_name, + 'agent_id': target_agent.get('id', ''), + 'is_enabled': is_enabled, + 'fallback_agent_name': fallback_agent_name, + 'user': str(get_current_user_id()), + } + ) + setattr(builtins, "kernel_reload_needed", True) + return jsonify({'success': True, 'fallback_agent_name': fallback_agent_name}) + except Exception as e: + log_event(f"Error updating agent enabled state: {e}", level=logging.ERROR, exceptionTraceback=True) + return jsonify({'error': 'Failed to update agent enabled state.'}), 500 + + @bpa.route('/api/admin/agents/default-model-migration/preview', methods=['GET']) @swagger_route( security=get_auth_security() @@ -1194,7 +1261,7 @@ def run_default_model_agent_migration(): @admin_required def add_agent(): try: - agents = get_global_agents() + agents = get_global_agents(include_disabled=True) new_agent = request.json.copy() if hasattr(request.json, 'copy') else dict(request.json) try: cleaned_agent = sanitize_agent_payload(new_agent) @@ -1300,7 +1367,7 @@ def update_agent_setting(setting_name): @admin_required def edit_agent(agent_name): try: - agents = get_global_agents() + agents = get_global_agents(include_disabled=True) updated_agent = request.json.copy() if hasattr(request.json, 'copy') else dict(request.json) try: cleaned_agent = sanitize_agent_payload(updated_agent) @@ -1360,7 +1427,7 @@ def edit_agent(agent_name): @admin_required def delete_agent(agent_name): try: - agents = get_global_agents() + agents = get_global_agents(include_disabled=True) # Find the agent to delete agent_to_delete = None @@ -1510,6 +1577,7 @@ def get_global_agent_settings(include_admin_extras=False, user_id=None, group_id "gpt_model": settings.get("gpt_model", {}), "allow_user_agents": settings.get("allow_user_agents", False), "allow_user_custom_endpoints": settings.get("allow_user_custom_endpoints", False), + "allow_user_workflows": settings.get("allow_user_workflows", True), "allow_group_agents": settings.get("allow_group_agents", False), "allow_group_custom_endpoints": settings.get("allow_group_custom_endpoints", False), "allow_ai_foundry_agents": settings.get("allow_ai_foundry_agents", False), diff --git a/application/single_app/route_backend_chats.py b/application/single_app/route_backend_chats.py index e16d7242..76586494 100644 --- a/application/single_app/route_backend_chats.py +++ b/application/single_app/route_backend_chats.py @@ -36,6 +36,7 @@ from functions_group import find_group_by_id, get_group_model_endpoints, get_user_role_in_group from functions_chat import * from functions_content import generate_embedding, generate_embeddings_batch +from functions_chart_operations import INLINE_CHART_BLOCK_LANGUAGE from functions_conversation_metadata import collect_conversation_metadata, update_conversation_with_metadata from functions_conversation_unread import mark_conversation_unread from functions_debug import debug_print @@ -46,13 +47,22 @@ from azure.identity import ClientSecretCredential, DefaultAzureCredential, get_bearer_token_provider from functions_keyvault import SecretReturnType, keyvault_model_endpoint_get_helper from functions_message_artifacts import ( + build_agent_citation_tool_label, build_agent_citation_artifact_documents, build_message_artifact_payload_map, filter_assistant_artifact_items, hydrate_agent_citations_from_artifacts, make_json_serializable, ) +from functions_document_actions import ( + DOCUMENT_ACTION_TYPE_COMPARISON, + DOCUMENT_ACTION_TYPE_EXHAUSTIVE_REVIEW, + DOCUMENT_ACTION_TYPE_NONE, + normalize_document_action_config, +) +from functions_exhaustive_document_review import CHAT_EXHAUSTIVE_REVIEW_MAX_DOCUMENTS from functions_thoughts import ThoughtTracker +from functions_workflow_runner import _execute_document_action_workflow def _strip_agent_citation_artifact_refs(agent_citations): @@ -74,6 +84,69 @@ def _strip_agent_citation_artifact_refs(agent_citations): FACT_MEMORY_TYPE_FACT = 'fact' FACT_MEMORY_TYPE_INSTRUCTION = 'instruction' FACT_MEMORY_TYPE_LEGACY_DESCRIBER = 'describer' +INLINE_CHART_ID_PATTERN_TEMPLATE = '"chartId":"{}"' + + +def _normalize_inline_chart_markdown(chart_markdown): + block = str(chart_markdown or '').strip() + if not block.startswith(f'```{INLINE_CHART_BLOCK_LANGUAGE}'): + return None + return block + + +def _collect_inline_chart_blocks(candidate, chart_blocks): + if isinstance(candidate, dict): + normalized_chart_markdown = _normalize_inline_chart_markdown(candidate.get('chart_markdown')) + if normalized_chart_markdown: + chart_blocks.append({ + 'chart_id': candidate.get('chart_payload', {}).get('chartId') if isinstance(candidate.get('chart_payload'), dict) else None, + 'chart_markdown': normalized_chart_markdown, + }) + + for value in candidate.values(): + _collect_inline_chart_blocks(value, chart_blocks) + return + + if isinstance(candidate, list): + for item in candidate: + _collect_inline_chart_blocks(item, chart_blocks) + + +def _append_inline_chart_blocks_to_message(message_content, agent_citations): + chart_blocks = [] + _collect_inline_chart_blocks(agent_citations, chart_blocks) + + if not chart_blocks: + return message_content + + existing_content = str(message_content or '').strip() + appended_blocks = [] + seen_chart_ids = set() + + for chart_block in chart_blocks: + chart_id = str(chart_block.get('chart_id') or '').strip() + chart_markdown = chart_block.get('chart_markdown') + if not chart_markdown: + continue + + if chart_id: + if chart_id in seen_chart_ids: + continue + if INLINE_CHART_ID_PATTERN_TEMPLATE.format(chart_id) in existing_content: + seen_chart_ids.add(chart_id) + continue + seen_chart_ids.add(chart_id) + + if chart_markdown in existing_content: + continue + + appended_blocks.append(chart_markdown) + + if not appended_blocks: + return message_content + + separator = '\n\n' if existing_content else '' + return f"{existing_content}{separator}{'\n\n'.join(appended_blocks)}" def normalize_fact_memory_type(memory_type): @@ -968,6 +1041,96 @@ def build_tabular_computed_results_system_message(source_label, tabular_analysis ) +def user_requested_chart_visualization(user_message): + """Return True when the user is explicitly asking for a plotted visualization.""" + normalized_message = re.sub(r'\s+', ' ', str(user_message or '').strip().lower()) + if not normalized_message: + return False + + non_visual_patterns = ( + 'chart of accounts', + 'org chart', + 'organization chart', + 'organizational chart', + 'chart out ', + ) + if any(pattern in normalized_message for pattern in non_visual_patterns): + return False + + if re.search( + r'\b(?:bar|line|pie|doughnut|scatter|bubble|radar|histogram|heatmap|area|stacked(?:\s+bar|\s+line)?)\s+chart\b', + normalized_message, + ): + return True + + if 'table and chart' in normalized_message or 'chart and table' in normalized_message: + return True + + if re.search(r'\b(?:graph|plot|visuali[sz]e?|visuali[sz]ation)\b', normalized_message): + return True + + return bool( + re.search( + r'\b(?:include|with|show|create|generate|render|make|build|draw|produce)\b[^.!?\n]{0,80}\bchart\b', + normalized_message, + ) + ) + + +def build_chart_tool_usage_system_message(): + """Instruct the outer agent handoff to prefer the real chart action over ASCII output.""" + return ( + "If the user explicitly asks for a chart, graph, plot, or visualization and a chart action/tool is available, " + "use that chart action/tool to produce a real inline chart. " + "Do not substitute ASCII bars, text-only pseudo-charts, or a promise to create a chart later when the chart tool is available. " + "If computed tabular results are already present in system messages, use those tool-backed values as the chart data source whenever they are sufficient. " + "Still include a table when the user asked for one. " + "If no chart action/tool is available, say briefly that a real chart tool is unavailable instead of pretending an ASCII chart satisfies the request." + ) + + +def insert_system_message_after_existing_system_messages(conversation_history, system_message_content): + """Insert a system message after existing system messages while avoiding duplicates.""" + if not isinstance(conversation_history, list): + return conversation_history + + normalized_content = str(system_message_content or '').strip() + if not normalized_content: + return conversation_history + + for message in conversation_history: + if ( + isinstance(message, dict) + and message.get('role') == 'system' + and str(message.get('content') or '').strip() == normalized_content + ): + return conversation_history + + insertion_index = 0 + while insertion_index < len(conversation_history): + message = conversation_history[insertion_index] + if not isinstance(message, dict) or message.get('role') != 'system': + break + insertion_index += 1 + + conversation_history.insert(insertion_index, { + 'role': 'system', + 'content': normalized_content, + }) + return conversation_history + + +def maybe_append_chart_tool_system_message(conversation_history, user_message, selected_agent): + """Add chart-tool guidance only when an agent is active and the user asked for a chart.""" + if not selected_agent or not user_requested_chart_visualization(user_message): + return conversation_history + + return insert_system_message_after_existing_system_messages( + conversation_history, + build_chart_tool_usage_system_message(), + ) + + MULTI_FILE_TABULAR_DISTINCT_URL_EXTRACT_PATTERN = ( r'(?i)https?://[^\s/]+/[^\s]*?(?:sites/|sitecollection/|teams/)[^\s"\']+' ) @@ -4592,12 +4755,12 @@ def build_system_prompt(force_tool_use=False, tool_error_messages=None, service_id="tabular-analysis", function_choice_behavior=( FunctionChoiceBehavior.Required( - maximum_auto_invoke_attempts=8, + maximum_auto_invoke_attempts=20, filters=allowed_function_filters, ) if force_tool_use else FunctionChoiceBehavior.Auto( - maximum_auto_invoke_attempts=7, + maximum_auto_invoke_attempts=20, filters=allowed_function_filters, ) ), @@ -4960,7 +5123,12 @@ def collect_tabular_sk_citations(user_id, conversation_id): parameters = getattr(inv, 'parameters', {}) or {} sheet_name = parameters.get('sheet_name') sheet_index = parameters.get('sheet_index') - tool_name = f"{inv.plugin_name}.{inv.function_name}" + tool_name = build_agent_citation_tool_label( + inv.plugin_name, + inv.function_name, + parameters, + inv.result, + ) if sheet_name: tool_name = f"{tool_name} [{sheet_name}]" elif sheet_index not in (None, ''): @@ -5935,6 +6103,496 @@ def inject_fact_memory_context( conversation_history.insert(0, message) return prompt_payload + def normalize_terminal_chat_payload(payload): + return make_json_serializable({ + 'done': True, + 'conversation_id': payload.get('conversation_id'), + 'conversation_title': payload.get('conversation_title'), + 'classification': payload.get('classification', []), + 'model_deployment_name': payload.get('model_deployment_name'), + 'message_id': payload.get('message_id'), + 'user_message_id': payload.get('user_message_id'), + 'augmented': payload.get('augmented', False), + 'hybrid_citations': payload.get('hybrid_citations', []), + 'web_search_citations': payload.get('web_search_citations', []), + 'agent_citations': payload.get('agent_citations', []), + 'agent_display_name': payload.get('agent_display_name'), + 'agent_name': payload.get('agent_name'), + 'full_content': payload.get('reply', ''), + 'image_url': payload.get('image_url'), + 'reload_messages': payload.get('reload_messages', False), + 'kernel_fallback_notice': payload.get('kernel_fallback_notice'), + 'thoughts_enabled': payload.get('thoughts_enabled', False), + 'blocked': payload.get('blocked', False), + 'review_coverage': payload.get('review_coverage', {}), + 'document_action': payload.get('document_action', {}), + }) + + def _build_document_action_stream_content(event): + event = event if isinstance(event, dict) else {} + event_type = str(event.get('type') or '').strip().lower() + document_name = str(event.get('document_name') or 'Document').strip() or 'Document' + window_range = event.get('window_range') if isinstance(event.get('window_range'), dict) else {} + window_number = window_range.get('window_number') + progress = event.get('progress') if isinstance(event.get('progress'), dict) else {} + documents = progress.get('documents') if isinstance(progress.get('documents'), list) else [] + document_progress = next( + (document for document in documents if document.get('document_id') == event.get('document_id')), + {}, + ) + total_windows = document_progress.get('total_windows') or event.get('window_count') or 0 + + if event_type == 'document_started': + return f'Starting exhaustive review for {document_name}' + if event_type == 'window_started' and window_number is not None: + return f'Reviewing window {window_number} of {total_windows} for {document_name}' + if event_type == 'window_retry' and window_number is not None: + return f'Retrying window {window_number} for {document_name} (attempt {event.get("attempt_number")})' + if event_type == 'window_failed' and window_number is not None: + return f'Window {window_number} failed for {document_name}' + if event_type == 'window_completed' and window_number is not None: + return f'Completed window {window_number} of {total_windows} for {document_name}' + if event_type == 'document_completed': + return f'Completed exhaustive review for {document_name}' + if event_type == 'comparison_started': + right_document_name = str(event.get('right_document_name') or 'Document').strip() or 'Document' + return f'Comparing {document_name} to {right_document_name}' + if event_type == 'comparison_completed': + right_document_name = str(event.get('right_document_name') or 'Document').strip() or 'Document' + return f'Completed comparison of {document_name} to {right_document_name}' + if event_type == 'comparison_reduction_started': + return 'Combining comparison findings across the selected documents' + return 'Running exhaustive review across the selected documents' + + def _build_document_action_stream_activity_callback(publish_background_event, assistant_message_id): + if not callable(publish_background_event) or not assistant_message_id: + return None, None + + step_index_state = {'value': 0} + + def publish_thought(content, progress=None): + payload = { + 'type': 'thought', + 'message_id': assistant_message_id, + 'step_index': step_index_state['value'], + 'step_type': 'document_review', + 'content': content, + } + if isinstance(progress, dict) and progress: + payload['progress'] = progress + + step_index_state['value'] += 1 + publish_background_event(f"data: {json.dumps(make_json_serializable(payload))}\n\n") + + def callback(event): + event = event if isinstance(event, dict) else {} + publish_thought( + _build_document_action_stream_content(event), + progress=event.get('progress') if isinstance(event.get('progress'), dict) else None, + ) + + return publish_thought, callback + + def _get_latest_chat_thread_id(conversation_id): + try: + rows = list(cosmos_messages_container.query_items( + query=( + 'SELECT TOP 1 c.metadata.thread_info.thread_id as thread_id ' + 'FROM c WHERE c.conversation_id = @conversation_id ' + 'ORDER BY c.timestamp DESC' + ), + parameters=[{'name': '@conversation_id', 'value': conversation_id}], + partition_key=conversation_id, + )) + return rows[0].get('thread_id') if rows else None + except Exception: + return None + + def _load_or_create_exhaustive_review_conversation(user_id, conversation_id=None): + if conversation_id: + try: + conversation_item = cosmos_conversations_container.read_item( + item=conversation_id, + partition_key=conversation_id, + ) + if conversation_item.get('user_id') != user_id: + raise PermissionError('You do not have access to this conversation.') + return conversation_item + except CosmosResourceNotFoundError: + pass + + created_conversation_id = conversation_id or str(uuid.uuid4()) + conversation_item = { + 'id': created_conversation_id, + 'user_id': user_id, + 'last_updated': datetime.utcnow().isoformat(), + 'title': 'New Conversation', + 'context': [], + 'tags': [], + 'strict': False, + 'chat_type': 'new', + 'has_unread_assistant_response': False, + 'last_unread_assistant_message_id': None, + 'last_unread_assistant_at': None, + } + cosmos_conversations_container.upsert_item(conversation_item) + log_conversation_creation( + user_id=user_id, + conversation_id=created_conversation_id, + title='New Conversation', + workspace_type='personal', + ) + conversation_item['added_to_activity_log'] = True + cosmos_conversations_container.upsert_item(conversation_item) + return conversation_item + + def execute_document_action_chat_request(data=None, publish_background_event=None, forced_action_type=None): + settings = get_settings() + data = data if isinstance(data, dict) else (request.get_json() or {}) + user_id = get_current_user_id() + if not user_id: + return {'error': 'User not authenticated'}, 401 + + user_message = str(data.get('message') or '').strip() + if not user_message: + return {'error': 'Message is required'}, 400 + + conversation_id = getattr(g, 'conversation_id', None) or data.get('conversation_id') + if conversation_id is not None: + conversation_id = str(conversation_id).strip() or None + + selected_document_id = data.get('selected_document_id') + selected_document_ids = data.get('selected_document_ids', []) + if not selected_document_ids and selected_document_id: + selected_document_ids = [selected_document_id] + + requested_action = data.get('document_action') if isinstance(data.get('document_action'), dict) else {} + if forced_action_type == DOCUMENT_ACTION_TYPE_EXHAUSTIVE_REVIEW and not requested_action: + requested_action = { + 'type': DOCUMENT_ACTION_TYPE_EXHAUSTIVE_REVIEW, + 'document_ids': selected_document_ids, + 'doc_scope': data.get('doc_scope'), + 'active_group_ids': data.get('active_group_ids') or data.get('active_group_id'), + 'active_public_workspace_id': data.get('active_public_workspace_ids') or data.get('active_public_workspace_id'), + 'window_unit': 'pages', + 'max_retries_per_window': 1, + } + try: + normalized_action = normalize_document_action_config( + action_payload=requested_action, + max_documents=CHAT_EXHAUSTIVE_REVIEW_MAX_DOCUMENTS, + ) + except ValueError as exc: + return {'error': str(exc)}, 400 + if normalized_action.get('type') == DOCUMENT_ACTION_TYPE_NONE: + return {'error': 'Select a document action before sending this request.'}, 400 + + selected_document_ids = normalized_action.get('document_ids', []) + document_scope = normalized_action.get('doc_scope', 'all') + active_group_ids = normalized_action.get('active_group_ids', []) + active_public_workspace_ids = normalized_action.get('active_public_workspace_id', []) + request_agent_info = data.get('agent_info') if isinstance(data.get('agent_info'), dict) else {} + runner_type = 'agent' if request_agent_info else 'model' + + try: + conversation_item = _load_or_create_exhaustive_review_conversation(user_id, conversation_id=conversation_id) + except PermissionError as exc: + return {'error': str(exc)}, 403 + + conversation_id = conversation_item.get('id') + g.conversation_id = conversation_id + + previous_thread_id = _get_latest_chat_thread_id(conversation_id) + current_thread_id = str(uuid.uuid4()) + user_message_id = f"{conversation_id}_user_{int(time.time())}_{random.randint(1000,9999)}" + user_metadata = { + 'user_info': { + 'user_id': user_id, + }, + 'thread_info': { + 'thread_id': current_thread_id, + 'previous_thread_id': previous_thread_id, + 'active_thread': True, + 'thread_attempt': 1, + }, + 'model_selection': { + 'selected_model': data.get('model_deployment'), + 'model_id': data.get('model_id'), + 'model_endpoint_id': data.get('model_endpoint_id'), + 'model_provider': data.get('model_provider'), + }, + 'exhaustive_review': { + 'enabled': normalized_action.get('type') == DOCUMENT_ACTION_TYPE_EXHAUSTIVE_REVIEW, + 'document_ids': normalized_action.get('document_ids', []), + 'doc_scope': normalized_action.get('doc_scope'), + 'active_group_ids': normalized_action.get('active_group_ids'), + 'active_public_workspace_id': normalized_action.get('active_public_workspace_id'), + }, + 'document_action': normalized_action, + } + user_message_doc = make_json_serializable({ + 'id': user_message_id, + 'conversation_id': conversation_id, + 'role': 'user', + 'content': user_message, + 'timestamp': datetime.utcnow().isoformat(), + 'model_deployment_name': data.get('model_deployment'), + 'metadata': user_metadata, + }) + cosmos_messages_container.upsert_item(user_message_doc) + + assistant_message_id, _, assistant_thread_attempt, response_message_context = _initialize_assistant_response_tracking( + conversation_id=conversation_id, + user_message_id=user_message_id, + current_user_thread_id=current_thread_id, + previous_thread_id=previous_thread_id, + retry_thread_attempt=None, + is_retry=False, + user_id=user_id, + ) + + publish_stream_thought = None + stream_activity_callback = None + if callable(publish_background_event): + publish_stream_thought, stream_activity_callback = _build_document_action_stream_activity_callback( + publish_background_event, + assistant_message_id, + ) + if callable(publish_stream_thought): + publish_stream_thought( + f"Queued {normalized_action.get('type').replace('_', ' ')} for {len(selected_document_ids)} selected document{'s' if len(selected_document_ids) != 1 else ''}" + ) + + workflow_like = { + 'id': f'chat-exhaustive-review:{conversation_id}', + 'user_id': user_id, + 'name': 'Chat Document Action', + 'task_prompt': user_message, + 'runner_type': runner_type, + 'selected_agent': request_agent_info, + 'model_endpoint_id': str(data.get('model_endpoint_id') or '').strip(), + 'model_id': str(data.get('model_id') or '').strip(), + 'legacy_model_deployment': str(data.get('model_deployment') or '').strip(), + 'model_binding_summary': { + 'endpoint_id': str(data.get('model_endpoint_id') or '').strip(), + 'model_id': str(data.get('model_id') or '').strip(), + 'provider': str(data.get('model_provider') or '').strip(), + }, + 'document_action': normalized_action, + 'exhaustive_review': { + 'enabled': normalized_action.get('type') == DOCUMENT_ACTION_TYPE_EXHAUSTIVE_REVIEW, + 'document_ids': normalized_action.get('document_ids', []), + 'doc_scope': normalized_action.get('doc_scope'), + 'active_group_ids': normalized_action.get('active_group_ids', []), + 'active_public_workspace_id': normalized_action.get('active_public_workspace_id', []), + 'window_unit': normalized_action.get('window_unit'), + 'window_size': normalized_action.get('window_size'), + 'window_percent': normalized_action.get('window_percent'), + 'max_retries_per_window': normalized_action.get('max_retries_per_window'), + }, + } + + try: + execution_result = _execute_document_action_workflow( + workflow_like, + settings, + conversation_id=conversation_id, + run_id=None, + thought_tracker=None, + external_activity_callback=stream_activity_callback, + ) + except Exception as exc: + log_event( + f'[ChatExhaustiveReview] Exhaustive chat review failed: {exc}', + extra={ + 'conversation_id': conversation_id, + 'user_id': user_id, + 'document_count': len(selected_document_ids), + }, + level=logging.ERROR, + exceptionTraceback=True, + ) + return {'error': str(exc), 'conversation_id': conversation_id, 'user_message_id': user_message_id}, 500 + + assistant_timestamp = datetime.utcnow().isoformat() + prepared_agent_citations = persist_agent_citation_artifacts( + conversation_id=conversation_id, + assistant_message_id=assistant_message_id, + agent_citations=execution_result.get('agent_citations') or [], + created_timestamp=assistant_timestamp, + user_info=response_message_context.get('user_info'), + ) + + assistant_doc = make_json_serializable({ + 'id': assistant_message_id, + 'conversation_id': conversation_id, + 'role': 'assistant', + 'content': execution_result.get('reply', ''), + 'timestamp': assistant_timestamp, + 'augmented': False, + 'hybrid_citations': [], + 'web_search_citations': [], + 'hybridsearch_query': None, + 'agent_citations': prepared_agent_citations, + 'model_deployment_name': execution_result.get('model_deployment_name'), + 'agent_display_name': execution_result.get('agent_display_name'), + 'agent_name': execution_result.get('agent_name'), + 'metadata': { + 'user_info': response_message_context.get('user_info'), + 'thread_info': { + 'thread_id': response_message_context.get('thread_id'), + 'previous_thread_id': response_message_context.get('previous_thread_id'), + 'active_thread': True, + 'thread_attempt': assistant_thread_attempt, + }, + 'exhaustive_review': { + 'enabled': normalized_action.get('type') == DOCUMENT_ACTION_TYPE_EXHAUSTIVE_REVIEW, + 'coverage': execution_result.get('review_coverage') or {}, + }, + 'document_action': normalized_action, + }, + }) + cosmos_messages_container.upsert_item(assistant_doc) + + conversation_item['last_updated'] = datetime.utcnow().isoformat() + conversation_item['chat_type'] = data.get('chat_type') or conversation_item.get('chat_type') or 'new' + + try: + conversation_item = collect_conversation_metadata( + user_message=user_message, + conversation_id=conversation_id, + user_id=user_id, + active_group_id=active_group_ids[0] if active_group_ids else None, + active_group_ids=active_group_ids, + document_scope=document_scope, + selected_document_id=selected_document_ids[0] if selected_document_ids else None, + model_deployment=execution_result.get('model_deployment_name'), + hybrid_search_enabled=False, + image_gen_enabled=False, + selected_documents=execution_result.get('review_result', {}).get('documents', []), + selected_agent=execution_result.get('agent_name'), + selected_agent_details=request_agent_info, + search_results=None, + conversation_item=conversation_item, + active_public_workspace_id=active_public_workspace_ids[0] if active_public_workspace_ids else None, + active_public_workspace_ids=active_public_workspace_ids, + ) + except Exception as exc: + debug_print(f'[ChatExhaustiveReview] Conversation metadata update failed: {exc}') + + cosmos_conversations_container.upsert_item(conversation_item) + + return make_json_serializable({ + 'reply': execution_result.get('reply', ''), + 'conversation_id': conversation_id, + 'conversation_title': conversation_item.get('title', 'New Conversation'), + 'classification': conversation_item.get('classification', []), + 'context': conversation_item.get('context', []), + 'chat_type': conversation_item.get('chat_type'), + 'scope_locked': conversation_item.get('scope_locked'), + 'locked_contexts': conversation_item.get('locked_contexts', []), + 'model_deployment_name': execution_result.get('model_deployment_name'), + 'agent_display_name': execution_result.get('agent_display_name'), + 'agent_name': execution_result.get('agent_name'), + 'message_id': assistant_message_id, + 'user_message_id': user_message_id, + 'blocked': False, + 'augmented': False, + 'hybrid_citations': [], + 'web_search_citations': [], + 'agent_citations': prepared_agent_citations, + 'reload_messages': False, + 'kernel_fallback_notice': None, + 'thoughts_enabled': False, + 'review_coverage': execution_result.get('review_coverage') or {}, + 'document_action': normalized_action, + }), 200 + + def execute_exhaustive_review_chat_request(data=None, publish_background_event=None): + return execute_document_action_chat_request( + data=data, + publish_background_event=publish_background_event, + forced_action_type=DOCUMENT_ACTION_TYPE_EXHAUSTIVE_REVIEW, + ) + + @app.route('/api/chat/document-action', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def chat_document_action_api(): + payload, status_code = execute_document_action_chat_request() + return jsonify(payload), status_code + + @app.route('/api/chat/document-action/stream', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def chat_document_action_stream_api(): + data = request.get_json() or {} + conversation_id = getattr(g, 'conversation_id', None) or data.get('conversation_id') + if conversation_id is not None: + conversation_id = str(conversation_id).strip() or None + if not conversation_id: + conversation_id = str(uuid.uuid4()) + data['conversation_id'] = conversation_id + g.conversation_id = conversation_id + + def generate_document_action_response(publish_background_event=None): + try: + payload, status_code = execute_document_action_chat_request( + data=data, + publish_background_event=publish_background_event, + ) + if status_code >= 400: + error_message = payload.get('error') or f'Document action failed ({status_code})' + yield f"data: {json.dumps({'error': error_message, 'conversation_id': payload.get('conversation_id')})}\n\n" + return + + yield f"data: {json.dumps(normalize_terminal_chat_payload(payload))}\n\n" + except Exception as document_action_error: + yield f"data: {json.dumps({'error': str(document_action_error), 'conversation_id': conversation_id})}\n\n" + + return build_background_stream_response(generate_document_action_response) + + @app.route('/api/chat/exhaustive-review', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def chat_exhaustive_review_api(): + payload, status_code = execute_exhaustive_review_chat_request() + return jsonify(payload), status_code + + @app.route('/api/chat/exhaustive-review/stream', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def chat_exhaustive_review_stream_api(): + data = request.get_json() or {} + conversation_id = getattr(g, 'conversation_id', None) or data.get('conversation_id') + if conversation_id is not None: + conversation_id = str(conversation_id).strip() or None + if not conversation_id: + conversation_id = str(uuid.uuid4()) + data['conversation_id'] = conversation_id + g.conversation_id = conversation_id + + def generate_exhaustive_review_response(publish_background_event=None): + try: + payload, status_code = execute_exhaustive_review_chat_request( + data=data, + publish_background_event=publish_background_event, + ) + if status_code >= 400: + error_message = payload.get('error') or f'Exhaustive review failed ({status_code})' + yield f"data: {json.dumps({'error': error_message, 'conversation_id': payload.get('conversation_id')})}\n\n" + return + + yield f"data: {json.dumps(normalize_terminal_chat_payload(payload))}\n\n" + except Exception as exhaustive_error: + yield f"data: {json.dumps({'error': str(exhaustive_error), 'conversation_id': conversation_id})}\n\n" + + return build_background_stream_response(generate_exhaustive_review_response) + @app.route('/api/chat', methods=['POST']) @swagger_route(security=get_auth_security()) @login_required @@ -6935,24 +7593,12 @@ def result_requires_message_reload(result: Any) -> bool: ) try: # Prepare search arguments - # Set default and maximum values for top_n - default_top_n = 12 - max_top_n = 500 # Reasonable cap to prevent excessive resource usage - - # Process top_n_results if provided - if top_n_results is not None: - try: - top_n = int(top_n_results) - # Ensure top_n is within reasonable bounds - if top_n < 1: - top_n = default_top_n - elif top_n > max_top_n: - top_n = max_top_n - except (ValueError, TypeError): - # If conversion fails, use default - top_n = default_top_n - else: - top_n = default_top_n + default_top_n = SEARCH_DEFAULT_TOP_N + top_n = normalize_search_top_n( + top_n_results, + default_top_n=SEARCH_DEFAULT_TOP_N, + max_top_n=SEARCH_MAX_TOP_N, + ) search_args = { "query": search_query, @@ -8178,6 +8824,12 @@ async def run_sk_call(callable_obj, *args, **kwargs): "kernel": bool(kernel is not None), } + conversation_history_for_api = maybe_append_chart_tool_system_message( + conversation_history_for_api, + user_message, + selected_agent, + ) + agent_message_history = [ ChatMessageContent( role=msg["role"], @@ -8269,9 +8921,15 @@ def agent_success(result): timestamp_str = inv.timestamp.isoformat() else: timestamp_str = str(inv.timestamp) + tool_name = build_agent_citation_tool_label( + inv.plugin_name, + inv.function_name, + inv.parameters, + inv.result, + ) citation = { - 'tool_name': f"{inv.plugin_name}.{inv.function_name}", + 'tool_name': tool_name, 'function_name': inv.function_name, 'plugin_name': inv.plugin_name, 'function_arguments': make_json_serializable(inv.parameters), @@ -8619,6 +9277,8 @@ def gpt_error(e): ai_message, final_model_used, chat_mode, kernel_fallback_notice = fallback_result token_usage_data = None + ai_message = _append_inline_chart_blocks_to_message(ai_message, agent_citations_list) + # Emit responded thought for non-agent paths (agent paths emit their own inside callbacks) if not selected_agent: gpt_total_duration_s = round(time.time() - request_start_time, 1) @@ -9469,6 +10129,17 @@ def generate(publish_background_event=None): 'reasoning_effort': reasoning_effort if reasoning_effort and reasoning_effort != 'none' else None, 'streaming': 'Enabled' } + + if request_agent_info and isinstance(request_agent_info, dict): + user_metadata['agent_selection'] = { + 'selected_agent': request_agent_info.get('name'), + 'agent_display_name': request_agent_info.get('display_name'), + 'is_global': request_agent_info.get('is_global', False), + 'is_group': request_agent_info.get('is_group', False), + 'group_id': request_agent_info.get('group_id'), + 'group_name': request_agent_info.get('group_name'), + 'agent_id': request_agent_info.get('id') + } user_metadata['chat_context'] = { 'conversation_id': conversation_id @@ -9554,13 +10225,28 @@ def generate(publish_background_event=None): user_thread_id = response_message_context.get('thread_id') user_previous_thread_id = response_message_context.get('previous_thread_id') - def serialize_thought_event(step_type, content, step_index, message_id=None): - return f"data: {json.dumps({'type': 'thought', 'message_id': message_id or assistant_message_id, 'step_index': step_index, 'step_type': step_type, 'content': content})}\n\n" + def serialize_thought_event(step_type, content, step_index, message_id=None, detail=None, activity=None, progress=None): + payload = { + 'type': 'thought', + 'message_id': message_id or assistant_message_id, + 'step_index': step_index, + 'step_type': step_type, + 'content': content, + } + + if detail is not None: + payload['detail'] = detail + if isinstance(activity, dict) and activity: + payload['activity'] = activity + if isinstance(progress, dict) and progress: + payload['progress'] = progress + + return f"data: {json.dumps(payload)}\n\n" def emit_thought(step_type, content, detail=None): """Add a thought to Cosmos and return an SSE event string.""" thought_tracker.add_thought(step_type, content, detail) - return serialize_thought_event(step_type, content, thought_tracker.current_index - 1) + return serialize_thought_event(step_type, content, thought_tracker.current_index - 1, detail=detail) def publish_live_plugin_thought(thought_payload): if not callable(publish_background_event): @@ -9576,6 +10262,9 @@ def publish_live_plugin_thought(thought_payload): thought_payload.get('content', ''), step_index, message_id=thought_payload.get('message_id') or assistant_message_id, + detail=thought_payload.get('detail'), + activity=thought_payload.get('activity'), + progress=thought_payload.get('progress'), ) ) @@ -9683,6 +10372,7 @@ def publish_live_plugin_thought(thought_payload): 'web_search_citations': [], 'agent_citations': [], 'model_deployment_name': None, + 'metadata': safety_doc.get('metadata', {}), 'thoughts_enabled': thought_tracker.enabled, }) yield f"data: {json.dumps(final_data)}\n\n" @@ -10417,7 +11107,22 @@ def publish_live_plugin_thought(thought_payload): if all_agents: agent_name_to_select = None - if per_user_semantic_kernel: + if request_agent_info: + if isinstance(request_agent_info, dict): + agent_name_to_select = request_agent_info.get('name') + selected_agent_metadata = { + 'selected_agent': request_agent_info.get('name'), + 'agent_display_name': request_agent_info.get('display_name'), + 'is_global': request_agent_info.get('is_global', False), + 'is_group': request_agent_info.get('is_group', False), + 'group_id': request_agent_info.get('group_id'), + 'group_name': request_agent_info.get('group_name'), + 'agent_id': request_agent_info.get('id') + } + else: + agent_name_to_select = request_agent_info + debug_print(f"[Streaming] Request agent name to select: {agent_name_to_select}") + elif per_user_semantic_kernel: # user_settings.get('selected_agent') returns a dict with agent info selected_agent_info = user_settings.get('selected_agent') if isinstance(selected_agent_info, dict): @@ -10492,6 +11197,15 @@ def publish_live_plugin_thought(thought_payload): else: debug_print(f"[Streaming] ⚠️ No agent selected, falling back to GPT") + if selected_agent_metadata: + user_metadata['agent_selection'] = selected_agent_metadata + + conversation_history_for_api = maybe_append_chart_tool_system_message( + conversation_history_for_api, + user_message, + selected_agent, + ) + # Stream the response accumulated_content = "" token_usage_data = None # Will be populated from final stream chunk @@ -10596,7 +11310,7 @@ def publish_live_plugin_thought(thought_payload): if chunk_content: accumulated_content += chunk_content - yield f"data: {json.dumps({'content': chunk_content})}\\n\\n" + yield f"data: {json.dumps({'content': chunk_content})}\n\n" if agent_retry_plan: debug_print( @@ -10729,9 +11443,15 @@ def publish_live_plugin_thought(thought_payload): timestamp_str = inv.timestamp.isoformat() else: timestamp_str = str(inv.timestamp) + tool_name = build_agent_citation_tool_label( + inv.plugin_name, + inv.function_name, + inv.parameters, + inv.result, + ) citation = { - 'tool_name': f"{inv.plugin_name}.{inv.function_name}", + 'tool_name': tool_name, 'function_name': inv.function_name, 'plugin_name': inv.plugin_name, 'function_arguments': make_json_serializable(inv.parameters), @@ -10825,6 +11545,7 @@ def publish_live_plugin_thought(thought_payload): yield emit_thought('generation', f"'{gpt_model}' responded ({gpt_stream_total_duration_s}s from initial message)") # Stream complete - save message and send final metadata + accumulated_content = _append_inline_chart_blocks_to_message(accumulated_content, agent_citations_list) user_info_for_assistant = response_message_context.get('user_info') user_thread_id = response_message_context.get('thread_id') user_previous_thread_id = response_message_context.get('previous_thread_id') @@ -10902,6 +11623,19 @@ def publish_live_plugin_thought(thought_payload): # Update conversation conversation_item['last_updated'] = datetime.utcnow().isoformat() + + try: + user_message_doc = cosmos_messages_container.read_item( + item=user_message_id, + partition_key=conversation_id + ) + if 'metadata' in user_message_doc and 'model_selection' in user_message_doc['metadata']: + user_message_doc['metadata']['model_selection']['selected_model'] = final_model_used if use_agent_streaming else gpt_model + if selected_agent_metadata: + user_message_doc.setdefault('metadata', {})['agent_selection'] = selected_agent_metadata + cosmos_messages_container.upsert_item(user_message_doc) + except Exception as e: + debug_print(f"Warning: Could not update streaming user message metadata: {e}") try: conversation_item = collect_conversation_metadata( @@ -10912,7 +11646,7 @@ def publish_live_plugin_thought(thought_payload): active_group_ids=effective_active_group_ids, document_scope=effective_document_scope, selected_document_id=effective_selected_document_id, - model_deployment=gpt_model, + model_deployment=final_model_used if use_agent_streaming else gpt_model, hybrid_search_enabled=hybrid_search_enabled or history_grounded_search_used, image_gen_enabled=False, selected_documents=combined_documents if combined_documents else None, @@ -10970,6 +11704,7 @@ def publish_live_plugin_thought(thought_payload): 'agent_citations': prepared_agent_citations, 'agent_display_name': agent_display_name_used if use_agent_streaming else None, 'agent_name': agent_name_used if use_agent_streaming else None, + 'metadata': assistant_doc.get('metadata', {}), 'full_content': accumulated_content, 'thoughts_enabled': thought_tracker.enabled }) diff --git a/application/single_app/route_backend_collaboration.py b/application/single_app/route_backend_collaboration.py new file mode 100644 index 00000000..34a7642f --- /dev/null +++ b/application/single_app/route_backend_collaboration.py @@ -0,0 +1,1646 @@ +# route_backend_collaboration.py + +import json +import threading +import time + +import app_settings_cache +from flask import Response, current_app, jsonify, redirect, request, session, stream_with_context + +from config import * +from collaboration_models import MEMBERSHIP_STATUS_PENDING, MESSAGE_KIND_AI_REQUEST, add_seconds_to_iso, normalize_collaboration_user, utc_now_iso +from functions_appinsights import log_event +from functions_authentication import * +from functions_collaboration import ( + assert_user_can_participate_in_collaboration_conversation, + assert_user_can_view_collaboration_conversation, + create_collaboration_message_notifications, + create_group_collaboration_conversation_record, + create_personal_collaboration_conversation_record, + delete_collaboration_message, + delete_personal_collaboration_conversation, + ensure_collaboration_source_conversation, + ensure_group_collaboration_for_legacy_conversation, + ensure_personal_collaboration_for_legacy_conversation, + get_collaboration_conversation, + get_collaboration_message, + get_collaboration_user_state, + invite_personal_collaboration_participants, + leave_personal_collaboration_conversation, + list_collaboration_messages, + list_group_collaboration_conversations_for_user, + list_personal_collaboration_conversations_for_user, + mirror_source_message_to_collaboration, + persist_collaboration_message, + record_personal_invite_response, + remove_personal_collaboration_member, + resolve_collaboration_mentions, + serialize_collaboration_conversation, + serialize_collaboration_message, + sync_collaboration_conversation_metadata_from_source, + toggle_personal_collaboration_hide, + toggle_personal_collaboration_pin, + update_personal_collaboration_member_role, + update_personal_collaboration_title, +) +from functions_group import assert_group_role, check_group_status_allows_operation, find_group_by_id +from functions_image_messages import decode_image_content, get_complete_image_content, is_external_image_url +from functions_notifications import mark_collaboration_message_notifications_read_for_conversation +from functions_message_artifacts import make_json_serializable +from functions_settings import get_settings, get_user_settings +from swagger_wrapper import swagger_route, get_auth_security + + +COLLABORATION_EVENT_HEARTBEAT_SECONDS = 15 +COLLABORATION_EVENT_TTL_SECONDS = 3600 + + +class CollaborationEventSession: + HEARTBEAT_EVENT = ': keep-alive\n\n' + + def __init__(self, conversation_id, heartbeat_interval_seconds=15, session_ttl_seconds=3600): + self.conversation_id = conversation_id + self.heartbeat_interval_seconds = heartbeat_interval_seconds + self.session_ttl_seconds = session_ttl_seconds + self.cache_key = f'collaboration:{conversation_id}' + self._condition = threading.Condition() + + def _build_metadata(self): + return { + 'conversation_id': self.conversation_id, + 'active': True, + 'heartbeat_interval_seconds': self.heartbeat_interval_seconds, + 'updated_at': utc_now_iso(), + } + + def initialize(self): + existing_metadata = app_settings_cache.get_stream_session_meta(self.cache_key) + if existing_metadata: + app_settings_cache.set_stream_session_meta( + self.cache_key, + self._build_metadata(), + ttl_seconds=self.session_ttl_seconds, + ) + return + + app_settings_cache.initialize_stream_session_cache( + self.cache_key, + self._build_metadata(), + ttl_seconds=self.session_ttl_seconds, + ) + + def publish(self, event_payload): + self.initialize() + event_text = f'data: {json.dumps(event_payload)}\n\n' + app_settings_cache.append_stream_session_event( + self.cache_key, + event_text, + ttl_seconds=self.session_ttl_seconds, + ) + app_settings_cache.set_stream_session_meta( + self.cache_key, + self._build_metadata(), + ttl_seconds=self.session_ttl_seconds, + ) + with self._condition: + self._condition.notify_all() + + def iter_events(self, start_index=0): + self.initialize() + next_index = max(int(start_index or 0), 0) + last_heartbeat_at = time.time() + + while True: + pending_events = app_settings_cache.get_stream_session_events( + self.cache_key, + start_index=next_index, + ) or [] + if pending_events: + for event_to_yield in pending_events: + next_index += 1 + last_heartbeat_at = time.time() + yield event_to_yield + continue + + metadata = app_settings_cache.get_stream_session_meta(self.cache_key) + if not metadata: + self.initialize() + metadata = app_settings_cache.get_stream_session_meta(self.cache_key) + if not metadata: + return + + heartbeat_interval_seconds = int( + metadata.get('heartbeat_interval_seconds') or self.heartbeat_interval_seconds + ) + remaining_heartbeat_seconds = max( + heartbeat_interval_seconds - (time.time() - last_heartbeat_at), + 0.25, + ) + with self._condition: + self._condition.wait(timeout=min(1.0, remaining_heartbeat_seconds)) + + if (time.time() - last_heartbeat_at) >= heartbeat_interval_seconds: + last_heartbeat_at = time.time() + yield self.HEARTBEAT_EVENT + + +class CollaborationEventRegistry: + def __init__(self, heartbeat_interval_seconds=15, session_ttl_seconds=3600): + self.heartbeat_interval_seconds = heartbeat_interval_seconds + self.session_ttl_seconds = session_ttl_seconds + self._sessions = {} + self._lock = threading.Lock() + + def get_session(self, conversation_id): + with self._lock: + session = self._sessions.get(conversation_id) + if session is None: + session = CollaborationEventSession( + conversation_id=conversation_id, + heartbeat_interval_seconds=self.heartbeat_interval_seconds, + session_ttl_seconds=self.session_ttl_seconds, + ) + self._sessions[conversation_id] = session + session.initialize() + return session + + def publish(self, conversation_id, event_payload): + self.get_session(conversation_id).publish(event_payload) + + +COLLABORATION_EVENT_REGISTRY = CollaborationEventRegistry( + heartbeat_interval_seconds=COLLABORATION_EVENT_HEARTBEAT_SECONDS, + session_ttl_seconds=COLLABORATION_EVENT_TTL_SECONDS, +) + + +def get_user_state_or_none(user_id, conversation_id): + try: + return get_collaboration_user_state(user_id, conversation_id) + except CosmosResourceNotFoundError: + return None + + +def _build_collaboration_event(conversation_id, event_type, payload): + return { + 'conversation_id': conversation_id, + 'event_type': event_type, + 'occurred_at': utc_now_iso(), + 'payload': payload, + } + + +def _require_collaboration_feature_enabled(): + settings = get_settings() + if not settings.get('enable_collaborative_conversations', False): + raise PermissionError('Collaborative conversations are disabled by configuration') + return settings + + +def _get_current_collaboration_user(): + current_user = get_current_user_info() + return normalize_collaboration_user(current_user) + + +def _normalize_participant_payload(raw_payload): + if raw_payload is None: + return [] + if isinstance(raw_payload, dict): + raw_payload = [raw_payload] + + normalized_participants = [] + for raw_participant in raw_payload: + participant_summary = normalize_collaboration_user(raw_participant) + if participant_summary: + normalized_participants.append(participant_summary) + return normalized_participants + + +def _read_source_message_doc(source_conversation_id, source_message_id): + normalized_conversation_id = str(source_conversation_id or '').strip() + normalized_message_id = str(source_message_id or '').strip() + if not normalized_conversation_id or not normalized_message_id: + raise CosmosResourceNotFoundError(message='Source message not found') + + try: + return cosmos_messages_container.read_item( + item=normalized_message_id, + partition_key=normalized_conversation_id, + ) + except CosmosResourceNotFoundError: + query = 'SELECT TOP 1 * FROM c WHERE c.id = @message_id' + items = list(cosmos_messages_container.query_items( + query=query, + parameters=[{'name': '@message_id', 'value': normalized_message_id}], + enable_cross_partition_query=True, + )) + if not items: + raise + return items[0] + + +def _serialize_stream_error(error_message, **extra_fields): + payload = {'error': str(error_message or 'Streaming request failed')} + payload.update({key: value for key, value in extra_fields.items() if value is not None}) + return f'data: {json.dumps(payload)}\n\n' + + +def _build_collaboration_stream_request_payload(data, source_conversation_id, message_content): + return { + 'message': message_content, + 'conversation_id': source_conversation_id, + 'hybrid_search': bool(data.get('hybrid_search')), + 'web_search_enabled': bool(data.get('web_search_enabled')), + 'selected_document_id': data.get('selected_document_id'), + 'selected_document_ids': data.get('selected_document_ids') or [], + 'classifications': data.get('classifications'), + 'tags': data.get('tags') or [], + 'image_generation': bool(data.get('image_generation')), + 'doc_scope': data.get('doc_scope'), + 'chat_type': data.get('chat_type', 'user'), + 'active_group_ids': data.get('active_group_ids') or [], + 'active_group_id': data.get('active_group_id'), + 'active_public_workspace_ids': data.get('active_public_workspace_ids') or [], + 'active_public_workspace_id': data.get('active_public_workspace_id'), + 'model_deployment': data.get('model_deployment'), + 'model_id': data.get('model_id'), + 'model_endpoint_id': data.get('model_endpoint_id'), + 'model_provider': data.get('model_provider'), + 'prompt_info': data.get('prompt_info'), + 'agent_info': data.get('agent_info'), + 'reasoning_effort': data.get('reasoning_effort'), + } + + +def register_route_backend_collaboration(app): + @app.route('/api/collaboration/conversations', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def list_collaboration_conversations_api(): + try: + _require_collaboration_feature_enabled() + current_user = _get_current_collaboration_user() + if not current_user: + return jsonify({'error': 'User not authenticated'}), 401 + + scope_filter = str(request.args.get('scope') or 'all').strip().lower() + include_pending = str(request.args.get('include_pending', 'true')).strip().lower() != 'false' + conversations = [] + + if scope_filter in ('all', 'personal'): + for conversation_doc, user_state in list_personal_collaboration_conversations_for_user(current_user['user_id']): + serialized = serialize_collaboration_conversation( + conversation_doc, + current_user_id=current_user['user_id'], + user_state=user_state, + ) + if include_pending or serialized.get('membership_status') != MEMBERSHIP_STATUS_PENDING: + conversations.append(serialized) + + if scope_filter in ('all', 'group'): + for conversation_doc, user_state in list_group_collaboration_conversations_for_user(current_user['user_id']): + serialized = serialize_collaboration_conversation( + conversation_doc, + current_user_id=current_user['user_id'], + user_state=user_state, + ) + if include_pending or serialized.get('membership_status') != MEMBERSHIP_STATUS_PENDING: + conversations.append(serialized) + + conversations.sort( + key=lambda item: item.get('updated_at') or item.get('created_at') or '', + reverse=True, + ) + return jsonify({'conversations': conversations}), 200 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + except Exception as exc: + log_event( + f'[Collaboration] Failed to list conversations: {exc}', + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Failed to load collaborative conversations'}), 500 + + @app.route('/api/collaboration/conversations', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def create_collaboration_conversation_api(): + try: + _require_collaboration_feature_enabled() + current_user = _get_current_collaboration_user() + if not current_user: + return jsonify({'error': 'User not authenticated'}), 401 + + data = request.get_json(silent=True) or {} + conversation_type = str(data.get('conversation_type') or '').strip().lower() + title = str(data.get('title') or '').strip() + + if conversation_type == 'personal': + participants_to_invite = _normalize_participant_payload(data.get('participants', [])) + conversation_doc, user_states = create_personal_collaboration_conversation_record( + title=title, + creator_user=current_user, + invited_participants=participants_to_invite, + ) + creator_state = next( + (state for state in user_states if state.get('user_id') == current_user['user_id']), + None, + ) + serialized = serialize_collaboration_conversation( + conversation_doc, + current_user_id=current_user['user_id'], + user_state=creator_state, + ) + COLLABORATION_EVENT_REGISTRY.publish( + conversation_doc.get('id'), + _build_collaboration_event( + conversation_doc.get('id'), + 'collaboration.created', + {'conversation': serialized}, + ), + ) + return jsonify({'conversation': serialized}), 201 + + if conversation_type == 'group': + group_id = str(data.get('group_id') or '').strip() + participants_to_invite = _normalize_participant_payload(data.get('participants', [])) + if not group_id: + user_settings = get_user_settings(current_user['user_id']) + group_id = str( + ((user_settings or {}).get('settings') or {}).get('activeGroupOid') or '' + ).strip() + if not group_id: + return jsonify({'error': 'group_id is required for group collaborative conversations'}), 400 + + group_doc = find_group_by_id(group_id) + if not group_doc: + return jsonify({'error': 'Group not found'}), 404 + + assert_group_role( + current_user['user_id'], + group_id, + allowed_roles=('Owner', 'Admin', 'DocumentManager', 'User'), + ) + allowed, reason = check_group_status_allows_operation(group_doc, 'chat') + if not allowed: + return jsonify({'error': reason}), 403 + + conversation_doc, user_states = create_group_collaboration_conversation_record( + title=title, + creator_user=current_user, + group_doc=group_doc, + invited_participants=participants_to_invite, + ) + creator_state = next( + (state for state in user_states if state.get('user_id') == current_user['user_id']), + None, + ) + serialized = serialize_collaboration_conversation( + conversation_doc, + current_user_id=current_user['user_id'], + user_state=creator_state, + ) + COLLABORATION_EVENT_REGISTRY.publish( + conversation_doc.get('id'), + _build_collaboration_event( + conversation_doc.get('id'), + 'collaboration.created', + {'conversation': serialized}, + ), + ) + return jsonify({'conversation': serialized}), 201 + + return jsonify({'error': 'conversation_type must be personal or group'}), 400 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + except (LookupError, ValueError) as exc: + return jsonify({'error': str(exc)}), 400 + except Exception as exc: + log_event( + f'[Collaboration] Failed to create conversation: {exc}', + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Failed to create collaborative conversation'}), 500 + + @app.route('/api/collaboration/conversations/', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def get_collaboration_conversation_api(conversation_id): + try: + _require_collaboration_feature_enabled() + current_user = _get_current_collaboration_user() + if not current_user: + return jsonify({'error': 'User not authenticated'}), 401 + + conversation_doc = get_collaboration_conversation(conversation_id) + access_context = assert_user_can_view_collaboration_conversation( + current_user['user_id'], + conversation_doc, + allow_pending=True, + ) + serialized = serialize_collaboration_conversation( + conversation_doc, + current_user_id=current_user['user_id'], + user_state=access_context.get('user_state'), + ) + return jsonify({'conversation': serialized}), 200 + except CosmosResourceNotFoundError: + return jsonify({'error': 'Collaborative conversation not found'}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + except Exception as exc: + log_event( + f'[Collaboration] Failed to load conversation {conversation_id}: {exc}', + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Failed to load collaborative conversation'}), 500 + + @app.route('/api/collaboration/conversations//invite-response', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def respond_to_collaboration_invite_api(conversation_id): + try: + _require_collaboration_feature_enabled() + current_user = _get_current_collaboration_user() + if not current_user: + return jsonify({'error': 'User not authenticated'}), 401 + + data = request.get_json(silent=True) or {} + action = str(data.get('action') or '').strip().lower() + if action not in ('accept', 'decline'): + return jsonify({'error': 'action must be accept or decline'}), 400 + + conversation_doc, user_state, participant_record = record_personal_invite_response( + conversation_id, + current_user['user_id'], + action, + ) + serialized = serialize_collaboration_conversation( + conversation_doc, + current_user_id=current_user['user_id'], + user_state=user_state, + ) + event_type = 'collaboration.invite.accepted' if action == 'accept' else 'collaboration.invite.declined' + COLLABORATION_EVENT_REGISTRY.publish( + conversation_id, + _build_collaboration_event( + conversation_id, + event_type, + { + 'conversation': serialized, + 'participant': participant_record, + }, + ), + ) + return jsonify({'conversation': serialized}), 200 + except CosmosResourceNotFoundError: + return jsonify({'error': 'Collaborative conversation not found'}), 404 + except (LookupError, PermissionError, ValueError) as exc: + return jsonify({'error': str(exc)}), 403 if isinstance(exc, PermissionError) else 400 + except Exception as exc: + log_event( + f'[Collaboration] Failed to respond to invite for {conversation_id}: {exc}', + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Failed to update invite response'}), 500 + + @app.route('/api/collaboration/conversations//members', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def invite_collaboration_members_api(conversation_id): + try: + _require_collaboration_feature_enabled() + current_user = _get_current_collaboration_user() + if not current_user: + return jsonify({'error': 'User not authenticated'}), 401 + + data = request.get_json(silent=True) or {} + participants_to_add = _normalize_participant_payload( + data.get('participants', data.get('participant')) + ) + if not participants_to_add: + return jsonify({'error': 'participants are required'}), 400 + + conversation_doc, state_docs = invite_personal_collaboration_participants( + conversation_id, + current_user['user_id'], + participants_to_add, + ) + serialized = serialize_collaboration_conversation( + conversation_doc, + current_user_id=current_user['user_id'], + ) + invited_participants = [] + for state_doc in state_docs: + invited_participants.append({ + 'user_id': state_doc.get('user_id'), + 'display_name': state_doc.get('user_display_name'), + 'email': state_doc.get('user_email'), + 'membership_status': state_doc.get('membership_status'), + }) + if invited_participants: + COLLABORATION_EVENT_REGISTRY.publish( + conversation_id, + _build_collaboration_event( + conversation_id, + 'collaboration.member.invited', + { + 'conversation': serialized, + 'participants': invited_participants, + }, + ), + ) + return jsonify({'conversation': serialized, 'invited_participants': invited_participants}), 200 + except CosmosResourceNotFoundError: + return jsonify({'error': 'Collaborative conversation not found'}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + except (LookupError, ValueError) as exc: + return jsonify({'error': str(exc)}), 400 + except Exception as exc: + log_event( + f'[Collaboration] Failed to invite members for {conversation_id}: {exc}', + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Failed to invite collaborative conversation members'}), 500 + + @app.route('/api/collaboration/conversations/from-personal//members', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def convert_personal_conversation_to_collaboration_api(conversation_id): + try: + _require_collaboration_feature_enabled() + current_user = _get_current_collaboration_user() + if not current_user: + return jsonify({'error': 'User not authenticated'}), 401 + + data = request.get_json(silent=True) or {} + participants_to_add = _normalize_participant_payload( + data.get('participants', data.get('participant')) + ) + if not participants_to_add: + return jsonify({'error': 'participants are required'}), 400 + + conversation_doc, invited_state_docs, created_new, _ = ensure_personal_collaboration_for_legacy_conversation( + conversation_id, + current_user, + invited_participants=participants_to_add, + ) + serialized = serialize_collaboration_conversation( + conversation_doc, + current_user_id=current_user['user_id'], + ) + invited_participants = [ + { + 'user_id': state_doc.get('user_id'), + 'display_name': state_doc.get('user_display_name'), + 'email': state_doc.get('user_email'), + 'membership_status': state_doc.get('membership_status'), + } + for state_doc in invited_state_docs + ] + + if created_new: + COLLABORATION_EVENT_REGISTRY.publish( + conversation_doc.get('id'), + _build_collaboration_event( + conversation_doc.get('id'), + 'collaboration.created', + {'conversation': serialized, 'source_conversation_id': conversation_id}, + ), + ) + + if invited_participants: + COLLABORATION_EVENT_REGISTRY.publish( + conversation_doc.get('id'), + _build_collaboration_event( + conversation_doc.get('id'), + 'collaboration.member.invited', + { + 'conversation': serialized, + 'participants': invited_participants, + 'source_conversation_id': conversation_id, + }, + ), + ) + + return jsonify({ + 'conversation': serialized, + 'invited_participants': invited_participants, + 'created': created_new, + 'source_conversation_id': conversation_id, + }), 201 if created_new else 200 + except CosmosResourceNotFoundError: + return jsonify({'error': 'Conversation not found'}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + except Exception as exc: + log_event( + f'[Collaboration] Failed to convert personal conversation {conversation_id}: {exc}', + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Failed to convert conversation to collaborative conversation'}), 500 + + @app.route('/api/collaboration/conversations/from-group//members', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def convert_group_conversation_to_collaboration_api(conversation_id): + try: + _require_collaboration_feature_enabled() + current_user = _get_current_collaboration_user() + if not current_user: + return jsonify({'error': 'User not authenticated'}), 401 + + data = request.get_json(silent=True) or {} + participants_to_add = _normalize_participant_payload( + data.get('participants', data.get('participant')) + ) + if not participants_to_add: + return jsonify({'error': 'participants are required'}), 400 + + conversation_doc, invited_state_docs, created_new, _ = ensure_group_collaboration_for_legacy_conversation( + conversation_id, + current_user, + invited_participants=participants_to_add, + ) + serialized = serialize_collaboration_conversation( + conversation_doc, + current_user_id=current_user['user_id'], + user_state=get_user_state_or_none(current_user['user_id'], conversation_doc.get('id')), + ) + invited_participants = [ + { + 'user_id': state_doc.get('user_id'), + 'display_name': state_doc.get('user_display_name'), + 'email': state_doc.get('user_email'), + 'membership_status': state_doc.get('membership_status'), + } + for state_doc in invited_state_docs + ] + + if created_new: + COLLABORATION_EVENT_REGISTRY.publish( + conversation_doc.get('id'), + _build_collaboration_event( + conversation_doc.get('id'), + 'collaboration.created', + {'conversation': serialized, 'source_conversation_id': conversation_id}, + ), + ) + + if invited_participants: + COLLABORATION_EVENT_REGISTRY.publish( + conversation_doc.get('id'), + _build_collaboration_event( + conversation_doc.get('id'), + 'collaboration.member.invited', + { + 'conversation': serialized, + 'participants': invited_participants, + 'source_conversation_id': conversation_id, + }, + ), + ) + + return jsonify({ + 'conversation': serialized, + 'invited_participants': invited_participants, + 'created': created_new, + 'source_conversation_id': conversation_id, + }), 201 if created_new else 200 + except CosmosResourceNotFoundError: + return jsonify({'error': 'Conversation not found'}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + except (LookupError, ValueError) as exc: + return jsonify({'error': str(exc)}), 400 + except Exception as exc: + log_event( + f'[Collaboration] Failed to convert group conversation {conversation_id}: {exc}', + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Failed to convert group conversation to collaborative conversation'}), 500 + + @app.route('/api/collaboration/conversations//members/', methods=['DELETE']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def remove_collaboration_member_api(conversation_id, member_user_id): + try: + _require_collaboration_feature_enabled() + current_user = _get_current_collaboration_user() + if not current_user: + return jsonify({'error': 'User not authenticated'}), 401 + + conversation_doc, removed_participant = remove_personal_collaboration_member( + conversation_id, + current_user['user_id'], + member_user_id, + ) + serialized = serialize_collaboration_conversation( + conversation_doc, + current_user_id=current_user['user_id'], + ) + COLLABORATION_EVENT_REGISTRY.publish( + conversation_id, + _build_collaboration_event( + conversation_id, + 'collaboration.member.removed', + { + 'conversation': serialized, + 'participant': removed_participant, + }, + ), + ) + return jsonify({'conversation': serialized, 'removed_participant': removed_participant}), 200 + except CosmosResourceNotFoundError: + return jsonify({'error': 'Collaborative conversation not found'}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + except (LookupError, ValueError) as exc: + return jsonify({'error': str(exc)}), 400 + except Exception as exc: + log_event( + f'[Collaboration] Failed to remove member for {conversation_id}: {exc}', + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Failed to remove collaborative conversation member'}), 500 + + @app.route('/api/collaboration/conversations//members//role', methods=['PUT']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def update_collaboration_member_role_api(conversation_id, member_user_id): + try: + _require_collaboration_feature_enabled() + current_user = _get_current_collaboration_user() + if not current_user: + return jsonify({'error': 'User not authenticated'}), 401 + + data = request.get_json(silent=True) or {} + new_role = str(data.get('role') or '').strip().lower() + if not new_role: + return jsonify({'error': 'role is required'}), 400 + + conversation_doc, updated_participant = update_personal_collaboration_member_role( + conversation_id, + current_user['user_id'], + member_user_id, + new_role, + ) + serialized = serialize_collaboration_conversation( + conversation_doc, + current_user_id=current_user['user_id'], + user_state=get_user_state_or_none(current_user['user_id'], conversation_id), + ) + COLLABORATION_EVENT_REGISTRY.publish( + conversation_id, + _build_collaboration_event( + conversation_id, + 'collaboration.member.role_updated', + { + 'conversation': serialized, + 'participant': updated_participant, + }, + ), + ) + return jsonify({'conversation': serialized, 'participant': updated_participant}), 200 + except CosmosResourceNotFoundError: + return jsonify({'error': 'Collaborative conversation not found'}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + except (LookupError, ValueError) as exc: + return jsonify({'error': str(exc)}), 400 + except Exception as exc: + log_event( + f'[Collaboration] Failed to update member role for {conversation_id}: {exc}', + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Failed to update collaborative conversation role'}), 500 + + @app.route('/api/collaboration/conversations/', methods=['PUT']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def update_collaboration_conversation_title_api(conversation_id): + try: + _require_collaboration_feature_enabled() + current_user = _get_current_collaboration_user() + if not current_user: + return jsonify({'error': 'User not authenticated'}), 401 + + data = request.get_json(silent=True) or {} + new_title = str(data.get('title') or '').strip() + if not new_title: + return jsonify({'error': 'Title is required'}), 400 + + conversation_doc = update_personal_collaboration_title( + conversation_id, + current_user['user_id'], + new_title, + ) + serialized = serialize_collaboration_conversation( + conversation_doc, + current_user_id=current_user['user_id'], + user_state=get_user_state_or_none(current_user['user_id'], conversation_id), + ) + COLLABORATION_EVENT_REGISTRY.publish( + conversation_id, + _build_collaboration_event( + conversation_id, + 'collaboration.updated', + {'conversation': serialized}, + ), + ) + return jsonify(serialized), 200 + except CosmosResourceNotFoundError: + return jsonify({'error': 'Collaborative conversation not found'}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except Exception as exc: + log_event( + f'[Collaboration] Failed to update title for {conversation_id}: {exc}', + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Failed to update collaborative conversation'}), 500 + + @app.route('/api/collaboration/conversations//pin', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def toggle_collaboration_conversation_pin_api(conversation_id): + try: + _require_collaboration_feature_enabled() + current_user = _get_current_collaboration_user() + if not current_user: + return jsonify({'error': 'User not authenticated'}), 401 + + _, user_state = toggle_personal_collaboration_pin(conversation_id, current_user['user_id']) + return jsonify({'success': True, 'is_pinned': bool(user_state.get('is_pinned', False))}), 200 + except CosmosResourceNotFoundError: + return jsonify({'error': 'Collaborative conversation not found'}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + except Exception as exc: + log_event( + f'[Collaboration] Failed to toggle pin for {conversation_id}: {exc}', + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Failed to toggle collaborative pin status'}), 500 + + @app.route('/api/collaboration/conversations//hide', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def toggle_collaboration_conversation_hide_api(conversation_id): + try: + _require_collaboration_feature_enabled() + current_user = _get_current_collaboration_user() + if not current_user: + return jsonify({'error': 'User not authenticated'}), 401 + + _, user_state = toggle_personal_collaboration_hide(conversation_id, current_user['user_id']) + return jsonify({'success': True, 'is_hidden': bool(user_state.get('is_hidden', False))}), 200 + except CosmosResourceNotFoundError: + return jsonify({'error': 'Collaborative conversation not found'}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + except Exception as exc: + log_event( + f'[Collaboration] Failed to toggle hide for {conversation_id}: {exc}', + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Failed to toggle collaborative hide status'}), 500 + + @app.route('/api/collaboration/conversations//delete-action', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def collaboration_delete_action_api(conversation_id): + try: + _require_collaboration_feature_enabled() + current_user = _get_current_collaboration_user() + if not current_user: + return jsonify({'error': 'User not authenticated'}), 401 + + data = request.get_json(silent=True) or {} + action = str(data.get('action') or '').strip().lower() + new_owner_user_id = str(data.get('new_owner_user_id') or '').strip() or None + + if action == 'delete': + conversation_doc = get_collaboration_conversation(conversation_id) + serialized = serialize_collaboration_conversation( + conversation_doc, + current_user_id=current_user['user_id'], + user_state=get_user_state_or_none(current_user['user_id'], conversation_id), + ) + COLLABORATION_EVENT_REGISTRY.publish( + conversation_id, + _build_collaboration_event( + conversation_id, + 'collaboration.deleted', + { + 'conversation': serialized, + 'deleted_by_user_id': current_user['user_id'], + }, + ), + ) + delete_personal_collaboration_conversation(conversation_id, current_user['user_id']) + return jsonify({'success': True, 'action': 'delete', 'conversation_id': conversation_id}), 200 + + if action == 'leave': + conversation_doc, removed_participant, promoted_participant = leave_personal_collaboration_conversation( + conversation_id, + current_user['user_id'], + new_owner_user_id=new_owner_user_id, + ) + serialized = serialize_collaboration_conversation( + conversation_doc, + current_user_id=current_user['user_id'], + user_state=get_user_state_or_none(current_user['user_id'], conversation_id), + ) + COLLABORATION_EVENT_REGISTRY.publish( + conversation_id, + _build_collaboration_event( + conversation_id, + 'collaboration.member.removed', + { + 'conversation': serialized, + 'participant': removed_participant, + 'promoted_participant': promoted_participant, + }, + ), + ) + return jsonify({ + 'success': True, + 'action': 'leave', + 'conversation': serialized, + 'removed_participant': removed_participant, + 'promoted_participant': promoted_participant, + }), 200 + + return jsonify({'error': 'action must be delete or leave'}), 400 + except CosmosResourceNotFoundError: + return jsonify({'error': 'Collaborative conversation not found'}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + except (LookupError, ValueError) as exc: + return jsonify({'error': str(exc)}), 400 + except Exception as exc: + log_event( + f'[Collaboration] Failed to complete delete action for {conversation_id}: {exc}', + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Failed to update collaborative conversation membership'}), 500 + + @app.route('/api/collaboration/conversations//messages', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def get_collaboration_messages_api(conversation_id): + try: + _require_collaboration_feature_enabled() + current_user = _get_current_collaboration_user() + if not current_user: + return jsonify({'error': 'User not authenticated'}), 401 + + conversation_doc = get_collaboration_conversation(conversation_id) + assert_user_can_view_collaboration_conversation( + current_user['user_id'], + conversation_doc, + allow_pending=True, + ) + messages = [serialize_collaboration_message(doc) for doc in list_collaboration_messages(conversation_id)] + return jsonify({'messages': messages}), 200 + except CosmosResourceNotFoundError: + return jsonify({'error': 'Collaborative conversation not found'}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + except Exception as exc: + log_event( + f'[Collaboration] Failed to load messages for {conversation_id}: {exc}', + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Failed to load collaborative conversation messages'}), 500 + + @app.route('/api/collaboration/conversations//images/', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def get_collaboration_image_api(conversation_id, message_id): + try: + _require_collaboration_feature_enabled() + current_user = _get_current_collaboration_user() + if not current_user: + return jsonify({'error': 'User not authenticated'}), 401 + + conversation_doc = get_collaboration_conversation(conversation_id) + assert_user_can_view_collaboration_conversation( + current_user['user_id'], + conversation_doc, + allow_pending=True, + ) + + message_doc = get_collaboration_message(message_id) + if str(message_doc.get('conversation_id') or '').strip() != str(conversation_id or '').strip(): + return jsonify({'error': 'Collaborative image not found'}), 404 + + message_metadata = message_doc.get('metadata', {}) if isinstance(message_doc.get('metadata'), dict) else {} + source_conversation_id = str(message_metadata.get('source_conversation_id') or '').strip() + source_message_id = str(message_metadata.get('source_message_id') or '').strip() + if not source_conversation_id or not source_message_id: + return jsonify({'error': 'Source image not found'}), 404 + + _, complete_content = get_complete_image_content( + cosmos_messages_container, + source_conversation_id, + source_message_id, + ) + + if is_external_image_url(complete_content): + return redirect(complete_content) + + mime_type, image_data = decode_image_content(complete_content) + return Response( + image_data, + mimetype=mime_type, + headers={ + 'Content-Length': len(image_data), + 'Cache-Control': 'public, max-age=3600', + }, + ) + except CosmosResourceNotFoundError: + return jsonify({'error': 'Collaborative image not found'}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except Exception as exc: + log_event( + f'[Collaboration] Failed to load image {message_id} for {conversation_id}: {exc}', + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Failed to load collaborative image'}), 500 + + @app.route('/api/collaboration/conversations//messages', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def post_collaboration_message_api(conversation_id): + try: + _require_collaboration_feature_enabled() + current_user = _get_current_collaboration_user() + if not current_user: + return jsonify({'error': 'User not authenticated'}), 401 + + data = request.get_json(silent=True) or {} + message_content = str(data.get('content') or '').strip() + reply_to_message_id = str(data.get('reply_to_message_id') or '').strip() or None + if not message_content: + return jsonify({'error': 'content is required'}), 400 + + conversation_doc = get_collaboration_conversation(conversation_id) + assert_user_can_participate_in_collaboration_conversation(current_user['user_id'], conversation_doc) + mentioned_participants = resolve_collaboration_mentions( + conversation_doc, + data.get('mentioned_participants'), + ) + message_doc, updated_conversation_doc = persist_collaboration_message( + conversation_doc, + current_user, + message_content, + reply_to_message_id=reply_to_message_id, + mentioned_participants=mentioned_participants, + ) + create_collaboration_message_notifications(updated_conversation_doc, message_doc) + serialized_message = serialize_collaboration_message(message_doc) + serialized_conversation = serialize_collaboration_conversation( + updated_conversation_doc, + current_user_id=current_user['user_id'], + ) + COLLABORATION_EVENT_REGISTRY.publish( + conversation_id, + _build_collaboration_event( + conversation_id, + 'collaboration.message.created', + { + 'conversation': serialized_conversation, + 'message': serialized_message, + }, + ), + ) + return jsonify({'conversation': serialized_conversation, 'message': serialized_message}), 201 + except CosmosResourceNotFoundError: + return jsonify({'error': 'Collaborative conversation not found'}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + except Exception as exc: + log_event( + f'[Collaboration] Failed to post message for {conversation_id}: {exc}', + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Failed to post collaborative conversation message'}), 500 + + @app.route('/api/collaboration/conversations//stream', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def stream_collaboration_message_api(conversation_id): + try: + _require_collaboration_feature_enabled() + current_user = _get_current_collaboration_user() + if not current_user: + return jsonify({'error': 'User not authenticated'}), 401 + + data = request.get_json(silent=True) or {} + message_content = str(data.get('content') or data.get('message') or '').strip() + reply_to_message_id = str(data.get('reply_to_message_id') or '').strip() or None + if not message_content: + return jsonify({'error': 'content is required'}), 400 + + conversation_doc = get_collaboration_conversation(conversation_id) + assert_user_can_participate_in_collaboration_conversation(current_user['user_id'], conversation_doc) + source_conversation_doc, conversation_doc = ensure_collaboration_source_conversation( + conversation_doc, + current_user, + ) + source_conversation_id = str((source_conversation_doc or {}).get('id') or '').strip() + if not source_conversation_id: + return jsonify({'error': 'Failed to initialize collaboration AI context'}), 500 + + mentioned_participants = resolve_collaboration_mentions( + conversation_doc, + data.get('mentioned_participants'), + ) + invocation_target = data.get('invocation_target') if isinstance(data.get('invocation_target'), dict) else None + extra_metadata = {} + if invocation_target: + extra_metadata['ai_invocation_target'] = invocation_target + + user_message_doc, updated_conversation_doc = persist_collaboration_message( + conversation_doc, + current_user, + message_content, + reply_to_message_id=reply_to_message_id, + mentioned_participants=mentioned_participants, + message_kind=MESSAGE_KIND_AI_REQUEST, + extra_metadata=extra_metadata, + ) + user_message_doc.setdefault('metadata', {})['source_conversation_id'] = source_conversation_id + cosmos_collaboration_messages_container.upsert_item(user_message_doc) + + create_collaboration_message_notifications(updated_conversation_doc, user_message_doc) + serialized_user_message = serialize_collaboration_message(user_message_doc) + serialized_user_conversation = serialize_collaboration_conversation( + updated_conversation_doc, + current_user_id=current_user['user_id'], + user_state=get_user_state_or_none(current_user['user_id'], conversation_id), + ) + COLLABORATION_EVENT_REGISTRY.publish( + conversation_id, + _build_collaboration_event( + conversation_id, + 'collaboration.message.created', + { + 'conversation': serialized_user_conversation, + 'message': serialized_user_message, + }, + ), + ) + + session_snapshot = dict(session) + source_owner_user = normalize_collaboration_user({ + 'user_id': updated_conversation_doc.get('created_by_user_id'), + 'display_name': updated_conversation_doc.get('created_by_display_name'), + }) or current_user + stream_request_payload = _build_collaboration_stream_request_payload( + data, + source_conversation_id, + message_content, + ) + + def generate_stream(): + try: + internal_stream_view = current_app.view_functions.get('chat_stream_api') + if not callable(internal_stream_view): + yield _serialize_stream_error( + 'Chat streaming endpoint is unavailable', + user_message_id=serialized_user_message.get('id'), + message_persisted=True, + conversation_id=conversation_id, + ) + return + + buffer = '' + with current_app.test_request_context('/api/chat/stream', method='POST', json=stream_request_payload): + session.clear() + session.update(session_snapshot) + internal_response = current_app.make_response(internal_stream_view()) + + if int(internal_response.status_code or 500) >= 400: + try: + error_payload = internal_response.get_json(silent=True) or {} + except Exception: + error_payload = {} + yield _serialize_stream_error( + error_payload.get('error') or error_payload.get('message') or 'Failed to start collaboration AI workflow', + user_message_id=serialized_user_message.get('id'), + message_persisted=True, + conversation_id=conversation_id, + ) + return + + def transform_event_block(event_block): + normalized_event_block = str(event_block or '') + if not normalized_event_block.strip(): + return None + + if normalized_event_block.lstrip().startswith(':'): + return normalized_event_block + '\n\n' + + data_lines = [ + line for line in normalized_event_block.split('\n') + if line.startswith('data:') + ] + if not data_lines: + return normalized_event_block + '\n\n' + + json_text = '\n'.join(line[5:].lstrip() for line in data_lines) + try: + stream_payload = json.loads(json_text) + except json.JSONDecodeError: + return normalized_event_block + '\n\n' + + if stream_payload.get('error'): + return _serialize_stream_error( + stream_payload.get('error'), + partial_content=stream_payload.get('partial_content'), + user_message_id=serialized_user_message.get('id'), + message_persisted=True, + conversation_id=conversation_id, + ) + + if not stream_payload.get('done'): + return normalized_event_block + '\n\n' + + source_message_id = str(stream_payload.get('message_id') or '').strip() + if not source_message_id: + return _serialize_stream_error( + 'AI workflow completed without a source assistant message', + user_message_id=serialized_user_message.get('id'), + message_persisted=True, + conversation_id=conversation_id, + ) + + source_user_message_id = str(stream_payload.get('user_message_id') or '').strip() + if source_user_message_id: + try: + saved_user_message_doc = cosmos_collaboration_messages_container.read_item( + item=serialized_user_message.get('id'), + partition_key=conversation_id, + ) + saved_user_message_doc['metadata'] = { + **dict(saved_user_message_doc.get('metadata', {}) or {}), + 'source_message_id': source_user_message_id, + 'source_conversation_id': source_conversation_id, + 'source_thought_user_id': current_user['user_id'], + } + cosmos_collaboration_messages_container.upsert_item(saved_user_message_doc) + except Exception: + pass + + try: + source_message_doc = _read_source_message_doc(source_conversation_id, source_message_id) + except CosmosResourceNotFoundError: + return _serialize_stream_error( + 'Failed to load the generated assistant response', + user_message_id=serialized_user_message.get('id'), + message_persisted=True, + conversation_id=conversation_id, + ) + + collaboration_conversation_doc = updated_conversation_doc + + try: + source_conversation_doc = cosmos_conversations_container.read_item( + item=source_conversation_id, + partition_key=source_conversation_id, + ) + collaboration_conversation_doc, _ = sync_collaboration_conversation_metadata_from_source( + collaboration_conversation_doc, + source_conversation_doc, + ) + except CosmosResourceNotFoundError: + source_conversation_doc = None + except Exception: + source_conversation_doc = None + + mirrored_message_doc, final_conversation_doc, _ = mirror_source_message_to_collaboration( + collaboration_conversation_doc, + source_message_doc, + source_owner_user, + reply_to_message_id=serialized_user_message.get('id'), + extra_metadata={ + 'source_conversation_id': source_conversation_id, + 'source_thought_user_id': current_user['user_id'], + }, + ) + if not mirrored_message_doc: + return _serialize_stream_error( + 'Failed to mirror the assistant response into the collaboration conversation', + user_message_id=serialized_user_message.get('id'), + message_persisted=True, + conversation_id=conversation_id, + ) + + create_collaboration_message_notifications(final_conversation_doc, mirrored_message_doc) + serialized_assistant_message = serialize_collaboration_message(mirrored_message_doc) + serialized_final_conversation = serialize_collaboration_conversation( + final_conversation_doc, + current_user_id=current_user['user_id'], + user_state=get_user_state_or_none(current_user['user_id'], conversation_id), + ) + COLLABORATION_EVENT_REGISTRY.publish( + conversation_id, + _build_collaboration_event( + conversation_id, + 'collaboration.message.created', + { + 'conversation': serialized_final_conversation, + 'message': serialized_assistant_message, + }, + ), + ) + + transformed_payload = { + **stream_payload, + 'conversation_id': conversation_id, + 'conversation_title': serialized_final_conversation.get('title'), + 'chat_type': serialized_final_conversation.get('chat_type'), + 'classification': serialized_final_conversation.get('classification', []), + 'context': serialized_final_conversation.get('context', []), + 'scope_locked': serialized_final_conversation.get('scope_locked'), + 'locked_contexts': serialized_final_conversation.get('locked_contexts', []), + 'message_id': serialized_assistant_message.get('id'), + 'user_message_id': serialized_user_message.get('id'), + 'model_deployment_name': serialized_assistant_message.get('model_deployment_name') or stream_payload.get('model_deployment_name'), + 'augmented': serialized_assistant_message.get('augmented', False), + 'hybrid_citations': serialized_assistant_message.get('hybrid_citations', []), + 'web_search_citations': serialized_assistant_message.get('web_search_citations', []), + 'agent_citations': serialized_assistant_message.get('agent_citations', []), + 'agent_display_name': serialized_assistant_message.get('agent_display_name'), + 'agent_name': serialized_assistant_message.get('agent_name'), + 'full_content': serialized_assistant_message.get('content') if serialized_assistant_message.get('role') != 'image' else stream_payload.get('full_content', ''), + 'image_url': serialized_assistant_message.get('content') if serialized_assistant_message.get('role') == 'image' else stream_payload.get('image_url'), + 'reload_messages': False, + } + return f'data: {json.dumps(make_json_serializable(transformed_payload))}\n\n' + + for chunk in internal_response.response: + if chunk is None: + continue + + chunk_text = chunk.decode('utf-8') if isinstance(chunk, (bytes, bytearray)) else str(chunk) + buffer += chunk_text.replace('\r', '') + + while '\n\n' in buffer: + event_block, buffer = buffer.split('\n\n', 1) + transformed_block = transform_event_block(event_block) + if transformed_block: + yield transformed_block + + if buffer.strip(): + transformed_block = transform_event_block(buffer.strip()) + if transformed_block: + yield transformed_block + except Exception as exc: + log_event( + f'[Collaboration] Failed to stream AI message for {conversation_id}: {exc}', + level=logging.ERROR, + exceptionTraceback=True, + ) + yield _serialize_stream_error( + 'Failed to stream collaborative AI response', + user_message_id=serialized_user_message.get('id'), + message_persisted=True, + conversation_id=conversation_id, + ) + + return Response(stream_with_context(generate_stream()), mimetype='text/event-stream') + except CosmosResourceNotFoundError: + return jsonify({'error': 'Collaborative conversation not found'}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + except Exception as exc: + log_event( + f'[Collaboration] Failed to start AI stream for {conversation_id}: {exc}', + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Failed to start collaborative AI workflow'}), 500 + + @app.route('/api/collaboration/conversations//messages/', methods=['DELETE']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def delete_collaboration_message_api(conversation_id, message_id): + try: + _require_collaboration_feature_enabled() + current_user = _get_current_collaboration_user() + if not current_user: + return jsonify({'error': 'User not authenticated'}), 401 + + deleted_message_doc, updated_conversation_doc = delete_collaboration_message( + conversation_id, + message_id, + current_user['user_id'], + ) + serialized_conversation = serialize_collaboration_conversation( + updated_conversation_doc, + current_user_id=current_user['user_id'], + user_state=get_user_state_or_none(current_user['user_id'], conversation_id), + ) + COLLABORATION_EVENT_REGISTRY.publish( + conversation_id, + _build_collaboration_event( + conversation_id, + 'collaboration.message.deleted', + { + 'conversation': serialized_conversation, + 'message_id': message_id, + 'deleted_by_user_id': current_user['user_id'], + 'deleted_message': { + 'id': deleted_message_doc.get('id'), + 'sender_user_id': ( + ((deleted_message_doc.get('metadata') or {}).get('sender') or {}).get('user_id') + ), + }, + }, + ), + ) + return jsonify({ + 'success': True, + 'deleted_message_ids': [message_id], + 'archived': False, + 'conversation': serialized_conversation, + }), 200 + except CosmosResourceNotFoundError: + return jsonify({'error': 'Collaborative message not found'}), 404 + except LookupError as exc: + return jsonify({'error': str(exc)}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + except Exception as exc: + log_event( + f'[Collaboration] Failed to delete message {message_id} for {conversation_id}: {exc}', + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Failed to delete collaborative conversation message'}), 500 + + @app.route('/api/collaboration/conversations//mark-read', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def mark_collaboration_conversation_read_api(conversation_id): + try: + _require_collaboration_feature_enabled() + current_user = _get_current_collaboration_user() + if not current_user: + return jsonify({'error': 'User not authenticated'}), 401 + + conversation_doc = get_collaboration_conversation(conversation_id) + assert_user_can_view_collaboration_conversation( + current_user['user_id'], + conversation_doc, + allow_pending=True, + ) + notifications_marked_read = mark_collaboration_message_notifications_read_for_conversation( + current_user['user_id'], + conversation_id, + ) + + return jsonify({ + 'success': True, + 'conversation_id': conversation_id, + 'notifications_marked_read': notifications_marked_read, + }), 200 + except CosmosResourceNotFoundError: + return jsonify({'error': 'Collaborative conversation not found'}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + except Exception as exc: + log_event( + f'[Collaboration] Failed to mark conversation {conversation_id} read: {exc}', + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Failed to mark collaborative conversation read'}), 500 + + @app.route('/api/collaboration/conversations//typing', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def collaboration_typing_api(conversation_id): + try: + _require_collaboration_feature_enabled() + current_user = _get_current_collaboration_user() + if not current_user: + return jsonify({'error': 'User not authenticated'}), 401 + + data = request.get_json(silent=True) or {} + is_typing = bool(data.get('is_typing', True)) + conversation_doc = get_collaboration_conversation(conversation_id) + assert_user_can_participate_in_collaboration_conversation(current_user['user_id'], conversation_doc) + + typing_payload = { + 'user': current_user, + 'is_typing': is_typing, + 'expires_at': add_seconds_to_iso(utc_now_iso(), 8), + } + COLLABORATION_EVENT_REGISTRY.publish( + conversation_id, + _build_collaboration_event( + conversation_id, + 'collaboration.typing.updated', + typing_payload, + ), + ) + return jsonify({'success': True}), 200 + except CosmosResourceNotFoundError: + return jsonify({'error': 'Collaborative conversation not found'}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + except Exception as exc: + log_event( + f'[Collaboration] Failed to publish typing event for {conversation_id}: {exc}', + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Failed to publish typing event'}), 500 + + @app.route('/api/collaboration/conversations//events', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def collaboration_events_api(conversation_id): + try: + _require_collaboration_feature_enabled() + current_user = _get_current_collaboration_user() + if not current_user: + return jsonify({'error': 'User not authenticated'}), 401 + + conversation_doc = get_collaboration_conversation(conversation_id) + assert_user_can_view_collaboration_conversation( + current_user['user_id'], + conversation_doc, + allow_pending=True, + ) + + start_index = request.args.get('start_index', 0) + session = COLLABORATION_EVENT_REGISTRY.get_session(conversation_id) + return Response( + stream_with_context(session.iter_events(start_index=start_index)), + mimetype='text/event-stream', + headers={ + 'Cache-Control': 'no-cache', + 'X-Accel-Buffering': 'no', + 'Connection': 'keep-alive', + }, + ) + except CosmosResourceNotFoundError: + return jsonify({'error': 'Collaborative conversation not found'}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + except Exception as exc: + log_event( + f'[Collaboration] Failed to attach event stream for {conversation_id}: {exc}', + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Failed to attach collaborative event stream'}), 500 \ No newline at end of file diff --git a/application/single_app/route_backend_conversation_export.py b/application/single_app/route_backend_conversation_export.py index 086019f1..c57d3b6c 100644 --- a/application/single_app/route_backend_conversation_export.py +++ b/application/single_app/route_backend_conversation_export.py @@ -3,6 +3,7 @@ import io import json import markdown2 +import os import re import tempfile import zipfile @@ -17,6 +18,17 @@ from functions_appinsights import log_event from functions_authentication import * from functions_chat import sort_messages_by_thread +from functions_chart_export import ( + decode_base64_image_data_uri, + replace_inline_chart_blocks_with_export_html, +) +from functions_collaboration import ( + assert_user_can_view_collaboration_conversation, + get_accessible_collaboration_message_thoughts, + get_collaboration_conversation, + is_collaboration_conversation, + list_collaboration_messages, +) from functions_conversation_metadata import update_conversation_with_metadata from functions_debug import debug_print from functions_message_artifacts import ( @@ -84,29 +96,43 @@ def api_export_conversations(): settings = get_settings() exported = [] for conv_id in conversation_ids: + conversation = None + messages = [] try: conversation = cosmos_conversations_container.read_item( item=conv_id, partition_key=conv_id ) + if conversation.get('user_id') != user_id: + debug_print(f"Export: user {user_id} does not own conversation {conv_id}") + continue + + message_query = """ + SELECT * FROM c + WHERE c.conversation_id = @conversation_id + ORDER BY c.timestamp ASC + """ + messages = list(cosmos_messages_container.query_items( + query=message_query, + parameters=[{'name': '@conversation_id', 'value': conv_id}], + partition_key=conv_id + )) except Exception: - debug_print(f"Export: conversation {conv_id} not found or access denied") - continue - - if conversation.get('user_id') != user_id: - debug_print(f"Export: user {user_id} does not own conversation {conv_id}") - continue - - message_query = """ - SELECT * FROM c - WHERE c.conversation_id = @conversation_id - ORDER BY c.timestamp ASC - """ - messages = list(cosmos_messages_container.query_items( - query=message_query, - parameters=[{'name': '@conversation_id', 'value': conv_id}], - partition_key=conv_id - )) + try: + conversation = get_collaboration_conversation(conv_id) + access_context = assert_user_can_view_collaboration_conversation( + user_id, + conversation, + allow_pending=True, + ) + user_state = access_context.get('user_state') or {} + conversation = dict(conversation) + conversation['is_pinned'] = bool(user_state.get('is_pinned', False)) + conversation['is_hidden'] = bool(user_state.get('is_hidden', False)) + messages = list_collaboration_messages(conv_id) + except Exception: + debug_print(f"Export: conversation {conv_id} not found or access denied") + continue exported.append( _build_export_entry( @@ -254,7 +280,7 @@ def _build_export_entry( filtered_messages = hydrate_agent_citations_from_artifacts(filtered_messages, artifact_payload_map) ordered_messages = sort_messages_by_thread(filtered_messages) - raw_thoughts = get_thoughts_for_conversation(conversation.get('id'), user_id) + raw_thoughts = [] if is_collaboration_conversation(conversation) else get_thoughts_for_conversation(conversation.get('id'), user_id) thoughts_by_message = defaultdict(list) for thought in raw_thoughts: thoughts_by_message[thought.get('message_id')].append(_sanitize_thought(thought)) @@ -275,6 +301,13 @@ def _build_export_entry( message_transcript_index = transcript_index thoughts = thoughts_by_message.get(message.get('id'), []) + if not thoughts and is_collaboration_conversation(conversation): + collaboration_thoughts = get_accessible_collaboration_message_thoughts( + conversation, + message, + user_id, + ) + thoughts = [_sanitize_thought(thought) for thought in collaboration_thoughts] exported_message = _sanitize_message( message, sequence_index=sequence_index, @@ -349,7 +382,7 @@ def _sanitize_conversation( return { 'id': conversation.get('id'), 'title': conversation.get('title', 'Untitled'), - 'last_updated': conversation.get('last_updated', ''), + 'last_updated': conversation.get('last_updated') or conversation.get('updated_at', ''), 'chat_type': conversation.get('chat_type', 'personal'), 'tags': conversation.get('tags', []), 'context': conversation.get('context', []), @@ -745,8 +778,15 @@ def generate_conversation_summary( # Persist to Cosmos when a conversation_id is available if conversation_id: try: - update_conversation_with_metadata(conversation_id, {'summary': summary_data}) - debug_print(f"Summary persisted to conversation {conversation_id}") + summary_persisted = update_conversation_with_metadata(conversation_id, {'summary': summary_data}) + if summary_persisted: + debug_print(f"Summary persisted to conversation {conversation_id}") + else: + debug_print(f"Summary was generated but not persisted for conversation {conversation_id}") + log_event( + f"Conversation summary persistence returned false for {conversation_id}", + level='WARNING' + ) except Exception as persist_exc: debug_print(f"Failed to persist summary to Cosmos: {persist_exc}") log_event(f"Failed to persist conversation summary: {persist_exc}", level="WARNING") @@ -1013,7 +1053,11 @@ def _conversation_to_markdown(entry: Dict[str, Any]) -> str: if message.get('timestamp'): lines.append(f"*{message.get('timestamp')}*") lines.append('') - lines.append(message.get('content_text') or '_No content recorded._') + lines.append( + replace_inline_chart_blocks_with_export_html( + message.get('content_text') or '_No content recorded._' + ) + ) lines.append('') lines.append('## Appendix A — Conversation Metadata') @@ -1082,7 +1126,11 @@ def _conversation_to_markdown(entry: Dict[str, Any]) -> str: if message.get('timestamp'): lines.append(f"*{message.get('timestamp')}*") lines.append('') - lines.append(message.get('content_text') or '_No content recorded._') + lines.append( + replace_inline_chart_blocks_with_export_html( + message.get('content_text') or '_No content recorded._' + ) + ) lines.append('') return '\n'.join(lines).strip() @@ -1351,7 +1399,9 @@ def _message_to_docx_bytes(message: Dict[str, Any]) -> bytes: doc.add_paragraph('') - content = _normalize_content(message.get('content', '')) + content = replace_inline_chart_blocks_with_export_html( + _normalize_content(message.get('content', '')) + ) if content: _add_markdown_content_to_doc(doc, content) else: @@ -2000,6 +2050,14 @@ def _append_inline_html_runs(paragraph, node: Any, formatting: Optional[Dict[str return if tag_name == 'img': + image_bytes = decode_base64_image_data_uri(node.get('src')) + if image_bytes: + try: + paragraph.add_run().add_picture(io.BytesIO(image_bytes), width=Inches(6.0)) + return + except Exception: + pass + alt_text = node.get('alt') or 'Image' run = paragraph.add_run(f'[{alt_text}]') _apply_run_formatting(run, formatting) @@ -2173,6 +2231,25 @@ def _apply_run_formatting(run, formatting: Dict[str, bool]): font-size: 8pt; color: #666; } +.export-inline-chart { + background-color: #fafafa; + border: 1px solid #ddd; + padding: 8pt; + margin-top: 6pt; + margin-bottom: 10pt; +} +.export-inline-chart img { + max-width: 100%; + height: auto; + display: block; + margin: 0 auto; +} +.export-inline-chart-caption { + font-size: 8pt; + color: #666; + text-align: center; + margin-top: 4pt; +} a { color: #0066cc; } @@ -2226,7 +2303,7 @@ def _build_pdf_html_body(entry: Dict[str, Any]) -> str: if summary_intro.get('enabled') and summary_intro.get('generated') and summary_intro.get('content'): parts.append('

Abstract

') abstract_html = markdown2.markdown( - summary_intro.get('content', ''), + replace_inline_chart_blocks_with_export_html(summary_intro.get('content', '')), extras=['fenced-code-blocks', 'tables'] ) parts.append(f'
{abstract_html}
') @@ -2268,7 +2345,7 @@ def _build_pdf_html_body(entry: Dict[str, Any]) -> str: f'{_escape_html(speaker)}{ts_str}

' ) content_html = markdown2.markdown( - content, + replace_inline_chart_blocks_with_export_html(content), extras=['fenced-code-blocks', 'tables', 'break-on-newline'] ) parts.append(content_html) @@ -2365,7 +2442,7 @@ def _build_pdf_html_body(entry: Dict[str, Any]) -> str: ) content = message.get('content_text', '') or 'No content recorded.' content_html = markdown2.markdown( - content, + replace_inline_chart_blocks_with_export_html(content), extras=['fenced-code-blocks', 'tables', 'break-on-newline'] ) parts.append(content_html) diff --git a/application/single_app/route_backend_conversations.py b/application/single_app/route_backend_conversations.py index 23f1a071..e4e881d3 100644 --- a/application/single_app/route_backend_conversations.py +++ b/application/single_app/route_backend_conversations.py @@ -2,13 +2,22 @@ from config import * from functions_authentication import * +from functions_collaboration import ( + assert_user_can_view_collaboration_conversation, + assert_user_can_participate_in_collaboration_conversation, + ensure_collaboration_source_conversation, + get_collaboration_conversation, + list_collaboration_messages, +) from functions_settings import * from functions_conversation_metadata import get_conversation_metadata, update_conversation_with_metadata from functions_conversation_unread import clear_conversation_unread, normalize_conversation_unread_state +from functions_image_messages import decode_image_content, get_complete_image_content, hydrate_image_messages, is_external_image_url from functions_notifications import mark_chat_response_notifications_read_for_conversation from flask import Response, request from functions_debug import debug_print from functions_message_artifacts import filter_assistant_artifact_items +from functions_simplechat_operations import create_personal_conversation_for_current_user from swagger_wrapper import swagger_route, get_auth_security from functions_activity_logging import log_conversation_creation, log_conversation_deletion, log_conversation_archival from functions_thoughts import archive_thoughts_for_conversation, delete_thoughts_for_conversation @@ -72,6 +81,43 @@ def _collect_child_message_documents(conversation_id, root_message_ids): return child_docs + +def _load_scope_lock_conversation(conversation_id, user_id): + try: + conversation_item = cosmos_conversations_container.read_item( + item=conversation_id, + partition_key=conversation_id, + ) + if conversation_item.get('user_id') != user_id: + raise PermissionError('Forbidden') + return conversation_item, 'personal' + except CosmosResourceNotFoundError: + pass + + try: + conversation_item = get_collaboration_conversation(conversation_id) + except CosmosResourceNotFoundError as exc: + raise LookupError('Conversation not found') from exc + + assert_user_can_participate_in_collaboration_conversation(user_id, conversation_item) + return conversation_item, 'collaboration' + + +def _persist_scope_lock_update(conversation_item, conversation_kind, user_id, new_value): + timestamp = datetime.utcnow().isoformat() + conversation_item['scope_locked'] = new_value + + if conversation_kind == 'collaboration': + conversation_item['updated_at'] = timestamp + cosmos_collaboration_conversations_container.upsert_item(conversation_item) + current_user = get_current_user_info() or {'userId': user_id} + _, conversation_item = ensure_collaboration_source_conversation(conversation_item, current_user) + return conversation_item + + conversation_item['last_updated'] = timestamp + cosmos_conversations_container.upsert_item(conversation_item) + return conversation_item + def register_route_backend_conversations(app): @app.route('/api/get_messages', methods=['GET']) @@ -124,90 +170,12 @@ def api_get_messages(): all_items = filtered_items debug_print(f"After filtering: {len(all_items)} items remaining") - - - # Process messages and reassemble chunked images - messages = [] - chunked_images = {} # Store image chunks by parent_message_id - - for item in all_items: - if item.get('role') == 'image_chunk': - # This is a chunk, store it for reassembly - parent_id = item.get('parent_message_id') - if parent_id not in chunked_images: - chunked_images[parent_id] = {} - chunk_index = item.get('metadata', {}).get('chunk_index', 0) - chunked_images[parent_id][chunk_index] = item.get('content', '') - else: - # Regular message or main image document - if item.get('role') == 'image' and item.get('metadata', {}).get('is_chunked'): - # This is a chunked image main document - image_id = item.get('id') - total_chunks = item.get('metadata', {}).get('total_chunks', 1) - - # We'll reassemble after collecting all chunks - messages.append(item) - else: - # Regular message - messages.append(item) - - # Reassemble chunked images - for message in messages: - if (message.get('role') == 'image' and - message.get('metadata', {}).get('is_chunked')): - - image_id = message.get('id') - total_chunks = message.get('metadata', {}).get('total_chunks', 1) - - debug_print(f"Reassembling chunked image {image_id} with {total_chunks} chunks") - debug_print(f"Available chunks in chunked_images: {list(chunked_images.get(image_id, {}).keys())}") - - # Preserve extracted_text and vision_analysis from main message - extracted_text = message.get('extracted_text') - vision_analysis = message.get('vision_analysis') - - debug_print(f"Image has extracted_text: {bool(extracted_text)}, vision_analysis: {bool(vision_analysis)}") - - # Start with the content from the main message (chunk 0) - complete_content = message.get('content', '') - debug_print(f"Main message content length: {len(complete_content)} bytes") - - # Add remaining chunks in order (chunks 1, 2, 3, etc.) - if image_id in chunked_images: - chunks = chunked_images[image_id] - for chunk_index in range(1, total_chunks): - if chunk_index in chunks: - chunk_content = chunks[chunk_index] - complete_content += chunk_content - debug_print(f"Added chunk {chunk_index}, length: {len(chunk_content)} bytes") - else: - print(f"WARNING: Missing chunk {chunk_index} for image {image_id}") - else: - print(f"WARNING: No chunks found for image {image_id} in chunked_images") - - debug_print(f"Final reassembled image total size: {len(complete_content)} bytes") - - # For large images (>1MB), use a URL reference instead of embedding in JSON - if len(complete_content) > 1024 * 1024: # 1MB threshold - debug_print(f"Large image detected ({len(complete_content)} bytes), using URL reference") - # Store the complete content temporarily and provide a URL reference - message['content'] = f"/api/image/{image_id}" - message['metadata']['is_large_image'] = True - message['metadata']['image_size'] = len(complete_content) - # Store the complete content in a way that can be retrieved by the image endpoint - # For now, we'll modify the message in place but this could be optimized - message['_complete_image_data'] = complete_content - else: - # Small enough to embed directly - message['content'] = complete_content - - # IMPORTANT: Preserve extracted_text and vision_analysis in the final message - # These fields are needed by the frontend to display the info drawer - if extracted_text: - message['extracted_text'] = extracted_text - if vision_analysis: - message['vision_analysis'] = vision_analysis - + + messages = hydrate_image_messages( + all_items, + image_url_builder=lambda image_id: f"/api/image/{image_id}", + ) + return jsonify({'messages': messages}) except CosmosResourceNotFoundError: return jsonify({'messages': []}) @@ -240,100 +208,32 @@ def api_get_image(image_id): conversation_id = '_'.join(parts[:-3]) debug_print(f"Serving image {image_id} from conversation {conversation_id}") - - # Query for the main image document and chunks - message_query = f"SELECT * FROM c WHERE c.conversation_id = '{conversation_id}'" - all_items = list(cosmos_messages_container.query_items( - query=message_query, - partition_key=conversation_id - )) - - # Find the specific image and its chunks - main_image = None - chunks = {} - - debug_print(f"Searching through {len(all_items)} items for image {image_id}") - - for item in all_items: - item_id = item.get('id') - item_role = item.get('role') - debug_print(f"Checking item {item_id}, role: {item_role}") - - if item_id == image_id and item_role == 'image': - main_image = item - debug_print(f"✅ Found main image document: {item_id}") - debug_print(f"Main image content length: {len(item.get('content', ''))} bytes") - debug_print(f"Main image metadata: {item.get('metadata', {})}") - elif (item_role == 'image_chunk' and - item.get('parent_message_id') == image_id): - chunk_index = item.get('metadata', {}).get('chunk_index', 0) - chunk_content = item.get('content', '') - chunks[chunk_index] = chunk_content - debug_print(f"✅ Found chunk {chunk_index}: {len(chunk_content)} bytes") - debug_print(f"Chunk {chunk_index} starts with: {chunk_content[:50]}...") - debug_print(f"Chunk {chunk_index} ends with: ...{chunk_content[-20:]}") - - debug_print(f"Found main_image: {main_image is not None}") - debug_print(f"Found chunks: {list(chunks.keys())}") - - if not main_image: - print(f"ERROR: Main image not found for {image_id}") - return jsonify({'error': 'Image not found'}), 404 - - # Reassemble the image - complete_content = main_image.get('content', '') - total_chunks = main_image.get('metadata', {}).get('total_chunks', 1) - - debug_print(f"Starting reassembly...") - debug_print(f"Main content length: {len(complete_content)} bytes") - debug_print(f"Expected total chunks: {total_chunks}") - debug_print(f"Available chunk indices: {list(chunks.keys())}") - debug_print(f"Main content starts with: {complete_content[:50]}...") - debug_print(f"Main content ends with: ...{complete_content[-20:]}") - - reassembly_log = [] - original_length = len(complete_content) - - for chunk_index in range(1, total_chunks): - if chunk_index in chunks: - chunk_content = chunks[chunk_index] - complete_content += chunk_content - reassembly_log.append(f"Added chunk {chunk_index}: {len(chunk_content)} bytes") - debug_print(f"Added chunk {chunk_index}: {len(chunk_content)} bytes") - debug_print(f"Total length now: {len(complete_content)} bytes") - else: - error_msg = f"Missing chunk {chunk_index}" - reassembly_log.append(f"❌ {error_msg}") - print(f"WARNING: {error_msg}") - - final_length = len(complete_content) - debug_print(f"Reassembly complete!") - debug_print(f"Original length: {original_length} bytes") - debug_print(f"Final length: {final_length} bytes") - debug_print(f"Added: {final_length - original_length} bytes") - debug_print(f"Reassembly log: {reassembly_log}") - debug_print(f"Final content starts with: {complete_content[:50]}...") - debug_print(f"Final content ends with: ...{complete_content[-20:]}") - - # Return the image data with appropriate headers - if complete_content.startswith('data:image/'): - # Extract mime type and base64 data - header, base64_data = complete_content.split(',', 1) - mime_type = header.split(':')[1].split(';')[0] - - import base64 - image_data = base64.b64decode(base64_data) - - return Response( - image_data, - mimetype=mime_type, - headers={ - 'Content-Length': len(image_data), - 'Cache-Control': 'public, max-age=3600' # Cache for 1 hour - } - ) - else: - return jsonify({'error': 'Invalid image format'}), 400 + + conversation_item = cosmos_conversations_container.read_item( + item=conversation_id, + partition_key=conversation_id, + ) + if conversation_item.get('user_id') != user_id: + return jsonify({'error': 'Unauthorized access to image'}), 403 + + _, complete_content = get_complete_image_content( + cosmos_messages_container, + conversation_id, + image_id, + ) + + if is_external_image_url(complete_content): + return redirect(complete_content) + + mime_type, image_data = decode_image_content(complete_content) + return Response( + image_data, + mimetype=mime_type, + headers={ + 'Content-Length': len(image_data), + 'Cache-Control': 'public, max-age=3600' + } + ) except Exception as e: print(f"ERROR: Failed to serve image {image_id}: {str(e)}") @@ -366,39 +266,11 @@ def create_conversation(): if not user_id: return jsonify({'error': 'User not authenticated'}), 401 - conversation_id = str(uuid.uuid4()) - conversation_item = { - 'id': conversation_id, - 'user_id': user_id, - 'last_updated': datetime.utcnow().isoformat(), - 'title': 'New Conversation', - 'context': [], - 'tags': [], - 'strict': False, - 'is_pinned': False, - 'is_hidden': False, - 'chat_type': 'new', - 'has_unread_assistant_response': False, - 'last_unread_assistant_message_id': None, - 'last_unread_assistant_at': None, - } - cosmos_conversations_container.upsert_item(conversation_item) - - # Log conversation creation - log_conversation_creation( - user_id=user_id, - conversation_id=conversation_id, - title='New Conversation', - workspace_type='personal' - ) - - # Mark as logged to activity logs to prevent duplicate migration - conversation_item['added_to_activity_log'] = True - cosmos_conversations_container.upsert_item(conversation_item) + conversation_item = create_personal_conversation_for_current_user() return jsonify({ - 'conversation_id': conversation_id, - 'title': 'New Conversation' + 'conversation_id': conversation_item.get('id'), + 'title': conversation_item.get('title', 'New Conversation') }), 200 @app.route('/api/conversations/', methods=['PUT']) @@ -891,6 +763,7 @@ def get_conversation_metadata_api(conversation_id): "scope_locked": conversation_item.get('scope_locked'), "locked_contexts": conversation_item.get('locked_contexts', []), "chat_type": conversation_item.get('chat_type'), + "workflow_id": conversation_item.get('workflow_id'), "summary": conversation_item.get('summary') }), 200 @@ -960,6 +833,9 @@ def generate_conversation_summary_api(conversation_id): if not user_id: return jsonify({'error': 'User not authenticated'}), 401 + conversation_item = None + is_collaboration_summary = False + try: conversation_item = cosmos_conversations_container.read_item( item=conversation_id, @@ -968,7 +844,21 @@ def generate_conversation_summary_api(conversation_id): if conversation_item.get('user_id') != user_id: return jsonify({'error': 'Forbidden'}), 403 except CosmosResourceNotFoundError: - return jsonify({'error': 'Conversation not found'}), 404 + try: + conversation_item = get_collaboration_conversation(conversation_id) + assert_user_can_view_collaboration_conversation( + user_id, + conversation_item, + allow_pending=True, + ) + is_collaboration_summary = True + except CosmosResourceNotFoundError: + return jsonify({'error': 'Conversation not found'}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + except Exception as e: + debug_print(f"Error reading collaborative conversation for summary: {e}") + return jsonify({'error': 'Failed to read conversation'}), 500 except Exception as e: debug_print(f"Error reading conversation for summary: {e}") return jsonify({'error': 'Failed to read conversation'}), 500 @@ -978,13 +868,16 @@ def generate_conversation_summary_api(conversation_id): # Query messages for this conversation try: - query = "SELECT * FROM c WHERE c.conversation_id = @cid ORDER BY c.timestamp ASC" - params = [{"name": "@cid", "value": conversation_id}] - raw_messages = list(cosmos_messages_container.query_items( - query=query, - parameters=params, - enable_cross_partition_query=True - )) + if is_collaboration_summary: + raw_messages = list_collaboration_messages(conversation_id) + else: + query = "SELECT * FROM c WHERE c.conversation_id = @cid ORDER BY c.timestamp ASC" + params = [{"name": "@cid", "value": conversation_id}] + raw_messages = list(cosmos_messages_container.query_items( + query=query, + parameters=params, + enable_cross_partition_query=True + )) raw_messages = filter_assistant_artifact_items(raw_messages) except Exception as e: debug_print(f"Error querying messages for summary: {e}") @@ -1058,28 +951,25 @@ def patch_conversation_scope_lock(conversation_id): return jsonify({'error': 'Scope unlock is disabled by administrator'}), 403 try: - conversation_item = cosmos_conversations_container.read_item( - item=conversation_id, partition_key=conversation_id + conversation_item, conversation_kind = _load_scope_lock_conversation(conversation_id, user_id) + conversation_item = _persist_scope_lock_update( + conversation_item, + conversation_kind, + user_id, + new_value, ) - if conversation_item.get('user_id') != user_id: - return jsonify({'error': 'Forbidden'}), 403 - - conversation_item['scope_locked'] = new_value - # locked_contexts are PRESERVED regardless — needed for re-locking - - from datetime import datetime - conversation_item['last_updated'] = datetime.utcnow().isoformat() - cosmos_conversations_container.upsert_item(conversation_item) return jsonify({ "success": True, "scope_locked": new_value, "locked_contexts": conversation_item.get('locked_contexts', []) }), 200 - except CosmosResourceNotFoundError: + except PermissionError as exc: + return jsonify({'error': str(exc) or 'Forbidden'}), 403 + except (CosmosResourceNotFoundError, LookupError): return jsonify({'error': 'Conversation not found'}), 404 except Exception as e: - print(f"Error updating scope lock: {e}") + debug_print(f"Error updating scope lock: {e}") return jsonify({'error': 'Failed to update scope lock'}), 500 @app.route('/api/conversations/classifications', methods=['GET']) diff --git a/application/single_app/route_backend_groups.py b/application/single_app/route_backend_groups.py index 0e35d211..a848ffd7 100644 --- a/application/single_app/route_backend_groups.py +++ b/application/single_app/route_backend_groups.py @@ -5,6 +5,10 @@ from functions_group import * from functions_debug import debug_print from functions_notifications import create_notification +from functions_simplechat_operations import ( + add_group_member_for_current_user, + create_group_for_current_user, +) from swagger_wrapper import swagger_route, get_auth_security def register_route_backend_groups(app): @@ -148,8 +152,10 @@ def api_create_group(): description = data.get("description", "") try: - group_doc = create_group(name, description) + group_doc = create_group_for_current_user(name, description) return jsonify({"id": group_doc["id"], "name": group_doc["name"]}), 201 + except PermissionError as ex: + return jsonify({"error": str(ex)}), 403 except Exception as ex: return jsonify({"error": str(ex)}), 400 @@ -385,101 +391,22 @@ def add_member_directly(group_id): Body: { "userId": "", "displayName": "...", etc. } Only Owner or Admin can add members directly (bypass request flow). """ - user_info = get_current_user_info() - user_id = user_info["userId"] - user_email = user_info.get("email", "unknown") - - group_doc = find_group_by_id(group_id) - - if not group_doc: - return jsonify({"error": "Group not found"}), 404 - - role = get_user_role_in_group(group_doc, user_id) - if role not in ["Owner", "Admin"]: - return jsonify({"error": "Only the owner or admin can add members"}), 403 - data = request.get_json() - new_user_id = data.get("userId") - if not new_user_id: - return jsonify({"error": "Missing userId"}), 400 - - if get_user_role_in_group(group_doc, new_user_id): - return jsonify({"error": "User is already a member"}), 400 - - # Get role from request, default to 'user' - member_role = data.get("role", "user").lower() - - # Validate role - valid_roles = ['admin', 'document_manager', 'user'] - if member_role not in valid_roles: - return jsonify({"error": f"Invalid role. Must be: {', '.join(valid_roles)}"}), 400 - - new_member_doc = { - "userId": new_user_id, - "email": data.get("email", ""), - "displayName": data.get("displayName", "New User") - } - group_doc["users"].append(new_member_doc) - - # Add to appropriate role array - if member_role == 'admin': - if new_user_id not in group_doc.get('admins', []): - group_doc.setdefault('admins', []).append(new_user_id) - elif member_role == 'document_manager': - if new_user_id not in group_doc.get('documentManagers', []): - group_doc.setdefault('documentManagers', []).append(new_user_id) - - group_doc["modifiedDate"] = datetime.utcnow().isoformat() - - cosmos_groups_container.upsert_item(group_doc) - - # Log activity for member addition - try: - activity_record = { - 'id': str(uuid.uuid4()), - 'activity_type': 'add_member_directly', - 'timestamp': datetime.utcnow().isoformat(), - 'added_by_user_id': user_id, - 'added_by_email': user_email, - 'added_by_role': role, - 'group_id': group_id, - 'group_name': group_doc.get('name', 'Unknown'), - 'member_user_id': new_user_id, - 'member_email': new_member_doc.get('email', ''), - 'member_name': new_member_doc.get('displayName', ''), - 'member_role': member_role, - 'description': f"{role} {user_email} added member {new_member_doc.get('displayName', '')} ({new_member_doc.get('email', '')}) to group {group_doc.get('name', group_id)} as {member_role}" - } - cosmos_activity_logs_container.create_item(body=activity_record) - except Exception as log_error: - debug_print(f"Failed to log member addition activity: {log_error}") - - # Create notification for the new member try: - from functions_notifications import create_notification - role_display = { - 'admin': 'Admin', - 'document_manager': 'Document Manager', - 'user': 'Member' - }.get(member_role, 'Member') - - create_notification( - user_id=new_user_id, - notification_type='system_announcement', - title='Added to Group', - message=f"You have been added to the group '{group_doc.get('name', 'Unknown')}' as {role_display} by {user_email}.", - link_url=f"/manage_group/{group_id}", - metadata={ - 'group_id': group_id, - 'group_name': group_doc.get('name', 'Unknown'), - 'added_by': user_email, - 'role': member_role - } + result = add_group_member_for_current_user( + group_id=group_id, + user_id=data.get("userId", ""), + email=data.get("email", ""), + display_name=data.get("displayName", ""), + role=data.get("role", "user"), ) - except Exception as notif_error: - debug_print(f"Failed to create member addition notification: {notif_error}") - - return jsonify({"message": "Member added", "success": True}), 200 + return jsonify({"message": result.get("message", "Member added"), "success": True}), 200 + except PermissionError as exc: + return jsonify({"error": str(exc)}), 403 + except LookupError as exc: + return jsonify({"error": str(exc)}), 404 + except ValueError as exc: + return jsonify({"error": str(exc)}), 400 @app.route("/api/groups//members/", methods=["DELETE"]) @swagger_route(security=get_auth_security()) diff --git a/application/single_app/route_backend_notifications.py b/application/single_app/route_backend_notifications.py index 8fe8dd58..5787ccb2 100644 --- a/application/single_app/route_backend_notifications.py +++ b/application/single_app/route_backend_notifications.py @@ -83,6 +83,30 @@ def api_get_notification_count(): 'count': 0 }), 500 + @app.route("/api/notifications/workflow-alerts", methods=["GET"]) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def api_get_workflow_alert_notifications(): + """Get unread workflow alert notifications for the current user.""" + try: + user_id = get_current_user_id() + limit = int(request.args.get('limit', 5)) + if limit < 1 or limit > 10: + limit = 5 + + notifications = get_unread_workflow_priority_notifications(user_id, limit=limit) + return jsonify({ + 'success': True, + 'notifications': notifications, + }) + except Exception as e: + debug_print(f"Error fetching workflow alert notifications: {e}") + return jsonify({ + 'success': False, + 'notifications': [], + }), 500 + @app.route("/api/notifications//read", methods=["POST"]) @swagger_route(security=get_auth_security()) @login_required diff --git a/application/single_app/route_backend_plugins.py b/application/single_app/route_backend_plugins.py index 115bf282..fceb4ffa 100644 --- a/application/single_app/route_backend_plugins.py +++ b/application/single_app/route_backend_plugins.py @@ -1,8 +1,11 @@ -#route_backlend_plugins.py +# route_backend_plugins.py import re import builtins import json +from azure.cosmos import CosmosClient +from azure.cosmos.exceptions import CosmosHttpResponseError +from azure.identity import DefaultAzureCredential from flask import Blueprint, jsonify, request, current_app from semantic_kernel_plugins.plugin_loader import get_all_plugin_metadata from semantic_kernel_plugins.plugin_health_checker import PluginHealthChecker, PluginErrorRecovery @@ -37,12 +40,79 @@ #from functions_personal_actions import delete_personal_action from functions_debug import debug_print -from json_schema_validation import PLUGIN_STORAGE_MANAGED_FIELDS, validate_plugin +from json_schema_validation import PLUGIN_STORAGE_MANAGED_FIELDS, apply_plugin_validation_defaults, validate_plugin from functions_activity_logging import ( log_action_creation, log_action_update, log_action_deletion, ) +from functions_azure_maps import AZURE_MAPS_DEFAULT_ENDPOINT, AZURE_MAPS_PLUGIN_TYPE +from functions_blob_storage_operations import ( + BLOB_STORAGE_PLUGIN_TYPE, + derive_blob_endpoint_from_connection_string, + get_default_blob_storage_capabilities, + get_default_blob_storage_read_file_types, + get_default_blob_storage_upload_file_types, +) +from functions_chart_operations import CHART_DEFAULT_ENDPOINT, CHART_PLUGIN_TYPE +from functions_msgraph_operations import MSGRAPH_DEFAULT_ENDPOINT, MSGRAPH_PLUGIN_TYPE +from functions_simplechat_operations import SIMPLECHAT_DEFAULT_ENDPOINT, SIMPLECHAT_PLUGIN_TYPE + + +DOCUMENT_SEARCH_INTERNAL_ENDPOINT = 'internal://document-search' + + +def _apply_plugin_runtime_defaults(plugin_payload): + if not isinstance(plugin_payload, dict): + return plugin_payload + + plugin_type = plugin_payload.get('type', '') + if plugin_type in ['sql_schema', 'sql_query']: + if not str(plugin_payload.get('endpoint') or '').strip(): + plugin_payload['endpoint'] = f'sql://{plugin_type}' + elif plugin_type == CHART_PLUGIN_TYPE: + if not str(plugin_payload.get('endpoint') or '').strip(): + plugin_payload['endpoint'] = CHART_DEFAULT_ENDPOINT + auth = plugin_payload.get('auth') if isinstance(plugin_payload.get('auth'), dict) else {} + auth['type'] = 'user' + plugin_payload['auth'] = auth + elif plugin_type == MSGRAPH_PLUGIN_TYPE: + if not str(plugin_payload.get('endpoint') or '').strip(): + plugin_payload['endpoint'] = MSGRAPH_DEFAULT_ENDPOINT + auth = plugin_payload.get('auth') if isinstance(plugin_payload.get('auth'), dict) else {} + auth['type'] = 'user' + plugin_payload['auth'] = auth + elif plugin_type in ['search', 'document_search']: + if not str(plugin_payload.get('endpoint') or '').strip(): + plugin_payload['endpoint'] = DOCUMENT_SEARCH_INTERNAL_ENDPOINT + auth = plugin_payload.get('auth') if isinstance(plugin_payload.get('auth'), dict) else {} + auth['type'] = 'NoAuth' + plugin_payload['auth'] = auth + elif plugin_type == AZURE_MAPS_PLUGIN_TYPE: + if not str(plugin_payload.get('endpoint') or '').strip(): + plugin_payload['endpoint'] = AZURE_MAPS_DEFAULT_ENDPOINT + auth = plugin_payload.get('auth') if isinstance(plugin_payload.get('auth'), dict) else {} + auth['type'] = 'key' + plugin_payload['auth'] = auth + elif plugin_type == BLOB_STORAGE_PLUGIN_TYPE: + auth = plugin_payload.get('auth') if isinstance(plugin_payload.get('auth'), dict) else {} + additional_fields = plugin_payload.get('additionalFields') if isinstance(plugin_payload.get('additionalFields'), dict) else {} + if not plugin_payload.get('endpoint') and auth.get('type') == 'connection_string': + derived_endpoint = derive_blob_endpoint_from_connection_string(auth.get('key') or '') + if derived_endpoint: + plugin_payload['endpoint'] = derived_endpoint + additional_fields.setdefault('blob_storage_capabilities', get_default_blob_storage_capabilities()) + additional_fields.setdefault('blob_storage_read_file_types', get_default_blob_storage_read_file_types()) + additional_fields.setdefault('blob_storage_upload_file_types', get_default_blob_storage_upload_file_types()) + plugin_payload['additionalFields'] = additional_fields + elif plugin_type == SIMPLECHAT_PLUGIN_TYPE: + if not str(plugin_payload.get('endpoint') or '').strip(): + plugin_payload['endpoint'] = SIMPLECHAT_DEFAULT_ENDPOINT + auth = plugin_payload.get('auth') if isinstance(plugin_payload.get('auth'), dict) else {} + auth['type'] = 'user' + plugin_payload['auth'] = auth + + return plugin_payload def discover_plugin_types(): # Dynamically discover allowed plugin types from available plugin classes. @@ -145,7 +215,37 @@ def get_plugin_types(): 'connection_string': ':memory:', 'metadata': {'description': 'Example SQL plugin'} } - elif any(x in module_name.lower() for x in ['azure_function', 'blob_storage', 'queue_storage']): + elif 'cosmos' in module_name.lower(): + safe_manifest = { + 'endpoint': 'https://example.documents.azure.com:443/', + 'auth': {'type': 'identity', 'identity': 'managed_identity'}, + 'additionalFields': { + 'database_name': 'SimpleChat', + 'container_name': 'documents', + 'partition_key_path': '/id', + 'field_hints': ['id', 'title', 'user_id'], + 'max_items': 100, + 'timeout': 30, + }, + 'metadata': {'description': 'Example Cosmos query plugin'} + } + elif 'blob_storage' in module_name.lower(): + safe_manifest = { + 'endpoint': 'https://example.blob.core.windows.net', + 'auth': { + 'type': 'connection_string', + 'key': 'DefaultEndpointsProtocol=https;AccountName=example;AccountKey=ZmFrZQ==;EndpointSuffix=core.windows.net' + }, + 'additionalFields': { + 'container_name': 'content', + 'blob_prefix': 'docs', + 'blob_storage_capabilities': get_default_blob_storage_capabilities(), + 'blob_storage_read_file_types': get_default_blob_storage_read_file_types(), + 'blob_storage_upload_file_types': get_default_blob_storage_upload_file_types(), + }, + 'metadata': {'description': 'Example Blob Storage plugin'} + } + elif any(x in module_name.lower() for x in ['azure_function', 'queue_storage']): safe_manifest = { 'endpoint': 'https://example.azure.com', 'auth': {'type': 'key', 'key': 'dummy'}, @@ -156,6 +256,12 @@ def get_plugin_types(): 'auth': {'type': 'user'}, 'metadata': {'description': 'Microsoft Graph plugin'} } + elif 'azure_maps' in module_name.lower(): + safe_manifest = { + 'endpoint': AZURE_MAPS_DEFAULT_ENDPOINT, + 'auth': {'type': 'key', 'key': 'dummy'}, + 'metadata': {'description': 'Azure Maps visualization plugin'} + } elif 'log_analytics' in module_name.lower(): safe_manifest = { 'endpoint': 'https://api.loganalytics.io', @@ -230,8 +336,8 @@ def _redact_plugin_for_logging(plugin): return redact_plugin_secret_values(plugin) -def _resolve_secret_value_for_sql_test(value, field_name): - """Resolve a Key Vault reference for SQL test-connection flows.""" +def _resolve_secret_value_for_plugin_test(value, field_name, plugin_label='plugin'): + """Resolve a Key Vault reference for plugin test-connection flows.""" if not isinstance(value, str) or not value: return value if not validate_secret_name_dynamic(value): @@ -239,12 +345,12 @@ def _resolve_secret_value_for_sql_test(value, field_name): resolved_value = retrieve_secret_from_key_vault_by_full_name(value) if validate_secret_name_dynamic(resolved_value): - raise ValueError(f"Unable to resolve stored Key Vault secret for SQL field '{field_name}'.") + raise ValueError(f"Unable to resolve stored Key Vault secret for {plugin_label} field '{field_name}'.") return resolved_value -def _load_existing_plugin_for_sql_test(plugin_context, user_id): - """Load an existing plugin manifest with Key Vault reference names for edit-time SQL tests.""" +def _load_existing_plugin_for_test(plugin_context, user_id): + """Load an existing plugin manifest with Key Vault reference names for edit-time plugin tests.""" if not isinstance(plugin_context, dict): return None @@ -267,6 +373,16 @@ def _load_existing_plugin_for_sql_test(plugin_context, user_id): return get_personal_action(user_id, plugin_identifier, return_type=SecretReturnType.NAME) + +def _resolve_secret_value_for_sql_test(value, field_name): + """Resolve a Key Vault reference for SQL test-connection flows.""" + return _resolve_secret_value_for_plugin_test(value, field_name, plugin_label='SQL') + + +def _load_existing_plugin_for_sql_test(plugin_context, user_id): + """Load an existing plugin manifest with Key Vault reference names for edit-time SQL tests.""" + return _load_existing_plugin_for_test(plugin_context, user_id) + # === USER PLUGINS ENDPOINTS === @bpap.route('/api/user/plugins', methods=['GET']) @swagger_route(security=get_auth_security()) @@ -357,17 +473,8 @@ def set_user_plugins(): # Handle endpoint based on plugin type plugin_type = plugin_to_save.get('type', '') - if plugin_type in ['sql_schema', 'sql_query']: - # SQL plugins don't use endpoints, but schema validation requires one - # Use a placeholder that indicates it's a SQL plugin - plugin_to_save.setdefault('endpoint', f'sql://{plugin_type}') - elif plugin_type == 'msgraph': - # MS Graph plugin does not require an endpoint, but schema validation requires one - #TODO: Update to support different clouds - plugin_to_save.setdefault('endpoint', 'https://graph.microsoft.com') - else: - # For other plugin types, require a real endpoint - plugin_to_save.setdefault('endpoint', '') + plugin_to_save.setdefault('endpoint', '') + _apply_plugin_runtime_defaults(plugin_to_save) # Ensure auth has default structure if 'auth' not in plugin_to_save: @@ -547,12 +654,7 @@ def create_group_action_route(): for key in ('group_id', 'last_updated', 'user_id', 'is_global', 'is_group', 'scope'): payload.pop(key, None) - # Handle endpoint based on plugin type (same logic as personal plugins) - plugin_type = payload.get('type', '') - if plugin_type in ['sql_schema', 'sql_query']: - payload.setdefault('endpoint', f'sql://{plugin_type}') - elif plugin_type == 'msgraph': - payload.setdefault('endpoint', 'https://graph.microsoft.com') + _apply_plugin_runtime_defaults(payload) # Merge with schema to ensure all required fields are present (same as global actions) schema_dir = os.path.join(current_app.root_path, 'static', 'json', 'schemas') @@ -611,12 +713,7 @@ def update_group_action_route(action_id): merged['is_group'] = True merged['id'] = existing.get('id', action_id) - # Handle endpoint based on plugin type (same logic as personal plugins) - plugin_type = merged.get('type', '') - if plugin_type in ['sql_schema', 'sql_query']: - merged.setdefault('endpoint', f'sql://{plugin_type}') - elif plugin_type == 'msgraph': - merged.setdefault('endpoint', 'https://graph.microsoft.com') + _apply_plugin_runtime_defaults(merged) try: validate_group_action_payload(merged, partial=False) @@ -752,21 +849,72 @@ def update_core_plugin_settings(): @admin_required def list_plugins(): try: - plugins = get_global_actions() + plugins = get_global_actions(include_disabled=True) log_event("List plugins", extra={"action": "list", "user": str(getattr(request, 'user', 'unknown'))}) return jsonify(plugins) except Exception as e: log_event(f"Error listing plugins: {e}", level=logging.ERROR) return jsonify({'error': 'Failed to list plugins.'}), 500 + +@bpap.route('/api/admin/plugins//enabled', methods=['PATCH']) +@swagger_route(security=get_auth_security()) +@login_required +@admin_required +def set_plugin_enabled(plugin_name): + try: + data = request.get_json(silent=True) or {} + if 'is_enabled' not in data or not isinstance(data.get('is_enabled'), bool): + return jsonify({'error': 'Field "is_enabled" must be a boolean.'}), 400 + + is_enabled = data.get('is_enabled') + plugins = get_global_actions(include_disabled=True) + plugin_to_update = next((plugin for plugin in plugins if plugin.get('name') == plugin_name), None) + if plugin_to_update is None: + log_event("Toggle plugin enabled failed: not found", level=logging.WARNING, extra={"action": "toggle-enabled", "plugin_name": plugin_name}) + return jsonify({'error': 'Plugin not found.'}), 404 + + result = update_global_action_enabled( + plugin_to_update.get('id'), + is_enabled, + user_id=str(get_current_user_id()) + ) + if not result: + return jsonify({'error': 'Failed to update action enabled state.'}), 500 + + log_action_update( + user_id=str(get_current_user_id()), + action_id=plugin_to_update.get('id', ''), + action_name=plugin_name, + action_type=plugin_to_update.get('type', ''), + scope='global' + ) + log_event( + "Plugin enabled state updated", + extra={ + "action": "toggle-enabled", + "plugin_name": plugin_name, + "plugin_id": plugin_to_update.get('id', ''), + "is_enabled": is_enabled, + "user": str(get_current_user_id()) + } + ) + setattr(builtins, "kernel_reload_needed", True) + return jsonify({'success': True}) + except Exception as e: + log_event(f"Error updating plugin enabled state: {e}", level=logging.ERROR) + return jsonify({'error': 'Failed to update action enabled state.'}), 500 + @bpap.route('/api/admin/plugins', methods=['POST']) @swagger_route(security=get_auth_security()) @login_required @admin_required def add_plugin(): try: - plugins = get_global_actions() - new_plugin = request.json + plugins = get_global_actions(include_disabled=True) + new_plugin = request.get_json(silent=True) or {} + _apply_plugin_runtime_defaults(new_plugin) + new_plugin = apply_plugin_validation_defaults(new_plugin) # Strict validation with dynamic allowed types allowed_types = discover_plugin_types() @@ -820,8 +968,10 @@ def add_plugin(): @admin_required def edit_plugin(plugin_name): try: - plugins = get_global_actions() - updated_plugin = request.json + plugins = get_global_actions(include_disabled=True) + updated_plugin = request.get_json(silent=True) or {} + _apply_plugin_runtime_defaults(updated_plugin) + updated_plugin = apply_plugin_validation_defaults(updated_plugin) # Strict validation with dynamic allowed types allowed_types = discover_plugin_types() @@ -896,7 +1046,7 @@ def get_admin_plugin_types(): @admin_required def delete_plugin(plugin_name): try: - plugins = get_global_actions() + plugins = get_global_actions(include_disabled=True) # Find the plugin by name plugin_to_delete = None @@ -1111,7 +1261,7 @@ def test_sql_connection(): else: if not server or not database: return jsonify({'success': False, 'error': 'Server and database are required for individual parameters connection.'}), 400 - drv = driver or 'ODBC Driver 17 for SQL Server' + drv = driver or 'ODBC Driver 18 for SQL Server' conn_str = f"DRIVER={{{drv}}};SERVER={server};DATABASE={database}" if port: conn_str += f",{port}" @@ -1186,10 +1336,162 @@ def test_sql_connection(): return jsonify({'success': False, 'error': f'Unsupported database type: {database_type}'}), 400 except ImportError as e: + if database_type == 'sqlserver' and 'libodbc' in str(e): + return jsonify({ + 'success': False, + 'error': 'Database driver not installed: the container image is missing the unixODBC runtime required for SQL Server connections.' + }), 400 return jsonify({'success': False, 'error': f'Database driver not installed: {str(e)}'}), 400 except Exception as e: error_msg = str(e) + if database_type == 'sqlserver' and "Can't open lib 'ODBC Driver 17 for SQL Server'" in error_msg: + error_msg = 'The selected ODBC Driver 17 is not installed in this container image. Select ODBC Driver 18 for SQL Server or rebuild the image with Driver 17.' # Sanitize error message to avoid leaking sensitive details if 'password' in error_msg.lower() or 'pwd' in error_msg.lower(): error_msg = 'Authentication failed. Please check your credentials.' return jsonify({'success': False, 'error': f'Connection failed: {error_msg}'}), 400 + + +@bpap.route('/api/plugins/test-cosmos-connection', methods=['POST']) +@swagger_route(security=get_auth_security()) +@login_required +@user_required +def test_cosmos_connection(): + """Test an Azure Cosmos DB for NoSQL connection using managed identity or an account key.""" + data = request.get_json(silent=True) or {} + user_id = get_current_user_id() + endpoint = (data.get('endpoint') or '').strip() + database_name = (data.get('database_name') or '').strip() + container_name = (data.get('container_name') or '').strip() + auth_type = (data.get('auth_type') or 'identity').strip().lower() + auth_key = (data.get('auth_key') or '').strip() + timeout = min(max(int(data.get('timeout', 10)), 1), 30) + + if auth_type == 'managed_identity': + auth_type = 'identity' + + if not endpoint: + return jsonify({'success': False, 'error': 'Cosmos DB account endpoint is required.'}), 400 + if not database_name: + return jsonify({'success': False, 'error': 'Database name is required.'}), 400 + if not container_name: + return jsonify({'success': False, 'error': 'Container name is required.'}), 400 + + try: + existing_plugin = _load_existing_plugin_for_test(data.get('existing_plugin'), user_id) + except PermissionError as exc: + return jsonify({'success': False, 'error': str(exc)}), 403 + except LookupError as exc: + return jsonify({'success': False, 'error': str(exc)}), 404 + except ValueError as exc: + return jsonify({'success': False, 'error': str(exc)}), 400 + + existing_auth = {} + if isinstance(existing_plugin, dict) and isinstance(existing_plugin.get('auth'), dict): + existing_auth = existing_plugin['auth'] + + if auth_type == 'key': + if auth_key == ui_trigger_word: + auth_key = existing_auth.get('key', '') + + if auth_key == ui_trigger_word: + return jsonify({'success': False, 'error': 'Stored Cosmos DB account key could not be resolved for testing. Re-enter the account key.'}), 400 + + try: + auth_key = _resolve_secret_value_for_plugin_test(auth_key, 'auth.key', plugin_label='Cosmos DB') + except ValueError as exc: + return jsonify({'success': False, 'error': str(exc)}), 400 + + if not auth_key: + return jsonify({'success': False, 'error': 'Account key is required when using key authentication.'}), 400 + elif auth_type != 'identity': + return jsonify({'success': False, 'error': "Cosmos DB auth_type must be either 'identity' or 'key'."}), 400 + + try: + headers = {} + + def capture_response_headers(response_headers, _): + headers.clear() + headers.update(response_headers) + + client = CosmosClient( + endpoint, + credential=DefaultAzureCredential() if auth_type == 'identity' else auth_key, + timeout=timeout, + connection_timeout=timeout, + ) + database_client = client.get_database_client(database_name) + database_client.read() + container_client = database_client.get_container_client(container_name) + container_client.read() + list( + container_client.query_items( + query='SELECT TOP 1 VALUE c.id FROM c', + enable_cross_partition_query=True, + max_item_count=1, + response_hook=capture_response_headers, + ) + ) + + log_event( + '[Plugins] Cosmos connection test succeeded', + extra={ + 'user_id': user_id, + 'endpoint': endpoint, + 'database_name': database_name, + 'container_name': container_name, + 'auth_type': auth_type, + 'request_charge': headers.get('x-ms-request-charge'), + }, + level=logging.INFO, + ) + return jsonify({ + 'success': True, + 'message': f'Successfully connected to Cosmos DB container {container_name} in database {database_name}.' + }) + except CosmosHttpResponseError as exc: + status_code = getattr(exc, 'status_code', None) + if status_code in (401, 403): + if auth_type == 'key': + error_msg = 'Account key authentication failed. Verify the Cosmos DB account key and confirm key-based access is enabled for this account.' + else: + error_msg = 'Managed identity authentication or authorization failed. Ensure the application identity has Azure Cosmos DB built-in data reader access.' + status = 403 + elif status_code == 404: + error_msg = 'The configured database or container was not found at the specified account endpoint.' + status = 404 + else: + error_msg = f'Cosmos DB connection failed: {str(exc)}' + status = 400 + + log_event( + f'[Plugins] Cosmos connection test failed: {exc}', + extra={ + 'user_id': user_id, + 'endpoint': endpoint, + 'database_name': database_name, + 'container_name': container_name, + 'auth_type': auth_type, + 'status_code': status_code, + }, + level=logging.WARNING, + exceptionTraceback=True, + ) + return jsonify({'success': False, 'error': error_msg}), status + except Exception as exc: + log_event( + f'[Plugins] Cosmos connection test failed unexpectedly: {exc}', + extra={ + 'user_id': user_id, + 'endpoint': endpoint, + 'database_name': database_name, + 'container_name': container_name, + 'auth_type': auth_type, + }, + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({ + 'success': False, + 'error': 'Cosmos DB authentication failed or the account could not be reached. Verify the endpoint and the selected authentication settings.' + }), 400 diff --git a/application/single_app/route_backend_search.py b/application/single_app/route_backend_search.py new file mode 100644 index 00000000..5a5a98bc --- /dev/null +++ b/application/single_app/route_backend_search.py @@ -0,0 +1,136 @@ +# route_backend_search.py + +from config import * +from functions_appinsights import log_event +from functions_authentication import get_current_user_id, login_required, user_required +from functions_search_service import ( + get_document_chunks_payload, + search_documents as run_document_search, + summarize_document_content, +) +from swagger_wrapper import swagger_route, get_auth_security + + +def register_route_backend_search(app): + @app.route('/api/search/documents', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def api_search_documents(): + user_id = get_current_user_id() + if not user_id: + return jsonify({'error': 'User not authenticated'}), 401 + + data = request.get_json(silent=True) or {} + + try: + payload = run_document_search( + query=data.get('query'), + user_id=user_id, + top_n=data.get('top_n'), + doc_scope=data.get('doc_scope', 'all'), + document_id=data.get('document_id'), + document_ids=data.get('document_ids'), + tags_filter=data.get('tags_filter', data.get('tags')), + active_group_ids=data.get('active_group_ids', data.get('active_group_id')), + active_public_workspace_id=data.get('active_public_workspace_id'), + enable_file_sharing=data.get('enable_file_sharing', True), + ) + return jsonify(payload), 200 + except ValueError as e: + return jsonify({'error': str(e)}), 400 + except Exception as e: + log_event( + '[Backend Search] Document search failed.', + extra={'user_id': user_id, 'error_message': str(e)}, + level=logging.ERROR, + ) + return jsonify({'error': 'Document search failed'}), 500 + + @app.route('/api/search/document-chunks', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def api_get_document_chunks(): + user_id = get_current_user_id() + if not user_id: + return jsonify({'error': 'User not authenticated'}), 401 + + data = request.get_json(silent=True) or {} + document_id = str(data.get('document_id') or '').strip() + if not document_id: + return jsonify({'error': 'document_id is required'}), 400 + + try: + payload = get_document_chunks_payload( + document_id=document_id, + user_id=user_id, + doc_scope=data.get('doc_scope', 'all'), + active_group_ids=data.get('active_group_ids', data.get('active_group_id')), + active_public_workspace_id=data.get('active_public_workspace_id'), + window_unit=data.get('window_unit', 'pages'), + window_size=data.get('window_size'), + window_percent=data.get('window_percent'), + window_number=data.get('window_number'), + ) + return jsonify(payload), 200 + except LookupError as e: + return jsonify({'error': str(e)}), 404 + except Exception as e: + log_event( + '[Backend Search] Document chunk retrieval failed.', + extra={ + 'user_id': user_id, + 'document_id': document_id, + 'error_message': str(e), + }, + level=logging.ERROR, + ) + return jsonify({'error': 'Document chunk retrieval failed'}), 500 + + @app.route('/api/search/document-summary', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def api_summarize_document(): + user_id = get_current_user_id() + if not user_id: + return jsonify({'error': 'User not authenticated'}), 401 + + data = request.get_json(silent=True) or {} + document_id = str(data.get('document_id') or '').strip() + if not document_id: + return jsonify({'error': 'document_id is required'}), 400 + + try: + payload = summarize_document_content( + document_id=document_id, + user_id=user_id, + doc_scope=data.get('doc_scope', 'all'), + active_group_ids=data.get('active_group_ids', data.get('active_group_id')), + active_public_workspace_id=data.get('active_public_workspace_id'), + focus_instructions=data.get('focus_instructions', ''), + final_target_length=data.get('final_target_length', data.get('target_length', '2 pages')), + window_target_length=data.get('window_target_length', '2 pages'), + window_unit=data.get('window_unit', 'pages'), + window_size=data.get('window_size'), + window_percent=data.get('window_percent'), + reduction_batch_size=data.get('reduction_batch_size'), + max_reduction_rounds=data.get('max_reduction_rounds'), + ) + return jsonify(payload), 200 + except LookupError as e: + return jsonify({'error': str(e)}), 404 + except RuntimeError as e: + return jsonify({'error': str(e)}), 400 + except Exception as e: + log_event( + '[Backend Search] Document summarization failed.', + extra={ + 'user_id': user_id, + 'document_id': document_id, + 'error_message': str(e), + }, + level=logging.ERROR, + ) + return jsonify({'error': 'Document summarization failed'}), 500 \ No newline at end of file diff --git a/application/single_app/route_backend_thoughts.py b/application/single_app/route_backend_thoughts.py index 69aed271..441e6db9 100644 --- a/application/single_app/route_backend_thoughts.py +++ b/application/single_app/route_backend_thoughts.py @@ -1,7 +1,14 @@ # route_backend_thoughts.py from flask import request, jsonify +from config import CosmosResourceNotFoundError from functions_authentication import login_required, user_required, get_current_user_id +from functions_collaboration import ( + assert_user_can_view_collaboration_conversation, + get_accessible_collaboration_message_thoughts, + get_collaboration_conversation, + get_collaboration_message, +) from functions_settings import get_settings from functions_thoughts import get_thoughts_for_message, get_pending_thoughts from swagger_wrapper import swagger_route, get_auth_security @@ -26,6 +33,23 @@ def api_get_message_thoughts(conversation_id, message_id): try: thoughts = get_thoughts_for_message(conversation_id, message_id, user_id) + if not thoughts: + try: + message_doc = get_collaboration_message(message_id) + if str(message_doc.get('conversation_id') or '') == str(conversation_id or ''): + collaboration_conversation = get_collaboration_conversation(conversation_id) + assert_user_can_view_collaboration_conversation( + user_id, + collaboration_conversation, + allow_pending=True, + ) + thoughts = get_accessible_collaboration_message_thoughts( + collaboration_conversation, + message_doc, + user_id, + ) + except CosmosResourceNotFoundError: + thoughts = thoughts or [] # Strip internal Cosmos fields before returning sanitized = [] for t in thoughts: @@ -36,10 +60,14 @@ def api_get_message_thoughts(conversation_id, message_id): 'step_type': t.get('step_type'), 'content': t.get('content'), 'detail': t.get('detail'), + 'activity': t.get('activity'), + 'progress': t.get('progress'), 'duration_ms': t.get('duration_ms'), 'timestamp': t.get('timestamp') }) return jsonify({'thoughts': sanitized, 'enabled': True}), 200 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 except Exception as e: log_event(f"api_get_message_thoughts error: {e}", level="WARNING") return jsonify({'error': 'Failed to retrieve thoughts'}), 500 @@ -63,6 +91,17 @@ def api_get_pending_thoughts(conversation_id): return jsonify({'thoughts': [], 'enabled': False}), 200 try: + try: + collaboration_conversation = get_collaboration_conversation(conversation_id) + assert_user_can_view_collaboration_conversation( + user_id, + collaboration_conversation, + allow_pending=True, + ) + return jsonify({'thoughts': [], 'enabled': True}), 200 + except CosmosResourceNotFoundError: + pass + message_id = request.args.get('message_id') thoughts = get_pending_thoughts(conversation_id, user_id, message_id=message_id) sanitized = [] @@ -74,10 +113,14 @@ def api_get_pending_thoughts(conversation_id): 'step_type': t.get('step_type'), 'content': t.get('content'), 'detail': t.get('detail'), + 'activity': t.get('activity'), + 'progress': t.get('progress'), 'duration_ms': t.get('duration_ms'), 'timestamp': t.get('timestamp') }) return jsonify({'thoughts': sanitized, 'enabled': True}), 200 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 except Exception as e: log_event(f"api_get_pending_thoughts error: {e}", level="WARNING") return jsonify({'error': 'Failed to retrieve pending thoughts'}), 500 diff --git a/application/single_app/route_backend_users.py b/application/single_app/route_backend_users.py index 459aa800..f98e9091 100644 --- a/application/single_app/route_backend_users.py +++ b/application/single_app/route_backend_users.py @@ -1,6 +1,7 @@ # route_backend_users.py from config import * +from collaboration_models import normalize_collaboration_user from functions_authentication import * from functions_settings import * from swagger_wrapper import swagger_route, get_auth_security @@ -97,6 +98,86 @@ def api_get_user_info(user_id): return jsonify({ "error": f"User not found for oid {user_id}" }), 404 + + @app.route('/api/user/collaboration-suggestions', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def api_collaboration_suggestions(): + user_id = get_current_user_id() + if not user_id: + return jsonify({"error": "Unable to identify user"}), 401 + + query = str(request.args.get('query') or '').strip().lower() + recent_only = str(request.args.get('recent_only', 'false')).strip().lower() == 'true' + + try: + requested_limit = int(request.args.get('limit', 8)) + except (TypeError, ValueError): + requested_limit = 8 + limit = max(1, min(requested_limit, 20)) + + user_settings_doc = get_user_settings(user_id) or {} + recent_collaborators = ((user_settings_doc.get('settings') or {}).get('recentCollaborators') or []) + + suggestions = [] + seen_user_ids = set() + + def add_suggestion(raw_value, source_label): + fallback_user_id = None + if isinstance(raw_value, dict): + fallback_user_id = raw_value.get('id') + + normalized_user = normalize_collaboration_user(raw_value, fallback_user_id=fallback_user_id) + if not normalized_user: + return + + normalized_user_id = normalized_user.get('user_id') + if not normalized_user_id or normalized_user_id == user_id or normalized_user_id in seen_user_ids: + return + + haystack = f"{normalized_user.get('display_name', '')} {normalized_user.get('email', '')}".strip().lower() + if query and query not in haystack: + return + + seen_user_ids.add(normalized_user_id) + suggestions.append({ + 'user_id': normalized_user_id, + 'display_name': normalized_user.get('display_name'), + 'email': normalized_user.get('email'), + 'source': source_label, + }) + + for recent_collaborator in recent_collaborators: + add_suggestion(recent_collaborator, 'recent') + if len(suggestions) >= limit: + return jsonify({'results': suggestions[:limit]}), 200 + + if not recent_only and query: + user_query = ( + f'SELECT TOP {max(limit * 3, 12)} c.id, c.display_name, c.email FROM c ' + 'WHERE c.id != @current_user_id AND ' + '((IS_DEFINED(c.display_name) AND CONTAINS(LOWER(c.display_name), @query)) ' + 'OR (IS_DEFINED(c.email) AND CONTAINS(LOWER(c.email), @query)))' + ) + local_results = list(cosmos_user_settings_container.query_items( + query=user_query, + parameters=[ + {'name': '@current_user_id', 'value': user_id}, + {'name': '@query', 'value': query}, + ], + enable_cross_partition_query=True, + )) + for local_result in local_results: + add_suggestion({ + 'id': local_result.get('id'), + 'display_name': local_result.get('display_name'), + 'email': local_result.get('email'), + }, 'local') + if len(suggestions) >= limit: + break + + return jsonify({'results': suggestions[:limit]}), 200 @app.route('/api/user/settings', methods=['GET', 'POST']) @swagger_route(security=get_auth_security()) @@ -152,11 +233,16 @@ def user_settings(): # Chat UI settings 'navbar_layout', 'chatLayout', 'showChatTitle', 'chatSplitSizes', # Microphone permission settings - 'microphonePermissionState', + 'microphonePermissionPreference', 'microphonePermissionState', # Text-to-speech settings 'ttsEnabled', 'ttsVoice', 'ttsSpeed', 'ttsAutoplay', # Tutorial visibility settings 'showTutorialButtons', + 'recentCollaborators', + # Personal workspace settings managed by other backend/frontend flows + 'personal_model_endpoints', 'tag_definitions', + # Retention settings kept for current and legacy profile payloads + 'retention_policy', 'retention_policy_enabled', 'retention_policy_days', # Metrics and other settings 'metrics', 'lastUpdated' } # Add others as needed diff --git a/application/single_app/route_backend_workflows.py b/application/single_app/route_backend_workflows.py new file mode 100644 index 00000000..0e216f33 --- /dev/null +++ b/application/single_app/route_backend_workflows.py @@ -0,0 +1,382 @@ +# route_backend_workflows.py + +""" +Backend routes for personal workflows. +""" + +import json +import logging +import time +from datetime import datetime, timezone + +from flask import Response, jsonify, request, stream_with_context + +from background_tasks import acquire_distributed_task_lock, release_distributed_task_lock +from config import CosmosResourceNotFoundError, cosmos_conversations_container +from functions_activity_logging import ( + log_workflow_creation, + log_workflow_deletion, + log_workflow_update, +) +from functions_appinsights import log_event +from functions_authentication import get_current_user_id, login_required, user_required +from functions_thoughts import get_thoughts_for_message +from functions_workflow_activity import build_workflow_activity_snapshot +from functions_personal_workflows import ( + compute_next_run_at, + delete_personal_workflow, + get_latest_personal_workflow_run_for_conversation, + get_personal_workflow, + get_personal_workflow_run, + get_personal_workflows, + list_personal_workflow_runs, + save_personal_workflow, + update_personal_workflow_runtime_fields, +) +from functions_settings import enabled_required +from functions_workflow_runner import run_personal_workflow +from swagger_wrapper import swagger_route, get_auth_security + + +def _normalize_identifier(value): + return str(value or '').strip() + + +def _load_workflow_conversation(user_id, conversation_id): + if not conversation_id: + return None + + try: + conversation = cosmos_conversations_container.read_item( + item=conversation_id, + partition_key=conversation_id, + ) + except CosmosResourceNotFoundError as exc: + raise ValueError('Workflow conversation not found.') from exc + + if conversation.get('user_id') != user_id: + raise PermissionError('Forbidden') + if conversation.get('chat_type') != 'workflow': + raise ValueError('Workflow activity is only available for workflow conversations.') + return {key: value for key, value in conversation.items() if not str(key).startswith('_')} + + +def _resolve_workflow_activity_context(user_id, conversation_id='', workflow_id='', run_id=''): + workflow_id = _normalize_identifier(workflow_id) + conversation_id = _normalize_identifier(conversation_id) + run_id = _normalize_identifier(run_id) + + if not any([conversation_id, workflow_id, run_id]): + raise ValueError('A workflow activity request needs a conversation, workflow, or run identifier.') + + workflow = get_personal_workflow(user_id, workflow_id) if workflow_id else None + run_record = get_personal_workflow_run(user_id, run_id) if run_id else None + + if run_id and not run_record: + raise ValueError('Workflow run not found.') + + if run_record and workflow_id and _normalize_identifier(run_record.get('workflow_id')) != workflow_id: + raise ValueError('The requested run does not belong to this workflow.') + + if run_record and not workflow: + workflow = get_personal_workflow(user_id, run_record.get('workflow_id')) + + if not conversation_id: + conversation_id = _normalize_identifier((run_record or {}).get('conversation_id') or (workflow or {}).get('conversation_id')) + + conversation = _load_workflow_conversation(user_id, conversation_id) if conversation_id else None + + if conversation and workflow_id and _normalize_identifier(conversation.get('workflow_id')) not in {'', workflow_id}: + raise ValueError('The requested conversation does not belong to this workflow.') + + if not workflow and conversation: + workflow = get_personal_workflow(user_id, conversation.get('workflow_id')) + + if not run_record and conversation_id: + run_record = get_latest_personal_workflow_run_for_conversation( + user_id, + conversation_id, + workflow_id=_normalize_identifier((workflow or {}).get('id')) or workflow_id, + ) + + if run_record and conversation_id and _normalize_identifier(run_record.get('conversation_id')) not in {'', conversation_id}: + raise ValueError('The requested run does not belong to this workflow conversation.') + + thoughts = [] + if run_record and conversation_id and _normalize_identifier(run_record.get('assistant_message_id')): + thoughts = get_thoughts_for_message( + conversation_id, + run_record.get('assistant_message_id'), + user_id, + ) + + return build_workflow_activity_snapshot( + run_record=run_record, + workflow=workflow, + conversation=conversation, + thoughts=thoughts, + ) + + +def _stream_workflow_activity(user_id, conversation_id='', workflow_id='', run_id=''): + last_payload = None + terminal_snapshots_seen = 0 + + yield 'retry: 750\n\n' + + for _ in range(300): + snapshot = _resolve_workflow_activity_context( + user_id, + conversation_id=conversation_id, + workflow_id=workflow_id, + run_id=run_id, + ) + payload = json.dumps(snapshot, default=str, sort_keys=True) + + if payload != last_payload: + last_payload = payload + yield f'data: {payload}\n\n' + else: + yield ': keep-alive\n\n' + + run_status = str(((snapshot.get('run') or {}).get('status') or '')).strip().lower() + if run_status and run_status != 'running': + terminal_snapshots_seen += 1 + if terminal_snapshots_seen >= 2: + break + else: + terminal_snapshots_seen = 0 + + time.sleep(0.5) + + +def register_route_backend_workflows(app): + @app.route('/api/user/workflows', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + @enabled_required('allow_user_workflows') + def get_user_workflows(): + user_id = get_current_user_id() + return jsonify({'workflows': get_personal_workflows(user_id)}) + + + @app.route('/api/user/workflows', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + @enabled_required('allow_user_workflows') + def save_user_workflow(): + user_id = get_current_user_id() + payload = request.get_json(silent=True) or {} + is_create = not str(payload.get('id') or '').strip() + + try: + workflow = save_personal_workflow(user_id, payload, actor_user_id=user_id) + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except Exception as exc: + log_event( + f'[WorkflowRoutes] Failed to save workflow: {exc}', + extra={'user_id': user_id}, + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Unable to save workflow right now.'}), 500 + + if is_create: + log_workflow_creation( + user_id=user_id, + workflow_id=workflow.get('id', ''), + workflow_name=workflow.get('name', ''), + runner_type=workflow.get('runner_type'), + trigger_type=workflow.get('trigger_type'), + ) + else: + log_workflow_update( + user_id=user_id, + workflow_id=workflow.get('id', ''), + workflow_name=workflow.get('name', ''), + runner_type=workflow.get('runner_type'), + trigger_type=workflow.get('trigger_type'), + ) + + return jsonify({'success': True, 'workflow': workflow}), 201 if is_create else 200 + + + @app.route('/api/user/workflows/', methods=['DELETE']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + @enabled_required('allow_user_workflows') + def delete_user_workflow(workflow_id): + user_id = get_current_user_id() + workflow = get_personal_workflow(user_id, workflow_id) + if not workflow: + return jsonify({'error': 'Workflow not found.'}), 404 + + deleted = delete_personal_workflow(user_id, workflow_id) + if not deleted: + return jsonify({'error': 'Workflow not found.'}), 404 + + log_workflow_deletion( + user_id=user_id, + workflow_id=workflow_id, + workflow_name=workflow.get('name', ''), + ) + return jsonify({'success': True}) + + + @app.route('/api/user/workflows//runs', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + @enabled_required('allow_user_workflows') + def get_user_workflow_runs(workflow_id): + user_id = get_current_user_id() + workflow = get_personal_workflow(user_id, workflow_id) + if not workflow: + return jsonify({'error': 'Workflow not found.'}), 404 + + return jsonify({ + 'workflow_id': workflow_id, + 'runs': list_personal_workflow_runs(user_id, workflow_id, limit=50), + }) + + + @app.route('/api/user/workflows/activity', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + @enabled_required('allow_user_workflows') + def get_user_workflow_activity_snapshot(): + user_id = get_current_user_id() + conversation_id = request.args.get('conversation_id', '') + workflow_id = request.args.get('workflow_id', '') + run_id = request.args.get('run_id', '') + + try: + snapshot = _resolve_workflow_activity_context( + user_id, + conversation_id=conversation_id, + workflow_id=workflow_id, + run_id=run_id, + ) + return jsonify(snapshot) + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except Exception as exc: + log_event( + f'[WorkflowRoutes] Failed to load workflow activity snapshot: {exc}', + extra={ + 'user_id': user_id, + 'conversation_id': conversation_id, + 'workflow_id': workflow_id, + 'run_id': run_id, + }, + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Unable to load workflow activity right now.'}), 500 + + + @app.route('/api/user/workflows/activity/stream', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + @enabled_required('allow_user_workflows') + def stream_user_workflow_activity(): + user_id = get_current_user_id() + conversation_id = request.args.get('conversation_id', '') + workflow_id = request.args.get('workflow_id', '') + run_id = request.args.get('run_id', '') + + try: + _resolve_workflow_activity_context( + user_id, + conversation_id=conversation_id, + workflow_id=workflow_id, + run_id=run_id, + ) + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except Exception as exc: + log_event( + f'[WorkflowRoutes] Failed to initialize workflow activity stream: {exc}', + extra={ + 'user_id': user_id, + 'conversation_id': conversation_id, + 'workflow_id': workflow_id, + 'run_id': run_id, + }, + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Unable to open workflow activity stream right now.'}), 500 + + return Response( + stream_with_context( + _stream_workflow_activity( + user_id, + conversation_id=conversation_id, + workflow_id=workflow_id, + run_id=run_id, + ) + ), + mimetype='text/event-stream', + headers={ + 'Cache-Control': 'no-cache', + 'X-Accel-Buffering': 'no', + }, + ) + + + @app.route('/api/user/workflows//run', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + @enabled_required('allow_user_workflows') + def run_user_workflow(workflow_id): + user_id = get_current_user_id() + workflow = get_personal_workflow(user_id, workflow_id) + if not workflow: + return jsonify({'error': 'Workflow not found.'}), 404 + + lock_document = acquire_distributed_task_lock(f'workflow_run_{workflow_id}', lease_seconds=900) + if not lock_document: + return jsonify({'error': 'This workflow is already running.'}), 409 + + try: + started_at = datetime.now(timezone.utc).isoformat() + update_personal_workflow_runtime_fields( + user_id, + workflow_id, + { + 'status': 'running', + 'last_run_started_at': started_at, + 'last_run_trigger_source': 'manual', + 'last_run_error': '', + }, + ) + + result = run_personal_workflow(workflow, trigger_source='manual') + update_fields = dict(result.get('workflow_updates') or {}) + update_fields['status'] = 'idle' + if workflow.get('trigger_type') == 'interval' and workflow.get('is_enabled', False) and not workflow.get('next_run_at'): + update_fields['next_run_at'] = compute_next_run_at(workflow, from_time=datetime.now(timezone.utc)) + + updated_workflow = update_personal_workflow_runtime_fields(user_id, workflow_id, update_fields) + response_body = { + 'success': bool(result.get('success')), + 'workflow': updated_workflow, + 'run': result.get('run'), + } + if result.get('success'): + return jsonify(response_body) + return jsonify(response_body), 500 + finally: + release_distributed_task_lock(lock_document) \ No newline at end of file diff --git a/application/single_app/route_enhanced_citations.py b/application/single_app/route_enhanced_citations.py index ca1b9e48..54f04a5a 100644 --- a/application/single_app/route_enhanced_citations.py +++ b/application/single_app/route_enhanced_citations.py @@ -12,7 +12,7 @@ from functions_authentication import login_required, user_required, get_current_user_id from functions_settings import get_settings, enabled_required -from functions_documents import get_document_metadata +from functions_documents import get_document_blob_storage_info from functions_group import get_user_groups from functions_public_workspaces import get_user_visible_public_workspace_ids_from_settings from swagger_wrapper import swagger_route, get_auth_security @@ -62,6 +62,14 @@ def _serialize_tabular_preview_table(df_preview): ] return columns, rows + +def _resolve_document_blob_reference(raw_doc): + """Resolve the persisted blob container and path for the cited document.""" + container_name, blob_name = get_document_blob_storage_info(raw_doc) + if not container_name or not blob_name: + raise FileNotFoundError("Blob reference is incomplete for this document") + return container_name, blob_name + def register_enhanced_citations_routes(app): """Register enhanced citations routes""" @@ -430,11 +438,10 @@ def get_enhanced_citation_tabular_preview(): # Download blob with size cap to protect memory settings = get_settings() max_blob_size = int(settings.get('tabular_preview_max_blob_size_mb', 200)) * 1024 * 1024 - workspace_type, container_name = determine_workspace_type_and_container(raw_doc) - blob_name = get_blob_name(raw_doc, workspace_type) blob_service_client = CLIENTS.get("storage_account_office_docs_client") if not blob_service_client: return jsonify({"error": "Blob storage client not available"}), 500 + container_name, blob_name = _resolve_document_blob_reference(raw_doc) blob_client = blob_service_client.get_blob_client(container=container_name, blob=blob_name) blob_props = blob_client.get_blob_properties() if blob_props.size > max_blob_size: @@ -590,54 +597,21 @@ def get_document(user_id, doc_id): # If document not found in any workspace return {"error": "Document not found or access denied"}, 404 -def determine_workspace_type_and_container(raw_doc): - """ - Determine workspace type and appropriate container based on document metadata - """ - if raw_doc.get('public_workspace_id'): - return 'public', raw_doc.get('blob_container') or storage_account_public_documents_container_name - elif raw_doc.get('group_id'): - return 'group', raw_doc.get('blob_container') or storage_account_group_documents_container_name - else: - return 'personal', raw_doc.get('blob_container') or storage_account_user_documents_container_name - -def get_blob_name(raw_doc, workspace_type): - """ - Determine the correct blob name based on workspace type - """ - _, blob_name = get_document_blob_storage_info(raw_doc) - if blob_name: - return blob_name - - if workspace_type == 'public': - return f"{raw_doc['public_workspace_id']}/{raw_doc['file_name']}" - elif workspace_type == 'group': - return f"{raw_doc['group_id']}/{raw_doc['file_name']}" - else: - return f"{raw_doc['user_id']}/{raw_doc['file_name']}" - def serve_enhanced_citation_content(raw_doc, content_type=None, force_download=False): """ Server-side rendering: Serve enhanced citation file content directly Based on the logic from the existing view_pdf function but serves content directly """ - settings = get_settings() - # Get blob storage client blob_service_client = CLIENTS.get("storage_account_office_docs_client") if not blob_service_client: raise Exception("Blob storage client not available") - - # Determine workspace type and container - workspace_type, container_name = determine_workspace_type_and_container(raw_doc) - container_client = blob_service_client.get_container_client(container_name) - - # Build blob name based on workspace type - blob_name = get_blob_name(raw_doc, workspace_type) + + container_name, blob_name = _resolve_document_blob_reference(raw_doc) try: # Download blob content directly - blob_client = container_client.get_blob_client(blob_name) + blob_client = blob_service_client.get_blob_client(container=container_name, blob=blob_name) blob_data = blob_client.download_blob() content = blob_data.readall() @@ -701,17 +675,12 @@ def serve_enhanced_citation_pdf_content(raw_doc, page_number, show_all=False): blob_service_client = CLIENTS.get("storage_account_office_docs_client") if not blob_service_client: raise Exception("Blob storage client not available") - - # Determine workspace type and container - workspace_type, container_name = determine_workspace_type_and_container(raw_doc) - container_client = blob_service_client.get_container_client(container_name) - - # Build blob name based on workspace type - blob_name = get_blob_name(raw_doc, workspace_type) + + container_name, blob_name = _resolve_document_blob_reference(raw_doc) try: # Download blob content directly - blob_client = container_client.get_blob_client(blob_name) + blob_client = blob_service_client.get_blob_client(container=container_name, blob=blob_name) blob_data = blob_client.download_blob() content = blob_data.readall() diff --git a/application/single_app/route_frontend_admin_settings.py b/application/single_app/route_frontend_admin_settings.py index 129dfcde..bf99dd53 100644 --- a/application/single_app/route_frontend_admin_settings.py +++ b/application/single_app/route_frontend_admin_settings.py @@ -20,6 +20,7 @@ ) ALLOWED_PIL_IMAGE_UPLOAD_FORMATS = ('PNG', 'JPEG') +MAX_CUSTOM_LOGO_STORAGE_HEIGHT = 500 def allowed_file(filename, allowed_extensions): return '.' in filename and \ @@ -37,6 +38,32 @@ def open_allowed_uploaded_image(file_bytes, filename): return img, detected_format +def prepare_logo_image_for_storage(file_bytes, filename, max_height=MAX_CUSTOM_LOGO_STORAGE_HEIGHT): + img, detected_format = open_allowed_uploaded_image(file_bytes, filename) + original_size = img.size + + if img.mode == 'P': + img = img.convert('RGBA') + elif img.mode != 'RGB' and img.mode != 'RGBA': + img = img.convert('RGB') + + if max_height and img.height > max_height: + aspect_ratio = img.width / img.height + resized_width = max(1, int(round(aspect_ratio * max_height))) + img = img.resize((resized_width, max_height), Image.Resampling.LANCZOS) + + img_bytes_io = BytesIO() + img.save(img_bytes_io, format='PNG', optimize=True) + png_data = img_bytes_io.getvalue() + + return { + 'detected_format': detected_format, + 'original_size': original_size, + 'stored_size': img.size, + 'png_data': png_data, + 'base64_str': base64.b64encode(png_data).decode('utf-8'), + } + def register_route_frontend_admin_settings(app): @app.route('/admin/settings', methods=['GET', 'POST']) @swagger_route(security=get_auth_security()) @@ -229,6 +256,8 @@ def admin_settings(): settings['allow_user_custom_endpoints'] = settings.get('allow_user_custom_agent_endpoints', False) if 'allow_user_plugins' not in settings: settings['allow_user_plugins'] = False + if 'allow_user_workflows' not in settings: + settings['allow_user_workflows'] = True if 'allow_group_agents' not in settings: settings['allow_group_agents'] = False if 'allow_group_custom_endpoints' not in settings: @@ -436,6 +465,18 @@ def parse_admin_int(raw_value, fallback_value, field_name="unknown", hard_defaul # --- Fetch all other form data as before --- app_title = form_data.get('app_title', 'AI Chat Application') + landing_page_logo_scale_percent = min( + 500, + max( + 50, + parse_admin_int( + form_data.get('landing_page_logo_scale_percent'), + settings.get('landing_page_logo_scale_percent', 100), + 'landing_page_logo_scale_percent', + 100 + ) + ) + ) max_file_size_mb = int(form_data.get('max_file_size_mb', 16)) conversation_history_limit = int(form_data.get('conversation_history_limit', 10)) enable_idle_timeout = form_data.get('enable_idle_timeout') == 'on' @@ -1092,6 +1133,7 @@ def is_valid_url(url): 'favicon_version': settings.get('favicon_version', 1), 'landing_page_text': form_data.get('landing_page_text', ''), 'landing_page_alignment': form_data.get('landing_page_alignment', 'left'), + 'landing_page_logo_scale_percent': landing_page_logo_scale_percent, 'enable_dark_mode_default': form_data.get('enable_dark_mode_default') == 'on', 'enable_left_nav_default': form_data.get('enable_left_nav_default') == 'on', 'release_notifications_registered': form_data.get('release_notifications_registered', 'false').lower() == 'true', @@ -1392,64 +1434,22 @@ def is_valid_url(url): content=f"Logo file uploaded: {logo_file.filename}" ) - # 3) Load into Pillow from the original bytes for processing - img, detected_format = open_allowed_uploaded_image(file_bytes, logo_file.filename) - - add_file_task_to_file_processing_log( - document_id='Image_Upload', # Placeholder if needed - user_id='New_image', - content=f"Loaded image for processing: {logo_file.filename} (format: {detected_format})" - ) - - # Ensure image mode is compatible (e.g., convert palette modes) - if img.mode == 'P': - img = img.convert('RGBA') - elif img.mode != 'RGB' and img.mode != 'RGBA': - img = img.convert('RGB') - - add_file_task_to_file_processing_log( - document_id='Image_Upload', # Placeholder if needed - user_id='New_image', - content=f"Converted image mode for processing: {logo_file.filename} (mode: {img.mode})" - ) - - # 4) Resize to height=100 - w, h = img.size - if h > 100: - aspect = w / h - new_height = 100 - new_width = int(aspect * new_height) - # Use LANCZOS (previously ANTIALIAS) for resizing - img = img.resize((new_width, new_height), Image.Resampling.LANCZOS) - - add_file_task_to_file_processing_log( - document_id='Image_Upload', # Placeholder if needed - user_id='New_image', - content=f"Resized image for processing: {logo_file.filename} (new size: {img.size})" - ) - - # 5) Convert to PNG in-memory - img_bytes_io = BytesIO() - img.save(img_bytes_io, format='PNG') - png_data = img_bytes_io.getvalue() - - add_file_task_to_file_processing_log( - document_id='Image_Upload', # Placeholder if needed - user_id='New_image', - content=f"Converted image to PNG for processing: {logo_file.filename}" - ) - - # 6) Turn to base64 - base64_str = base64.b64encode(png_data).decode('utf-8') + processed_logo = prepare_logo_image_for_storage(file_bytes, logo_file.filename) add_file_task_to_file_processing_log( document_id='Image_Upload', # Placeholder if needed user_id='New_image', - content=f"Converted image to base64 for processing: {base64_str}" + content=( + f"Prepared logo asset: {logo_file.filename} " + f"(format: {processed_logo['detected_format']}, " + f"original size: {processed_logo['original_size']}, " + f"stored size: {processed_logo['stored_size']}, " + f"png bytes: {len(processed_logo['png_data'])})" + ) ) # ****** CHANGE HERE: Update only on success ***** - new_settings['custom_logo_base64'] = base64_str + new_settings['custom_logo_base64'] = processed_logo['base64_str'] current_version = settings.get('logo_version', 1) # Get version from settings loaded at start new_settings['logo_version'] = current_version + 1 # Increment @@ -1474,64 +1474,22 @@ def is_valid_url(url): content=f"Dark mode logo file uploaded: {logo_dark_file.filename}" ) - # 2) Load into Pillow from the original bytes for processing - img, detected_format = open_allowed_uploaded_image(file_bytes, logo_dark_file.filename) - - add_file_task_to_file_processing_log( - document_id='Image_Upload', # Placeholder if needed - user_id='New_image', - content=f"Loaded dark mode logo image for processing: {logo_dark_file.filename} (format: {detected_format})" - ) - - # 3) Ensure image mode is compatible (e.g., convert palette modes) - if img.mode == 'P': - img = img.convert('RGBA') - elif img.mode != 'RGB' and img.mode != 'RGBA': - img = img.convert('RGB') - - add_file_task_to_file_processing_log( - document_id='Image_Upload', # Placeholder if needed - user_id='New_image', - content=f"Converted dark mode logo image mode for processing: {logo_dark_file.filename} (mode: {img.mode})" - ) - - # 4) Resize to height=100 - w, h = img.size - if h > 100: - aspect = w / h - new_height = 100 - new_width = int(aspect * new_height) - # Use LANCZOS (previously ANTIALIAS) for resizing - img = img.resize((new_width, new_height), Image.Resampling.LANCZOS) - - add_file_task_to_file_processing_log( - document_id='Image_Upload', # Placeholder if needed - user_id='New_image', - content=f"Resized dark mode logo image for processing: {logo_dark_file.filename} (new size: {img.size})" - ) - - # 5) Convert to PNG in-memory - img_bytes_io = BytesIO() - img.save(img_bytes_io, format='PNG') - png_data = img_bytes_io.getvalue() + processed_dark_logo = prepare_logo_image_for_storage(file_bytes, logo_dark_file.filename) add_file_task_to_file_processing_log( document_id='Image_Upload', # Placeholder if needed user_id='New_image', - content=f"Converted dark mode logo image to PNG for processing: {logo_dark_file.filename}" - ) - - # 6) Turn to base64 - base64_str = base64.b64encode(png_data).decode('utf-8') - - add_file_task_to_file_processing_log( - document_id='Image_Upload', # Placeholder if needed - user_id='New_image', - content=f"Converted dark mode logo image to base64 for processing: {base64_str}" + content=( + f"Prepared dark mode logo asset: {logo_dark_file.filename} " + f"(format: {processed_dark_logo['detected_format']}, " + f"original size: {processed_dark_logo['original_size']}, " + f"stored size: {processed_dark_logo['stored_size']}, " + f"png bytes: {len(processed_dark_logo['png_data'])})" + ) ) # ****** CHANGE HERE: Update only on success ***** - new_settings['custom_logo_dark_base64'] = base64_str + new_settings['custom_logo_dark_base64'] = processed_dark_logo['base64_str'] current_version = settings.get('logo_dark_version', 1) # Get version from settings loaded at start new_settings['logo_dark_version'] = current_version + 1 # Increment diff --git a/application/single_app/route_frontend_chats.py b/application/single_app/route_frontend_chats.py index 2679f57b..a2854347 100644 --- a/application/single_app/route_frontend_chats.py +++ b/application/single_app/route_frontend_chats.py @@ -9,6 +9,7 @@ from functions_group import find_group_by_id, get_group_model_endpoints, get_user_groups from functions_group_agents import get_group_agents from functions_global_agents import get_global_agents +from functions_image_messages import build_image_message_documents from functions_personal_agents import ensure_migration_complete, get_personal_agents from functions_prompts import list_all_prompts_for_scope from functions_public_workspaces import find_public_workspace_by_id, get_user_visible_public_workspace_ids_from_settings @@ -401,6 +402,24 @@ def chats(): chat_model_options=chat_model_options, initial_chat_model_selection=initial_chat_model_selection, ) + + @app.route('/workflow-activity', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + @enabled_required('allow_user_workflows') + def workflow_activity(): + user_id = get_current_user_id() + if not user_id: + return redirect(url_for('login')) + + settings = get_settings() + public_settings = sanitize_settings_for_user(settings) + + return render_template( + 'workflow_activity.html', + settings=public_settings, + ) @app.route('/upload', methods=['POST']) @swagger_route(security=get_auth_security()) @@ -595,134 +614,48 @@ def upload_file(): # For images with base64 data, store as 'image' role (like system-generated images) if image_base64_url: - # Check if image data is too large for a single Cosmos document (2MB limit) - # Use 1.5MB as safe limit for base64 content - max_content_size = 1500000 # 1.5MB in bytes - - if len(image_base64_url) > max_content_size: - print(f"Large image detected ({len(image_base64_url)} bytes), splitting across multiple documents") - - # Extract base64 part for splitting - data_url_prefix = image_base64_url.split(',')[0] + ',' - base64_content = image_base64_url.split(',')[1] - - # Calculate chunks - chunk_size = max_content_size - len(data_url_prefix) - 200 # Room for JSON overhead - chunks = [base64_content[i:i+chunk_size] for i in range(0, len(base64_content), chunk_size)] - total_chunks = len(chunks) - - print(f"Splitting into {total_chunks} chunks of max {chunk_size} bytes each") - - # Threading logic for file upload - previous_thread_id = None - try: - last_msg_query = f"SELECT TOP 1 c.metadata.thread_info.thread_id as thread_id FROM c WHERE c.conversation_id = '{conversation_id}' ORDER BY c.timestamp DESC" - last_msgs = list(cosmos_messages_container.query_items(query=last_msg_query, partition_key=conversation_id)) - if last_msgs: - previous_thread_id = last_msgs[0].get('thread_id') - except Exception as ex: - pass - - current_thread_id = str(uuid.uuid4()) - - # Create main image document with first chunk - main_image_doc = { - 'id': file_message_id, - 'conversation_id': conversation_id, - 'role': 'image', - 'content': f"{data_url_prefix}{chunks[0]}", - 'filename': filename, - 'prompt': f"User uploaded: {filename}", - 'created_at': datetime.utcnow().isoformat(), - 'timestamp': datetime.utcnow().isoformat(), - 'model_deployment_name': None, - 'metadata': { - 'is_chunked': True, - 'total_chunks': total_chunks, - 'chunk_index': 0, - 'original_size': len(image_base64_url), - 'is_user_upload': True, - 'thread_info': { - 'thread_id': current_thread_id, - 'previous_thread_id': previous_thread_id, - 'active_thread': True, - 'thread_attempt': 1 - } + previous_thread_id = None + try: + last_msg_query = f"SELECT TOP 1 c.metadata.thread_info.thread_id as thread_id FROM c WHERE c.conversation_id = '{conversation_id}' ORDER BY c.timestamp DESC" + last_msgs = list(cosmos_messages_container.query_items(query=last_msg_query, partition_key=conversation_id)) + if last_msgs: + previous_thread_id = last_msgs[0].get('thread_id') + except Exception: + pass + + current_thread_id = str(uuid.uuid4()) + image_message = { + 'id': file_message_id, + 'conversation_id': conversation_id, + 'content': image_base64_url, + 'filename': filename, + 'prompt': f"User uploaded: {filename}", + 'created_at': datetime.utcnow().isoformat(), + 'timestamp': datetime.utcnow().isoformat(), + 'model_deployment_name': None, + 'metadata': { + 'is_user_upload': True, + 'thread_info': { + 'thread_id': current_thread_id, + 'previous_thread_id': previous_thread_id, + 'active_thread': True, + 'thread_attempt': 1 } } - - # Add vision analysis and extracted text if available - if vision_analysis: - main_image_doc['vision_analysis'] = vision_analysis - if extracted_content: - main_image_doc['extracted_text'] = extracted_content - - cosmos_messages_container.upsert_item(main_image_doc) - - # Create chunk documents - for i in range(1, total_chunks): - chunk_doc = { - 'id': f"{file_message_id}_chunk_{i}", - 'conversation_id': conversation_id, - 'role': 'image_chunk', - 'content': chunks[i], - 'parent_message_id': file_message_id, - 'created_at': datetime.utcnow().isoformat(), - 'timestamp': datetime.utcnow().isoformat(), - 'metadata': { - 'is_chunk': True, - 'chunk_index': i, - 'total_chunks': total_chunks, - 'parent_message_id': file_message_id - } - } - cosmos_messages_container.upsert_item(chunk_doc) - - print(f"Created {total_chunks} chunked image documents for {filename}") + } + + if vision_analysis: + image_message['vision_analysis'] = vision_analysis + if extracted_content: + image_message['extracted_text'] = extracted_content + + image_documents = build_image_message_documents(image_message) + for image_document in image_documents: + cosmos_messages_container.upsert_item(image_document) + + if image_documents[0].get('metadata', {}).get('is_chunked'): + print(f"Created {len(image_documents)} chunked image documents for {filename}") else: - # Small enough to store in single document - # Threading logic for file upload - previous_thread_id = None - try: - last_msg_query = f"SELECT TOP 1 c.metadata.thread_info.thread_id as thread_id FROM c WHERE c.conversation_id = '{conversation_id}' ORDER BY c.timestamp DESC" - last_msgs = list(cosmos_messages_container.query_items(query=last_msg_query, partition_key=conversation_id)) - if last_msgs: - previous_thread_id = last_msgs[0].get('thread_id') - except Exception as ex: - pass - - current_thread_id = str(uuid.uuid4()) - - image_message = { - 'id': file_message_id, - 'conversation_id': conversation_id, - 'role': 'image', - 'content': image_base64_url, - 'filename': filename, - 'prompt': f"User uploaded: {filename}", - 'created_at': datetime.utcnow().isoformat(), - 'timestamp': datetime.utcnow().isoformat(), - 'model_deployment_name': None, - 'metadata': { - 'is_chunked': False, - 'original_size': len(image_base64_url), - 'is_user_upload': True, - 'thread_info': { - 'thread_id': current_thread_id, - 'previous_thread_id': previous_thread_id, - 'active_thread': True, - 'thread_attempt': 1 - } - } - } - - # Add vision analysis and extracted text if available - if vision_analysis: - image_message['vision_analysis'] = vision_analysis - if extracted_content: - image_message['extracted_text'] = extracted_content - - cosmos_messages_container.upsert_item(image_message) print(f"Created single image document for {filename}") else: # Non-image file or failed to convert to base64, store as 'file' role diff --git a/application/single_app/route_frontend_conversations.py b/application/single_app/route_frontend_conversations.py index d2b428fe..d3fb0274 100644 --- a/application/single_app/route_frontend_conversations.py +++ b/application/single_app/route_frontend_conversations.py @@ -1,16 +1,68 @@ # route_frontend_conversations.py +import logging +import re + +import requests +from flask import Response, jsonify, redirect, render_template, request + from config import * +from functions_appinsights import log_event +from functions_azure_maps import ( + AZURE_MAPS_DEFAULT_ENDPOINT, + AZURE_MAPS_DEFAULT_LANGUAGE, + AZURE_MAPS_DEFAULT_TILESET_ID, + AZURE_MAPS_DEFAULT_VIEW, + AZURE_MAPS_TILE_API_VERSION, + decode_tile_proxy_token, + refresh_azure_maps_citation_payload, + refresh_azure_maps_citation_payloads, + refresh_azure_maps_message_content, +) from functions_authentication import * from functions_debug import debug_print from functions_chat import sort_messages_by_thread +from functions_collaboration import ( + assert_user_can_view_collaboration_conversation, + build_collaboration_message_metadata_payload, + get_collaboration_conversation, + get_collaboration_message, + list_collaboration_messages, +) +from functions_image_messages import hydrate_image_messages from functions_message_artifacts import ( build_message_artifact_payload_map, filter_assistant_artifact_items, + hydrate_agent_citations_from_artifacts, ) from swagger_wrapper import swagger_route, get_auth_security def register_route_frontend_conversations(app): + def _disable_response_caching(response): + response.headers['Cache-Control'] = 'no-store, max-age=0' + response.headers['Pragma'] = 'no-cache' + response.headers['Expires'] = '0' + return response + + def _refresh_azure_maps_message_payloads(messages): + refreshed_messages = [] + for message in messages or []: + if not isinstance(message, dict): + refreshed_messages.append(message) + continue + + refreshed_message = dict(message) + refreshed_message['agent_citations'] = refresh_azure_maps_citation_payloads( + refreshed_message.get('agent_citations') + ) + if refreshed_message.get('role') == 'assistant': + refreshed_message['content'] = refresh_azure_maps_message_content( + refreshed_message.get('content') + ) + refreshed_messages.append(refreshed_message) + + return refreshed_messages + @app.route('/conversations') @swagger_route(security=get_auth_security()) @login_required @@ -57,7 +109,10 @@ def view_conversation(conversation_id): query=message_query, partition_key=conversation_id )) + artifact_payload_map = build_message_artifact_payload_map(messages) messages = filter_assistant_artifact_items(messages) + messages = hydrate_agent_citations_from_artifacts(messages, artifact_payload_map) + messages = _refresh_azure_maps_message_payloads(messages) return render_template('chat.html', conversation_id=conversation_id, messages=messages) @app.route('/conversation//messages', methods=['GET']) @@ -83,7 +138,10 @@ def get_conversation_messages(conversation_id): query=msg_query, partition_key=conversation_id )) + artifact_payload_map = build_message_artifact_payload_map(all_items) all_items = filter_assistant_artifact_items(all_items) + all_items = hydrate_agent_citations_from_artifacts(all_items, artifact_payload_map) + all_items = _refresh_azure_maps_message_payloads(all_items) debug_print(f"Frontend endpoint - Query returned {len(all_items)} total items (before filtering)") @@ -126,75 +184,18 @@ def get_conversation_messages(conversation_id): attempt = thread_info.get('thread_attempt', 'N/A') debug_print(f" {i+1}. {item.get('id')}: thread_id={thread_id}, prev={prev_thread_id}, attempt={attempt}, timestamp={timestamp}") - # Process messages and reassemble chunked images - messages = [] - chunked_images = {} # Store image chunks by parent_message_id - - for item in all_items: - if item.get('role') == 'image_chunk': - # This is a chunk, store it for reassembly - parent_id = item.get('parent_message_id') - if parent_id not in chunked_images: - chunked_images[parent_id] = {} - chunk_index = item.get('metadata', {}).get('chunk_index', 0) - chunked_images[parent_id][chunk_index] = item.get('content', '') - debug_print(f"Frontend endpoint - Stored chunk {chunk_index} for parent {parent_id}") - else: - # Regular message or main image document - if item.get('role') == 'image' and item.get('metadata', {}).get('is_chunked'): - # This is a chunked image main document - messages.append(item) - else: - # Regular message - messages.append(item) - - # Reassemble chunked images - for message in messages: - if (message.get('role') == 'image' and - message.get('metadata', {}).get('is_chunked')): - - image_id = message.get('id') - total_chunks = message.get('metadata', {}).get('total_chunks', 1) - - debug_print(f"Frontend endpoint - Reassembling chunked image {image_id} with {total_chunks} chunks") - debug_print(f"Frontend endpoint - Available chunks: {list(chunked_images.get(image_id, {}).keys())}") - - # Start with the content from the main message (chunk 0) - complete_content = message.get('content', '') - debug_print(f"Frontend endpoint - Main message content length: {len(complete_content)} bytes") - - # Add remaining chunks in order (chunks 1, 2, 3, etc.) - if image_id in chunked_images: - chunks = chunked_images[image_id] - for chunk_index in range(1, total_chunks): - if chunk_index in chunks: - chunk_content = chunks[chunk_index] - complete_content += chunk_content - debug_print(f"Frontend endpoint - Added chunk {chunk_index}, length: {len(chunk_content)} bytes") - else: - print(f"WARNING: Frontend endpoint - Missing chunk {chunk_index} for image {image_id}") - else: - print(f"WARNING: Frontend endpoint - No chunks found for image {image_id}") - - debug_print(f"Frontend endpoint - Final reassembled image total size: {len(complete_content)} bytes") - - # For large images (>1MB), use a URL reference instead of embedding in JSON - if len(complete_content) > 1024 * 1024: # 1MB threshold - debug_print(f"Frontend endpoint - Large image detected ({len(complete_content)} bytes), using URL reference") - # Store the complete content temporarily and provide a URL reference - message['content'] = f"/api/image/{image_id}" - message['metadata']['is_large_image'] = True - message['metadata']['image_size'] = len(complete_content) - else: - # Small enough to embed directly - message['content'] = complete_content + messages = hydrate_image_messages( + all_items, + image_url_builder=lambda image_id: f"/api/image/{image_id}", + ) # Remove file content for security for m in messages: if m.get('role') == 'file' and 'file_content' in m: del m['file_content'] - return jsonify({'messages': messages}) + response = jsonify({'messages': messages}) + return _disable_response_caching(response) @app.route('/api/conversation//agent-citation/', methods=['GET']) @swagger_route(security=get_auth_security()) @@ -205,24 +206,49 @@ def get_agent_citation_artifact(conversation_id, artifact_id): if not user_id: return jsonify({'error': 'User not authenticated'}), 401 + artifact_lookup_conversation_id = conversation_id + try: conversation = cosmos_conversations_container.read_item( item=conversation_id, partition_key=conversation_id, ) except CosmosResourceNotFoundError: - return jsonify({'error': 'Conversation not found'}), 404 + try: + conversation = get_collaboration_conversation(conversation_id) + except CosmosResourceNotFoundError: + return jsonify({'error': 'Conversation not found'}), 404 - if conversation.get('user_id') != user_id: - return jsonify({'error': 'Unauthorized access to conversation'}), 403 + try: + assert_user_can_view_collaboration_conversation(user_id, conversation) + except PermissionError: + return jsonify({'error': 'Unauthorized access to conversation'}), 403 + + artifact_lookup_conversation_id = str(conversation.get('source_conversation_id') or '').strip() + if artifact_lookup_conversation_id: + conversation_messages = list(cosmos_messages_container.query_items( + query="SELECT * FROM c WHERE c.conversation_id = @conversation_id", + parameters=[{'name': '@conversation_id', 'value': artifact_lookup_conversation_id}], + partition_key=artifact_lookup_conversation_id, + )) + else: + conversation_messages = list_collaboration_messages(conversation_id) + else: + if conversation.get('user_id') != user_id: + return jsonify({'error': 'Unauthorized access to conversation'}), 403 + + conversation_messages = list(cosmos_messages_container.query_items( + query="SELECT * FROM c WHERE c.conversation_id = @conversation_id", + parameters=[{'name': '@conversation_id', 'value': conversation_id}], + partition_key=conversation_id, + )) - conversation_messages = list(cosmos_messages_container.query_items( - query="SELECT * FROM c WHERE c.conversation_id = @conversation_id", - parameters=[{'name': '@conversation_id', 'value': conversation_id}], - partition_key=conversation_id, - )) artifact_payload_map = build_message_artifact_payload_map(conversation_messages) artifact_payload = artifact_payload_map.get(str(artifact_id or '')) + if artifact_payload is None and artifact_lookup_conversation_id != conversation_id: + collaboration_messages = list_collaboration_messages(conversation_id) + artifact_payload_map = build_message_artifact_payload_map(collaboration_messages) + artifact_payload = artifact_payload_map.get(str(artifact_id or '')) if not isinstance(artifact_payload, dict): return jsonify({'error': 'Agent citation artifact not found'}), 404 @@ -230,7 +256,81 @@ def get_agent_citation_artifact(conversation_id, artifact_id): if citation is None: return jsonify({'error': 'Agent citation payload not found'}), 404 - return jsonify({'citation': citation}) + response = jsonify({'citation': refresh_azure_maps_citation_payload(citation)}) + return _disable_response_caching(response) + + @app.route('/api/azure-maps/tile', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def get_azure_maps_tile(): + tile_proxy_token = str(request.args.get('token') or '').strip() + token_payload = decode_tile_proxy_token(tile_proxy_token) + if not token_payload: + return jsonify({'error': 'Invalid or expired Azure Maps tile token.'}), 400 + + subscription_key = str(token_payload.get('subscription_key') or '').strip() + if not subscription_key: + return jsonify({'error': 'Azure Maps tile token is missing a subscription key.'}), 400 + + try: + zoom = int(str(request.args.get('zoom') or '').strip()) + tile_x = int(str(request.args.get('x') or '').strip()) + tile_y = int(str(request.args.get('y') or '').strip()) + except ValueError: + return jsonify({'error': 'Tile requests must include numeric zoom, x, and y values.'}), 400 + + raw_tileset_id = str(request.args.get('tilesetId') or AZURE_MAPS_DEFAULT_TILESET_ID).strip() + raw_language = str(request.args.get('language') or AZURE_MAPS_DEFAULT_LANGUAGE).strip() + raw_view = str(request.args.get('view') or AZURE_MAPS_DEFAULT_VIEW).strip() + raw_tile_size = str(request.args.get('tileSize') or '256').strip() + + if not re.fullmatch(r'[A-Za-z0-9._-]+', raw_tileset_id): + return jsonify({'error': 'tilesetId contains unsupported characters.'}), 400 + if raw_language and not re.fullmatch(r'[A-Za-z0-9-]{2,16}', raw_language): + return jsonify({'error': 'language contains unsupported characters.'}), 400 + if raw_view and not re.fullmatch(r'[A-Za-z]+', raw_view): + return jsonify({'error': 'view contains unsupported characters.'}), 400 + if raw_tile_size not in {'256', '512'}: + return jsonify({'error': 'tileSize must be 256 or 512.'}), 400 + + upstream_params = { + 'api-version': AZURE_MAPS_TILE_API_VERSION, + 'tilesetId': raw_tileset_id, + 'zoom': zoom, + 'x': tile_x, + 'y': tile_y, + 'tileSize': raw_tile_size, + 'language': raw_language or AZURE_MAPS_DEFAULT_LANGUAGE, + 'view': raw_view or AZURE_MAPS_DEFAULT_VIEW, + 'subscription-key': subscription_key, + } + + try: + upstream_response = requests.get( + f'{AZURE_MAPS_DEFAULT_ENDPOINT}/map/tile', + params=upstream_params, + timeout=20, + ) + except requests.RequestException as exc: + log_event( + f"[AzureMaps] Tile proxy request failed: {exc}", + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Azure Maps tile request failed.'}), 502 + + proxy_response = Response( + upstream_response.content, + status=upstream_response.status_code, + content_type=upstream_response.headers.get('Content-Type', 'image/png'), + ) + + cache_control = upstream_response.headers.get('Cache-Control') + if cache_control: + proxy_response.headers['Cache-Control'] = cache_control + proxy_response.headers['X-Content-Type-Options'] = 'nosniff' + return proxy_response @app.route('/api/message//metadata', methods=['GET']) @swagger_route(security=get_auth_security()) @@ -253,7 +353,14 @@ def get_message_metadata(message_id): )) if not messages: - return jsonify({'error': 'Message not found'}), 404 + message = get_collaboration_message(message_id) + conversation = get_collaboration_conversation(message.get('conversation_id')) + assert_user_can_view_collaboration_conversation( + user_id, + conversation, + allow_pending=True, + ) + return jsonify(build_collaboration_message_metadata_payload(message, conversation)) message = messages[0] @@ -282,7 +389,12 @@ def get_message_metadata(message_id): else: # Assistant, image, file messages - return full document return jsonify(message) + + except CosmosResourceNotFoundError: + return jsonify({'error': 'Message not found'}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 except Exception as e: - print(f"Error fetching message metadata: {str(e)}") + log_event(f"get_message_metadata failed: {e}", level="WARNING") return jsonify({'error': 'Failed to fetch message metadata'}), 500 \ No newline at end of file diff --git a/application/single_app/semantic_kernel_loader.py b/application/single_app/semantic_kernel_loader.py index 3a2ca4b5..c1ee8cbf 100644 --- a/application/single_app/semantic_kernel_loader.py +++ b/application/single_app/semantic_kernel_loader.py @@ -22,6 +22,7 @@ from semantic_kernel.functions.kernel_plugin import KernelPlugin from semantic_kernel_plugins.embedding_model_plugin import EmbeddingModelPlugin from semantic_kernel_plugins.fact_memory_plugin import FactMemoryPlugin +from semantic_kernel_plugins.document_search_plugin import DocumentSearchPlugin from semantic_kernel_plugins.tabular_processing_plugin import TabularProcessingPlugin from functions_settings import get_settings, get_user_settings, is_tabular_processing_enabled from foundry_agent_runtime import ( @@ -45,9 +46,29 @@ from functions_personal_actions import get_personal_actions, ensure_migration_complete as ensure_actions_migration_complete from functions_personal_agents import get_personal_agents, ensure_migration_complete as ensure_agents_migration_complete from functions_agent_payload import can_agent_use_default_multi_endpoint_model +from functions_chart_operations import ( + CHART_PLUGIN_TYPE, + get_enabled_chart_type_keys, + resolve_chart_action_capabilities, +) +from functions_blob_storage_operations import ( + BLOB_STORAGE_PLUGIN_TYPE, + get_blob_storage_enabled_function_names, + resolve_blob_storage_action_capabilities, +) +from functions_msgraph_operations import ( + MSGRAPH_PLUGIN_TYPE, + get_msgraph_enabled_function_names, + resolve_msgraph_action_capabilities, +) +from functions_simplechat_operations import ( + SIMPLECHAT_PLUGIN_TYPE, + get_simplechat_enabled_function_names, + resolve_simplechat_action_capabilities, +) from semantic_kernel_plugins.plugin_loader import discover_plugins from semantic_kernel_plugins.openapi_plugin_factory import OpenApiPluginFactory -from functions_agent_scope import find_agent_by_scope +from functions_agent_scope import find_agent_by_scope, is_selected_agent_scope_enabled import app_settings_cache # Agent and Azure OpenAI chat service imports @@ -792,6 +813,13 @@ def load_fact_memory_plugin(kernel: Kernel): description="Provides functions for managing persistent facts." ) +def load_document_search_plugin(kernel: Kernel): + kernel.add_plugin( + DocumentSearchPlugin(), + plugin_name="document_search", + description="Provides hybrid document search, exhaustive chunk retrieval, and hierarchical document summarization." + ) + def load_embedding_model_plugin(kernel: Kernel, settings): embedding_endpoint = settings.get('azure_openai_embedding_endpoint') embedding_key = settings.get('azure_openai_embedding_key') @@ -824,6 +852,12 @@ def load_core_plugins_only(kernel: Kernel, settings): load_fact_memory_plugin(kernel) log_event("[SK Loader] Loaded Fact Memory plugin.", level=logging.INFO) + try: + load_document_search_plugin(kernel) + log_event("[SK Loader] Loaded Document Search plugin.", level=logging.INFO) + except Exception as e: + log_event(f"[SK Loader] Failed to load Document Search plugin: {e}", level=logging.WARNING) + if settings.get('enable_math_plugin', True): load_math_plugin(kernel) log_event("[SK Loader] Loaded Math plugin.", level=logging.INFO) @@ -902,7 +936,7 @@ def initialize_semantic_kernel(user_id: str=None, redis_client=None): ) debug_print(f"[SK Loader] Semantic Kernel Agent and Plugins loading completed.") -def load_agent_specific_plugins(kernel, plugin_names, settings, mode_label="global", user_id=None, group_id=None): +def load_agent_specific_plugins(kernel, plugin_names, settings, mode_label="global", user_id=None, group_id=None, agent_other_settings=None): """ Load specific plugins by name for an agent with enhanced logging. @@ -958,6 +992,12 @@ def load_agent_specific_plugins(kernel, plugin_names, settings, mode_label="glob if p.get('name') in plugin_names or p.get('id') in plugin_names ] + plugin_manifests = _apply_agent_plugin_runtime_overlays( + plugin_manifests, + agent_other_settings=agent_other_settings, + group_id=group_id, + ) + debug_print(f"[SK Loader] Filtered to {len(plugin_manifests)} plugin manifests after matching names/IDs") debug_print(f"[SK Loader] Plugin manifests to load: {plugin_manifests}") @@ -1043,7 +1083,12 @@ def load_agent_specific_plugins(kernel, plugin_names, settings, mode_label="glob else: all_plugin_manifests = get_global_actions(return_type=SecretReturnType.NAME) - plugin_manifests = [p for p in all_plugin_manifests if p.get('name') in plugin_names] + plugin_manifests = [p for p in all_plugin_manifests if p.get('name') in plugin_names or p.get('id') in plugin_names] + plugin_manifests = _apply_agent_plugin_runtime_overlays( + plugin_manifests, + agent_other_settings=agent_other_settings, + group_id=group_id, + ) _load_agent_plugins_original_method(kernel, plugin_manifests, mode_label) except Exception as fallback_error: log_event( @@ -1055,6 +1100,88 @@ def load_agent_specific_plugins(kernel, plugin_names, settings, mode_label="glob print(f"[SK Loader][Error] Fallback plugin loading also failed: {fallback_error}") +def _apply_agent_plugin_runtime_overlays(plugin_manifests, agent_other_settings=None, group_id=None): + action_capabilities = {} + if isinstance(agent_other_settings, dict): + raw_action_capabilities = agent_other_settings.get('action_capabilities') + if isinstance(raw_action_capabilities, dict): + action_capabilities = raw_action_capabilities + + overlaid_manifests = [] + for manifest in plugin_manifests or []: + manifest_copy = dict(manifest) + if group_id and not manifest_copy.get('group_id'): + manifest_copy['default_group_id'] = group_id + + if manifest_copy.get('type') == SIMPLECHAT_PLUGIN_TYPE: + action_defaults = manifest_copy.get('simplechat_capabilities') + if action_defaults is None: + additional_fields = manifest_copy.get('additionalFields') + if isinstance(additional_fields, dict): + action_defaults = additional_fields.get('simplechat_capabilities') + + capabilities = resolve_simplechat_action_capabilities( + action_capabilities, + action_defaults=action_defaults, + action_id=manifest_copy.get('id'), + action_name=manifest_copy.get('name'), + ) + manifest_copy['simplechat_capabilities'] = capabilities + manifest_copy['enabled_functions'] = get_simplechat_enabled_function_names(capabilities) + + if manifest_copy.get('type') == CHART_PLUGIN_TYPE: + action_defaults = manifest_copy.get('chart_capabilities') + if action_defaults is None: + additional_fields = manifest_copy.get('additionalFields') + if isinstance(additional_fields, dict): + action_defaults = additional_fields.get('chart_capabilities') + + capabilities = resolve_chart_action_capabilities( + action_capability_map=action_capabilities, + default_capabilities=action_defaults, + action_id=manifest_copy.get('id'), + action_name=manifest_copy.get('name'), + ) + manifest_copy['chart_capabilities'] = capabilities + manifest_copy['enabled_chart_types'] = get_enabled_chart_type_keys(capabilities) + + if manifest_copy.get('type') == MSGRAPH_PLUGIN_TYPE: + action_defaults = manifest_copy.get('msgraph_capabilities') + if action_defaults is None: + additional_fields = manifest_copy.get('additionalFields') + if isinstance(additional_fields, dict): + action_defaults = additional_fields.get('msgraph_capabilities') + + capabilities = resolve_msgraph_action_capabilities( + action_capabilities, + action_defaults=action_defaults, + action_id=manifest_copy.get('id'), + action_name=manifest_copy.get('name'), + ) + manifest_copy['msgraph_capabilities'] = capabilities + manifest_copy['enabled_functions'] = get_msgraph_enabled_function_names(capabilities) + + if manifest_copy.get('type') == BLOB_STORAGE_PLUGIN_TYPE: + action_defaults = manifest_copy.get('blob_storage_capabilities') + if action_defaults is None: + additional_fields = manifest_copy.get('additionalFields') + if isinstance(additional_fields, dict): + action_defaults = additional_fields.get('blob_storage_capabilities') + + capabilities = resolve_blob_storage_action_capabilities( + action_capabilities, + action_defaults=action_defaults, + action_id=manifest_copy.get('id'), + action_name=manifest_copy.get('name'), + ) + manifest_copy['blob_storage_capabilities'] = capabilities + manifest_copy['enabled_functions'] = get_blob_storage_enabled_function_names(capabilities) + + overlaid_manifests.append(manifest_copy) + + return overlaid_manifests + + def _load_agent_plugins_original_method(kernel, plugin_manifests, mode_label="global"): """ Original agent plugin loading method as fallback. @@ -1317,6 +1444,52 @@ def _extract_sql_schema_for_instructions(kernel) -> str: return "\n".join(schema_parts) +def _extract_cosmos_context_for_instructions(kernel) -> str: + """ + Check if any Cosmos Query plugins are loaded in the kernel and extract + their configured container context for agent instructions. + """ + from semantic_kernel_plugins.cosmos_query_plugin import CosmosQueryPlugin + + cosmos_parts = [] + + try: + for plugin_name, plugin in kernel.plugins.items(): + plugin_obj = None + + if isinstance(plugin, CosmosQueryPlugin): + plugin_obj = plugin + elif hasattr(plugin, '_plugin_instance') and isinstance(plugin._plugin_instance, CosmosQueryPlugin): + plugin_obj = plugin._plugin_instance + else: + for _, func in plugin.functions.items(): + if hasattr(func, 'method') and hasattr(func.method, '__self__'): + if isinstance(func.method.__self__, CosmosQueryPlugin): + plugin_obj = func.method.__self__ + break + + if plugin_obj is not None: + try: + cosmos_parts.append(plugin_obj.build_instruction_context()) + print(f"[SK Loader] Extracted Cosmos context for plugin: {plugin_name}") + except Exception as e: + print(f"[SK Loader] Warning: Failed to build Cosmos context from {plugin_name}: {e}") + log_event( + f"[SK Loader] Failed to build Cosmos context for injection: {e}", + extra={"plugin_name": plugin_name, "error": str(e)}, + level=logging.WARNING, + ) + except Exception as e: + print(f"[SK Loader] Warning: Error iterating kernel plugins for Cosmos context: {e}") + log_event( + f"[SK Loader] Error iterating kernel plugins for Cosmos context: {e}", + extra={"error": str(e)}, + level=logging.WARNING, + ) + + return "\n\n".join(cosmos_parts) + + def load_single_agent_for_kernel(kernel, agent_cfg, settings, context_obj, redis_client=None, mode_label="global", group_scope_id=None): """ DRY helper to load a single agent (default agent) for the kernel. @@ -1515,6 +1688,7 @@ def create_chat_completion_service(): plugin_mode, user_id=resolved_user_id, group_id=group_id, + agent_other_settings=agent_config.get("other_settings"), ) # Auto-inject SQL database schema into agent instructions if SQL plugins are loaded @@ -1538,6 +1712,26 @@ def create_chat_completion_service(): extra={"agent_name": agent_config["name"], "error": str(e)}, level=logging.WARNING) + try: + cosmos_context_summary = _extract_cosmos_context_for_instructions(kernel) + if cosmos_context_summary: + agent_config["instructions"] = ( + agent_config.get("instructions", "") + + "\n\n## Available Cosmos DB Containers\n" + "The following Azure Cosmos DB for NoSQL containers are available through the Cosmos Query plugin. " + "Use the configured container hints below when writing read-only SELECT queries, and pass the partition_key argument when the partition value is known.\n\n" + + cosmos_context_summary + + "\n\nWhen a user asks about data in one of these containers, construct a parameterized read-only Cosmos DB SQL query that matches the configured fields and partition key guidance." + ) + print(f"[SK Loader] Injected Cosmos context into agent instructions for {agent_config['name']}") + except Exception as e: + print(f"[SK Loader] Warning: Failed to inject Cosmos context into instructions: {e}") + log_event( + f"[SK Loader] Failed to inject Cosmos context into agent instructions: {e}", + extra={"agent_name": agent_config["name"], "error": str(e)}, + level=logging.WARNING, + ) + try: kwargs = { "name": agent_config["name"], @@ -1551,7 +1745,7 @@ def create_chat_completion_service(): "deployment_name": agent_config["deployment"], "azure_endpoint": agent_config["endpoint"], "api_version": agent_config["api_version"], - "function_choice_behavior": FunctionChoiceBehavior.Auto(maximum_auto_invoke_attempts=10) + "function_choice_behavior": FunctionChoiceBehavior.Auto(maximum_auto_invoke_attempts=60) } # Don't pass plugins to agent since they're already loaded in kernel agent_obj = LoggingChatCompletionAgent(**kwargs) @@ -1692,6 +1886,12 @@ def load_plugins_for_kernel(kernel, plugin_manifests, settings, mode_label="glob except Exception as e: log_event(f"[SK Loader] Failed to load Fact Memory Plugin: {e}", level=logging.WARNING) + try: + load_document_search_plugin(kernel) + log_event("[SK Loader] Loaded Document Search Plugin.", level=logging.INFO) + except Exception as e: + log_event(f"[SK Loader] Failed to load Document Search Plugin: {e}", level=logging.WARNING) + # Register Tabular Processing Plugin if enabled (requires enhanced citations) if is_tabular_processing_enabled(settings): try: @@ -1897,24 +2097,34 @@ def load_user_semantic_kernel(kernel: Kernel, settings, user_id: str, redis_clie # Append selected group agent (if any) to the candidate list so downstream selection logic can resolve it selected_agent_data = selected_agent if isinstance(selected_agent, dict) else {} + selected_agent_is_global = selected_agent_data.get('is_global', False) selected_agent_is_group = selected_agent_data.get('is_group', False) selected_agent_group_id = selected_agent_data.get('group_id') conversation_group_id = getattr(g, "conversation_group_id", None) allow_user_agents = settings.get('allow_user_agents', False) allow_group_agents = settings.get('allow_group_agents', False) - if selected_agent_is_group and not allow_group_agents: - log_event( - "[SK Loader] Group agents are disabled; skipping group agent load.", - level=logging.WARNING - ) - load_core_plugins_only(kernel, settings) - return kernel, None - if not selected_agent_is_group and not allow_user_agents: - log_event( - "[SK Loader] User agents are disabled; skipping personal agent load.", - level=logging.WARNING - ) + if not is_selected_agent_scope_enabled(settings, selected_agent_data): + if selected_agent_is_group: + log_event( + "[SK Loader] Group agents are disabled; skipping group agent load.", + level=logging.WARNING, + extra={ + 'agent_name': selected_agent_data.get('name'), + 'allow_group_agents': allow_group_agents, + 'is_global': selected_agent_is_global, + } + ) + else: + log_event( + "[SK Loader] User agents are disabled; skipping personal agent load.", + level=logging.WARNING, + extra={ + 'agent_name': selected_agent_data.get('name'), + 'allow_user_agents': allow_user_agents, + 'is_global': selected_agent_is_global, + } + ) load_core_plugins_only(kernel, settings) return kernel, None @@ -2353,7 +2563,7 @@ def load_semantic_kernel(kernel: Kernel, settings): "deployment_name": agent_config["deployment"], "azure_endpoint": agent_config["endpoint"], "api_version": agent_config["api_version"], - "function_choice_behavior": FunctionChoiceBehavior.Auto(maximum_auto_invoke_attempts=10) + "function_choice_behavior": FunctionChoiceBehavior.Auto(maximum_auto_invoke_attempts=60) } if agent_config.get("actions_to_load"): kwargs["plugins"] = agent_config["actions_to_load"] @@ -2692,7 +2902,7 @@ def pick(key): if hasattr(prompt_exec_settings, 'function_choice_behavior'): if getattr(prompt_exec_settings, 'function_choice_behavior', None) is None: try: - prompt_exec_settings.function_choice_behavior = FunctionChoiceBehavior.Auto(maximum_auto_invoke_attempts=10) + prompt_exec_settings.function_choice_behavior = FunctionChoiceBehavior.Auto(maximum_auto_invoke_attempts=60) except Exception: # pass this to prevent additional future agent types from potentially failing pass diff --git a/application/single_app/semantic_kernel_plugins/SQL_Plugins_Configuration_Guide.md b/application/single_app/semantic_kernel_plugins/SQL_Plugins_Configuration_Guide.md index 08558ba9..2f782e7a 100644 --- a/application/single_app/semantic_kernel_plugins/SQL_Plugins_Configuration_Guide.md +++ b/application/single_app/semantic_kernel_plugins/SQL_Plugins_Configuration_Guide.md @@ -77,7 +77,7 @@ Choose authentication method: { "name": "azure_sql_schema", "database_type": "azure_sql", - "connection_string": "DRIVER={ODBC Driver 17 for SQL Server};SERVER=myserver.database.windows.net;DATABASE=mydatabase;Authentication=ActiveDirectoryMsi", + "connection_string": "DRIVER={ODBC Driver 18 for SQL Server};SERVER=myserver.database.windows.net;DATABASE=mydatabase;Authentication=ActiveDirectoryMsi", "metadata": { "description": "Extract schema from Azure SQL database using Managed Identity" } @@ -89,7 +89,7 @@ Choose authentication method: { "name": "azure_sql_query", "database_type": "azure_sql", - "connection_string": "DRIVER={ODBC Driver 17 for SQL Server};SERVER=myserver.database.windows.net;DATABASE=mydatabase;Authentication=ActiveDirectoryMsi", + "connection_string": "DRIVER={ODBC Driver 18 for SQL Server};SERVER=myserver.database.windows.net;DATABASE=mydatabase;Authentication=ActiveDirectoryMsi", "read_only": true, "max_rows": 500, "timeout": 30, diff --git a/application/single_app/semantic_kernel_plugins/azure_maps_openlayers_plugin.py b/application/single_app/semantic_kernel_plugins/azure_maps_openlayers_plugin.py new file mode 100644 index 00000000..95f8559c --- /dev/null +++ b/application/single_app/semantic_kernel_plugins/azure_maps_openlayers_plugin.py @@ -0,0 +1,398 @@ +# azure_maps_openlayers_plugin.py +"""Semantic Kernel plugin for inline Azure Maps visualizations rendered with OpenLayers.""" + +import json +import logging +import re +from typing import Any, Dict, List, Optional, Sequence, Tuple + +from semantic_kernel.functions import kernel_function + +from functions_appinsights import log_event +from functions_azure_maps import ( + AZURE_MAPS_DEFAULT_TILESET_ID, + AZURE_MAPS_DEFAULT_LANGUAGE, + AZURE_MAPS_DEFAULT_VIEW, + AZURE_MAPS_PLUGIN_DISPLAY_NAME, + AZURE_MAPS_PLUGIN_TYPE, + AZURE_MAPS_RENDER_TYPE, + AZURE_MAPS_TILE_ATTRIBUTION, + build_tile_proxy_url_template, + create_tile_proxy_token, +) +from semantic_kernel_plugins.base_plugin import BasePlugin +from semantic_kernel_plugins.plugin_invocation_logger import plugin_function_logger + + +class AzureMapsOpenLayersPlugin(BasePlugin): + def __init__(self, manifest: Optional[Dict[str, Any]] = None): + super().__init__(manifest) + self.manifest = manifest or {} + + @property + def display_name(self) -> str: + return AZURE_MAPS_PLUGIN_DISPLAY_NAME + + @property + def metadata(self) -> Dict[str, Any]: + return { + "name": self.manifest.get("name", AZURE_MAPS_PLUGIN_TYPE), + "type": AZURE_MAPS_PLUGIN_TYPE, + "description": ( + "Prepare structured Azure Maps visualizations that the chat UI renders inline with OpenLayers. " + "Use this when you already know the locations or polygon coordinates to show on an interactive map." + ), + "methods": [ + { + "name": "create_map_visualization", + "description": "Create an interactive Azure Maps visualization for chat. Supply location markers plus optional path and polygon overlays as JSON using longitude/latitude coordinates.", + "parameters": [ + { + "name": "title", + "type": "str", + "description": "Short title displayed above the map card.", + "required": True, + }, + { + "name": "summary", + "type": "str", + "description": "Optional one or two sentence summary shown above the map.", + "required": False, + }, + { + "name": "locations_json", + "type": "str", + "description": "JSON array of point objects. Each item should include longitude and latitude, plus optional label, description, color, and icon_name.", + "required": False, + }, + { + "name": "areas_json", + "type": "str", + "description": "JSON array of polygon area objects. Each item should include coordinates as longitude/latitude pairs, plus optional label, description, stroke_color, and fill_color.", + "required": False, + }, + { + "name": "paths_json", + "type": "str", + "description": "JSON array of path objects. Each item should include ordered coordinates as longitude/latitude pairs, plus optional label, description, stroke_color, and line_width.", + "required": False, + }, + { + "name": "view_json", + "type": "str", + "description": "Optional JSON object with preferred center, zoom, max_zoom, and fit_to_features settings.", + "required": False, + }, + { + "name": "tileset_id", + "type": "str", + "description": "Optional Azure Maps raster tileset ID such as microsoft.base.road or microsoft.imagery.", + "required": False, + }, + ], + "returns": { + "type": "dict", + "description": "Structured visualization payload with a secure tile proxy URL template for inline chat rendering.", + }, + } + ], + } + + def _parse_json(self, raw_value: Any, field_name: str, expected_type: type, default_value: Any) -> Any: + if raw_value in (None, ""): + return default_value + + if isinstance(raw_value, expected_type): + return raw_value + + try: + parsed_value = json.loads(str(raw_value)) + except json.JSONDecodeError as exc: + raise ValueError(f"{field_name} must be valid JSON.") from exc + + if not isinstance(parsed_value, expected_type): + raise ValueError(f"{field_name} must decode to {expected_type.__name__}.") + + return parsed_value + + def _coerce_float(self, value: Any, field_name: str) -> float: + try: + return float(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"{field_name} must be a valid number.") from exc + + def _normalize_label(self, raw_label: Any, fallback_label: str) -> str: + normalized_label = str(raw_label or "").strip() + return normalized_label or fallback_label + + def _normalize_marker(self, marker: Dict[str, Any], index: int) -> Dict[str, Any]: + latitude = marker.get("latitude", marker.get("lat")) + longitude = marker.get("longitude", marker.get("lon", marker.get("lng"))) + if latitude is None or longitude is None: + raise ValueError(f"locations_json[{index}] must include latitude and longitude.") + + return { + "id": str(marker.get("id") or f"marker-{index + 1}"), + "label": self._normalize_label( + marker.get("label") or marker.get("title") or marker.get("name"), + f"Location {index + 1}", + ), + "description": str( + marker.get("description") or marker.get("popup") or marker.get("details") or "" + ).strip(), + "latitude": self._coerce_float(latitude, f"locations_json[{index}].latitude"), + "longitude": self._coerce_float(longitude, f"locations_json[{index}].longitude"), + "color": str(marker.get("color") or "#0d6efd").strip() or "#0d6efd", + "icon_name": str(marker.get("icon_name") or marker.get("icon") or "").strip(), + } + + def _normalize_area_ring(self, coordinates: Any, index: int) -> List[List[float]]: + if not isinstance(coordinates, list) or not coordinates: + raise ValueError(f"areas_json[{index}].coordinates must be a JSON array of longitude/latitude pairs.") + + first_item = coordinates[0] + if isinstance(first_item, list) and first_item and isinstance(first_item[0], list): + coordinates = first_item + + normalized_ring: List[List[float]] = [] + for point_index, point in enumerate(coordinates): + if not isinstance(point, Sequence) or len(point) < 2: + raise ValueError( + f"areas_json[{index}].coordinates[{point_index}] must contain longitude and latitude." + ) + normalized_ring.append([ + self._coerce_float(point[0], f"areas_json[{index}].coordinates[{point_index}].longitude"), + self._coerce_float(point[1], f"areas_json[{index}].coordinates[{point_index}].latitude"), + ]) + + if len(normalized_ring) < 3: + raise ValueError(f"areas_json[{index}] must contain at least three coordinate pairs.") + + if normalized_ring[0] != normalized_ring[-1]: + normalized_ring.append(list(normalized_ring[0])) + + return normalized_ring + + def _normalize_area(self, area: Dict[str, Any], index: int) -> Dict[str, Any]: + return { + "id": str(area.get("id") or f"area-{index + 1}"), + "label": self._normalize_label( + area.get("label") or area.get("title") or area.get("name"), + f"Area {index + 1}", + ), + "description": str( + area.get("description") or area.get("popup") or area.get("details") or "" + ).strip(), + "coordinates": self._normalize_area_ring(area.get("coordinates"), index), + "stroke_color": str(area.get("stroke_color") or area.get("strokeColor") or "#b02a37").strip() or "#b02a37", + "fill_color": str(area.get("fill_color") or area.get("fillColor") or "rgba(176, 42, 55, 0.20)").strip() or "rgba(176, 42, 55, 0.20)", + } + + def _normalize_path_coordinates(self, coordinates: Any, index: int) -> List[List[float]]: + if not isinstance(coordinates, list) or not coordinates: + raise ValueError(f"paths_json[{index}].coordinates must be a JSON array of longitude/latitude pairs.") + + if isinstance(coordinates[0], list) and coordinates[0] and isinstance(coordinates[0][0], list): + coordinates = coordinates[0] + + normalized_path: List[List[float]] = [] + for point_index, point in enumerate(coordinates): + if not isinstance(point, Sequence) or len(point) < 2: + raise ValueError( + f"paths_json[{index}].coordinates[{point_index}] must contain longitude and latitude." + ) + normalized_path.append([ + self._coerce_float(point[0], f"paths_json[{index}].coordinates[{point_index}].longitude"), + self._coerce_float(point[1], f"paths_json[{index}].coordinates[{point_index}].latitude"), + ]) + + if len(normalized_path) < 2: + raise ValueError(f"paths_json[{index}] must contain at least two coordinate pairs.") + + return normalized_path + + def _normalize_path(self, path: Dict[str, Any], index: int) -> Dict[str, Any]: + raw_line_width = path.get("line_width", path.get("lineWidth", path.get("width", 4))) + line_width = int(self._coerce_float(raw_line_width, f"paths_json[{index}].line_width")) + return { + "id": str(path.get("id") or f"path-{index + 1}"), + "label": self._normalize_label( + path.get("label") or path.get("title") or path.get("name"), + f"Path {index + 1}", + ), + "description": str( + path.get("description") or path.get("popup") or path.get("details") or "" + ).strip(), + "coordinates": self._normalize_path_coordinates(path.get("coordinates"), index), + "stroke_color": str(path.get("stroke_color") or path.get("strokeColor") or "#0b5ed7").strip() or "#0b5ed7", + "line_width": max(1, min(12, line_width)), + } + + def _collect_reference_points( + self, + markers: List[Dict[str, Any]], + areas: List[Dict[str, Any]], + paths: List[Dict[str, Any]], + ) -> List[Tuple[float, float]]: + points = [ + (marker["longitude"], marker["latitude"]) + for marker in markers + ] + + for area in areas: + for longitude, latitude in area.get("coordinates", []): + points.append((float(longitude), float(latitude))) + + for path in paths: + for longitude, latitude in path.get("coordinates", []): + points.append((float(longitude), float(latitude))) + + return points + + def _normalize_view( + self, + raw_view: Dict[str, Any], + markers: List[Dict[str, Any]], + areas: List[Dict[str, Any]], + paths: List[Dict[str, Any]], + ) -> Dict[str, Any]: + reference_points = self._collect_reference_points(markers, areas, paths) + provided_center = raw_view.get("center") + + if isinstance(provided_center, Sequence) and len(provided_center) >= 2: + center = [ + self._coerce_float(provided_center[0], "view_json.center[0]"), + self._coerce_float(provided_center[1], "view_json.center[1]"), + ] + elif reference_points: + avg_longitude = sum(point[0] for point in reference_points) / len(reference_points) + avg_latitude = sum(point[1] for point in reference_points) / len(reference_points) + center = [round(avg_longitude, 6), round(avg_latitude, 6)] + else: + center = [0.0, 20.0] + + raw_zoom = raw_view.get("zoom") + if raw_zoom in (None, ""): + zoom = 14 if len(markers) == 1 and not areas and not paths else 10 + else: + zoom = int(self._coerce_float(raw_zoom, "view_json.zoom")) + + raw_max_zoom = raw_view.get("max_zoom", raw_view.get("maxZoom")) + if raw_max_zoom in (None, ""): + max_zoom = 15 + else: + max_zoom = int(self._coerce_float(raw_max_zoom, "view_json.max_zoom")) + + fit_to_features = raw_view.get("fit_to_features", raw_view.get("fitToFeatures", True)) + + return { + "center": center, + "zoom": max(1, min(22, zoom)), + "max_zoom": max(1, min(22, max_zoom)), + "fit_to_features": bool(fit_to_features), + } + + def _normalize_tileset_id(self, tileset_id: str) -> str: + normalized_tileset_id = str(tileset_id or AZURE_MAPS_DEFAULT_TILESET_ID).strip() + if not normalized_tileset_id: + return AZURE_MAPS_DEFAULT_TILESET_ID + if not re.fullmatch(r"[A-Za-z0-9._-]+", normalized_tileset_id): + raise ValueError("tileset_id contains unsupported characters.") + return normalized_tileset_id + + @plugin_function_logger("AzureMapsOpenLayersPlugin") + @kernel_function( + description=( + "Create an inline Azure Maps visualization for chat using OpenLayers. " + "Provide locations_json as a JSON array with longitude and latitude for each point, " + "and optionally provide polygon areas_json or ordered paths_json using longitude/latitude coordinate pairs." + ) + ) + def create_map_visualization( + self, + title: str, + summary: str = "", + locations_json: str = "[]", + areas_json: str = "[]", + paths_json: str = "[]", + view_json: str = "{}", + tileset_id: str = AZURE_MAPS_DEFAULT_TILESET_ID, + ) -> dict: + try: + azure_maps_key = str((self.manifest.get("auth") or {}).get("key") or "").strip() + if not azure_maps_key: + raise ValueError("This action is missing its Azure Maps subscription key.") + + raw_locations = self._parse_json(locations_json, "locations_json", list, []) + raw_areas = self._parse_json(areas_json, "areas_json", list, []) + raw_paths = self._parse_json(paths_json, "paths_json", list, []) + raw_view = self._parse_json(view_json, "view_json", dict, {}) + + markers = [self._normalize_marker(marker, index) for index, marker in enumerate(raw_locations)] + areas = [self._normalize_area(area, index) for index, area in enumerate(raw_areas)] + paths = [self._normalize_path(path, index) for index, path in enumerate(raw_paths)] + if not markers and not areas and not paths: + raise ValueError("Provide at least one marker in locations_json, one path in paths_json, or one polygon in areas_json.") + + normalized_title = str(title or "").strip() or "Interactive Map" + normalized_summary = str(summary or "").strip() + normalized_tileset_id = self._normalize_tileset_id(tileset_id) + view = self._normalize_view(raw_view, markers, areas, paths) + tile_proxy_token = create_tile_proxy_token(azure_maps_key) + + map_payload = { + "title": normalized_title, + "summary": normalized_summary, + "map_provider": "azure_maps", + "map_library": "openlayers", + "tileset_id": normalized_tileset_id, + "tile_url_template": build_tile_proxy_url_template( + tile_proxy_token, + tileset_id=normalized_tileset_id, + language=AZURE_MAPS_DEFAULT_LANGUAGE, + view=AZURE_MAPS_DEFAULT_VIEW, + tile_size=256, + ), + "tile_attribution": AZURE_MAPS_TILE_ATTRIBUTION, + "view": view, + "markers": markers, + "paths": paths, + "areas": areas, + "source_action_name": str(self.manifest.get("name") or AZURE_MAPS_PLUGIN_TYPE), + } + + marker_count = len(markers) + path_count = len(paths) + area_count = len(areas) + feature_counts = [] + if marker_count: + feature_counts.append(f"{marker_count} marker{'s' if marker_count != 1 else ''}") + if path_count: + feature_counts.append(f"{path_count} path{'s' if path_count != 1 else ''}") + if area_count: + feature_counts.append(f"{area_count} area{'s' if area_count != 1 else ''}") + feature_summary = ', '.join(feature_counts) if feature_counts else '0 features' + return { + "success": True, + "render_type": AZURE_MAPS_RENDER_TYPE, + "summary": f"Prepared an interactive Azure Maps view with {feature_summary}.", + "map_payload": map_payload, + } + except ValueError as exc: + return { + "success": False, + "error": str(exc), + "error_type": "validation", + } + except Exception as exc: + log_event( + f"[AzureMapsPlugin] Failed to build Azure Maps visualization: {exc}", + level=logging.ERROR, + exceptionTraceback=True, + ) + return { + "success": False, + "error": "Failed to build Azure Maps visualization.", + "error_type": "unexpected", + "details": str(exc), + } \ No newline at end of file diff --git a/application/single_app/semantic_kernel_plugins/blob_storage_plugin.py b/application/single_app/semantic_kernel_plugins/blob_storage_plugin.py index 23f2478f..d6ea2992 100644 --- a/application/single_app/semantic_kernel_plugins/blob_storage_plugin.py +++ b/application/single_app/semantic_kernel_plugins/blob_storage_plugin.py @@ -1,29 +1,82 @@ -import mimetypes, base64 -from typing import Dict, Any, List, Optional -from semantic_kernel_plugins.base_plugin import BasePlugin -from azure.storage.blob import BlobServiceClient -from semantic_kernel.functions import kernel_function +# blob_storage_plugin.py +"""Semantic Kernel plugin for container-scoped Azure Blob Storage operations.""" + +import logging +from typing import Any, Dict, List, Optional + +from azure.core.exceptions import AzureError, ResourceNotFoundError from azure.identity import DefaultAzureCredential +from azure.storage.blob import BlobServiceClient, ContentSettings +from semantic_kernel.functions import kernel_function + +from functions_appinsights import log_event +from functions_blob_storage_operations import ( + BLOB_STORAGE_CAPABILITY_DEFINITIONS, + BLOB_STORAGE_PLUGIN_TYPE, + detect_blob_storage_file_type, + derive_blob_endpoint_from_connection_string, + get_blob_storage_content_type, + get_blob_storage_enabled_function_names, + get_enabled_blob_storage_read_file_types, + get_enabled_blob_storage_upload_file_types, + is_blob_storage_file_type_enabled, + normalize_blob_prefix, + normalize_blob_storage_capabilities, + normalize_blob_storage_read_file_types, + normalize_blob_storage_upload_file_types, +) +from semantic_kernel_plugins.base_plugin import BasePlugin from semantic_kernel_plugins.plugin_invocation_logger import plugin_function_logger + class BlobStoragePlugin(BasePlugin): - def __init__(self, manifest: Dict[str, Any]): + """Container-scoped Azure Blob Storage plugin with capability-gated operations.""" + + DEFAULT_ENDPOINT = "https://blob.core.windows.net" + MAX_LIST_RESULTS = 200 + MAX_READ_BYTES = 1024 * 1024 + + def __init__(self, manifest: Optional[Dict[str, Any]] = None): super().__init__(manifest) - self.manifest = manifest - self.endpoint = manifest.get('endpoint') - self.key = manifest.get('auth', {}).get('key') - self.auth_type = manifest.get('auth', {}).get('type', 'key') - self._metadata = manifest.get('metadata', {}) - if not self.endpoint or not self.auth_type: - raise ValueError("BlobStoragePlugin requires 'endpoint' and 'auth.type' in the manifest.") - if self.auth_type == 'identity': - self.service_client = BlobServiceClient(account_url=self.endpoint, credential=DefaultAzureCredential()) - elif self.auth_type == 'key': - if not self.key: - raise ValueError("BlobStoragePlugin requires 'auth.key' when using key authentication.") - self.service_client = BlobServiceClient(account_url=self.endpoint, credential=self.key) - else: - raise ValueError(f"Unsupported auth.type: {self.auth_type}") + self.manifest = manifest or {} + self._metadata = self.manifest.get("metadata", {}) or {} + self._additional_fields = self.manifest.get("additionalFields", {}) or {} + self._auth = self.manifest.get("auth", {}) or {} + self.auth_type = str(self._auth.get("type") or "connection_string").strip().lower() + self.connection_string = str(self._auth.get("key") or "").strip() if self.auth_type == "connection_string" else "" + self.auth_key = str(self._auth.get("key") or "").strip() if self.auth_type == "key" else "" + self.auth_identity = str(self._auth.get("identity") or "managed_identity").strip() or "managed_identity" + self.endpoint = str( + self.manifest.get("endpoint") + or derive_blob_endpoint_from_connection_string(self.connection_string) + or self.DEFAULT_ENDPOINT + ).strip().rstrip("/") + self.container_name = str( + self.manifest.get("container_name") + or self._additional_fields.get("container_name") + or "" + ).strip() + self.blob_prefix = normalize_blob_prefix(self._additional_fields.get("blob_prefix") or self.manifest.get("blob_prefix")) + self._capabilities = normalize_blob_storage_capabilities( + self.manifest.get("blob_storage_capabilities") + or self._additional_fields.get("blob_storage_capabilities") + ) + self._read_file_types = normalize_blob_storage_read_file_types( + self.manifest.get("blob_storage_read_file_types") + or self._additional_fields.get("blob_storage_read_file_types") + ) + self._upload_file_types = normalize_blob_storage_upload_file_types( + self.manifest.get("blob_storage_upload_file_types") + or self._additional_fields.get("blob_storage_upload_file_types") + ) + self._enabled_function_names = set( + self.manifest.get("enabled_functions") + or get_blob_storage_enabled_function_names(self._capabilities) + ) + + self._validate_configuration() + self.service_client = self._build_service_client() + self.container_client = self.service_client.get_container_client(self.container_name) @property def display_name(self) -> str: @@ -31,117 +84,322 @@ def display_name(self) -> str: @property def metadata(self) -> Dict[str, Any]: + enabled_methods = set(self.get_functions()) + supported_read_types = get_enabled_blob_storage_read_file_types(self._read_file_types) + supported_upload_types = get_enabled_blob_storage_upload_file_types(self._upload_file_types) + read_type_text = ", ".join(supported_read_types) if supported_read_types else "none" + upload_type_text = ", ".join(supported_upload_types) if supported_upload_types else "none" + + method_specs = { + "list_container_contents": { + "name": "list_container_contents", + "description": "List blobs in the configured container and optional prefix.", + "parameters": [ + { + "name": "prefix", + "type": "str", + "description": "Optional prefix below the configured default prefix.", + "required": False, + }, + { + "name": "max_results", + "type": "int", + "description": f"Maximum number of blobs to return, capped at {self.MAX_LIST_RESULTS}.", + "required": False, + }, + ], + "returns": {"type": "dict", "description": "Blob listing results and support flags."}, + }, + "read_file_content": { + "name": "read_file_content", + "description": f"Read supported file content from the configured container. Enabled read file types: {read_type_text}.", + "parameters": [ + { + "name": "blob_name", + "type": "str", + "description": "Blob name or relative path within the configured container.", + "required": True, + } + ], + "returns": {"type": "dict", "description": "Blob content and related metadata."}, + }, + "upload_file_to_container": { + "name": "upload_file_to_container", + "description": f"Upload supported file content into the configured container. Enabled upload file types: {upload_type_text}.", + "parameters": [ + { + "name": "blob_name", + "type": "str", + "description": "Blob name or relative path within the configured container.", + "required": True, + }, + { + "name": "content", + "type": "str", + "description": "UTF-8 text content to upload.", + "required": True, + }, + { + "name": "overwrite", + "type": "bool", + "description": "If true, overwrite an existing blob with the same name.", + "required": False, + }, + ], + "returns": {"type": "dict", "description": "Upload result details."}, + }, + } + return { "name": self.manifest.get("name", "blob_storage_plugin"), - "type": "blob_storage", - "description": self.manifest.get("description", "Plugin for Azure Blob Storage operations that uses key or managed identity authentication allowing querying of blob storage data sources."), + "type": BLOB_STORAGE_PLUGIN_TYPE, + "description": ( + "Container-scoped Azure Blob Storage action for listing blobs, reading supported text files, " + "and uploading supported text files using the configured container and optional prefix." + ), "methods": [ - { - "name": "list_containers", - "description": "List all containers in the storage account.", - "parameters": [], - "returns": {"type": "List[str]", "description": "List of container names."} - }, - { - "name": "list_blobs", - "description": "List all blobs in a given container.", - "parameters": [ - {"name": "container_name", "type": "str", "description": "Name of the container.", "required": True} - ], - "returns": {"type": "List[str]", "description": "List of blob names."} - }, - { - "name": "get_blob_metadata", - "description": "Get metadata for a specific blob.", - "parameters": [ - {"name": "container_name", "type": "str", "description": "Name of the container.", "required": True}, - {"name": "blob_name", "type": "str", "description": "Name of the blob.", "required": True} - ], - "returns": {"type": "dict", "description": "Blob metadata as a dictionary."} - }, - { - "name": "get_blob_content", - "description": "Read the contents of a blob as text or base64 for images to be renderable in the browser.", - "parameters": [ - {"name": "container_name", "type": "str", "description": "Name of the container.", "required": True}, - {"name": "blob_name", "type": "str", "description": "Name of the blob.", "required": True} - ], - "returns": {"type": "str", "description": "Blob content as a string."} - }, - { - "name": "iterate_blobs_in_container", - "description": "Iterates over all blobs in a container, reads their data, and return a dict of blob_name: content. Uses text or base64 for images to be renderable in the browser.", - "parameters": [ - {"name": "container_name", "type": "str", "description": "Name of the container.", "required": True} - ], - "returns": {"type": "dict", "description": "Dictionary of blob_name: content for all blobs in the container."} - } - ] + method_specs[definition["function_name"]] + for definition in BLOB_STORAGE_CAPABILITY_DEFINITIONS + if definition["function_name"] in enabled_methods + ], } def get_functions(self) -> List[str]: return [ - "list_containers", - "list_blobs", - "get_blob_metadata", - "get_blob_content", - "iterate_blobs_in_container" + definition["function_name"] + for definition in BLOB_STORAGE_CAPABILITY_DEFINITIONS + if definition["function_name"] in self._enabled_function_names ] - @plugin_function_logger("BlobStoragePlugin") - @kernel_function(description="List all containers in the storage account.") - def list_containers(self) -> List[str]: - containers = self.service_client.list_containers() - return [c['name'] for c in containers] + def _validate_configuration(self): + if not self.container_name: + raise ValueError("BlobStoragePlugin requires additionalFields.container_name in the manifest.") - @plugin_function_logger("BlobStoragePlugin") - @kernel_function(description="List all blobs in a given container.") - def list_blobs(self, container_name: str) -> List[str]: - container_client = self.service_client.get_container_client(container_name) - blobs = container_client.list_blobs() - return [b['name'] for b in blobs] + if self.auth_type == "connection_string": + if not self.connection_string: + raise ValueError("BlobStoragePlugin requires auth.key when using connection string authentication.") + return + + if self.auth_type == "identity": + if not self.endpoint: + raise ValueError("BlobStoragePlugin requires 'endpoint' when using managed identity authentication.") + return + + if self.auth_type == "key": + if not self.endpoint: + raise ValueError("BlobStoragePlugin requires 'endpoint' when using account-key authentication.") + if not self.auth_key: + raise ValueError("BlobStoragePlugin requires auth.key when using account-key authentication.") + return + + raise ValueError(f"Unsupported auth.type for BlobStoragePlugin: {self.auth_type}") + + def _build_service_client(self) -> BlobServiceClient: + if self.auth_type == "connection_string": + return BlobServiceClient.from_connection_string(self.connection_string) + if self.auth_type == "identity": + return BlobServiceClient(account_url=self.endpoint, credential=DefaultAzureCredential()) + return BlobServiceClient(account_url=self.endpoint, credential=self.auth_key) + + def _resolve_effective_prefix(self, prefix: str = "") -> str: + default_prefix = self.blob_prefix + requested_prefix = normalize_blob_prefix(prefix) + if default_prefix and requested_prefix: + if requested_prefix.startswith(f"{default_prefix}/") or requested_prefix == default_prefix: + return requested_prefix + return f"{default_prefix}/{requested_prefix}".strip("/") + return default_prefix or requested_prefix + + def _resolve_blob_name(self, blob_name: str) -> str: + normalized_blob_name = str(blob_name or "").strip().strip("/") + if not normalized_blob_name: + raise ValueError("A blob name is required.") + + effective_prefix = self._resolve_effective_prefix() + if effective_prefix and not normalized_blob_name.startswith(f"{effective_prefix}/") and normalized_blob_name != effective_prefix: + return f"{effective_prefix}/{normalized_blob_name}".strip("/") + return normalized_blob_name + + def _get_relative_blob_name(self, blob_name: str) -> str: + effective_prefix = self._resolve_effective_prefix() + normalized_blob_name = str(blob_name or "").strip().strip("/") + if effective_prefix and normalized_blob_name.startswith(f"{effective_prefix}/"): + return normalized_blob_name[len(effective_prefix) + 1:] + return normalized_blob_name + + def _is_read_supported(self, blob_name: str) -> bool: + if "read_file_content" not in self._enabled_function_names: + return False + return is_blob_storage_file_type_enabled(blob_name, self._read_file_types) + + def _is_upload_supported(self, blob_name: str) -> bool: + if "upload_file_to_container" not in self._enabled_function_names: + return False + return is_blob_storage_file_type_enabled(blob_name, self._upload_file_types) + + def _build_list_item(self, blob) -> Dict[str, Any]: + blob_name = getattr(blob, "name", "") + file_type = detect_blob_storage_file_type(blob_name) + return { + "blob_name": blob_name, + "relative_path": self._get_relative_blob_name(blob_name), + "size": getattr(blob, "size", None), + "file_type": file_type or "unsupported", + "supported_for_read": self._is_read_supported(blob_name), + "supported_for_upload": self._is_upload_supported(blob_name), + } + + def _error_response(self, message: str, error_type: str = "validation", **extra: Any) -> Dict[str, Any]: + payload = { + "success": False, + "error": message, + "error_type": error_type, + } + payload.update(extra) + return payload @plugin_function_logger("BlobStoragePlugin") - @kernel_function(description="Get metadata for a specific blob.") - def get_blob_metadata(self, container_name: str, blob_name: str) -> dict: - blob_client = self.service_client.get_blob_client(container=container_name, blob=blob_name) - return blob_client.get_blob_properties().metadata + @kernel_function(description="List blobs in the configured Azure Blob Storage container and optional prefix.") + def list_container_contents(self, prefix: str = "", max_results: int = 50) -> Dict[str, Any]: + try: + requested_max_results = int(max_results or 50) + except (TypeError, ValueError): + requested_max_results = 50 + + effective_max_results = min(max(requested_max_results, 1), self.MAX_LIST_RESULTS) + effective_prefix = self._resolve_effective_prefix(prefix) + blobs = [] + has_more = False + + try: + iterator = self.container_client.list_blobs(name_starts_with=effective_prefix or None) + for index, blob in enumerate(iterator): + if index >= effective_max_results: + has_more = True + break + blobs.append(self._build_list_item(blob)) + + return { + "success": True, + "container_name": self.container_name, + "blob_prefix": effective_prefix, + "items": blobs, + "item_count": len(blobs), + "has_more": has_more, + } + except ResourceNotFoundError: + return self._error_response( + f"Blob container '{self.container_name}' was not found.", + error_type="not_found", + ) + except AzureError as exc: + log_event( + f"[BlobStoragePlugin] Failed to list container contents: {exc}", + level=logging.ERROR, + exceptionTraceback=True, + ) + return self._error_response("Failed to list container contents.", error_type="unexpected", details=str(exc)) @plugin_function_logger("BlobStoragePlugin") - @kernel_function(description="Read the contents of a blob as text or base64 for images to be renderable in the browser.") - def get_blob_content(self, container_name: str, blob_name: str) -> str: - blob_client = self.service_client.get_blob_client(container=container_name, blob=blob_name) - stream = blob_client.download_blob() - data = stream.readall() - content_type, _ = mimetypes.guess_type(blob_name) - if content_type and content_type.startswith("text"): + @kernel_function(description="Read supported file content from the configured Azure Blob Storage container.") + def read_file_content(self, blob_name: str) -> Dict[str, Any]: + try: + effective_blob_name = self._resolve_blob_name(blob_name) + except ValueError as exc: + return self._error_response(str(exc)) + + if not self._is_read_supported(effective_blob_name): + return self._error_response( + "The requested blob is not enabled for read operations. Only supported read file types can be opened.", + blob_name=effective_blob_name, + ) + + try: + blob_client = self.container_client.get_blob_client(effective_blob_name) + data = blob_client.download_blob().readall() + if len(data) > self.MAX_READ_BYTES: + return self._error_response( + f"The requested blob exceeds the {self.MAX_READ_BYTES} byte read limit.", + blob_name=effective_blob_name, + ) + try: - return data.decode('utf-8') + content = data.decode("utf-8") except UnicodeDecodeError: - return "[Unreadable text file]" - elif content_type and content_type.startswith("image"): - # Return base64 for images - return base64.b64encode(data).decode('utf-8') - else: - return f"[Binary file: {blob_name}, type: {content_type or 'unknown'}]" + return self._error_response( + "The requested blob could not be decoded as UTF-8 text.", + blob_name=effective_blob_name, + error_type="decode", + ) + + return { + "success": True, + "container_name": self.container_name, + "blob_name": effective_blob_name, + "relative_path": self._get_relative_blob_name(effective_blob_name), + "file_type": detect_blob_storage_file_type(effective_blob_name) or "unknown", + "content": content, + "content_length": len(content), + } + except ResourceNotFoundError: + return self._error_response( + f"Blob '{effective_blob_name}' was not found in container '{self.container_name}'.", + error_type="not_found", + ) + except AzureError as exc: + log_event( + f"[BlobStoragePlugin] Failed to read blob content: {exc}", + level=logging.ERROR, + exceptionTraceback=True, + ) + return self._error_response("Failed to read blob content.", error_type="unexpected", details=str(exc)) @plugin_function_logger("BlobStoragePlugin") - @kernel_function(description="Iterates over all blobs in a container, reads their data, and return a dict of blob_name: content. Uses text or base64 for images to be renderable in the browser.") - def iterate_blobs_in_container(self, container_name: str) -> dict: - container_client = self.service_client.get_container_client(container_name) - result = {} - for blob in container_client.list_blobs(): - blob_client = container_client.get_blob_client(blob) - data = blob_client.download_blob().readall() - content_type, _ = mimetypes.guess_type(blob['name']) - if content_type and content_type.startswith("text"): - try: - content = data.decode('utf-8') - except UnicodeDecodeError: - content = "[Unreadable text file]" - elif content_type and content_type.startswith("image"): - content = base64.b64encode(data).decode('utf-8') - else: - content = f"[Binary file: {blob['name']}, type: {content_type or 'unknown'}]" - result[blob['name']] = content - return result + @kernel_function(description="Upload supported file content to the configured Azure Blob Storage container.") + def upload_file_to_container(self, blob_name: str, content: str, overwrite: bool = False) -> Dict[str, Any]: + try: + effective_blob_name = self._resolve_blob_name(blob_name) + except ValueError as exc: + return self._error_response(str(exc)) + + if not self._is_upload_supported(effective_blob_name): + return self._error_response( + "The requested blob path is not enabled for upload operations. Only supported upload file types can be written.", + blob_name=effective_blob_name, + ) + + file_type = detect_blob_storage_file_type(effective_blob_name) + if file_type != "markdown": + return self._error_response( + "Only Markdown uploads are supported in this version.", + blob_name=effective_blob_name, + ) + + try: + blob_client = self.container_client.get_blob_client(effective_blob_name) + blob_client.upload_blob( + content.encode("utf-8"), + overwrite=bool(overwrite), + content_settings=ContentSettings(content_type=get_blob_storage_content_type(file_type)), + ) + return { + "success": True, + "container_name": self.container_name, + "blob_name": effective_blob_name, + "relative_path": self._get_relative_blob_name(effective_blob_name), + "file_type": file_type, + "overwrite": bool(overwrite), + "content_length": len(content), + } + except ResourceNotFoundError: + return self._error_response( + f"Blob container '{self.container_name}' was not found.", + error_type="not_found", + ) + except AzureError as exc: + log_event( + f"[BlobStoragePlugin] Failed to upload blob content: {exc}", + level=logging.ERROR, + exceptionTraceback=True, + ) + return self._error_response("Failed to upload blob content.", error_type="unexpected", details=str(exc)) diff --git a/application/single_app/semantic_kernel_plugins/chart_plugin.py b/application/single_app/semantic_kernel_plugins/chart_plugin.py new file mode 100644 index 00000000..9c70b253 --- /dev/null +++ b/application/single_app/semantic_kernel_plugins/chart_plugin.py @@ -0,0 +1,824 @@ +# chart_plugin.py +"""Semantic Kernel plugin for inline Chart.js visualizations in chat.""" + +import hashlib +import json +import logging +from typing import Any, Dict, List, Optional + +from semantic_kernel.functions import kernel_function +from semantic_kernel.functions.kernel_plugin import KernelPlugin + +from functions_appinsights import log_event +from functions_chart_operations import ( + CHART_CAPABILITY_DEFINITIONS, + CHART_PLUGIN_TYPE, + build_inline_chart_markdown, + get_enabled_chart_type_keys, + normalize_chart_capabilities, + normalize_chart_kind, +) +from semantic_kernel_plugins.base_plugin import BasePlugin +from semantic_kernel_plugins.plugin_invocation_logger import plugin_function_logger + + +DEFAULT_COLORS = [ + {'background': 'rgba(28, 110, 164, 0.18)', 'border': '#1c6ea4'}, + {'background': 'rgba(215, 91, 53, 0.18)', 'border': '#d75b35'}, + {'background': 'rgba(39, 123, 84, 0.18)', 'border': '#277b54'}, + {'background': 'rgba(153, 92, 32, 0.18)', 'border': '#995c20'}, + {'background': 'rgba(126, 77, 140, 0.18)', 'border': '#7e4d8c'}, + {'background': 'rgba(191, 66, 112, 0.18)', 'border': '#bf4270'}, + {'background': 'rgba(58, 141, 121, 0.18)', 'border': '#3a8d79'}, + {'background': 'rgba(101, 120, 48, 0.18)', 'border': '#657830'}, +] + +SAFE_COLOR_PREFIXES = ('#', 'rgb(', 'rgba(', 'hsl(', 'hsla(') + + +class ChartPlugin(BasePlugin): + def __init__(self, manifest: Optional[Dict[str, Any]] = None): + super().__init__(manifest) + self.manifest = manifest or {} + self._metadata = self.manifest.get('metadata', {}) + self._capabilities = normalize_chart_capabilities( + self.manifest.get('chart_capabilities') + ) + self._enabled_chart_types = get_enabled_chart_type_keys(self._capabilities) + + @property + def display_name(self) -> str: + return 'Interactive Charts' + + @property + def metadata(self) -> Dict[str, Any]: + enabled_chart_types = set(self._enabled_chart_types) + return { + 'name': self.manifest.get('name', CHART_PLUGIN_TYPE), + 'type': CHART_PLUGIN_TYPE, + 'description': ( + 'Generate validated inline Chart.js payloads for the chat experience. ' + 'Supports line, bar, pie, doughnut, scatter, area, bubble, radar, ' + 'stacked bar, and stacked line charts.' + ), + 'methods': [ + { + 'name': 'describe_available_chart_types', + 'description': 'List the chart types currently enabled for this action.', + 'parameters': [], + 'returns': {'type': 'dict', 'description': 'Enabled chart types and examples.'}, + }, + { + 'name': 'create_chart', + 'description': 'Build a validated inline chart payload and markdown block.', + 'parameters': [ + {'name': 'chart_type', 'type': 'string', 'description': 'Requested chart type.', 'required': True}, + {'name': 'chart_data_json', 'type': 'string', 'description': 'Chart data as JSON.', 'required': True}, + {'name': 'title', 'type': 'string', 'description': 'Chart title.', 'required': False}, + {'name': 'subtitle', 'type': 'string', 'description': 'Chart subtitle.', 'required': False}, + {'name': 'description', 'type': 'string', 'description': 'Supporting chart description.', 'required': False}, + {'name': 'x_axis_label', 'type': 'string', 'description': 'X axis label.', 'required': False}, + {'name': 'y_axis_label', 'type': 'string', 'description': 'Y axis label.', 'required': False}, + {'name': 'options_json', 'type': 'string', 'description': 'Optional chart display settings as JSON.', 'required': False}, + ], + 'returns': {'type': 'dict', 'description': 'Validated chart payload and inline markdown.'}, + }, + ], + 'enabled_chart_types': [ + definition['key'] + for definition in CHART_CAPABILITY_DEFINITIONS + if definition['key'] in enabled_chart_types + ], + } + + def get_functions(self) -> List[str]: + return ['describe_available_chart_types', 'create_chart'] + + def get_kernel_plugin(self, plugin_name: str = CHART_PLUGIN_TYPE) -> KernelPlugin: + functions = {} + for function_name in self.get_functions(): + bound_method = getattr(self, function_name, None) + if callable(bound_method) and hasattr(bound_method, '__kernel_function__'): + functions[function_name] = bound_method + + return KernelPlugin.from_object( + plugin_name, + functions, + description=self.metadata.get('description'), + ) + + @plugin_function_logger('ChartPlugin') + @kernel_function( + description='List the chart types currently enabled for this built-in chart action.' + ) + def describe_available_chart_types(self) -> Dict[str, Any]: + """Return the enabled chart types and expected payload shapes.""" + enabled_definitions = [ + definition + for definition in CHART_CAPABILITY_DEFINITIONS + if definition['key'] in set(self._enabled_chart_types) + ] + return { + 'success': True, + 'enabled_chart_types': enabled_definitions, + 'recommended_payload_shapes': { + 'label_series': { + 'rows': [ + {'month': 'Jan', 'revenue': 120, 'cost': 84}, + {'month': 'Feb', 'revenue': 146, 'cost': 91}, + ], + 'xField': 'month', + 'yFields': ['revenue', 'cost'], + }, + 'pivot_series': { + 'rows': [ + {'month': 'Jan', 'team': 'North', 'value': 120}, + {'month': 'Jan', 'team': 'South', 'value': 98}, + {'month': 'Feb', 'team': 'North', 'value': 136}, + ], + 'xField': 'month', + 'seriesField': 'team', + 'valueField': 'value', + }, + 'scatter': { + 'rows': [ + {'latency_ms': 120, 'tokens': 820, 'region': 'East US'}, + {'latency_ms': 95, 'tokens': 640, 'region': 'West Europe'}, + ], + 'xField': 'latency_ms', + 'yField': 'tokens', + 'seriesField': 'region', + }, + 'bubble': { + 'rows': [ + {'impact': 12, 'confidence': 78, 'volume': 18, 'team': 'A'}, + {'impact': 19, 'confidence': 61, 'volume': 10, 'team': 'B'}, + ], + 'xField': 'impact', + 'yField': 'confidence', + 'sizeField': 'volume', + 'seriesField': 'team', + }, + 'explicit_datasets': { + 'labels': ['Q1', 'Q2', 'Q3', 'Q4'], + 'datasets': [ + {'label': 'Revenue', 'data': [120, 132, 141, 168]}, + {'label': 'Target', 'data': [110, 128, 139, 160], 'type': 'line'}, + ], + }, + }, + } + + @plugin_function_logger('ChartPlugin') + @kernel_function( + description='Build a validated inline chart payload for Chart.js and return markdown that can be embedded directly in the assistant response.' + ) + def create_chart( + self, + chart_type: str, + chart_data_json: str, + title: str = '', + subtitle: str = '', + description: str = '', + x_axis_label: str = '', + y_axis_label: str = '', + options_json: str = '', + ) -> Dict[str, Any]: + """Build a validated inline chart payload and markdown fence.""" + try: + chart_kind = normalize_chart_kind(chart_type) + if chart_kind not in set(self._enabled_chart_types): + raise ValueError( + f"Chart type '{chart_type}' is not enabled for this action. " + f"Enabled types: {', '.join(self._enabled_chart_types)}" + ) + + chart_data = self._parse_json_argument(chart_data_json, 'chart_data_json') + options = self._parse_json_argument(options_json, 'options_json', allow_empty=True) + + payload = self._build_chart_payload( + chart_kind=chart_kind, + chart_data=chart_data, + options=options, + title=title, + subtitle=subtitle, + description=description, + x_axis_label=x_axis_label, + y_axis_label=y_axis_label, + ) + chart_markdown = build_inline_chart_markdown(payload) + + return { + 'success': True, + 'chart_type': chart_kind, + 'chart_payload': payload, + 'chart_markdown': chart_markdown, + 'summary': payload.get('summary', ''), + 'enabled_chart_types': self._enabled_chart_types, + } + except ValueError as exc: + return {'success': False, 'error': str(exc), 'error_type': 'validation'} + except Exception as exc: + log_event( + f"[ChartPlugin] create_chart failed: {exc}", + level=logging.ERROR, + exceptionTraceback=True, + ) + return { + 'success': False, + 'error': 'Failed to build chart payload.', + 'error_type': 'unexpected', + 'details': str(exc), + } + + def _parse_json_argument( + self, + raw_value: Any, + field_name: str, + allow_empty: bool = False, + ) -> Dict[str, Any]: + if raw_value in (None, ''): + if allow_empty: + return {} + raise ValueError(f'{field_name} is required.') + + if isinstance(raw_value, dict): + return raw_value + + if not isinstance(raw_value, str): + raise ValueError(f'{field_name} must be a JSON object string.') + + try: + parsed = json.loads(raw_value) + except json.JSONDecodeError as exc: + raise ValueError(f'{field_name} must be valid JSON: {exc.msg}') from exc + + if not isinstance(parsed, dict): + raise ValueError(f'{field_name} must deserialize into a JSON object.') + + return parsed + + def _sanitize_text(self, value: Any, max_length: int = 160) -> str: + return str(value or '').strip()[:max_length] + + def _coerce_number(self, value: Any, field_name: str) -> Optional[float]: + if value in (None, ''): + return None + if isinstance(value, bool): + raise ValueError(f'{field_name} must be numeric.') + if isinstance(value, (int, float)): + return float(value) + + candidate = str(value).strip().replace(',', '') + if not candidate: + return None + + try: + return float(candidate) + except ValueError as exc: + raise ValueError(f'{field_name} must be numeric.') from exc + + def _coerce_rows(self, chart_data: Dict[str, Any]) -> List[Dict[str, Any]]: + rows = chart_data.get('rows') + if not isinstance(rows, list) or not rows: + raise ValueError('chart_data_json.rows must be a non-empty array of objects.') + if len(rows) > 500: + raise ValueError('chart_data_json.rows supports up to 500 rows per chart.') + normalized_rows = [] + for index, row in enumerate(rows): + if not isinstance(row, dict): + raise ValueError(f'Row {index + 1} must be an object.') + normalized_rows.append(row) + return normalized_rows + + def _coerce_labels(self, labels: Any) -> List[str]: + if not isinstance(labels, list) or not labels: + raise ValueError('labels must be a non-empty array.') + if len(labels) > 200: + raise ValueError('labels supports up to 200 items per chart.') + return [self._sanitize_text(label, 80) or f'Item {index + 1}' for index, label in enumerate(labels)] + + def _sanitize_color(self, value: Any) -> Optional[str]: + candidate = str(value or '').strip() + if not candidate: + return None + if len(candidate) > 40: + return None + if candidate.startswith(SAFE_COLOR_PREFIXES): + return candidate + return None + + def _get_palette(self, index: int) -> Dict[str, str]: + return DEFAULT_COLORS[index % len(DEFAULT_COLORS)] + + def _get_chart_js_type(self, chart_kind: str) -> str: + type_map = { + 'area': 'line', + 'stacked_bar': 'bar', + 'stacked_line': 'line', + 'polar_area': 'polarArea', + } + return type_map.get(chart_kind, chart_kind) + + def _sanitize_options( + self, + chart_kind: str, + options: Dict[str, Any], + x_axis_label: str, + y_axis_label: str, + ) -> Dict[str, Any]: + normalized = { + 'legendPosition': self._sanitize_text(options.get('legendPosition') or 'top', 20) or 'top', + 'showLegend': bool(options.get('showLegend', True)), + 'showDataTable': bool(options.get('showDataTable', True)), + 'beginAtZero': bool(options.get('beginAtZero', True)), + 'horizontal': bool(options.get('horizontal', False)) if chart_kind in {'bar', 'stacked_bar'} else False, + 'fill': bool(options.get('fill', chart_kind == 'area')), + 'smooth': bool(options.get('smooth', chart_kind in {'line', 'area', 'stacked_line'})), + 'stacked': bool(options.get('stacked', chart_kind in {'stacked_bar', 'stacked_line'})), + 'cutout': self._sanitize_text(options.get('cutout') or '60%', 20) if chart_kind == 'doughnut' else '', + 'xAxisLabel': self._sanitize_text(x_axis_label or options.get('xAxisLabel'), 80), + 'yAxisLabel': self._sanitize_text(y_axis_label or options.get('yAxisLabel'), 80), + } + if normalized['legendPosition'] not in {'top', 'bottom', 'left', 'right'}: + normalized['legendPosition'] = 'top' + if chart_kind != 'doughnut': + normalized.pop('cutout', None) + return normalized + + def _build_chart_payload( + self, + chart_kind: str, + chart_data: Dict[str, Any], + options: Dict[str, Any], + title: str, + subtitle: str, + description: str, + x_axis_label: str, + y_axis_label: str, + ) -> Dict[str, Any]: + if isinstance(chart_data.get('datasets'), list): + data_payload = self._build_explicit_dataset_payload(chart_kind, chart_data) + else: + data_payload = self._build_rows_payload(chart_kind, chart_data) + + chart_options = self._sanitize_options(chart_kind, options, x_axis_label, y_axis_label) + payload = { + 'version': 1, + 'kind': chart_kind, + 'chartType': self._get_chart_js_type(chart_kind), + 'title': self._sanitize_text(title), + 'subtitle': self._sanitize_text(subtitle), + 'description': self._sanitize_text(description, 240), + 'data': data_payload['data'], + 'table': data_payload.get('table'), + 'options': chart_options, + 'summary': data_payload.get('summary'), + } + + chart_hash_source = json.dumps( + { + 'kind': payload['kind'], + 'title': payload['title'], + 'subtitle': payload['subtitle'], + 'data': payload['data'], + 'options': payload['options'], + }, + sort_keys=True, + separators=(',', ':'), + ) + payload['chartId'] = hashlib.sha256(chart_hash_source.encode('utf-8')).hexdigest()[:12] + return payload + + def _build_explicit_dataset_payload( + self, + chart_kind: str, + chart_data: Dict[str, Any], + ) -> Dict[str, Any]: + datasets = chart_data.get('datasets') + if not isinstance(datasets, list) or not datasets: + raise ValueError('datasets must be a non-empty array.') + if len(datasets) > 20: + raise ValueError('datasets supports up to 20 series per chart.') + + labels = chart_data.get('labels') + normalized_labels = self._coerce_labels(labels) if labels is not None else [] + normalized_datasets = [] + + for index, dataset in enumerate(datasets): + if not isinstance(dataset, dict): + raise ValueError(f'Dataset {index + 1} must be an object.') + normalized_datasets.append( + self._normalize_dataset( + chart_kind=chart_kind, + dataset=dataset, + dataset_index=index, + labels=normalized_labels, + ) + ) + + if chart_kind not in {'scatter', 'bubble'} and not normalized_labels: + max_points = max(len(dataset['data']) for dataset in normalized_datasets) + normalized_labels = [f'Item {index + 1}' for index in range(max_points)] + + data = {'datasets': normalized_datasets} + if normalized_labels: + data['labels'] = normalized_labels + + summary = self._build_summary(chart_kind, normalized_datasets, normalized_labels) + table = self._build_table(chart_kind, data) + return {'data': data, 'summary': summary, 'table': table} + + def _normalize_dataset( + self, + chart_kind: str, + dataset: Dict[str, Any], + dataset_index: int, + labels: List[str], + ) -> Dict[str, Any]: + raw_points = dataset.get('data') + if not isinstance(raw_points, list) or not raw_points: + raise ValueError(f"Dataset {dataset_index + 1} must include a non-empty 'data' array.") + if len(raw_points) > 200: + raise ValueError('Each dataset supports up to 200 points.') + + palette = self._get_palette(dataset_index) + normalized = { + 'label': self._sanitize_text(dataset.get('label') or f'Series {dataset_index + 1}', 80), + 'borderColor': self._sanitize_color(dataset.get('borderColor')) or palette['border'], + 'backgroundColor': self._sanitize_color(dataset.get('backgroundColor')) or palette['background'], + 'borderWidth': 2, + } + + if chart_kind in {'scatter', 'bubble'}: + normalized['data'] = [ + self._normalize_xy_point(point, chart_kind, dataset_index) + for point in raw_points + ] + else: + normalized['data'] = [ + self._coerce_number(point, f'dataset {dataset_index + 1} value') + for point in raw_points + ] + if labels and len(normalized['data']) != len(labels): + raise ValueError( + f"Dataset '{normalized['label']}' must contain the same number of values as labels." + ) + + if chart_kind in {'line', 'area', 'stacked_line'}: + normalized['fill'] = bool(dataset.get('fill', chart_kind == 'area')) + normalized['tension'] = 0.35 if bool(dataset.get('smooth', chart_kind != 'stacked_line')) else 0.0 + if chart_kind == 'radar': + normalized['fill'] = bool(dataset.get('fill', False)) + if chart_kind in {'bar', 'stacked_bar'}: + normalized['borderSkipped'] = False + + dataset_type = str(dataset.get('type') or '').strip().lower() + if dataset_type in {'line', 'bar'}: + normalized['type'] = dataset_type + + if isinstance(dataset.get('backgroundColor'), list): + normalized_colors = [ + self._sanitize_color(color) or self._get_palette(color_index)['background'] + for color_index, color in enumerate(dataset.get('backgroundColor')) + ] + normalized['backgroundColor'] = normalized_colors + elif chart_kind in {'pie', 'doughnut', 'polar_area'}: + normalized['backgroundColor'] = [ + self._get_palette(color_index)['background'] + for color_index, _ in enumerate(normalized['data']) + ] + normalized['borderColor'] = [ + self._get_palette(color_index)['border'] + for color_index, _ in enumerate(normalized['data']) + ] + + return normalized + + def _normalize_xy_point( + self, + point: Any, + chart_kind: str, + dataset_index: int, + ) -> Dict[str, Any]: + if not isinstance(point, dict): + raise ValueError( + f'Points for {chart_kind} datasets must be objects with x and y values. ' + f'Dataset {dataset_index + 1} contains an invalid point.' + ) + + normalized_point = { + 'x': self._coerce_number(point.get('x'), 'x'), + 'y': self._coerce_number(point.get('y'), 'y'), + } + if normalized_point['x'] is None or normalized_point['y'] is None: + raise ValueError('Scatter and bubble points require both x and y values.') + + if chart_kind == 'bubble': + normalized_point['r'] = self._coerce_number(point.get('r'), 'r') + if normalized_point['r'] is None: + raise ValueError('Bubble chart points require an r (radius) value.') + + return normalized_point + + def _build_rows_payload( + self, + chart_kind: str, + chart_data: Dict[str, Any], + ) -> Dict[str, Any]: + rows = self._coerce_rows(chart_data) + + if chart_kind in {'pie', 'doughnut', 'polar_area'}: + return self._build_rows_pie_payload(chart_kind, rows, chart_data) + if chart_kind == 'scatter': + return self._build_rows_scatter_payload(chart_kind, rows, chart_data) + if chart_kind == 'bubble': + return self._build_rows_bubble_payload(chart_kind, rows, chart_data) + + x_field = self._sanitize_text(chart_data.get('xField'), 80) + if not x_field: + raise ValueError('rows payloads for this chart type require xField.') + + series_field = self._sanitize_text(chart_data.get('seriesField'), 80) + value_field = self._sanitize_text(chart_data.get('valueField'), 80) + y_fields = chart_data.get('yFields') if isinstance(chart_data.get('yFields'), list) else None + + if series_field and y_fields: + raise ValueError('Use either seriesField/valueField or yFields, not both.') + + if series_field: + if not value_field: + raise ValueError('seriesField payloads also require valueField.') + return self._build_rows_pivot_payload(chart_kind, rows, x_field, series_field, value_field) + + value_fields = [] + if y_fields: + value_fields = [self._sanitize_text(field, 80) for field in y_fields if self._sanitize_text(field, 80)] + elif value_field: + value_fields = [value_field] + else: + sample_row = rows[0] + value_fields = [ + key for key, value in sample_row.items() + if key != x_field and isinstance(value, (int, float, str)) + ] + + value_fields = value_fields[:12] + if not value_fields: + raise ValueError('Unable to determine value fields from rows payload.') + + labels = [self._sanitize_text(row.get(x_field), 80) or f'Item {index + 1}' for index, row in enumerate(rows)] + datasets = [] + for dataset_index, field_name in enumerate(value_fields): + palette = self._get_palette(dataset_index) + values = [ + self._coerce_number(row.get(field_name), field_name) + for row in rows + ] + dataset = { + 'label': field_name.replace('_', ' ').title(), + 'data': values, + 'borderColor': palette['border'], + 'backgroundColor': palette['background'], + 'borderWidth': 2, + } + if chart_kind in {'line', 'area', 'stacked_line'}: + dataset['fill'] = chart_kind == 'area' + dataset['tension'] = 0.35 if chart_kind != 'stacked_line' else 0.0 + if chart_kind == 'radar': + dataset['fill'] = False + if chart_kind in {'bar', 'stacked_bar'}: + dataset['borderSkipped'] = False + datasets.append(dataset) + + data = {'labels': labels, 'datasets': datasets} + summary = self._build_summary(chart_kind, datasets, labels) + table = self._build_table(chart_kind, data) + return {'data': data, 'summary': summary, 'table': table} + + def _build_rows_pivot_payload( + self, + chart_kind: str, + rows: List[Dict[str, Any]], + x_field: str, + series_field: str, + value_field: str, + ) -> Dict[str, Any]: + labels = [] + series_names = [] + lookup = {} + for row in rows: + label = self._sanitize_text(row.get(x_field), 80) + series_name = self._sanitize_text(row.get(series_field), 80) + if not label or not series_name: + raise ValueError('rows payload contains blank xField or seriesField values.') + if label not in labels: + labels.append(label) + if series_name not in series_names: + series_names.append(series_name) + lookup[(label, series_name)] = self._coerce_number(row.get(value_field), value_field) + + datasets = [] + for dataset_index, series_name in enumerate(series_names[:12]): + palette = self._get_palette(dataset_index) + dataset = { + 'label': series_name, + 'data': [lookup.get((label, series_name)) for label in labels], + 'borderColor': palette['border'], + 'backgroundColor': palette['background'], + 'borderWidth': 2, + } + if chart_kind in {'line', 'area', 'stacked_line'}: + dataset['fill'] = chart_kind == 'area' + dataset['tension'] = 0.35 if chart_kind != 'stacked_line' else 0.0 + if chart_kind in {'bar', 'stacked_bar'}: + dataset['borderSkipped'] = False + datasets.append(dataset) + + data = {'labels': labels, 'datasets': datasets} + summary = self._build_summary(chart_kind, datasets, labels) + table = self._build_table(chart_kind, data) + return {'data': data, 'summary': summary, 'table': table} + + def _build_rows_pie_payload( + self, + chart_kind: str, + rows: List[Dict[str, Any]], + chart_data: Dict[str, Any], + ) -> Dict[str, Any]: + label_field = self._sanitize_text(chart_data.get('labelField') or chart_data.get('xField'), 80) + value_field = self._sanitize_text(chart_data.get('valueField') or chart_data.get('yField'), 80) + if not label_field or not value_field: + raise ValueError('Pie and doughnut row payloads require labelField and valueField.') + + labels = [self._sanitize_text(row.get(label_field), 80) or f'Item {index + 1}' for index, row in enumerate(rows)] + values = [self._coerce_number(row.get(value_field), value_field) for row in rows] + dataset = { + 'label': value_field.replace('_', ' ').title(), + 'data': values, + 'backgroundColor': [self._get_palette(index)['background'] for index in range(len(values))], + 'borderColor': [self._get_palette(index)['border'] for index in range(len(values))], + 'borderWidth': 2, + } + data = {'labels': labels, 'datasets': [dataset]} + summary = self._build_summary(chart_kind, [dataset], labels) + table = self._build_table(chart_kind, data) + return {'data': data, 'summary': summary, 'table': table} + + def _build_rows_scatter_payload( + self, + chart_kind: str, + rows: List[Dict[str, Any]], + chart_data: Dict[str, Any], + ) -> Dict[str, Any]: + x_field = self._sanitize_text(chart_data.get('xField'), 80) + y_field = self._sanitize_text(chart_data.get('yField'), 80) + series_field = self._sanitize_text(chart_data.get('seriesField'), 80) + if not x_field or not y_field: + raise ValueError('Scatter row payloads require xField and yField.') + + datasets = [] + if series_field: + grouped_rows = {} + for row in rows: + series_name = self._sanitize_text(row.get(series_field), 80) or 'Series 1' + grouped_rows.setdefault(series_name, []).append(row) + for dataset_index, (series_name, series_rows) in enumerate(grouped_rows.items()): + palette = self._get_palette(dataset_index) + datasets.append({ + 'label': series_name, + 'data': [ + { + 'x': self._coerce_number(row.get(x_field), x_field), + 'y': self._coerce_number(row.get(y_field), y_field), + } + for row in series_rows + ], + 'borderColor': palette['border'], + 'backgroundColor': palette['background'], + 'borderWidth': 2, + }) + else: + palette = self._get_palette(0) + datasets.append({ + 'label': self._sanitize_text(chart_data.get('datasetLabel') or 'Series 1', 80), + 'data': [ + { + 'x': self._coerce_number(row.get(x_field), x_field), + 'y': self._coerce_number(row.get(y_field), y_field), + } + for row in rows + ], + 'borderColor': palette['border'], + 'backgroundColor': palette['background'], + 'borderWidth': 2, + }) + + data = {'datasets': datasets} + summary = self._build_summary(chart_kind, datasets, []) + table = self._build_table(chart_kind, data) + return {'data': data, 'summary': summary, 'table': table} + + def _build_rows_bubble_payload( + self, + chart_kind: str, + rows: List[Dict[str, Any]], + chart_data: Dict[str, Any], + ) -> Dict[str, Any]: + x_field = self._sanitize_text(chart_data.get('xField'), 80) + y_field = self._sanitize_text(chart_data.get('yField'), 80) + size_field = self._sanitize_text(chart_data.get('sizeField'), 80) + series_field = self._sanitize_text(chart_data.get('seriesField'), 80) + if not x_field or not y_field or not size_field: + raise ValueError('Bubble row payloads require xField, yField, and sizeField.') + + datasets = [] + if series_field: + grouped_rows = {} + for row in rows: + series_name = self._sanitize_text(row.get(series_field), 80) or 'Series 1' + grouped_rows.setdefault(series_name, []).append(row) + for dataset_index, (series_name, series_rows) in enumerate(grouped_rows.items()): + palette = self._get_palette(dataset_index) + datasets.append({ + 'label': series_name, + 'data': [ + { + 'x': self._coerce_number(row.get(x_field), x_field), + 'y': self._coerce_number(row.get(y_field), y_field), + 'r': self._coerce_number(row.get(size_field), size_field), + } + for row in series_rows + ], + 'borderColor': palette['border'], + 'backgroundColor': palette['background'], + 'borderWidth': 2, + }) + else: + palette = self._get_palette(0) + datasets.append({ + 'label': self._sanitize_text(chart_data.get('datasetLabel') or 'Series 1', 80), + 'data': [ + { + 'x': self._coerce_number(row.get(x_field), x_field), + 'y': self._coerce_number(row.get(y_field), y_field), + 'r': self._coerce_number(row.get(size_field), size_field), + } + for row in rows + ], + 'borderColor': palette['border'], + 'backgroundColor': palette['background'], + 'borderWidth': 2, + }) + + data = {'datasets': datasets} + summary = self._build_summary(chart_kind, datasets, []) + table = self._build_table(chart_kind, data) + return {'data': data, 'summary': summary, 'table': table} + + def _build_summary( + self, + chart_kind: str, + datasets: List[Dict[str, Any]], + labels: List[str], + ) -> str: + point_count = sum(len(dataset.get('data', [])) for dataset in datasets) + series_count = len(datasets) + label_count = len(labels) + if chart_kind in {'scatter', 'bubble'}: + return f'{chart_kind.replace("_", " ").title()} with {series_count} series and {point_count} plotted points.' + if chart_kind in {'pie', 'doughnut', 'polar_area'}: + return f'{chart_kind.replace("_", " ").title()} with {label_count or point_count} segments.' + return ( + f'{chart_kind.replace("_", " ").title()} with {series_count} series ' + f'across {label_count or point_count} categories.' + ) + + def _build_table(self, chart_kind: str, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: + datasets = data.get('datasets') or [] + if not datasets: + return None + + if chart_kind in {'scatter', 'bubble'}: + columns = ['Series', 'X', 'Y'] + if chart_kind == 'bubble': + columns.append('Radius') + rows = [] + for dataset in datasets: + for point in dataset.get('data', []): + row = [dataset.get('label', 'Series'), point.get('x'), point.get('y')] + if chart_kind == 'bubble': + row.append(point.get('r')) + rows.append(row) + return {'columns': columns, 'rows': rows[:500]} + + labels = data.get('labels') or [] + columns = ['Label'] + [dataset.get('label', 'Series') for dataset in datasets] + rows = [] + for index, label in enumerate(labels): + row = [label] + for dataset in datasets: + dataset_values = dataset.get('data') or [] + row.append(dataset_values[index] if index < len(dataset_values) else None) + rows.append(row) + return {'columns': columns, 'rows': rows[:500]} \ No newline at end of file diff --git a/application/single_app/semantic_kernel_plugins/cosmos_query_plugin.py b/application/single_app/semantic_kernel_plugins/cosmos_query_plugin.py new file mode 100644 index 00000000..d760e28a --- /dev/null +++ b/application/single_app/semantic_kernel_plugins/cosmos_query_plugin.py @@ -0,0 +1,453 @@ +# cosmos_query_plugin.py +""" +Read-only Azure Cosmos DB query plugin for Semantic Kernel. +""" + +import hashlib +import itertools +import logging +import re +from typing import Any, Dict, List, Optional, Sequence, Union + +from azure.cosmos import CosmosClient +from azure.cosmos.exceptions import CosmosHttpResponseError +from azure.identity import DefaultAzureCredential +from semantic_kernel.functions import kernel_function + +from functions_appinsights import log_event +from semantic_kernel_plugins.base_plugin import BasePlugin +from semantic_kernel_plugins.plugin_invocation_logger import plugin_function_logger + + +class ResultWithMetadata: + def __init__(self, data: Any, metadata: Dict[str, Any]): + self.data = data + self.metadata = metadata + + def __str__(self) -> str: + return str(self.data) + + def __repr__(self) -> str: + return f"ResultWithMetadata(data={self.data!r}, metadata={self.metadata!r})" + + +class CosmosQueryPlugin(BasePlugin): + """Read-only Azure Cosmos DB for NoSQL query plugin.""" + + _client_cache: Dict[str, CosmosClient] = {} + + def __init__(self, manifest: Dict[str, Any]): + super().__init__(manifest) + self.manifest = manifest or {} + additional_fields = self.manifest.get("additionalFields", {}) or {} + + self.endpoint = (self.manifest.get("endpoint") or "").strip() + self.database_name = ( + self.manifest.get("database_name") + or additional_fields.get("database_name") + or "" + ).strip() + self.container_name = ( + self.manifest.get("container_name") + or additional_fields.get("container_name") + or "" + ).strip() + self.partition_key_path = ( + self.manifest.get("partition_key_path") + or additional_fields.get("partition_key_path") + or "" + ).strip() + self.field_hints = self._normalize_field_hints(additional_fields.get("field_hints")) + self.max_items = int(additional_fields.get("max_items", 100) or 100) + self.timeout = int(additional_fields.get("timeout", 30) or 30) + self.auth_type = (((self.manifest.get("auth") or {}).get("type") or "identity").strip().lower()) + self.auth_identity = ((self.manifest.get("auth") or {}).get("identity") or "managed_identity").strip() + self.auth_key = ((self.manifest.get("auth") or {}).get("key") or "").strip() + self._metadata = self.manifest.get("metadata", {}) or {} + self._container_client = None + + self._validate_configuration() + + log_event( + "[CosmosQueryPlugin] Initialized plugin", + extra={ + "endpoint": self.endpoint, + "database_name": self.database_name, + "container_name": self.container_name, + "partition_key_path": self.partition_key_path, + "field_hint_count": len(self.field_hints), + "max_items": self.max_items, + "timeout": self.timeout, + "auth_type": self.auth_type, + "auth_identity": self.auth_identity, + "has_auth_key": bool(self.auth_key), + }, + level=logging.INFO, + ) + + @property + def display_name(self) -> str: + return "Cosmos Query" + + @property + def metadata(self) -> Dict[str, Any]: + user_desc = self._metadata.get( + "description", + "Read-only Azure Cosmos DB query plugin for a single configured container.", + ) + api_desc = ( + "This plugin runs read-only Azure Cosmos DB for NoSQL queries against one configured " + "database and container. Use the configured partition key path and field hints when " + "constructing queries. Prefer parameterized queries, and provide the partition_key " + "argument when you know the partition value so the request can stay scoped to a single " + "partition. Mutation statements are blocked." + ) + return { + "name": self._metadata.get("name", "cosmos_query_plugin"), + "type": "cosmos_query", + "description": f"{user_desc}\n\n{api_desc}", + "methods": [ + { + "name": "execute_query", + "description": "Execute a read-only Azure Cosmos DB SQL query against the configured container.", + "parameters": [ + {"name": "query", "type": "str", "description": "Cosmos DB SQL query text.", "required": True}, + {"name": "parameters", "type": "List[Dict[str, Any]] | Dict[str, Any]", "description": "Optional query parameters using @name placeholders.", "required": False}, + {"name": "max_items", "type": "int", "description": "Optional per-call item cap.", "required": False}, + {"name": "partition_key", "type": "str", "description": "Optional partition key value to scope the query to one logical partition.", "required": False}, + ], + "returns": {"type": "ResultWithMetadata", "description": "Structured query results and execution metadata."}, + }, + { + "name": "validate_query", + "description": "Validate that a Cosmos DB query is read-only and shaped for this configured container.", + "parameters": [ + {"name": "query", "type": "str", "description": "Cosmos DB SQL query text.", "required": True}, + ], + "returns": {"type": "ResultWithMetadata", "description": "Validation result with issues and recommendations."}, + }, + { + "name": "query_container", + "description": "Execute a read-only query for a natural-language question and return structured results with the original question context.", + "parameters": [ + {"name": "question", "type": "str", "description": "The user question being answered.", "required": True}, + {"name": "query", "type": "str", "description": "Cosmos DB SQL query text.", "required": True}, + {"name": "parameters", "type": "List[Dict[str, Any]] | Dict[str, Any]", "description": "Optional query parameters using @name placeholders.", "required": False}, + {"name": "max_items", "type": "int", "description": "Optional per-call item cap.", "required": False}, + {"name": "partition_key", "type": "str", "description": "Optional partition key value to scope the query to one logical partition.", "required": False}, + ], + "returns": {"type": "ResultWithMetadata", "description": "Structured query results and execution metadata with question context."}, + }, + ], + } + + def get_functions(self) -> List[str]: + return ["execute_query", "validate_query", "query_container"] + + def build_instruction_context(self) -> str: + hint_lines = [f"- {field_hint}" for field_hint in self.field_hints] or ["- No field hints were configured."] + return ( + f"### Cosmos Container: {self.database_name}.{self.container_name}\n" + f"- Account endpoint: {self.endpoint}\n" + f"- Partition key path: {self.partition_key_path}\n" + f"- Max items per call: {self.max_items}\n" + "- Queries must be read-only SELECT statements.\n" + "- Prefer parameterized queries and pass the partition_key argument when the partition value is known.\n" + "- Configured field hints:\n" + + "\n".join(hint_lines) + ) + + @kernel_function(description="Execute a read-only Azure Cosmos DB SQL query against the configured database and container. Prefer parameterized queries and pass the partition_key argument when you know the partition value so the query can stay within a single logical partition.") + @plugin_function_logger("CosmosQueryPlugin") + def execute_query( + self, + query: str, + parameters: Optional[Union[Dict[str, Any], List[Dict[str, Any]]]] = None, + max_items: Optional[int] = None, + partition_key: Optional[str] = None, + ) -> ResultWithMetadata: + validation_result = self._validate_query(query) + if not validation_result["is_valid"]: + return ResultWithMetadata( + { + "error": "Invalid Cosmos DB query.", + "issues": validation_result["issues"], + "recommendations": validation_result["recommendations"], + "query": query, + }, + self.metadata, + ) + + normalized_parameters = self._normalize_query_parameters(parameters) + effective_max_items = min(max_items or self.max_items, self.max_items) + response_headers: Dict[str, str] = {} + + def capture_response_headers(headers: Dict[str, str], _: Dict[str, Any]) -> None: + response_headers.clear() + response_headers.update(headers) + + try: + container_client = self._get_container_client() + query_kwargs: Dict[str, Any] = { + "query": self._clean_query(query), + "parameters": normalized_parameters, + "max_item_count": effective_max_items, + "populate_query_metrics": True, + "response_hook": capture_response_headers, + } + + if partition_key not in (None, ""): + query_kwargs["partition_key"] = partition_key + query_kwargs["enable_cross_partition_query"] = False + else: + query_kwargs["enable_cross_partition_query"] = True + + iterator = container_client.query_items(**query_kwargs) + items = list(itertools.islice(iterator, effective_max_items + 1)) + is_truncated = len(items) > effective_max_items + if is_truncated: + items = items[:effective_max_items] + + result = { + "database_name": self.database_name, + "container_name": self.container_name, + "partition_key_path": self.partition_key_path, + "partition_key_applied": partition_key not in (None, ""), + "field_hints": self.field_hints, + "query": self._clean_query(query), + "parameters": normalized_parameters, + "items": items, + "item_count": len(items), + "is_truncated": is_truncated, + "request_charge": response_headers.get("x-ms-request-charge"), + "query_metrics": response_headers.get("x-ms-documentdb-query-metrics"), + "activity_id": response_headers.get("x-ms-activity-id"), + } + + log_event( + "[CosmosQueryPlugin] Query executed successfully", + extra={ + "database_name": self.database_name, + "container_name": self.container_name, + "partition_key_applied": partition_key not in (None, ""), + "item_count": len(items), + "is_truncated": is_truncated, + "request_charge": response_headers.get("x-ms-request-charge"), + }, + level=logging.INFO, + ) + return ResultWithMetadata(result, self.metadata) + except CosmosHttpResponseError as exc: + log_event( + f"[CosmosQueryPlugin] Cosmos query failed: {exc}", + extra={ + "database_name": self.database_name, + "container_name": self.container_name, + "status_code": getattr(exc, "status_code", None), + "activity_id": response_headers.get("x-ms-activity-id"), + }, + level=logging.ERROR, + exceptionTraceback=True, + ) + return ResultWithMetadata( + { + "error": str(exc), + "query": query, + "parameters": normalized_parameters, + "items": [], + "item_count": 0, + }, + self.metadata, + ) + except Exception as exc: + log_event( + f"[CosmosQueryPlugin] Unexpected query failure: {exc}", + extra={ + "database_name": self.database_name, + "container_name": self.container_name, + }, + level=logging.ERROR, + exceptionTraceback=True, + ) + return ResultWithMetadata( + { + "error": str(exc), + "query": query, + "parameters": normalized_parameters, + "items": [], + "item_count": 0, + }, + self.metadata, + ) + + @kernel_function(description="Validate that an Azure Cosmos DB query is read-only and suitable for the configured container before executing it.") + @plugin_function_logger("CosmosQueryPlugin") + def validate_query(self, query: str) -> ResultWithMetadata: + return ResultWithMetadata(self._validate_query(query), self.metadata) + + @kernel_function(description="Execute a read-only Azure Cosmos DB SQL query for a natural-language question and return the matching documents with the original question for context.") + @plugin_function_logger("CosmosQueryPlugin") + def query_container( + self, + question: str, + query: str, + parameters: Optional[Union[Dict[str, Any], List[Dict[str, Any]]]] = None, + max_items: Optional[int] = None, + partition_key: Optional[str] = None, + ) -> ResultWithMetadata: + query_result = self.execute_query( + query=query, + parameters=parameters, + max_items=max_items, + partition_key=partition_key, + ) + payload = dict(query_result.data) if isinstance(query_result.data, dict) else {"result": query_result.data} + payload["question"] = question + return ResultWithMetadata(payload, query_result.metadata) + + def _get_container_client(self): + if self._container_client is None: + client_cache_key = self._get_client_cache_key() + client = self._client_cache.get(client_cache_key) + if client is None: + client = CosmosClient( + self.endpoint, + credential=self._get_client_credential(), + timeout=self.timeout, + connection_timeout=self.timeout, + ) + self._client_cache[client_cache_key] = client + + database_client = client.get_database_client(self.database_name) + self._container_client = database_client.get_container_client(self.container_name) + return self._container_client + + def _validate_configuration(self) -> None: + missing_fields = [] + if not self.endpoint: + missing_fields.append("endpoint") + if not self.database_name: + missing_fields.append("database_name") + if not self.container_name: + missing_fields.append("container_name") + if not self.partition_key_path: + missing_fields.append("partition_key_path") + if self.auth_type == "identity": + pass + elif self.auth_type == "key": + if not self.auth_key: + raise ValueError("CosmosQueryPlugin requires auth.key when auth.type is 'key'.") + else: + raise ValueError("CosmosQueryPlugin only supports auth.type values 'identity' and 'key'.") + if missing_fields: + raise ValueError( + "CosmosQueryPlugin requires the following fields: " + ", ".join(missing_fields) + ) + if self.max_items < 1: + raise ValueError("CosmosQueryPlugin max_items must be at least 1.") + if self.timeout < 1: + raise ValueError("CosmosQueryPlugin timeout must be at least 1 second.") + + def _get_client_credential(self): + if self.auth_type == "key": + return self.auth_key + return DefaultAzureCredential() + + def _get_client_cache_key(self) -> str: + if self.auth_type == "key": + key_hash = hashlib.sha256(self.auth_key.encode("utf-8")).hexdigest()[:16] + return f"{self.endpoint}|{self.timeout}|key|{key_hash}" + return f"{self.endpoint}|{self.timeout}|identity|{self.auth_identity or 'managed_identity'}" + + def _normalize_field_hints(self, field_hints: Optional[Union[str, Sequence[str]]]) -> List[str]: + if isinstance(field_hints, str): + values = re.split(r"[,\n]", field_hints) + return [value.strip() for value in values if value.strip()] + if isinstance(field_hints, Sequence): + normalized = [] + for value in field_hints: + if isinstance(value, str) and value.strip(): + normalized.append(value.strip()) + return normalized + return [] + + def _normalize_query_parameters( + self, + parameters: Optional[Union[Dict[str, Any], List[Dict[str, Any]]]], + ) -> List[Dict[str, Any]]: + if parameters is None: + return [] + if isinstance(parameters, dict): + normalized = [] + for name, value in parameters.items(): + placeholder_name = name if str(name).startswith("@") else f"@{name}" + normalized.append({"name": placeholder_name, "value": value}) + return normalized + normalized_list: List[Dict[str, Any]] = [] + for parameter in parameters: + if not isinstance(parameter, dict): + raise ValueError("Cosmos query parameters must be dictionaries with 'name' and 'value' keys.") + name = parameter.get("name") + if not name: + raise ValueError("Each Cosmos query parameter requires a 'name'.") + normalized_list.append( + { + "name": name if str(name).startswith("@") else f"@{name}", + "value": parameter.get("value"), + } + ) + return normalized_list + + def _clean_query(self, query: str) -> str: + return (query or "").strip() + + def _validate_query(self, query: str) -> Dict[str, Any]: + cleaned_query = self._clean_query(query) + issues: List[str] = [] + recommendations: List[str] = [] + + if not cleaned_query: + issues.append("A Cosmos DB query is required.") + if cleaned_query and not re.match(r"^SELECT\b", cleaned_query, flags=re.IGNORECASE): + issues.append("Only read-only SELECT queries are allowed.") + if ";" in cleaned_query: + issues.append("Multiple statements are not allowed.") + + blocked_keywords = [ + "INSERT", + "UPDATE", + "DELETE", + "UPSERT", + "REPLACE", + "CREATE", + "ALTER", + "DROP", + "TRUNCATE", + "MERGE", + "EXEC", + "CALL", + "GRANT", + "REVOKE", + ] + blocked_keyword_pattern = r"\b(" + "|".join(blocked_keywords) + r")\b" + if cleaned_query and re.search(blocked_keyword_pattern, cleaned_query, flags=re.IGNORECASE): + issues.append("The query contains mutation or administrative keywords that are not allowed.") + + partition_key_field = self.partition_key_path.lstrip("/") + if partition_key_field and partition_key_field not in cleaned_query: + recommendations.append( + f"Consider filtering on the configured partition key field '{partition_key_field}' or passing the partition_key argument to reduce cross-partition cost." + ) + if self.field_hints: + recommendations.append( + "Use the configured field hints when selecting and filtering document properties: " + + ", ".join(self.field_hints) + ) + + return { + "is_valid": len(issues) == 0, + "issues": issues, + "recommendations": recommendations, + "partition_key_path": self.partition_key_path, + "field_hints": self.field_hints, + } \ No newline at end of file diff --git a/application/single_app/semantic_kernel_plugins/document_search_plugin.py b/application/single_app/semantic_kernel_plugins/document_search_plugin.py new file mode 100644 index 00000000..639671ad --- /dev/null +++ b/application/single_app/semantic_kernel_plugins/document_search_plugin.py @@ -0,0 +1,225 @@ +# document_search_plugin.py + +from typing import Annotated, Any, Dict + +from semantic_kernel.functions import kernel_function + +from functions_authentication import get_current_user_id +from functions_search import SEARCH_DEFAULT_TOP_N, SEARCH_MAX_TOP_N, normalize_search_scope, normalize_search_top_n +from functions_search_service import ( + SUMMARY_DEFAULT_FINAL_TARGET, + SUMMARY_DEFAULT_WINDOW_SUMMARY_TARGET, + SUMMARY_DEFAULT_WINDOW_UNIT, + get_document_chunks_payload, + search_documents as run_document_search, + summarize_document_content, +) +from semantic_kernel_plugins.base_plugin import BasePlugin +from semantic_kernel_plugins.plugin_invocation_logger import plugin_function_logger + + +class DocumentSearchPlugin(BasePlugin): + def __init__(self, manifest: Dict[str, Any] = None): + super().__init__(manifest) + + @property + def display_name(self) -> str: + return 'Document Search' + + @property + def metadata(self) -> Dict[str, Any]: + return { + 'name': 'document_search_plugin', + 'type': 'search', + 'description': ( + 'Hybrid document search, exhaustive chunk retrieval, and hierarchical document summarization ' + 'for personal, group, and public workspaces.' + ), + 'methods': [ + { + 'name': 'search_documents', + 'description': 'Run relevance-ranked hybrid search and return chunk-level results with document ids.', + 'parameters': [ + {'name': 'query', 'type': 'str', 'description': 'Natural-language search query.', 'required': True}, + {'name': 'doc_scope', 'type': 'str', 'description': 'all, personal, group, or public.', 'required': False}, + {'name': 'top_n', 'type': 'int', 'description': 'Maximum number of results to return.', 'required': False}, + ], + 'returns': {'type': 'dict', 'description': 'Search results with scope and document metadata.'}, + }, + { + 'name': 'retrieve_document_chunks', + 'description': 'Retrieve ordered chunks for one accessible document, optionally in windows.', + 'parameters': [ + {'name': 'document_id', 'type': 'str', 'description': 'Document id to retrieve.', 'required': True}, + {'name': 'doc_scope', 'type': 'str', 'description': 'all, personal, group, or public.', 'required': False}, + {'name': 'window_number', 'type': 'int', 'description': 'Optional 1-based window number to return.', 'required': False}, + ], + 'returns': {'type': 'dict', 'description': 'Ordered chunks and window metadata.'}, + }, + { + 'name': 'summarize_document', + 'description': 'Summarize a document hierarchically across ordered chunk windows.', + 'parameters': [ + {'name': 'document_id', 'type': 'str', 'description': 'Document id to summarize.', 'required': True}, + {'name': 'focus_instructions', 'type': 'str', 'description': 'Optional focus areas to emphasize.', 'required': False}, + {'name': 'final_target_length', 'type': 'str', 'description': 'Desired final summary length.', 'required': False}, + ], + 'returns': {'type': 'dict', 'description': 'Summary text plus stage and window metadata.'}, + }, + ], + } + + def _get_user_id(self): + user_id = get_current_user_id() + if not user_id: + raise RuntimeError('User context is unavailable for document search') + return user_id + + def _get_additional_fields(self) -> Dict[str, Any]: + if isinstance(self.manifest, dict) and isinstance(self.manifest.get('additionalFields'), dict): + return self.manifest['additionalFields'] + return {} + + def _coerce_positive_int(self, value): + try: + coerced_value = int(value) + except (TypeError, ValueError): + return None + return coerced_value if coerced_value > 0 else None + + def _resolve_doc_scope(self, requested_scope: str) -> str: + if str(requested_scope or '').strip(): + return normalize_search_scope(requested_scope) + + return normalize_search_scope(self._get_additional_fields().get('default_doc_scope', 'all')) + + def _resolve_top_n(self, requested_top_n: int) -> int: + top_n_value = self._coerce_positive_int(requested_top_n) + if top_n_value: + return normalize_search_top_n(top_n_value, SEARCH_DEFAULT_TOP_N, SEARCH_MAX_TOP_N) + + manifest_top_n = self._coerce_positive_int(self._get_additional_fields().get('default_top_n')) + if manifest_top_n: + return normalize_search_top_n(manifest_top_n, SEARCH_DEFAULT_TOP_N, SEARCH_MAX_TOP_N) + + return SEARCH_DEFAULT_TOP_N + + def _resolve_window_unit(self, requested_window_unit: str) -> str: + if str(requested_window_unit or '').strip(): + return requested_window_unit + + return self._get_additional_fields().get('default_window_unit', SUMMARY_DEFAULT_WINDOW_UNIT) + + def _resolve_optional_window_value(self, requested_value: int, manifest_key: str): + explicit_value = self._coerce_positive_int(requested_value) + if explicit_value: + return explicit_value + + return self._coerce_positive_int(self._get_additional_fields().get(manifest_key)) + + def _resolve_focus_instructions(self, requested_focus_instructions: str) -> str: + if str(requested_focus_instructions or '').strip(): + return requested_focus_instructions + + return self._get_additional_fields().get('default_focus_instructions', '') + + def _resolve_target_length(self, requested_value: str, manifest_key: str, fallback_value: str) -> str: + if str(requested_value or '').strip(): + return requested_value + + return self._get_additional_fields().get(manifest_key, fallback_value) + + @plugin_function_logger('DocumentSearchPlugin') + @kernel_function( + name='search_documents', + description='Run hybrid document search over accessible workspaces and return chunk-level results with document ids.', + ) + def search_documents( + self, + query: Annotated[str, 'Natural-language query to run against accessible documents.'], + doc_scope: Annotated[str, 'all, personal, group, or public.'] = '', + top_n: Annotated[int, 'Maximum number of chunk results to return.'] = 0, + document_ids: Annotated[str, 'Optional comma-separated document ids to restrict the search.'] = '', + tags_filter: Annotated[str, 'Optional comma-separated document tags that must all match.'] = '', + active_group_ids: Annotated[str, 'Optional comma-separated group ids when searching group content.'] = '', + active_public_workspace_id: Annotated[str, 'Optional public workspace id when searching public content.'] = '', + ) -> Annotated[dict, 'Search results and request metadata.']: + try: + return run_document_search( + query=query, + user_id=self._get_user_id(), + top_n=self._resolve_top_n(top_n), + doc_scope=self._resolve_doc_scope(doc_scope), + document_ids=document_ids, + tags_filter=tags_filter, + active_group_ids=active_group_ids, + active_public_workspace_id=active_public_workspace_id, + ) + except Exception as e: + return {'error': str(e)} + + @plugin_function_logger('DocumentSearchPlugin') + @kernel_function( + name='retrieve_document_chunks', + description='Retrieve ordered chunks for one accessible document, optionally selecting one window of chunks.', + ) + def retrieve_document_chunks( + self, + document_id: Annotated[str, 'Document id to retrieve chunk content from.'], + doc_scope: Annotated[str, 'all, personal, group, or public.'] = '', + window_unit: Annotated[str, 'pages or chunks for chunk windowing.'] = '', + window_size: Annotated[int, 'Optional explicit number of pages or chunks per window.'] = 0, + window_percent: Annotated[int, 'Optional percentage of the document to include per window.'] = 0, + window_number: Annotated[int, 'Optional 1-based window number to return instead of the full document.'] = 0, + active_group_ids: Annotated[str, 'Optional comma-separated group ids when resolving group content.'] = '', + active_public_workspace_id: Annotated[str, 'Optional public workspace id when resolving public content.'] = '', + ) -> Annotated[dict, 'Ordered chunks and window metadata for one document.']: + try: + return get_document_chunks_payload( + document_id=document_id, + user_id=self._get_user_id(), + doc_scope=self._resolve_doc_scope(doc_scope), + active_group_ids=active_group_ids, + active_public_workspace_id=active_public_workspace_id, + window_unit=self._resolve_window_unit(window_unit), + window_size=self._resolve_optional_window_value(window_size, 'default_window_size'), + window_percent=self._resolve_optional_window_value(window_percent, 'default_window_percent'), + window_number=window_number if int(window_number or 0) > 0 else None, + ) + except Exception as e: + return {'error': str(e)} + + @plugin_function_logger('DocumentSearchPlugin') + @kernel_function( + name='summarize_document', + description='Summarize one accessible document hierarchically across ordered chunk windows, with optional focus guidance.', + ) + def summarize_document( + self, + document_id: Annotated[str, 'Document id to summarize.'], + doc_scope: Annotated[str, 'all, personal, group, or public.'] = '', + focus_instructions: Annotated[str, 'Optional focus areas such as risks, deadlines, or architectural decisions.'] = '', + final_target_length: Annotated[str, 'Desired final summary length, for example 2 pages or 500 words.'] = '', + window_target_length: Annotated[str, 'Target length for each first-pass window summary.'] = '', + window_unit: Annotated[str, 'pages or chunks for chunk windowing.'] = '', + window_size: Annotated[int, 'Optional explicit number of pages or chunks per window.'] = 0, + window_percent: Annotated[int, 'Optional percentage of the document to include per first-pass window.'] = 0, + active_group_ids: Annotated[str, 'Optional comma-separated group ids when resolving group content.'] = '', + active_public_workspace_id: Annotated[str, 'Optional public workspace id when resolving public content.'] = '', + ) -> Annotated[dict, 'Final summary text plus stage and window metadata.']: + try: + return summarize_document_content( + document_id=document_id, + user_id=self._get_user_id(), + doc_scope=self._resolve_doc_scope(doc_scope), + active_group_ids=active_group_ids, + active_public_workspace_id=active_public_workspace_id, + focus_instructions=self._resolve_focus_instructions(focus_instructions), + final_target_length=self._resolve_target_length(final_target_length, 'default_final_target_length', SUMMARY_DEFAULT_FINAL_TARGET), + window_target_length=self._resolve_target_length(window_target_length, 'default_window_target_length', SUMMARY_DEFAULT_WINDOW_SUMMARY_TARGET), + window_unit=self._resolve_window_unit(window_unit), + window_size=self._resolve_optional_window_value(window_size, 'default_window_size'), + window_percent=self._resolve_optional_window_value(window_percent, 'default_window_percent'), + ) + except Exception as e: + return {'error': str(e)} \ No newline at end of file diff --git a/application/single_app/semantic_kernel_plugins/logged_plugin_loader.py b/application/single_app/semantic_kernel_plugins/logged_plugin_loader.py index 928f7851..0d79e107 100644 --- a/application/single_app/semantic_kernel_plugins/logged_plugin_loader.py +++ b/application/single_app/semantic_kernel_plugins/logged_plugin_loader.py @@ -321,6 +321,15 @@ def _create_logged_method(self, original_method, plugin_name: str, function_name def _register_plugin_with_kernel(self, plugin_instance, plugin_name: str): """Register the plugin with the Semantic Kernel.""" try: + if hasattr(plugin_instance, 'get_kernel_plugin'): + kernel_plugin = plugin_instance.get_kernel_plugin(plugin_name) + if hasattr(self.kernel, 'add_plugin'): + self.kernel.add_plugin(kernel_plugin) + else: + self.kernel.plugins.add(kernel_plugin) + self.logger.info(f"Registered plugin {plugin_name} with kernel") + return + # Try different registration methods based on SK version if hasattr(self.kernel, 'add_plugin'): # Newer SK versions diff --git a/application/single_app/semantic_kernel_plugins/msgraph_plugin.py b/application/single_app/semantic_kernel_plugins/msgraph_plugin.py index e1b2023e..2352a4c0 100644 --- a/application/single_app/semantic_kernel_plugins/msgraph_plugin.py +++ b/application/single_app/semantic_kernel_plugins/msgraph_plugin.py @@ -1,20 +1,30 @@ # msgraph_plugin.py +import re from typing import Any, Dict, List, Optional, Tuple from urllib.parse import quote import requests from requests import RequestException -from functions_authentication import get_valid_access_token_for_plugins +from functions_authentication import get_current_user_info, get_valid_access_token_for_plugins from functions_debug import debug_print from semantic_kernel.functions import kernel_function +from semantic_kernel.functions.kernel_plugin import KernelPlugin +from functions_group import assert_group_role, find_group_by_id, require_active_group +from functions_msgraph_operations import ( + MSGRAPH_CAPABILITY_DEFINITIONS, + MSGRAPH_DEFAULT_ENDPOINT, + MSGRAPH_PLUGIN_TYPE, + get_msgraph_enabled_function_names, + normalize_msgraph_capabilities, +) from semantic_kernel_plugins.base_plugin import BasePlugin from semantic_kernel_plugins.plugin_invocation_logger import plugin_function_logger class MSGraphPlugin(BasePlugin): - DEFAULT_ENDPOINT = "https://graph.microsoft.com" + DEFAULT_ENDPOINT = MSGRAPH_DEFAULT_ENDPOINT DEFAULT_TIMEOUT_SECONDS = 30 MAX_ITEMS_PER_RESULT = 25 MAX_PAGES_PER_REQUEST = 5 @@ -23,9 +33,19 @@ def __init__(self, manifest: Optional[Dict[str, Any]] = None): super().__init__(manifest) self.manifest = manifest or {} self._metadata = self.manifest.get("metadata", {}) - self._endpoint = self.manifest.get("endpoint", self.DEFAULT_ENDPOINT).rstrip("/") + self._endpoint = str(self.manifest.get("endpoint") or self.DEFAULT_ENDPOINT).rstrip("/") scope_overrides = self.manifest.get("scopes") or self._metadata.get("scopes") or {} self._scope_overrides = scope_overrides if isinstance(scope_overrides, dict) else {} + self._capabilities = normalize_msgraph_capabilities( + self.manifest.get("msgraph_capabilities") + ) + self._enabled_function_names = set( + self.manifest.get("enabled_functions") + or get_msgraph_enabled_function_names(self._capabilities) + ) + self._default_group_id = str( + self.manifest.get("group_id") or self.manifest.get("default_group_id") or "" + ).strip() @property def display_name(self) -> str: @@ -33,142 +53,195 @@ def display_name(self) -> str: @property def metadata(self) -> Dict[str, Any]: + enabled_methods = set(self.get_functions()) + method_specs = { + "get_my_profile": { + "name": "get_my_profile", + "description": "Get the signed-in user's profile details.", + "parameters": [ + { + "name": "select_fields", + "type": "str", + "description": "Optional comma-separated Graph fields to include.", + "required": False, + } + ], + "returns": {"type": "dict", "description": "User profile information from Microsoft Graph."}, + }, + "get_my_timezone": { + "name": "get_my_timezone", + "description": "Get the signed-in user's Microsoft 365 mailbox time zone and related formatting settings. Use this before answering timezone-sensitive questions.", + "parameters": [], + "returns": {"type": "dict", "description": "Mailbox timezone, date format, and time format settings from Microsoft Graph."}, + }, + "get_my_events": { + "name": "get_my_events", + "description": "Get upcoming calendar events for the signed-in user.", + "parameters": [ + {"name": "top", "type": "int", "description": "Maximum number of events to return.", "required": False}, + { + "name": "start_datetime", + "type": "str", + "description": "Optional ISO datetime. If provided with end_datetime, uses calendarView.", + "required": False, + }, + { + "name": "end_datetime", + "type": "str", + "description": "Optional ISO datetime. If provided with start_datetime, uses calendarView.", + "required": False, + }, + { + "name": "select_fields", + "type": "str", + "description": "Optional comma-separated Graph fields to include.", + "required": False, + }, + ], + "returns": {"type": "dict", "description": "Calendar event results from Microsoft Graph."}, + }, + "create_calendar_invite": { + "name": "create_calendar_invite", + "description": "Create a calendar invite for the signed-in user, optionally add current group members as attendees, and turn it into a Microsoft Teams meeting.", + "parameters": [ + {"name": "subject", "type": "str", "description": "Subject for the calendar invite.", "required": True}, + {"name": "start_datetime", "type": "str", "description": "Event start as an ISO 8601 datetime string.", "required": True}, + {"name": "end_datetime", "type": "str", "description": "Event end as an ISO 8601 datetime string.", "required": True}, + {"name": "body_content", "type": "str", "description": "Optional plain-text body content for the invite.", "required": False}, + {"name": "location", "type": "str", "description": "Optional location display name.", "required": False}, + {"name": "attendee_emails", "type": "str", "description": "Optional attendee emails separated by commas, semicolons, or new lines.", "required": False}, + {"name": "include_group_members", "type": "bool", "description": "If true, include current group members as required attendees.", "required": False}, + {"name": "group_id", "type": "str", "description": "Optional group id to use when include_group_members is true. Defaults to the action or active group context.", "required": False}, + {"name": "make_teams_meeting", "type": "bool", "description": "If true, create the invite as a Microsoft Teams meeting.", "required": False}, + {"name": "timezone", "type": "str", "description": "Optional Outlook time zone name for the event. Defaults to the user's mailbox time zone or UTC.", "required": False}, + {"name": "allow_new_time_proposals", "type": "bool", "description": "If true, attendees can propose a new time.", "required": False}, + ], + "returns": {"type": "dict", "description": "Created event result from Microsoft Graph."}, + }, + "get_my_messages": { + "name": "get_my_messages", + "description": "Get recent mail messages for the signed-in user.", + "parameters": [ + {"name": "top", "type": "int", "description": "Maximum number of messages to return.", "required": False}, + {"name": "folder", "type": "str", "description": "Optional mail folder name, such as inbox.", "required": False}, + {"name": "unread_only", "type": "bool", "description": "If true, only unread messages are returned.", "required": False}, + { + "name": "select_fields", + "type": "str", + "description": "Optional comma-separated Graph fields to include.", + "required": False, + }, + ], + "returns": {"type": "dict", "description": "Mail message results from Microsoft Graph."}, + }, + "mark_message_as_read": { + "name": "mark_message_as_read", + "description": "Mark a mail message as read or unread for the signed-in user. Requires Mail.ReadWrite delegated permission.", + "parameters": [ + { + "name": "message_id", + "type": "str", + "description": "Microsoft Graph message id to update.", + "required": True, + }, + { + "name": "is_read", + "type": "bool", + "description": "If true, marks the message as read. If false, marks it as unread.", + "required": False, + }, + ], + "returns": {"type": "dict", "description": "Updated mail message result from Microsoft Graph."}, + }, + "search_users": { + "name": "search_users", + "description": "Search directory users by name or email prefix.", + "parameters": [ + {"name": "query", "type": "str", "description": "Search text for display name or email.", "required": True}, + {"name": "top", "type": "int", "description": "Maximum number of users to return.", "required": False}, + { + "name": "select_fields", + "type": "str", + "description": "Optional comma-separated Graph fields to include.", + "required": False, + }, + ], + "returns": {"type": "dict", "description": "Matching users from Microsoft Graph."}, + }, + "get_user_by_email": { + "name": "get_user_by_email", + "description": "Get a directory user by exact email address or UPN.", + "parameters": [ + {"name": "email", "type": "str", "description": "Exact email address or user principal name.", "required": True}, + { + "name": "select_fields", + "type": "str", + "description": "Optional comma-separated Graph fields to include.", + "required": False, + }, + ], + "returns": {"type": "dict", "description": "User match information from Microsoft Graph."}, + }, + "list_drive_items": { + "name": "list_drive_items", + "description": "List OneDrive items from the root or a child path for the signed-in user.", + "parameters": [ + {"name": "path", "type": "str", "description": "Optional path below the drive root.", "required": False}, + {"name": "top", "type": "int", "description": "Maximum number of items to return.", "required": False}, + { + "name": "select_fields", + "type": "str", + "description": "Optional comma-separated Graph fields to include.", + "required": False, + }, + ], + "returns": {"type": "dict", "description": "Drive item results from Microsoft Graph."}, + }, + "get_my_security_alerts": { + "name": "get_my_security_alerts", + "description": "Get recent security alerts for the signed-in user. Requires elevated Graph permissions.", + "parameters": [ + {"name": "top", "type": "int", "description": "Maximum number of alerts to return.", "required": False} + ], + "returns": {"type": "dict", "description": "Security alert results from Microsoft Graph."}, + }, + } + return { "name": self.manifest.get("name", "msgraph_plugin"), - "type": "msgraph", + "type": MSGRAPH_PLUGIN_TYPE, "description": ( "Plugin for interacting with Microsoft Graph API. Supports user profile, " - "calendar, mailbox timezone settings, mail, directory, drive, and security alert operations." + "calendar reads and invite creation, mailbox timezone settings, mail, directory, " + "drive, and security alert operations." ), "methods": [ - { - "name": "get_my_profile", - "description": "Get the signed-in user's profile details.", - "parameters": [ - { - "name": "select_fields", - "type": "str", - "description": "Optional comma-separated Graph fields to include.", - "required": False, - } - ], - "returns": {"type": "dict", "description": "User profile information from Microsoft Graph."}, - }, - { - "name": "get_my_timezone", - "description": "Get the signed-in user's Microsoft 365 mailbox time zone and related formatting settings. Use this before answering timezone-sensitive questions.", - "parameters": [], - "returns": {"type": "dict", "description": "Mailbox timezone, date format, and time format settings from Microsoft Graph."}, - }, - { - "name": "get_my_events", - "description": "Get upcoming calendar events for the signed-in user.", - "parameters": [ - {"name": "top", "type": "int", "description": "Maximum number of events to return.", "required": False}, - { - "name": "start_datetime", - "type": "str", - "description": "Optional ISO datetime. If provided with end_datetime, uses calendarView.", - "required": False, - }, - { - "name": "end_datetime", - "type": "str", - "description": "Optional ISO datetime. If provided with start_datetime, uses calendarView.", - "required": False, - }, - { - "name": "select_fields", - "type": "str", - "description": "Optional comma-separated Graph fields to include.", - "required": False, - }, - ], - "returns": {"type": "dict", "description": "Calendar event results from Microsoft Graph."}, - }, - { - "name": "get_my_messages", - "description": "Get recent mail messages for the signed-in user.", - "parameters": [ - {"name": "top", "type": "int", "description": "Maximum number of messages to return.", "required": False}, - {"name": "folder", "type": "str", "description": "Optional mail folder name, such as inbox.", "required": False}, - {"name": "unread_only", "type": "bool", "description": "If true, only unread messages are returned.", "required": False}, - { - "name": "select_fields", - "type": "str", - "description": "Optional comma-separated Graph fields to include.", - "required": False, - }, - ], - "returns": {"type": "dict", "description": "Mail message results from Microsoft Graph."}, - }, - { - "name": "search_users", - "description": "Search directory users by name or email prefix.", - "parameters": [ - {"name": "query", "type": "str", "description": "Search text for display name or email.", "required": True}, - {"name": "top", "type": "int", "description": "Maximum number of users to return.", "required": False}, - { - "name": "select_fields", - "type": "str", - "description": "Optional comma-separated Graph fields to include.", - "required": False, - }, - ], - "returns": {"type": "dict", "description": "Matching users from Microsoft Graph."}, - }, - { - "name": "get_user_by_email", - "description": "Get a directory user by exact email address or UPN.", - "parameters": [ - {"name": "email", "type": "str", "description": "Exact email address or user principal name.", "required": True}, - { - "name": "select_fields", - "type": "str", - "description": "Optional comma-separated Graph fields to include.", - "required": False, - }, - ], - "returns": {"type": "dict", "description": "User match information from Microsoft Graph."}, - }, - { - "name": "list_drive_items", - "description": "List OneDrive items from the root or a child path for the signed-in user.", - "parameters": [ - {"name": "path", "type": "str", "description": "Optional path below the drive root.", "required": False}, - {"name": "top", "type": "int", "description": "Maximum number of items to return.", "required": False}, - { - "name": "select_fields", - "type": "str", - "description": "Optional comma-separated Graph fields to include.", - "required": False, - }, - ], - "returns": {"type": "dict", "description": "Drive item results from Microsoft Graph."}, - }, - { - "name": "get_my_security_alerts", - "description": "Get recent security alerts for the signed-in user. Requires elevated Graph permissions.", - "parameters": [ - {"name": "top", "type": "int", "description": "Maximum number of alerts to return.", "required": False} - ], - "returns": {"type": "dict", "description": "Security alert results from Microsoft Graph."}, - }, + method_specs[definition["function_name"]] + for definition in MSGRAPH_CAPABILITY_DEFINITIONS + if definition["function_name"] in enabled_methods ], } def get_functions(self) -> List[str]: return [ - "get_my_profile", - "get_my_timezone", - "get_my_events", - "get_my_messages", - "search_users", - "get_user_by_email", - "list_drive_items", - "get_my_security_alerts", + definition["function_name"] + for definition in MSGRAPH_CAPABILITY_DEFINITIONS + if definition["function_name"] in self._enabled_function_names ] + def get_kernel_plugin(self, plugin_name: str = "msgraph") -> KernelPlugin: + functions = {} + for function_name in self.get_functions(): + bound_method = getattr(self, function_name, None) + if callable(bound_method) and hasattr(bound_method, "__kernel_function__"): + functions[function_name] = bound_method + + return KernelPlugin.from_object( + plugin_name, + functions, + description=self.metadata.get("description"), + ) + def _get_scopes(self, operation_name: str, default_scopes: List[str]) -> List[str]: configured_scopes = self._scope_overrides.get(operation_name) if isinstance(configured_scopes, str) and configured_scopes.strip(): @@ -193,6 +266,213 @@ def _get_token(self, operation_name: str, default_scopes: List[str]) -> Tuple[Op error_payload.setdefault("scopes", scopes) return None, scopes, error_payload + def _invalid_parameter_error(self, operation_name: str, message: str) -> Dict[str, Any]: + return { + "error": "invalid_parameters", + "message": message, + "operation": operation_name, + } + + def _normalize_boolean_parameter( + self, + value: Any, + parameter_name: str, + operation_name: str, + ) -> Tuple[Optional[bool], Optional[Dict[str, Any]]]: + if isinstance(value, bool): + return value, None + + if isinstance(value, (int, float)) and value in (0, 1): + return bool(value), None + + if isinstance(value, str): + lowered_value = value.strip().lower() + if lowered_value in {"true", "1", "yes"}: + return True, None + if lowered_value in {"false", "0", "no"}: + return False, None + + return None, self._invalid_parameter_error( + operation_name, + f"{parameter_name} must be a boolean value.", + ) + + def _resolve_event_timezone(self, timezone_value: str = "") -> str: + normalized_timezone = str(timezone_value or "").strip() + if normalized_timezone: + return normalized_timezone + + mailbox_settings = self._perform_graph_request( + "resolve_calendar_timezone", + "GET", + "/v1.0/me/mailboxSettings", + ["MailboxSettings.Read"], + ) + if isinstance(mailbox_settings, dict) and not mailbox_settings.get("error"): + mailbox_timezone = str(mailbox_settings.get("timeZone") or "").strip() + if mailbox_timezone: + return mailbox_timezone + + return "UTC" + + def _add_attendee_candidate( + self, + attendees_by_email: Dict[str, Dict[str, Any]], + invalid_entries: List[str], + email: str, + name: str = "", + attendee_type: str = "required", + current_user_email: str = "", + strict: bool = True, + ) -> bool: + normalized_email = str(email or "").strip() + if not normalized_email: + return False + + lowered_email = normalized_email.lower() + if current_user_email and lowered_email == current_user_email.lower(): + return False + + if "@" not in normalized_email: + if strict: + invalid_entries.append(normalized_email) + return False + + normalized_type = str(attendee_type or "required").strip().lower() + if normalized_type not in {"required", "optional", "resource"}: + normalized_type = "required" + + if lowered_email in attendees_by_email: + return False + + attendees_by_email[lowered_email] = { + "emailAddress": { + "address": normalized_email, + "name": str(name or normalized_email).strip() or normalized_email, + }, + "type": normalized_type, + } + return True + + def _collect_attendees( + self, + attendees_by_email: Dict[str, Dict[str, Any]], + raw_attendees: Any, + invalid_entries: List[str], + current_user_email: str = "", + strict: bool = True, + ) -> None: + if raw_attendees is None: + return + + if isinstance(raw_attendees, str): + for raw_item in re.split(r"[,;\n]+", raw_attendees): + normalized_item = raw_item.strip() + if normalized_item: + self._add_attendee_candidate( + attendees_by_email, + invalid_entries, + normalized_item, + current_user_email=current_user_email, + strict=strict, + ) + return + + if isinstance(raw_attendees, dict): + email_address = raw_attendees.get("emailAddress") + if isinstance(email_address, dict): + email = email_address.get("address") + name = email_address.get("name") or raw_attendees.get("displayName") or raw_attendees.get("name") + attendee_type = raw_attendees.get("type", "required") + else: + email = ( + raw_attendees.get("email") + or raw_attendees.get("address") + or raw_attendees.get("mail") + or raw_attendees.get("userPrincipalName") + ) + name = raw_attendees.get("displayName") or raw_attendees.get("name") + attendee_type = raw_attendees.get("type", "required") + + self._add_attendee_candidate( + attendees_by_email, + invalid_entries, + email, + name=name or "", + attendee_type=attendee_type, + current_user_email=current_user_email, + strict=strict, + ) + return + + if isinstance(raw_attendees, (list, tuple, set)): + for entry in raw_attendees: + self._collect_attendees( + attendees_by_email, + entry, + invalid_entries, + current_user_email=current_user_email, + strict=strict, + ) + return + + if strict and str(raw_attendees or "").strip(): + invalid_entries.append(str(raw_attendees)) + + def _resolve_group_attendees( + self, + group_id: str, + attendees_by_email: Dict[str, Dict[str, Any]], + current_user_email: str = "", + ) -> Tuple[str, int]: + current_user = get_current_user_info() or {} + current_user_id = str(current_user.get("userId") or "").strip() + if not current_user_id: + raise PermissionError("Signed-in user context is required to include group members.") + + normalized_group_id = str(group_id or "").strip() or self._default_group_id + if not normalized_group_id: + normalized_group_id = require_active_group(current_user_id) + + assert_group_role( + current_user_id, + normalized_group_id, + allowed_roles=("Owner", "Admin", "DocumentManager", "User"), + ) + + group_doc = find_group_by_id(normalized_group_id) + if not group_doc: + raise LookupError("Group not found") + + added_count = 0 + invalid_entries: List[str] = [] + + owner = group_doc.get("owner") if isinstance(group_doc.get("owner"), dict) else {} + if owner and self._add_attendee_candidate( + attendees_by_email, + invalid_entries, + owner.get("email"), + name=owner.get("displayName") or owner.get("email") or "", + current_user_email=current_user_email, + strict=False, + ): + added_count += 1 + + for member in group_doc.get("users", []): + if not isinstance(member, dict): + continue + if self._add_attendee_candidate( + attendees_by_email, + invalid_entries, + member.get("email"), + name=member.get("displayName") or member.get("email") or "", + current_user_email=current_user_email, + strict=False, + ): + added_count += 1 + + return normalized_group_id, added_count + def _normalize_top(self, top: int) -> int: try: normalized_top = int(top) @@ -499,6 +779,153 @@ def get_my_events( additional_headers=headers, ) + @plugin_function_logger("MSGraphPlugin") + @kernel_function(description="Create a calendar invite for the signed-in user and optionally turn it into a Microsoft Teams meeting.") + def create_calendar_invite( + self, + subject: str, + start_datetime: str, + end_datetime: str, + body_content: str = "", + location: str = "", + attendee_emails: Any = "", + include_group_members: Any = False, + group_id: str = "", + make_teams_meeting: Any = False, + timezone: str = "", + allow_new_time_proposals: Any = True, + ) -> dict: + operation_name = "create_calendar_invite" + normalized_subject = str(subject or "").strip() + normalized_start = str(start_datetime or "").strip() + normalized_end = str(end_datetime or "").strip() + normalized_body = str(body_content or "").strip() + normalized_location = str(location or "").strip() + + if not normalized_subject: + return self._invalid_parameter_error(operation_name, "subject is required to create a calendar invite.") + if not normalized_start or not normalized_end: + return self._invalid_parameter_error(operation_name, "start_datetime and end_datetime are required to create a calendar invite.") + + normalized_include_group_members, boolean_error = self._normalize_boolean_parameter( + include_group_members, + "include_group_members", + operation_name, + ) + if boolean_error: + return boolean_error + + normalized_make_teams_meeting, boolean_error = self._normalize_boolean_parameter( + make_teams_meeting, + "make_teams_meeting", + operation_name, + ) + if boolean_error: + return boolean_error + + normalized_allow_new_time_proposals, boolean_error = self._normalize_boolean_parameter( + allow_new_time_proposals, + "allow_new_time_proposals", + operation_name, + ) + if boolean_error: + return boolean_error + + current_user = get_current_user_info() or {} + current_user_email = str(current_user.get("email") or "").strip() + attendees_by_email: Dict[str, Dict[str, Any]] = {} + invalid_entries: List[str] = [] + self._collect_attendees( + attendees_by_email, + attendee_emails, + invalid_entries, + current_user_email=current_user_email, + strict=True, + ) + if invalid_entries: + invalid_sample = ", ".join(invalid_entries[:5]) + return self._invalid_parameter_error( + operation_name, + f"attendee_emails must contain valid email addresses. Invalid entries: {invalid_sample}", + ) + + resolved_group_id = "" + group_attendee_count = 0 + if normalized_include_group_members: + try: + resolved_group_id, group_attendee_count = self._resolve_group_attendees( + group_id, + attendees_by_email, + current_user_email=current_user_email, + ) + except ValueError as exc: + return self._invalid_parameter_error(operation_name, str(exc)) + except LookupError as exc: + return { + "error": "not_found", + "message": str(exc), + "operation": operation_name, + } + except PermissionError as exc: + return { + "error": "permission_denied", + "message": str(exc), + "operation": operation_name, + } + + normalized_timezone = self._resolve_event_timezone(timezone) + attendees = list(attendees_by_email.values()) + event_payload: Dict[str, Any] = { + "subject": normalized_subject, + "start": { + "dateTime": normalized_start, + "timeZone": normalized_timezone, + }, + "end": { + "dateTime": normalized_end, + "timeZone": normalized_timezone, + }, + "allowNewTimeProposals": bool(normalized_allow_new_time_proposals), + } + + if normalized_body: + event_payload["body"] = { + "contentType": "Text", + "content": normalized_body, + } + if normalized_location: + event_payload["location"] = {"displayName": normalized_location} + if attendees: + event_payload["attendees"] = attendees + if normalized_make_teams_meeting: + event_payload["isOnlineMeeting"] = True + event_payload["onlineMeetingProvider"] = "teamsForBusiness" + + result = self._perform_graph_request( + operation_name, + "POST", + "/v1.0/me/events", + ["Calendars.ReadWrite"], + json_body=event_payload, + additional_headers={"Prefer": f'outlook.timezone="{normalized_timezone}"'}, + ) + if not isinstance(result, dict) or result.get("error"): + return result + + result.setdefault("operation", operation_name) + result["requested_attendee_count"] = len(attendees) + result["included_group_member_count"] = group_attendee_count + result["event_timezone"] = normalized_timezone + result["teams_meeting_requested"] = bool(normalized_make_teams_meeting) + if resolved_group_id: + result["group_id"] = resolved_group_id + + online_meeting = result.get("onlineMeeting") if isinstance(result.get("onlineMeeting"), dict) else {} + if online_meeting.get("joinUrl"): + result["join_url"] = online_meeting.get("joinUrl") + + return result + @plugin_function_logger("MSGraphPlugin") @kernel_function(description="Get recent mail messages for the signed-in user.") def get_my_messages( @@ -531,6 +958,39 @@ def get_my_messages( additional_headers=headers, ) + @plugin_function_logger("MSGraphPlugin") + @kernel_function(description="Mark a mail message as read or unread for the signed-in user.") + def mark_message_as_read(self, message_id: str, is_read: bool = True) -> dict: + normalized_message_id = (message_id or "").strip() + if not normalized_message_id: + return { + "error": "invalid_parameters", + "message": "message_id is required to update a mail message.", + "operation": "mark_message_as_read", + } + + normalized_is_read = is_read + if isinstance(is_read, str): + lowered_value = is_read.strip().lower() + if lowered_value in {"true", "1", "yes"}: + normalized_is_read = True + elif lowered_value in {"false", "0", "no"}: + normalized_is_read = False + else: + return { + "error": "invalid_parameters", + "message": "is_read must be a boolean value.", + "operation": "mark_message_as_read", + } + + return self._perform_graph_request( + "mark_message_as_read", + "PATCH", + f"/v1.0/me/messages/{quote(normalized_message_id, safe='')}", + ["Mail.ReadWrite"], + json_body={"isRead": bool(normalized_is_read)}, + ) + @plugin_function_logger("MSGraphPlugin") @kernel_function(description="Search directory users by name or email prefix.") def search_users(self, query: str, top: int = 5, select_fields: str = "") -> dict: diff --git a/application/single_app/semantic_kernel_plugins/plugin_health_checker.py b/application/single_app/semantic_kernel_plugins/plugin_health_checker.py index 4e64797b..32a97a2e 100644 --- a/application/single_app/semantic_kernel_plugins/plugin_health_checker.py +++ b/application/single_app/semantic_kernel_plugins/plugin_health_checker.py @@ -9,6 +9,9 @@ from typing import Dict, Any, List, Optional, Tuple from semantic_kernel_plugins.base_plugin import BasePlugin from functions_appinsights import log_event +from functions_azure_maps import AZURE_MAPS_DEFAULT_ENDPOINT, AZURE_MAPS_PLUGIN_TYPE +from functions_blob_storage_operations import BLOB_STORAGE_PLUGIN_TYPE +from functions_simplechat_operations import SIMPLECHAT_DEFAULT_ENDPOINT class PluginHealthChecker: @@ -40,11 +43,39 @@ def validate_plugin_manifest(manifest: Dict[str, Any], plugin_type: str) -> Tupl errors.append(f"Missing required field: {field}") # Validate specific plugin types - if plugin_type in ['azure_function', 'blob_storage', 'queue_storage']: + if plugin_type in ['azure_function', 'queue_storage']: if 'endpoint' not in manifest: errors.append(f"Plugin type '{plugin_type}' requires 'endpoint' field") if 'auth' not in manifest: errors.append(f"Plugin type '{plugin_type}' requires 'auth' field") + + elif plugin_type == BLOB_STORAGE_PLUGIN_TYPE: + additional_fields = manifest.get('additionalFields', {}) + if not isinstance(additional_fields, dict): + additional_fields = {} + + auth = manifest.get('auth', {}) if isinstance(manifest.get('auth'), dict) else {} + auth_type = (auth.get('type') or '').strip().lower() + endpoint = (manifest.get('endpoint') or '').strip() + container_name = str( + manifest.get('container_name') or additional_fields.get('container_name') or '' + ).strip() + + if not auth: + errors.append("Blob storage plugin requires 'auth' field") + if not container_name: + errors.append("Blob storage plugin requires 'container_name' in additionalFields") + if auth_type not in {'connection_string', 'identity', 'key'}: + errors.append("Blob storage plugin requires auth.type values 'connection_string', 'identity', or 'key'") + if auth_type == 'connection_string' and not auth.get('key'): + errors.append("Blob storage plugin requires auth.key when auth.type='connection_string'") + if auth_type == 'key': + if not endpoint: + errors.append("Blob storage plugin requires an 'endpoint' field when auth.type='key'") + if not auth.get('key'): + errors.append("Blob storage plugin requires auth.key when auth.type='key'") + if auth_type == 'identity' and not endpoint: + errors.append("Blob storage plugin requires an 'endpoint' field when auth.type='identity'") elif plugin_type in ['sql_query', 'sql_schema']: additional_fields = manifest.get('additionalFields', {}) @@ -60,11 +91,54 @@ def validate_plugin_manifest(manifest: Dict[str, Any], plugin_type: str) -> Tupl errors.append(f"SQL plugin requires 'database_type' field") if not connection_string and not (server and database): errors.append("SQL plugin requires either 'connection_string' or 'server' and 'database' fields") + + elif plugin_type == 'cosmos_query': + additional_fields = manifest.get('additionalFields', {}) + if not isinstance(additional_fields, dict): + additional_fields = {} + + endpoint = manifest.get('endpoint') + database_name = manifest.get('database_name') or additional_fields.get('database_name') + container_name = manifest.get('container_name') or additional_fields.get('container_name') + partition_key_path = manifest.get('partition_key_path') or additional_fields.get('partition_key_path') + auth = manifest.get('auth', {}) if isinstance(manifest.get('auth'), dict) else {} + auth_type = (auth.get('type') or 'identity').strip() + + if not endpoint: + errors.append("Cosmos plugin requires an 'endpoint' field") + if not database_name: + errors.append("Cosmos plugin requires 'database_name' in additionalFields") + if not container_name: + errors.append("Cosmos plugin requires 'container_name' in additionalFields") + if not partition_key_path: + errors.append("Cosmos plugin requires 'partition_key_path' in additionalFields") + if auth_type not in {'identity', 'key'}: + errors.append("Cosmos plugin only supports auth.type values 'identity' and 'key'") + if auth_type == 'key' and not auth.get('key'): + errors.append("Cosmos plugin requires auth.key when auth.type='key'") elif plugin_type == 'log_analytics': additional_fields = manifest.get('additionalFields', {}) if 'workspaceId' not in additional_fields: errors.append("Log Analytics plugin requires 'workspaceId' in additionalFields") + + elif plugin_type == 'simplechat': + endpoint = manifest.get('endpoint') + auth = manifest.get('auth', {}) if isinstance(manifest.get('auth'), dict) else {} + if not endpoint: + errors.append(f"SimpleChat plugin requires an 'endpoint' field (use {SIMPLECHAT_DEFAULT_ENDPOINT})") + if auth.get('type') != 'user': + errors.append("SimpleChat plugin requires auth.type='user'") + + elif plugin_type == AZURE_MAPS_PLUGIN_TYPE: + endpoint = manifest.get('endpoint') + auth = manifest.get('auth', {}) if isinstance(manifest.get('auth'), dict) else {} + if not endpoint: + errors.append(f"Azure Maps plugin requires an 'endpoint' field (use {AZURE_MAPS_DEFAULT_ENDPOINT})") + if auth.get('type') != 'key': + errors.append("Azure Maps plugin requires auth.type='key'") + if not auth.get('key'): + errors.append("Azure Maps plugin requires auth.key with an Azure Maps subscription key") return len(errors) == 0, errors diff --git a/application/single_app/semantic_kernel_plugins/plugin_invocation_logger.py b/application/single_app/semantic_kernel_plugins/plugin_invocation_logger.py index dea35f22..c070081a 100644 --- a/application/single_app/semantic_kernel_plugins/plugin_invocation_logger.py +++ b/application/single_app/semantic_kernel_plugins/plugin_invocation_logger.py @@ -12,6 +12,7 @@ import functools import inspect import threading +import uuid from typing import Any, Dict, List, Optional, Callable from datetime import datetime from dataclasses import dataclass, asdict @@ -29,6 +30,7 @@ class PluginInvocationStart: user_id: Optional[str] timestamp: str conversation_id: Optional[str] = None + invocation_id: Optional[str] = None def to_dict(self) -> Dict[str, Any]: """Convert to dictionary for logging.""" @@ -49,6 +51,7 @@ class PluginInvocation: timestamp: str success: bool conversation_id: Optional[str] = None + invocation_id: Optional[str] = None error_message: Optional[str] = None def to_dict(self) -> Dict[str, Any]: @@ -490,6 +493,7 @@ def log_plugin_invocation_started( function_name: str, parameters: Dict[str, Any], conversation_id: Optional[str] = None, + invocation_id: Optional[str] = None, ): """Convenience function to log the start of a plugin invocation.""" user_id, resolved_conversation_id = _resolve_invocation_context(conversation_id) @@ -501,6 +505,7 @@ def log_plugin_invocation_started( user_id=user_id, conversation_id=resolved_conversation_id, timestamp=datetime.utcnow().isoformat(), + invocation_id=invocation_id or str(uuid.uuid4()), ) _plugin_logger.log_invocation_start(invocation_start) @@ -510,7 +515,8 @@ def log_plugin_invocation(plugin_name: str, function_name: str, parameters: Dict[str, Any], result: Any, start_time: float, end_time: float, success: bool = True, error_message: Optional[str] = None, - conversation_id: Optional[str] = None): + conversation_id: Optional[str] = None, + invocation_id: Optional[str] = None): """Convenience function to log a plugin invocation.""" user_id, resolved_conversation_id = _resolve_invocation_context(conversation_id) @@ -524,6 +530,7 @@ def log_plugin_invocation(plugin_name: str, function_name: str, duration_ms=(end_time - start_time) * 1000, user_id=user_id, conversation_id=resolved_conversation_id, + invocation_id=invocation_id or str(uuid.uuid4()), timestamp=datetime.utcnow().isoformat(), success=success, error_message=error_message @@ -637,6 +644,7 @@ def _resolve_function_name(wrapper_func: Callable) -> str: @functools.wraps(func) async def wrapper(*args, **kwargs): start_time = time.time() + invocation_id = str(uuid.uuid4()) function_name = _resolve_function_name(wrapper) _log_start(function_name) parameters = _build_parameters(args, kwargs) @@ -645,6 +653,7 @@ async def wrapper(*args, **kwargs): plugin_name=plugin_name, function_name=function_name, parameters=parameters, + invocation_id=invocation_id, ) try: @@ -660,7 +669,8 @@ async def wrapper(*args, **kwargs): result=result, start_time=start_time, end_time=end_time, - success=True + success=True, + invocation_id=invocation_id, ) return result @@ -678,7 +688,8 @@ async def wrapper(*args, **kwargs): start_time=start_time, end_time=end_time, success=False, - error_message=str(e) + error_message=str(e), + invocation_id=invocation_id, ) raise @@ -686,6 +697,7 @@ async def wrapper(*args, **kwargs): @functools.wraps(func) def wrapper(*args, **kwargs): start_time = time.time() + invocation_id = str(uuid.uuid4()) function_name = _resolve_function_name(wrapper) _log_start(function_name) parameters = _build_parameters(args, kwargs) @@ -694,6 +706,7 @@ def wrapper(*args, **kwargs): plugin_name=plugin_name, function_name=function_name, parameters=parameters, + invocation_id=invocation_id, ) try: @@ -723,6 +736,7 @@ async def _await_and_log(awaitable_result): start_time=start_time, end_time=end_time, success=True, + invocation_id=invocation_id, ) return awaited_value except Exception as await_error: @@ -738,6 +752,7 @@ async def _await_and_log(awaitable_result): end_time=end_time, success=False, error_message=str(await_error), + invocation_id=invocation_id, ) raise @@ -754,7 +769,8 @@ async def _await_and_log(awaitable_result): result=result, start_time=start_time, end_time=end_time, - success=True + success=True, + invocation_id=invocation_id, ) return result @@ -772,7 +788,8 @@ async def _await_and_log(awaitable_result): start_time=start_time, end_time=end_time, success=False, - error_message=str(e) + error_message=str(e), + invocation_id=invocation_id, ) raise diff --git a/application/single_app/semantic_kernel_plugins/plugin_invocation_thoughts.py b/application/single_app/semantic_kernel_plugins/plugin_invocation_thoughts.py index c400ed11..35704cba 100644 --- a/application/single_app/semantic_kernel_plugins/plugin_invocation_thoughts.py +++ b/application/single_app/semantic_kernel_plugins/plugin_invocation_thoughts.py @@ -134,6 +134,23 @@ def _format_plugin_detail(invocation, parameters): return '; '.join(detail_parts) +def _build_plugin_activity_payload(invocation_or_start, state): + plugin_name = getattr(invocation_or_start, 'plugin_name', '') + function_name = getattr(invocation_or_start, 'function_name', '') + invocation_id = getattr(invocation_or_start, 'invocation_id', None) + return { + 'activity_key': invocation_id or f'{plugin_name}.{function_name}', + 'kind': 'tool_invocation', + 'title': f'{plugin_name}.{function_name}', + 'status': state, + 'state': state, + 'lane_key': plugin_name or 'tool', + 'lane_label': plugin_name or 'Tool', + 'plugin_name': plugin_name, + 'function_name': function_name, + } + + def format_plugin_invocation_start_thought(invocation_start): """Build a concise thought payload for an in-flight plugin invocation.""" plugin_name = getattr(invocation_start, 'plugin_name', 'Plugin') @@ -148,6 +165,7 @@ def format_plugin_invocation_start_thought(invocation_start): 'step_type': 'agent_tool_call', 'content': f"Invoking {plugin_name}.{function_name}", 'detail': detail or None, + 'activity': _build_plugin_activity_payload(invocation_start, 'running'), } @@ -158,6 +176,10 @@ def format_plugin_invocation_thought(invocation, actor_label='Agent'): 'step_type': 'agent_tool_call', 'content': _format_plugin_content(invocation, actor_label, parameters), 'detail': _format_plugin_detail(invocation, parameters), + 'activity': _build_plugin_activity_payload( + invocation, + 'failed' if not getattr(invocation, 'success', True) else 'completed', + ), } @@ -176,7 +198,8 @@ def add_and_publish_live_thought(thought_payload): thought_tracker.add_thought( thought_payload['step_type'], thought_payload['content'], - detail=thought_payload['detail'] + detail=thought_payload['detail'], + activity=thought_payload.get('activity'), ) if callable(live_thought_callback): diff --git a/application/single_app/semantic_kernel_plugins/simplechat_plugin.py b/application/single_app/semantic_kernel_plugins/simplechat_plugin.py new file mode 100644 index 00000000..b7c374aa --- /dev/null +++ b/application/single_app/semantic_kernel_plugins/simplechat_plugin.py @@ -0,0 +1,402 @@ +# simplechat_plugin.py +"""Semantic Kernel plugin for SimpleChat-native workspace operations.""" + +import logging +from typing import Any, Callable, Dict, List, Optional + +from semantic_kernel.functions import kernel_function +from semantic_kernel.functions.kernel_plugin import KernelPlugin + +from functions_appinsights import log_event +from functions_simplechat_operations import ( + SIMPLECHAT_CAPABILITY_DEFINITIONS, + add_conversation_message_for_current_user, + add_group_member_for_current_user, + create_group_collaboration_conversation_for_current_user, + create_group_for_current_user, + invite_group_conversation_members_for_current_user, + create_personal_workflow_for_current_user, + create_personal_collaboration_conversation_for_current_user, + create_personal_conversation_for_current_user, + get_simplechat_enabled_function_names, + make_group_inactive_for_current_user, + normalize_simplechat_capabilities, + upload_markdown_document_for_current_user, +) +from semantic_kernel_plugins.base_plugin import BasePlugin +from semantic_kernel_plugins.plugin_invocation_logger import plugin_function_logger + + +class SimpleChatPlugin(BasePlugin): + def __init__(self, manifest: Optional[Dict[str, Any]] = None): + super().__init__(manifest) + self.manifest = manifest or {} + self._metadata = self.manifest.get("metadata", {}) + self._capabilities = normalize_simplechat_capabilities( + self.manifest.get("simplechat_capabilities") + ) + self._enabled_function_names = set( + self.manifest.get("enabled_functions") + or get_simplechat_enabled_function_names(self._capabilities) + ) + self._default_group_id = str( + self.manifest.get("group_id") or self.manifest.get("default_group_id") or "" + ).strip() + + @property + def display_name(self) -> str: + return "Simple Chat" + + @property + def metadata(self) -> Dict[str, Any]: + enabled_methods = set(self.get_functions()) + return { + "name": self.manifest.get("name", "simplechat"), + "type": "simplechat", + "description": ( + "Simple Chat workspace actions for creating groups, conversations, " + "personal workflows, group membership changes, group status updates, " + "and Markdown document uploads using the " + "invoking user's own permissions." + ), + "methods": [ + { + "name": definition["function_name"], + "description": definition["description"], + "parameters": [], + "returns": {"type": "dict", "description": definition["description"]}, + } + for definition in SIMPLECHAT_CAPABILITY_DEFINITIONS + if definition["function_name"] in enabled_methods + ], + } + + def get_functions(self) -> List[str]: + return [ + definition["function_name"] + for definition in SIMPLECHAT_CAPABILITY_DEFINITIONS + if definition["function_name"] in self._enabled_function_names + ] + + def get_kernel_plugin(self, plugin_name: str = "simplechat") -> KernelPlugin: + functions = {} + for function_name in self.get_functions(): + bound_method = getattr(self, function_name, None) + if callable(bound_method) and hasattr(bound_method, "__kernel_function__"): + functions[function_name] = bound_method + + return KernelPlugin.from_object( + plugin_name, + functions, + description=self.metadata.get("description"), + ) + + def _execute_operation(self, operation_name: str, callback): + try: + result = callback() + if isinstance(result, dict): + payload = dict(result) + else: + payload = {"result": result} + payload.setdefault("success", True) + return payload + except PermissionError as exc: + return {"success": False, "error": str(exc), "error_type": "permission"} + except LookupError as exc: + return {"success": False, "error": str(exc), "error_type": "not_found"} + except ValueError as exc: + return {"success": False, "error": str(exc), "error_type": "validation"} + except Exception as exc: + log_event( + f"[SimpleChatPlugin] {operation_name} failed: {exc}", + level=logging.ERROR, + exceptionTraceback=True, + ) + return { + "success": False, + "error": f"Failed to {operation_name.replace('_', ' ')}", + "error_type": "unexpected", + "details": str(exc), + } + + def _normalize_initial_message(self, initial_message: str = "") -> str: + normalized_initial_message = str(initial_message or "").strip() + if not normalized_initial_message: + return "" + + if "add_conversation_message" not in self._enabled_function_names: + raise PermissionError( + "The add conversation message capability is disabled for this action. " + "Enable it to seed a newly created conversation." + ) + + return normalized_initial_message + + def _seed_initial_message_if_requested(self, conversation_id: str, initial_message: str = "") -> Dict[str, Any]: + normalized_initial_message = self._normalize_initial_message(initial_message) + if not normalized_initial_message: + return {} + + seeded_message_payload = add_conversation_message_for_current_user( + conversation_id=conversation_id, + content=normalized_initial_message, + ) + return { + "conversation": seeded_message_payload.get("conversation"), + "message": seeded_message_payload.get("message"), + "seeded_initial_message": True, + } + + def _create_group_conversation_with_optional_seed( + self, + title: str = "", + group_id: str = "", + initial_message: str = "", + ) -> Dict[str, Any]: + normalized_initial_message = self._normalize_initial_message(initial_message) + conversation, _, group_doc = create_group_collaboration_conversation_for_current_user( + title=title, + group_id=group_id, + default_group_id=self._default_group_id, + ) + + payload = self._build_seeded_creation_payload( + conversation, + initial_message=normalized_initial_message, + ) + normalized_group_doc = group_doc if isinstance(group_doc, dict) else {} + group_name = str(normalized_group_doc.get("name") or "Group Workspace").strip() or "Group Workspace" + conversation_title = str((conversation or {}).get("title") or "New Group Collaborative Conversation").strip() + payload["group"] = { + "id": normalized_group_doc.get("id"), + "name": group_name, + } + payload.setdefault( + "message", + ( + f"Created group multi-user conversation '{conversation_title}' in group '{group_name}'. " + "Use invite_group_conversation_members to add current group members as participants and grant access." + ), + ) + return payload + + @plugin_function_logger("SimpleChatPlugin") + @kernel_function(description="Create a new group workspace as the current user.") + def create_group(self, name: str, description: str = "") -> dict: + return self._execute_operation( + "create_group", + lambda: { + "group": create_group_for_current_user(name=name, description=description), + }, + ) + + @plugin_function_logger("SimpleChatPlugin") + @kernel_function(description="Create a new personal conversation for the current user.") + def create_personal_conversation( + self, + title: str = "New Conversation", + initial_message: str = "", + ) -> dict: + return self._execute_operation( + "create_personal_conversation", + lambda: self._create_conversation_with_optional_seed( + lambda: create_personal_conversation_for_current_user( + title=title, + notify_creation=True, + ), + initial_message=initial_message, + ), + ) + + @plugin_function_logger("SimpleChatPlugin") + @kernel_function(description="Create a new personal workflow under the current user's identity. runner_type can be 'model' or 'agent'. trigger_type can be 'manual' or 'interval'.") + def create_personal_workflow( + self, + name: str, + task_prompt: str, + description: str = "", + runner_type: str = "model", + trigger_type: str = "manual", + selected_agent_name: str = "", + selected_agent_id: str = "", + selected_agent_is_global: bool = False, + model_endpoint_id: str = "", + model_id: str = "", + alert_priority: str = "none", + is_enabled: bool = True, + schedule_value: int = 1, + schedule_unit: str = "hours", + conversation_id: str = "", + ) -> dict: + return self._execute_operation( + "create_personal_workflow", + lambda: create_personal_workflow_for_current_user( + name=name, + task_prompt=task_prompt, + description=description, + runner_type=runner_type, + trigger_type=trigger_type, + selected_agent_name=selected_agent_name, + selected_agent_id=selected_agent_id, + selected_agent_is_global=selected_agent_is_global, + model_endpoint_id=model_endpoint_id, + model_id=model_id, + alert_priority=alert_priority, + is_enabled=is_enabled, + schedule_value=schedule_value, + schedule_unit=schedule_unit, + conversation_id=conversation_id, + ), + ) + + @plugin_function_logger("SimpleChatPlugin") + @kernel_function(description="Create a new invite-managed group multi-user conversation in a group the current user can access. If group_id is omitted, the active group is used. Add current group members as participants to grant access.") + def create_group_conversation( + self, + title: str = "", + group_id: str = "", + initial_message: str = "", + ) -> dict: + return self._execute_operation( + "create_group_conversation", + lambda: self._create_group_conversation_with_optional_seed( + title=title, + group_id=group_id, + initial_message=initial_message, + ), + ) + + @plugin_function_logger("SimpleChatPlugin") + @kernel_function(description="Invite current group members into an existing invite-managed group multi-user conversation. Provide participant_identifiers as emails, user principal names, or user IDs separated by commas or new lines.") + def invite_group_conversation_members( + self, + conversation_id: str, + participant_identifiers: str = "", + ) -> dict: + return self._execute_operation( + "invite_group_conversation_members", + lambda: invite_group_conversation_members_for_current_user( + conversation_id=conversation_id, + participant_identifiers=participant_identifiers, + ), + ) + + @plugin_function_logger("SimpleChatPlugin") + @kernel_function(description="Mark a group inactive using the current user's Control Center admin permissions. If group_id is omitted, the action's group context or active group is used.") + def make_group_inactive( + self, + group_id: str = "", + reason: str = "", + ) -> dict: + return self._execute_operation( + "make_group_inactive", + lambda: make_group_inactive_for_current_user( + group_id=group_id, + reason=reason, + default_group_id=self._default_group_id, + ), + ) + + @plugin_function_logger("SimpleChatPlugin") + @kernel_function(description="Add a user-authored message to an existing personal or collaborative conversation the current user can access. Use this after creating a conversation when you need to seed the opening request.") + def add_conversation_message( + self, + conversation_id: str, + content: str, + reply_to_message_id: str = "", + ) -> dict: + return self._execute_operation( + "add_conversation_message", + lambda: add_conversation_message_for_current_user( + conversation_id=conversation_id, + content=content, + reply_to_message_id=reply_to_message_id, + ), + ) + + @plugin_function_logger("SimpleChatPlugin") + @kernel_function(description="Create and upload a Markdown document into the current user's personal workspace or an allowed group workspace. Use workspace_scope='group' to target a group workspace and optionally provide group_id.") + def upload_markdown_document( + self, + file_name: str, + markdown_content: str, + workspace_scope: str = "personal", + group_id: str = "", + ) -> dict: + return self._execute_operation( + "upload_markdown_document", + lambda: upload_markdown_document_for_current_user( + file_name=file_name, + markdown_content=markdown_content, + workspace_scope=workspace_scope, + group_id=group_id, + default_group_id=self._default_group_id, + ), + ) + + @plugin_function_logger("SimpleChatPlugin") + @kernel_function(description="Create a personal collaborative conversation and invite one or more users. Provide participant_identifiers as emails, user principal names, or user IDs separated by commas or new lines.") + def create_personal_collaboration_conversation( + self, + participant_identifiers: str = "", + title: str = "", + initial_message: str = "", + ) -> dict: + return self._execute_operation( + "create_personal_collaboration_conversation", + lambda: self._create_conversation_with_optional_seed( + lambda: create_personal_collaboration_conversation_for_current_user( + title=title, + participant_identifiers=participant_identifiers, + )[0], + initial_message=initial_message, + ), + ) + + @plugin_function_logger("SimpleChatPlugin") + @kernel_function(description="Add a user directly to a group as the current user. user_identifier can be an email, user principal name, or user ID. If group_id is omitted, the active group is used.") + def add_user_to_group( + self, + user_identifier: str = "", + group_id: str = "", + role: str = "user", + display_name: str = "", + email: str = "", + ) -> dict: + return self._execute_operation( + "add_group_member", + lambda: add_group_member_for_current_user( + group_id=group_id, + user_identifier=user_identifier, + email=email, + display_name=display_name, + role=role, + default_group_id=self._default_group_id, + ), + ) + + def _build_seeded_creation_payload( + self, + conversation: Dict[str, Any], + initial_message: str = "", + ) -> Dict[str, Any]: + payload = {"conversation": conversation} + seeded_payload = self._seed_initial_message_if_requested( + conversation_id=str((conversation or {}).get("id") or "").strip(), + initial_message=initial_message, + ) + if seeded_payload: + payload.update(seeded_payload) + return payload + + def _create_conversation_with_optional_seed( + self, + create_conversation: Callable[[], Dict[str, Any]], + initial_message: str = "", + ) -> Dict[str, Any]: + normalized_initial_message = self._normalize_initial_message(initial_message) + conversation = create_conversation() + return self._build_seeded_creation_payload( + conversation, + initial_message=normalized_initial_message, + ) \ No newline at end of file diff --git a/application/single_app/semantic_kernel_plugins/sql_plugin_factory.py b/application/single_app/semantic_kernel_plugins/sql_plugin_factory.py index 5a7929d2..bac41564 100644 --- a/application/single_app/semantic_kernel_plugins/sql_plugin_factory.py +++ b/application/single_app/semantic_kernel_plugins/sql_plugin_factory.py @@ -40,7 +40,7 @@ def create_sql_server_plugins( base_config.update({ "username": username, "password": password, - "driver": "ODBC Driver 17 for SQL Server" + "driver": "ODBC Driver 18 for SQL Server" }) # Schema plugin config @@ -227,9 +227,9 @@ def create_azure_sql_plugins( tuple: (schema_plugin, query_plugin) """ if use_managed_identity: - connection_string = f"DRIVER={{ODBC Driver 17 for SQL Server}};SERVER={server};DATABASE={database};Authentication=ActiveDirectoryMsi" + connection_string = f"DRIVER={{ODBC Driver 18 for SQL Server}};SERVER={server};DATABASE={database};Authentication=ActiveDirectoryMsi" else: - connection_string = f"DRIVER={{ODBC Driver 17 for SQL Server}};SERVER={server};DATABASE={database};UID={username};PWD={password}" + connection_string = f"DRIVER={{ODBC Driver 18 for SQL Server}};SERVER={server};DATABASE={database};UID={username};PWD={password}" base_config = { "database_type": "sqlserver", diff --git a/application/single_app/semantic_kernel_plugins/sql_query_plugin.py b/application/single_app/semantic_kernel_plugins/sql_query_plugin.py index 6eba26f8..cda27e74 100644 --- a/application/single_app/semantic_kernel_plugins/sql_query_plugin.py +++ b/application/single_app/semantic_kernel_plugins/sql_query_plugin.py @@ -81,7 +81,7 @@ def _setup_database_config(self): self.supported_databases = { 'sqlserver': { 'module': 'pyodbc', - 'default_driver': 'ODBC Driver 17 for SQL Server', + 'default_driver': 'ODBC Driver 18 for SQL Server', 'default_port': 1433 }, 'postgresql': { diff --git a/application/single_app/semantic_kernel_plugins/sql_schema_plugin.py b/application/single_app/semantic_kernel_plugins/sql_schema_plugin.py index 0b2f2898..4e798caf 100644 --- a/application/single_app/semantic_kernel_plugins/sql_schema_plugin.py +++ b/application/single_app/semantic_kernel_plugins/sql_schema_plugin.py @@ -74,7 +74,7 @@ def _setup_database_config(self): self.supported_databases = { 'sqlserver': { 'module': 'pyodbc', - 'default_driver': 'ODBC Driver 17 for SQL Server', + 'default_driver': 'ODBC Driver 18 for SQL Server', 'default_port': 1433 }, 'postgresql': { diff --git a/application/single_app/static/css/chats.css b/application/single_app/static/css/chats.css index 1ab7b282..46d5f072 100644 --- a/application/single_app/static/css/chats.css +++ b/application/single_app/static/css/chats.css @@ -1307,6 +1307,10 @@ a.citation-link:hover { justify-content: flex-start; } +.collaborator-message { + justify-content: flex-start; +} + /* Message bubble */ .message-bubble { max-width: 90%; @@ -1337,6 +1341,13 @@ a.citation-link:hover { min-width: min(320px, 90%); } +.collaborator-message .message-bubble { + background-color: #eef6ef; + color: black; + border-bottom-left-radius: 0; + min-width: min(280px, 90%); +} + /* File message bubble styling */ .file-message .message-bubble { background-color: #e8f5e9; /* Green */ @@ -1370,6 +1381,36 @@ a.citation-link:hover { color: #e9ecef; } +[data-bs-theme="dark"] .collaborator-message .message-bubble { + background-color: #35533f; + color: #f8f9fa; +} + +[data-bs-theme="dark"] .collaboration-reply-preview { + border-color: #2f6f44; + background-color: rgba(31, 94, 53, 0.45); +} + +[data-bs-theme="dark"] .collaboration-reply-preview-label, +[data-bs-theme="dark"] .collaboration-quote-label { + color: #8dd7a3; +} + +[data-bs-theme="dark"] .collaboration-reply-preview-text, +[data-bs-theme="dark"] .collaboration-quote-text { + color: #dfe7e2; +} + +[data-bs-theme="dark"] .collaboration-quote-block { + background-color: rgba(73, 167, 97, 0.15); + border-left-color: #57b676; +} + +[data-bs-theme="dark"] .avatar.avatar-initials { + background-color: #245637; + color: #dff5e7; +} + [data-bs-theme="dark"] .file-message .message-bubble { background-color: #198754; /* Darker green for dark mode */ color: #ffffff; @@ -1465,8 +1506,31 @@ a.citation-link:hover { .avatar { width: 30px; height: 30px; + min-width: 30px; + min-height: 30px; + flex: 0 0 30px; + border-radius: 50%; + object-fit: cover; + display: block; +} + +.avatar img { + width: 100%; + height: 100%; border-radius: 50%; object-fit: cover; + display: block; +} + +.avatar.avatar-initials { + display: flex; + align-items: center; + justify-content: center; + background-color: #d1e7dd; + color: #0f5132; + font-size: 0.72rem; + font-weight: 700; + overflow: hidden; } .user-message .avatar { @@ -1477,10 +1541,161 @@ a.citation-link:hover { margin-right: 10px; } +.collaborator-message .avatar { + margin-right: 10px; +} + +.collaborator-message .message-content { + justify-content: flex-start; +} + .file-message { justify-content: flex-end; } +.collaboration-typing-indicator { + min-height: 24px; + padding: 0 1rem 0.5rem 1rem; + font-size: 0.875rem; + color: #6c757d; +} + +.collaboration-reply-preview { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 0.5rem; + padding: 0.625rem 0.75rem; + border: 1px solid #b7dfc5; + border-radius: 0.75rem; + background-color: #eef9f1; +} + +.collaboration-reply-preview-body { + flex: 1; + min-width: 0; +} + +.collaboration-reply-preview-label { + font-size: 0.8rem; + font-weight: 600; + color: #198754; +} + +.collaboration-reply-preview-text { + font-size: 0.85rem; + color: #495057; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.collaboration-quote-block { + margin-bottom: 0.5rem; + padding: 0.5rem 0.75rem; + border-left: 3px solid #198754; + border-radius: 0.5rem; + background-color: rgba(25, 135, 84, 0.08); +} + +.collaboration-quote-label { + margin-bottom: 0.2rem; + font-size: 0.78rem; + font-weight: 600; + color: #146c43; +} + +.collaboration-quote-text { + font-size: 0.85rem; + color: #495057; +} +.collaboration-mentions-block { + margin-bottom: 0.5rem; +} +.collaboration-mentions-label { + margin-bottom: 0.25rem; + font-size: 0.78rem; + font-weight: 600; + color: #146c43; +} +.collaboration-mentions-list { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} +.collaboration-mention-chip { + display: inline-flex; + align-items: center; + padding: 0.18rem 0.55rem; + border-radius: 999px; + background-color: rgba(25, 135, 84, 0.12); + color: #146c43; + font-size: 0.78rem; + font-weight: 600; +} +.collaboration-mention-chip-current-user { + background-color: rgba(13, 110, 253, 0.15); + color: #0a58ca; +} +.collaboration-mention-chip-target-model { + background-color: rgba(13, 110, 253, 0.15); + color: #0a58ca; +} +.collaboration-mention-chip-target-agent { + background-color: rgba(255, 193, 7, 0.22); + color: #7a5a00; +} +.collaboration-mention-chip-target-image { + background-color: rgba(108, 117, 125, 0.16); + color: #495057; +} + +.collaboration-mention-menu { + position: absolute; + left: 0; + right: 0; + bottom: calc(100% + 0.5rem); + z-index: 1055; + max-height: 240px; + overflow-y: auto; + border: 1px solid rgba(0, 0, 0, 0.08); + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.12); +} + +.collaboration-mention-menu .list-group-item.active { + background-color: #0d6efd; + border-color: #0d6efd; +} + +[data-bs-theme="dark"] .collaboration-mentions-label { + color: #8dd7a3; +} +[data-bs-theme="dark"] .collaboration-mention-chip { + background-color: rgba(73, 167, 97, 0.2); + color: #d9f3e2; +} +[data-bs-theme="dark"] .collaboration-mention-chip-current-user { + background-color: rgba(84, 141, 255, 0.25); + color: #d7e4ff; +} +[data-bs-theme="dark"] .collaboration-mention-chip-target-model { + background-color: rgba(84, 141, 255, 0.25); + color: #d7e4ff; +} +[data-bs-theme="dark"] .collaboration-mention-chip-target-agent { + background-color: rgba(255, 193, 7, 0.28); + color: #ffe8a3; +} +[data-bs-theme="dark"] .collaboration-mention-chip-target-image { + background-color: rgba(173, 181, 189, 0.2); + color: #f1f3f5; +} + +.collaboration-participant-results { + max-height: 320px; + overflow-y: auto; +} + /* Style code blocks */ /* Code blocks: force internal scroll, never overflow parent */ @@ -1736,6 +1951,691 @@ ol { color: #adb5bd; } +.inline-visualizations-container { + display: grid; + gap: 0.85rem; + margin-top: 0.9rem; +} + +.inline-map-card { + border: 1px solid rgba(13, 110, 253, 0.16); + border-radius: 1rem; + overflow: hidden; + background: linear-gradient(180deg, rgba(248, 250, 252, 0.98) 0%, rgba(235, 243, 252, 0.96) 100%); + box-shadow: 0 10px 26px rgba(15, 23, 42, 0.08); +} + +.inline-map-card-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; + padding: 1rem 1rem 0.85rem; +} + +.inline-map-card-copy { + min-width: 0; +} + +.inline-map-card-title-row { + display: flex; + align-items: center; + gap: 0.55rem; + margin-bottom: 0.4rem; +} + +.inline-map-card-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + border-radius: 999px; + background: rgba(13, 110, 253, 0.12); + color: #0d6efd; +} + +.inline-map-card-title { + font-size: 1rem; + font-weight: 600; + color: #0f172a; +} + +.inline-map-card-summary { + color: #475569; + font-size: 0.92rem; +} + +.inline-map-badges { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 0.4rem; +} + +.inline-map-badge { + display: inline-flex; + align-items: center; + padding: 0.25rem 0.6rem; + border-radius: 999px; + background: rgba(15, 23, 42, 0.06); + color: #334155; + font-size: 0.78rem; + font-weight: 600; +} + +.inline-map-shell { + position: relative; + border-top: 1px solid rgba(148, 163, 184, 0.25); + border-bottom: 1px solid rgba(148, 163, 184, 0.25); +} + +.inline-map-canvas { + width: 100%; + height: 320px; + background: linear-gradient(180deg, #dbeafe 0%, #eff6ff 100%); +} + +.inline-map-popup { + min-width: 180px; + max-width: 260px; + padding: 0.6rem 0.75rem; + border-radius: 0.85rem; + background: rgba(15, 23, 42, 0.92); + color: #f8fafc; + box-shadow: 0 12px 24px rgba(15, 23, 42, 0.24); + transform: translate(-50%, calc(-100% - 14px)); + display: none; +} + +.inline-map-popup.is-visible { + display: block; +} + +.inline-map-popup-title { + font-size: 0.9rem; + font-weight: 700; +} + +.inline-map-popup-description { + margin-top: 0.35rem; + font-size: 0.82rem; + line-height: 1.4; + color: rgba(248, 250, 252, 0.88); +} + +.inline-map-footer { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + padding: 0.75rem 1rem 0.9rem; + font-size: 0.78rem; + color: #64748b; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.inline-map-footer-separator { + color: #94a3b8; +} + +.inline-map-fallback { + padding: 1rem; + color: #475569; + font-size: 0.9rem; +} + +.inline-image-gallery-card { + border: 1px solid rgba(14, 116, 144, 0.16); + border-radius: 1rem; + overflow: hidden; + background: linear-gradient(180deg, rgba(247, 252, 255, 0.98) 0%, rgba(236, 248, 255, 0.96) 100%); + box-shadow: 0 10px 26px rgba(15, 23, 42, 0.08); +} + +.inline-image-gallery-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; + padding: 1rem 1rem 0.85rem; +} + +.inline-image-gallery-copy { + min-width: 0; +} + +.inline-image-gallery-title-row { + display: flex; + align-items: center; + gap: 0.55rem; + margin-bottom: 0.4rem; +} + +.inline-image-gallery-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + border-radius: 999px; + background: rgba(8, 145, 178, 0.12); + color: #0891b2; +} + +.inline-image-gallery-title { + font-size: 1rem; + font-weight: 600; + color: #0f172a; +} + +.inline-image-gallery-summary { + color: #475569; + font-size: 0.92rem; +} + +.inline-image-gallery-badges { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 0.4rem; +} + +.inline-image-gallery-badge { + display: inline-flex; + align-items: center; + padding: 0.25rem 0.6rem; + border-radius: 999px; + background: rgba(15, 23, 42, 0.06); + color: #334155; + font-size: 0.78rem; + font-weight: 600; +} + +.inline-image-gallery-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 280px)); + justify-content: start; + gap: 0.85rem; + padding: 0 1rem 1rem; +} + +.inline-image-gallery-item { + border: 1px solid rgba(148, 163, 184, 0.22); + border-radius: 0.9rem; + overflow: hidden; + background: rgba(255, 255, 255, 0.86); + box-shadow: 0 8px 18px rgba(15, 23, 42, 0.06); +} + +.inline-image-gallery-stage { + position: relative; + display: flex; + align-items: center; + justify-content: center; + min-height: 220px; + max-height: 400px; + padding: 0.75rem; + background: linear-gradient(180deg, rgba(224, 242, 254, 0.84) 0%, rgba(207, 250, 254, 0.78) 100%); +} + +.inline-image-gallery-item-image { + width: auto; + max-width: 100%; + max-height: 400px; + object-fit: contain; + display: block; + cursor: pointer; +} + +.inline-image-gallery-info-btn { + position: absolute; + top: 0.6rem; + right: 0.6rem; + width: 2rem; + height: 2rem; + display: inline-flex; + align-items: center; + justify-content: center; + border: 0; + border-radius: 999px; + background: rgba(15, 23, 42, 0.72); + color: #f8fafc; + box-shadow: 0 6px 14px rgba(15, 23, 42, 0.18); +} + +.inline-image-gallery-info-btn:hover, +.inline-image-gallery-info-btn:focus-visible { + background: rgba(15, 23, 42, 0.92); + color: #ffffff; +} + +.inline-image-gallery-item-copy { + padding: 0.8rem 0.85rem 0.9rem; +} + +.inline-image-gallery-item-meta { + color: #0891b2; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.inline-image-gallery-item-title { + margin-top: 0.35rem; + color: #0f172a; + font-size: 0.92rem; + font-weight: 600; +} + +.inline-image-gallery-item-description { + margin-top: 0.35rem; + color: #475569; + font-size: 0.82rem; + line-height: 1.45; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.inline-image-gallery-footer { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + padding: 0.75rem 1rem 0.9rem; + font-size: 0.78rem; + color: #64748b; + text-transform: uppercase; + letter-spacing: 0.04em; + border-top: 1px solid rgba(148, 163, 184, 0.2); +} + +.inline-image-gallery-footer-separator { + color: #94a3b8; +} + +.inline-image-modal-stage { + display: flex; + justify-content: center; + padding: 0.5rem; + border-radius: 1rem; + background: linear-gradient(180deg, rgba(241, 245, 249, 0.9) 0%, rgba(226, 232, 240, 0.9) 100%); +} + +.inline-image-modal-description { + color: #475569; + font-size: 0.95rem; + line-height: 1.6; +} + +.inline-image-modal-meta { + display: grid; + gap: 0.75rem; +} + +.inline-image-modal-meta-row { + display: grid; + gap: 0.2rem; +} + +.inline-image-modal-meta-label { + color: #0891b2; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.inline-image-modal-meta-value { + color: #0f172a; + font-size: 0.95rem; + line-height: 1.5; +} + +.inline-video-gallery-card { + margin-top: 1rem; + border: 1px solid rgba(14, 116, 144, 0.16); + border-radius: 1.15rem; + background: linear-gradient(180deg, rgba(240, 249, 255, 0.98) 0%, rgba(236, 253, 245, 0.94) 100%); + box-shadow: 0 10px 24px rgba(14, 116, 144, 0.08); + overflow: hidden; +} + +.inline-video-gallery-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + padding: 1rem 1rem 0.75rem; +} + +.inline-video-gallery-copy { + display: grid; + gap: 0.45rem; +} + +.inline-video-gallery-title-row { + display: flex; + align-items: center; + gap: 0.65rem; +} + +.inline-video-gallery-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + border-radius: 999px; + background: rgba(14, 116, 144, 0.12); + color: #0f766e; + font-size: 1rem; +} + +.inline-video-gallery-title { + color: #0f172a; + font-size: 1rem; + font-weight: 700; +} + +.inline-video-gallery-summary { + color: #475569; + font-size: 0.92rem; +} + +.inline-video-gallery-badges { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 0.4rem; +} + +.inline-video-gallery-badge { + display: inline-flex; + align-items: center; + padding: 0.25rem 0.6rem; + border-radius: 999px; + background: rgba(15, 23, 42, 0.06); + color: #334155; + font-size: 0.78rem; + font-weight: 600; +} + +.inline-video-gallery-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 320px)); + justify-content: start; + gap: 0.85rem; + padding: 0 1rem 1rem; +} + +.inline-video-gallery-item { + border: 1px solid rgba(148, 163, 184, 0.22); + border-radius: 0.9rem; + overflow: hidden; + background: rgba(255, 255, 255, 0.86); + box-shadow: 0 8px 18px rgba(15, 23, 42, 0.06); +} + +.inline-video-gallery-stage { + position: relative; + display: flex; + align-items: center; + justify-content: center; + min-height: 220px; + max-height: 400px; + padding: 0.75rem; + background: linear-gradient(180deg, rgba(224, 242, 254, 0.84) 0%, rgba(209, 250, 229, 0.78) 100%); +} + +.inline-video-gallery-item-video { + width: 100%; + max-height: 400px; + display: block; + border-radius: 0.75rem; + background: #020617; +} + +.inline-video-gallery-info-btn { + position: absolute; + top: 0.6rem; + right: 0.6rem; + width: 2rem; + height: 2rem; + display: inline-flex; + align-items: center; + justify-content: center; + border: 0; + border-radius: 999px; + background: rgba(15, 23, 42, 0.72); + color: #f8fafc; + box-shadow: 0 6px 14px rgba(15, 23, 42, 0.18); +} + +.inline-video-gallery-info-btn:hover, +.inline-video-gallery-info-btn:focus-visible { + background: rgba(15, 23, 42, 0.92); + color: #ffffff; +} + +.inline-video-gallery-item-copy { + padding: 0.8rem 0.85rem 0.9rem; +} + +.inline-video-gallery-item-meta { + color: #0f766e; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.inline-video-gallery-item-title { + margin-top: 0.35rem; + color: #0f172a; + font-size: 0.92rem; + font-weight: 600; +} + +.inline-video-gallery-item-description { + margin-top: 0.35rem; + color: #475569; + font-size: 0.82rem; + line-height: 1.45; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.inline-video-gallery-footer { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + padding: 0.75rem 1rem 0.9rem; + font-size: 0.78rem; + color: #64748b; + text-transform: uppercase; + letter-spacing: 0.04em; + border-top: 1px solid rgba(148, 163, 184, 0.2); +} + +.inline-video-gallery-footer-separator { + color: #94a3b8; +} + +.inline-video-modal-stage { + display: flex; + justify-content: center; + padding: 0.5rem; + border-radius: 1rem; + background: linear-gradient(180deg, rgba(241, 245, 249, 0.9) 0%, rgba(226, 232, 240, 0.9) 100%); +} + +.inline-video-modal-stage video { + width: 100%; + max-height: min(70vh, 560px); + border-radius: 0.85rem; + background: #020617; +} + +.inline-video-modal-description { + color: #475569; + font-size: 0.95rem; + line-height: 1.6; +} + +.inline-video-modal-meta { + display: grid; + gap: 0.75rem; +} + +.inline-video-modal-meta-row { + display: grid; + gap: 0.2rem; +} + +.inline-video-modal-meta-label { + color: #0f766e; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.inline-video-modal-meta-value { + color: #0f172a; + font-size: 0.95rem; + line-height: 1.5; +} + +[data-bs-theme="dark"] .inline-map-card { + border-color: rgba(96, 165, 250, 0.18); + background: linear-gradient(180deg, rgba(15, 23, 42, 0.96) 0%, rgba(30, 41, 59, 0.96) 100%); + box-shadow: 0 10px 26px rgba(2, 6, 23, 0.38); +} + +[data-bs-theme="dark"] .inline-map-card-title { + color: #e2e8f0; +} + +[data-bs-theme="dark"] .inline-map-card-summary, +[data-bs-theme="dark"] .inline-map-fallback { + color: #cbd5e1; +} + +[data-bs-theme="dark"] .inline-map-badge { + background: rgba(148, 163, 184, 0.12); + color: #e2e8f0; +} + +[data-bs-theme="dark"] .inline-image-gallery-card { + border-color: rgba(34, 211, 238, 0.18); + background: linear-gradient(180deg, rgba(15, 23, 42, 0.96) 0%, rgba(22, 78, 99, 0.22) 100%); + box-shadow: 0 10px 26px rgba(2, 6, 23, 0.38); +} + +[data-bs-theme="dark"] .inline-image-gallery-title, +[data-bs-theme="dark"] .inline-image-gallery-item-title, +[data-bs-theme="dark"] .inline-image-modal-meta-value { + color: #e2e8f0; +} + +[data-bs-theme="dark"] .inline-image-gallery-summary, +[data-bs-theme="dark"] .inline-image-gallery-item-description, +[data-bs-theme="dark"] .inline-image-modal-description { + color: #cbd5e1; +} + +[data-bs-theme="dark"] .inline-image-gallery-badge { + background: rgba(148, 163, 184, 0.12); + color: #e2e8f0; +} + +[data-bs-theme="dark"] .inline-image-gallery-item { + border-color: rgba(148, 163, 184, 0.16); + background: rgba(15, 23, 42, 0.74); +} + +[data-bs-theme="dark"] .inline-image-gallery-stage, +[data-bs-theme="dark"] .inline-image-modal-stage { + background: linear-gradient(180deg, rgba(15, 23, 42, 0.82) 0%, rgba(30, 41, 59, 0.82) 100%); +} + +[data-bs-theme="dark"] .inline-image-gallery-footer { + color: #94a3b8; + border-top-color: rgba(148, 163, 184, 0.14); +} + +[data-bs-theme="dark"] .inline-video-gallery-card { + border-color: rgba(45, 212, 191, 0.18); + background: linear-gradient(180deg, rgba(15, 23, 42, 0.96) 0%, rgba(15, 118, 110, 0.2) 100%); + box-shadow: 0 10px 26px rgba(2, 6, 23, 0.38); +} + +[data-bs-theme="dark"] .inline-video-gallery-title, +[data-bs-theme="dark"] .inline-video-gallery-item-title, +[data-bs-theme="dark"] .inline-video-modal-meta-value { + color: #e2e8f0; +} + +[data-bs-theme="dark"] .inline-video-gallery-summary, +[data-bs-theme="dark"] .inline-video-gallery-item-description, +[data-bs-theme="dark"] .inline-video-modal-description { + color: #cbd5e1; +} + +[data-bs-theme="dark"] .inline-video-gallery-badge { + background: rgba(148, 163, 184, 0.12); + color: #e2e8f0; +} + +[data-bs-theme="dark"] .inline-video-gallery-item { + border-color: rgba(148, 163, 184, 0.16); + background: rgba(15, 23, 42, 0.74); +} + +[data-bs-theme="dark"] .inline-video-gallery-stage, +[data-bs-theme="dark"] .inline-video-modal-stage { + background: linear-gradient(180deg, rgba(15, 23, 42, 0.82) 0%, rgba(30, 41, 59, 0.82) 100%); +} + +[data-bs-theme="dark"] .inline-video-gallery-footer { + color: #94a3b8; + border-top-color: rgba(148, 163, 184, 0.14); +} + +[data-bs-theme="dark"] .inline-map-shell { + border-color: rgba(148, 163, 184, 0.18); +} + +[data-bs-theme="dark"] .inline-map-canvas { + background: linear-gradient(180deg, #132033 0%, #0f172a 100%); +} + +[data-bs-theme="dark"] .inline-map-footer { + color: #94a3b8; +} + +@media (max-width: 768px) { + .inline-map-card-header { + flex-direction: column; + } + + .inline-map-badges { + justify-content: flex-start; + } + + .inline-map-canvas { + height: 260px; + } +} + [data-bs-theme="dark"] .citation-toggle-btn { color: #adb5bd; } diff --git a/application/single_app/static/css/sidebar.css b/application/single_app/static/css/sidebar.css index 1b3f7eb8..1c2f76e7 100644 --- a/application/single_app/static/css/sidebar.css +++ b/application/single_app/static/css/sidebar.css @@ -88,6 +88,7 @@ body.sidebar-nav-enabled.has-classification-banner .container-fluid { flex: 1; display: flex; flex-direction: column; + gap: 0.75rem; min-height: 0; /* Allow shrinking */ overflow: hidden; /* Prevent container from expanding */ } @@ -99,38 +100,113 @@ body.sidebar-nav-enabled.has-classification-banner .container-fluid { max-height: none; /* Remove any max-height constraints */ } +#sidebar-workflow-section { + flex: 0 0 auto; + min-height: 0; +} + +.sidebar-section-toggle { + color: #6c757d; + font-size: 0.875rem; + font-weight: 500; + letter-spacing: 0.02em; + text-transform: none; + cursor: pointer; + user-select: none; +} + +.sidebar-workflow-list-container { + display: flex; + flex-direction: column; + min-height: 0; +} + +#sidebar-workflow-conversations-list { + max-height: 18rem; + min-height: 0; + overflow-y: auto; + scrollbar-gutter: stable; +} + +.sidebar-workflow-show-more-btn { + align-self: flex-start; + padding: 0.35rem 0.5rem; + margin-top: 0.25rem; + font-size: 0.75rem; + text-decoration: none; +} + +.sidebar-workflow-show-more-btn:hover, +.sidebar-workflow-show-more-btn:focus { + text-decoration: none; +} + /* Custom scrollbar styling for sidebar conversations */ #sidebar-conversations-list::-webkit-scrollbar { width: 8px; } +#sidebar-workflow-conversations-list::-webkit-scrollbar { + width: 8px; +} + #sidebar-conversations-list::-webkit-scrollbar-track { background: #f8f9fa; /* Match sidebar background in light mode */ border-radius: 4px; } +#sidebar-workflow-conversations-list::-webkit-scrollbar-track { + background: #f8f9fa; + border-radius: 4px; +} + #sidebar-conversations-list::-webkit-scrollbar-thumb { background: #dee2e6; border-radius: 4px; } +#sidebar-workflow-conversations-list::-webkit-scrollbar-thumb { + background: #dee2e6; + border-radius: 4px; +} + #sidebar-conversations-list::-webkit-scrollbar-thumb:hover { background: #adb5bd; } +#sidebar-workflow-conversations-list::-webkit-scrollbar-thumb:hover { + background: #adb5bd; +} + /* Dark mode scrollbar styles */ [data-bs-theme="dark"] #sidebar-conversations-list::-webkit-scrollbar-track { background: #343a40; /* Match sidebar background in dark mode */ } +[data-bs-theme="dark"] #sidebar-workflow-conversations-list::-webkit-scrollbar-track { + background: #343a40; +} + [data-bs-theme="dark"] #sidebar-conversations-list::-webkit-scrollbar-thumb { background: #495057; } +[data-bs-theme="dark"] #sidebar-workflow-conversations-list::-webkit-scrollbar-thumb { + background: #495057; +} + [data-bs-theme="dark"] #sidebar-conversations-list::-webkit-scrollbar-thumb:hover { background: #6c757d; } +[data-bs-theme="dark"] #sidebar-workflow-conversations-list::-webkit-scrollbar-thumb:hover { + background: #6c757d; +} + +[data-bs-theme="dark"] .sidebar-section-toggle { + color: rgba(233, 236, 239, 0.65); +} + /* Workspaces Section Styles */ .overflow-auto { overflow: visible !important; /* Remove unwanted scrolling from workspaces */ diff --git a/application/single_app/static/css/workflow-activity.css b/application/single_app/static/css/workflow-activity.css new file mode 100644 index 00000000..3bb7a88b --- /dev/null +++ b/application/single_app/static/css/workflow-activity.css @@ -0,0 +1,519 @@ +.workflow-activity-page { + --workflow-activity-accent: #1f6feb; + --workflow-activity-accent-soft: rgba(31, 111, 235, 0.12); + --workflow-activity-border: rgba(15, 23, 42, 0.08); + --workflow-activity-shadow: 0 22px 50px rgba(15, 23, 42, 0.08); + --workflow-activity-viewport-height: auto; + --workflow-activity-surface-start: rgba(255, 255, 255, 0.98); + --workflow-activity-surface-end: rgba(247, 249, 252, 0.98); + --workflow-activity-stat-bg: rgba(255, 255, 255, 0.82); + --workflow-activity-card-bg: rgba(255, 255, 255, 0.92); + --workflow-activity-muted-bg: rgba(248, 250, 252, 0.88); + --workflow-activity-empty-bg: rgba(248, 250, 252, 0.8); + --workflow-activity-outline-border: rgba(31, 41, 55, 0.08); + --workflow-activity-card-border: rgba(15, 23, 42, 0.08); + --workflow-activity-track-start: rgba(31, 111, 235, 0.28); + --workflow-activity-track-end: rgba(15, 23, 42, 0.12); + --workflow-activity-connector: rgba(31, 111, 235, 0.18); + --workflow-activity-node-border: rgba(255, 255, 255, 0.95); + --workflow-activity-scroll-track: rgba(15, 23, 42, 0.05); + --workflow-activity-scroll-thumb: rgba(31, 111, 235, 0.32); + --workflow-activity-scroll-thumb-hover: rgba(31, 111, 235, 0.5); + display: grid; + grid-template-rows: auto minmax(0, 1fr); + gap: 1.5rem; + min-height: var(--workflow-activity-viewport-height); + height: var(--workflow-activity-viewport-height); + overflow: hidden; +} + +[data-bs-theme="dark"] .workflow-activity-page { + --workflow-activity-accent: #6ea8fe; + --workflow-activity-accent-soft: rgba(110, 168, 254, 0.18); + --workflow-activity-border: rgba(255, 255, 255, 0.12); + --workflow-activity-shadow: 0 22px 50px rgba(0, 0, 0, 0.34); + --workflow-activity-surface-start: rgba(43, 48, 53, 0.96); + --workflow-activity-surface-end: rgba(33, 37, 41, 0.96); + --workflow-activity-stat-bg: rgba(43, 48, 53, 0.84); + --workflow-activity-card-bg: rgba(43, 48, 53, 0.92); + --workflow-activity-muted-bg: rgba(52, 58, 64, 0.88); + --workflow-activity-empty-bg: rgba(52, 58, 64, 0.76); + --workflow-activity-outline-border: rgba(255, 255, 255, 0.12); + --workflow-activity-card-border: rgba(255, 255, 255, 0.12); + --workflow-activity-track-start: rgba(110, 168, 254, 0.4); + --workflow-activity-track-end: rgba(255, 255, 255, 0.08); + --workflow-activity-connector: rgba(110, 168, 254, 0.24); + --workflow-activity-node-border: rgba(33, 37, 41, 0.95); + --workflow-activity-scroll-track: rgba(255, 255, 255, 0.06); + --workflow-activity-scroll-thumb: rgba(110, 168, 254, 0.38); + --workflow-activity-scroll-thumb-hover: rgba(110, 168, 254, 0.56); +} + +#main-content.workflow-activity-main-content { + width: 100%; + max-width: none; + padding-right: clamp(0.75rem, 1.6vw, 1.5rem); +} + +#main-content.workflow-activity-main-content:not(.sidebar-padding) { + padding-left: clamp(0.75rem, 1.6vw, 1.5rem); +} + +#main-content.workflow-activity-main-content.sidebar-padding { + padding-left: calc(var(--sidebar-width, 260px) + clamp(0.75rem, 1.6vw, 1.5rem)); +} + +.workflow-activity-hero-wrap { + min-height: 0; +} + +.workflow-activity-hero, +.workflow-activity-timeline-panel, +.workflow-activity-detail-panel { + background: + radial-gradient(circle at top right, var(--workflow-activity-accent-soft), transparent 32%), + linear-gradient(180deg, var(--workflow-activity-surface-start), var(--workflow-activity-surface-end)); + border: 1px solid var(--workflow-activity-border); + box-shadow: var(--workflow-activity-shadow); +} + +.workflow-activity-kicker, +.workflow-activity-detail-kicker { + font-size: 0.78rem; + letter-spacing: 0.16em; + text-transform: uppercase; + font-weight: 700; + color: var(--workflow-activity-accent); +} + +.workflow-activity-title { + font-size: clamp(1.8rem, 2.8vw, 2.9rem); + line-height: 1.05; + font-weight: 700; + color: var(--bs-emphasis-color); +} + +.workflow-activity-caption { + max-width: 64ch; + color: var(--bs-secondary-color); + font-size: 1rem; +} + +.workflow-activity-stat-card { + height: 100%; + padding: 1rem 1.1rem; + border-radius: 1rem; + background: var(--workflow-activity-stat-bg); + border: 1px solid var(--workflow-activity-outline-border); +} + +.workflow-activity-stat-label { + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.12em; + font-weight: 700; + color: var(--bs-secondary-color); +} + +.workflow-activity-stat-value { + margin-top: 0.5rem; + font-size: 1.2rem; + font-weight: 700; + color: var(--bs-emphasis-color); +} + +.workflow-activity-response { + border-radius: 1rem; + padding: 1rem 1.1rem; + background: var(--workflow-activity-stat-bg); + border: 1px solid var(--workflow-activity-outline-border); +} + +.workflow-activity-response-toggle-icon { + transition: transform 0.18s ease; +} + +.workflow-activity-response-toggle[aria-expanded="true"] .workflow-activity-response-toggle-icon { + transform: rotate(180deg); +} + +.workflow-activity-response-title { + font-size: 0.82rem; + text-transform: uppercase; + letter-spacing: 0.12em; + font-weight: 700; + color: var(--bs-secondary-color); + margin-bottom: 0.65rem; +} + +.workflow-activity-response-text { + margin: 0; + white-space: pre-wrap; + color: var(--bs-body-color); +} + +.workflow-activity-layout { + display: grid; + grid-template-columns: minmax(0, 1.35fr) minmax(320px, 0.85fr); + gap: 1.5rem; + align-items: stretch; + min-height: 0; +} + +.workflow-activity-timeline-panel, +.workflow-activity-detail-panel { + min-height: 0; + height: 100%; + overflow: hidden; +} + +.workflow-activity-panel-body, +.workflow-activity-detail-body { + display: grid; + grid-template-rows: auto minmax(0, 1fr); + min-height: 0; + height: 100%; +} + +.workflow-activity-timeline-viewport { + min-height: 0; + height: 100%; + max-height: 100%; + overflow-y: scroll; + overflow-x: hidden; + padding-right: 0.3rem; + padding-bottom: 0.5rem; + scroll-behavior: smooth; + scrollbar-gutter: stable; + scrollbar-width: thin; + scrollbar-color: var(--workflow-activity-scroll-thumb) var(--workflow-activity-scroll-track); +} + +.workflow-activity-timeline-viewport::-webkit-scrollbar, +.workflow-activity-event-history::-webkit-scrollbar { + width: 0.75rem; +} + +.workflow-activity-timeline-viewport::-webkit-scrollbar-track, +.workflow-activity-event-history::-webkit-scrollbar-track { + background: var(--workflow-activity-scroll-track); + border-radius: 999px; +} + +.workflow-activity-timeline-viewport::-webkit-scrollbar-thumb, +.workflow-activity-event-history::-webkit-scrollbar-thumb { + background: var(--workflow-activity-scroll-thumb); + border-radius: 999px; + border: 2px solid var(--workflow-activity-surface-start); +} + +.workflow-activity-timeline-viewport::-webkit-scrollbar-thumb:hover, +.workflow-activity-event-history::-webkit-scrollbar-thumb:hover { + background: var(--workflow-activity-scroll-thumb-hover); +} + +.workflow-activity-timeline { + position: relative; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.workflow-activity-timeline::before { + content: ""; + position: absolute; + top: 0.75rem; + bottom: 0.75rem; + left: 1.25rem; + width: 2px; + background: linear-gradient(180deg, var(--workflow-activity-track-start), var(--workflow-activity-track-end)); +} + +.workflow-activity-row { + position: relative; + padding-left: calc(3rem + (var(--lane-index, 0) * 2.5rem)); + min-height: 5.5rem; +} + +.workflow-activity-row::before { + content: ""; + position: absolute; + top: 0.95rem; + bottom: -1rem; + left: calc(1.25rem + (var(--lane-index, 0) * 2.5rem)); + width: 2px; + background: var(--workflow-activity-connector); +} + +.workflow-activity-row:last-child::before { + bottom: 2rem; +} + +.workflow-activity-row::after { + content: ""; + position: absolute; + top: 1.75rem; + left: 1.25rem; + width: calc(var(--lane-index, 0) * 2.5rem); + height: 2px; + background: var(--workflow-activity-connector); +} + +.workflow-activity-node { + position: absolute; + top: 1.35rem; + left: calc(0.9rem + (var(--lane-index, 0) * 2.5rem)); + width: 0.75rem; + height: 0.75rem; + border-radius: 999px; + border: 2px solid var(--workflow-activity-node-border); + box-shadow: 0 0 0 4px rgba(31, 111, 235, 0.16); +} + +.workflow-activity-node[data-status="completed"] { + background: #1a7f37; +} + +.workflow-activity-node[data-status="running"] { + background: #1f6feb; +} + +.workflow-activity-node[data-status="failed"] { + background: #cf222e; +} + +.workflow-activity-card { + width: 100%; + border: 1px solid var(--workflow-activity-card-border); + border-radius: 1.15rem; + background: var(--workflow-activity-card-bg); + padding: 1rem 1rem 1rem 1.05rem; + text-align: left; + box-shadow: 0 10px 28px rgba(15, 23, 42, 0.06); + transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease; +} + +.workflow-activity-card:hover, +.workflow-activity-card.is-selected { + transform: translateY(-1px); + box-shadow: 0 18px 36px rgba(15, 23, 42, 0.1); + border-color: rgba(31, 111, 235, 0.28); +} + +.workflow-activity-card[data-status="running"] { + border-left: 4px solid #1f6feb; +} + +.workflow-activity-card[data-status="completed"] { + border-left: 4px solid #1a7f37; +} + +.workflow-activity-card[data-status="failed"] { + border-left: 4px solid #cf222e; +} + +.workflow-activity-card-header { + display: flex; + justify-content: space-between; + gap: 0.85rem; + align-items: flex-start; +} + +.workflow-activity-card-title { + margin: 0; + font-size: 1rem; + font-weight: 700; + color: var(--bs-emphasis-color); +} + +.workflow-activity-card-summary { + margin: 0.45rem 0 0; + color: var(--bs-secondary-color); + font-size: 0.95rem; +} + +.workflow-activity-card-meta { + display: flex; + flex-wrap: wrap; + gap: 0.5rem 0.85rem; + margin-top: 0.85rem; + color: var(--bs-secondary-color); + font-size: 0.82rem; +} + +.workflow-activity-card-meta span { + display: inline-flex; + align-items: center; + gap: 0.35rem; +} + +.workflow-activity-badge { + display: inline-flex; + align-items: center; + padding: 0.35rem 0.6rem; + border-radius: 999px; + font-size: 0.78rem; + font-weight: 700; + text-transform: capitalize; +} + +.workflow-activity-badge[data-status="running"] { + background: rgba(31, 111, 235, 0.12); + color: #1f6feb; +} + +.workflow-activity-badge[data-status="completed"] { + background: rgba(26, 127, 55, 0.12); + color: #1a7f37; +} + +.workflow-activity-badge[data-status="failed"] { + background: rgba(207, 34, 46, 0.12); + color: #cf222e; +} + +.workflow-activity-empty { + border: 1px dashed var(--workflow-activity-card-border); + border-radius: 1.25rem; + padding: 2.25rem 1.5rem; + text-align: center; + background: var(--workflow-activity-empty-bg); +} + +.workflow-activity-empty-icon { + font-size: 2rem; + color: var(--workflow-activity-accent); + margin-bottom: 0.9rem; +} + +.workflow-activity-detail-sticky { + display: flex; + flex-direction: column; + min-height: 0; + height: 100%; +} + +.workflow-activity-detail-meta { + display: flex; + flex-wrap: wrap; + gap: 0.5rem 0.75rem; + color: var(--bs-secondary-color); + font-size: 0.88rem; +} + +.workflow-activity-detail-summary { + font-size: 1rem; +} + +.workflow-activity-detail-block { + border-radius: 1rem; + padding: 1rem 1rem 1.1rem; + background: var(--workflow-activity-muted-bg); + border: 1px solid var(--workflow-activity-card-border); +} + +.workflow-activity-event-history-block { + display: flex; + flex-direction: column; + min-height: 0; + flex: 1 1 auto; +} + +.workflow-activity-detail-label { + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.12em; + font-weight: 700; + color: var(--bs-secondary-color); + margin-bottom: 0.75rem; +} + +.workflow-activity-detail-text { + white-space: pre-wrap; + word-break: break-word; + background: transparent; + color: var(--bs-body-color); + font-size: 0.92rem; +} + +.workflow-activity-event-history { + display: flex; + flex-direction: column; + gap: 0.75rem; + min-height: 0; + overflow-y: auto; + padding-right: 0.2rem; + scrollbar-width: thin; + scrollbar-color: var(--workflow-activity-scroll-thumb) var(--workflow-activity-scroll-track); +} + +.workflow-activity-event-item { + border-radius: 0.85rem; + padding: 0.75rem 0.85rem; + background: var(--workflow-activity-card-bg); + border: 1px solid var(--workflow-activity-card-border); +} + +.workflow-activity-event-item-header { + display: flex; + justify-content: space-between; + gap: 0.75rem; + align-items: center; + margin-bottom: 0.35rem; +} + +.workflow-activity-event-item-title { + font-weight: 700; + font-size: 0.9rem; +} + +.workflow-activity-event-item-time { + font-size: 0.78rem; + color: var(--bs-secondary-color); +} + +.workflow-activity-event-item-detail { + white-space: pre-wrap; + word-break: break-word; + color: var(--bs-secondary-color); + font-size: 0.86rem; +} + +@media (max-width: 991.98px) { + #main-content.workflow-activity-main-content, + #main-content.workflow-activity-main-content.sidebar-padding, + #main-content.workflow-activity-main-content:not(.sidebar-padding) { + padding-left: 0.75rem; + padding-right: 0.75rem; + } + + .workflow-activity-page { + min-height: auto; + height: auto; + overflow: visible; + } + + .workflow-activity-layout { + grid-template-columns: 1fr; + } + + .workflow-activity-timeline-viewport { + max-height: 50vh; + } +} + +@media (max-width: 767.98px) { + .workflow-activity-row { + padding-left: 3rem; + } + + .workflow-activity-row::before, + .workflow-activity-row::after { + left: 1.25rem; + width: 0; + } + + .workflow-activity-node { + left: 0.9rem; + } +} diff --git a/application/single_app/static/images/custom_logo.png b/application/single_app/static/images/custom_logo.png index ecf6e652..a5b440e9 100644 Binary files a/application/single_app/static/images/custom_logo.png and b/application/single_app/static/images/custom_logo.png differ diff --git a/application/single_app/static/images/custom_logo_dark.png b/application/single_app/static/images/custom_logo_dark.png index 4f281945..df9485a9 100644 Binary files a/application/single_app/static/images/custom_logo_dark.png and b/application/single_app/static/images/custom_logo_dark.png differ diff --git a/application/single_app/static/images/favicon.ico b/application/single_app/static/images/favicon.ico index 3dc7742a..d8f058f6 100644 Binary files a/application/single_app/static/images/favicon.ico and b/application/single_app/static/images/favicon.ico differ diff --git a/application/single_app/static/js/admin/admin_agents.js b/application/single_app/static/js/admin/admin_agents.js index 5c1daed8..ed999e9e 100644 --- a/application/single_app/static/js/admin/admin_agents.js +++ b/application/single_app/static/js/admin/admin_agents.js @@ -65,13 +65,18 @@ window.loadAllAdminAgentData = loadAllAdminAgentData; // Expose for reloading af function renderAdminAgentDropdown(agentsList, selectedAgentName) { const dropdown = document.getElementById('default-agent-select'); + const statusMsg = document.getElementById('default-agent-select-msg'); if (!dropdown) return; dropdown.innerHTML = ''; - if (!agentsList.length) { + const enabledAgents = agentsList.filter(agent => agent.is_enabled !== false); + if (!enabledAgents.length) { dropdown.disabled = true; + if (statusMsg) { + statusMsg.textContent = 'No enabled global agents are available.'; + } return; } - agentsList.forEach(agent => { + enabledAgents.forEach(agent => { const opt = document.createElement('option'); opt.value = agent.name; opt.textContent = agent.display_name || agent.name; @@ -79,6 +84,9 @@ function renderAdminAgentDropdown(agentsList, selectedAgentName) { dropdown.appendChild(opt); }); dropdown.disabled = false; + if (statusMsg) { + statusMsg.textContent = ''; + } // Attach change handler only once if (!dropdown._handlerAttached) { dropdown.addEventListener('change', async function () { @@ -272,15 +280,20 @@ function renderAgentsTable() { agents.forEach((agent, idx) => { // Use global_selected_agent for badge logic (compare by name) const isSelected = selectedAgent && agent.name === selectedAgent; + const isEnabled = agent.is_enabled !== false; const tr = document.createElement('tr'); let selectedBadge = isSelected ? 'Selected' : ''; + let enabledBadge = isEnabled + ? 'Enabled' + : 'Disabled'; tr.innerHTML = ` - ${agent.name} + ${agent.name}${enabledBadge} ${agent.display_name} ${agent.description || ''} ${selectedBadge} + `; @@ -292,15 +305,24 @@ function renderAgentsTable() { } function handleAgentTableClick(e) { - if (e.target.classList.contains('edit-agent-btn')) { - const idx = parseInt(e.target.getAttribute('data-index'), 10); + const editButton = e.target.closest('.edit-agent-btn'); + const deleteButton = e.target.closest('.delete-agent-btn'); + const toggleButton = e.target.closest('.toggle-agent-btn'); + + if (editButton) { + const idx = parseInt(editButton.getAttribute('data-index'), 10); if (!isNaN(idx) && Array.isArray(agents)) { openAgentModal(agents[idx]); window.editingAgentIndex = idx; window.editingAgentName = agents[idx].name; } - } else if (e.target.classList.contains('delete-agent-btn')) { - const idx = parseInt(e.target.getAttribute('data-index'), 10); + } else if (toggleButton) { + const idx = parseInt(toggleButton.getAttribute('data-index'), 10); + if (!isNaN(idx) && Array.isArray(agents)) { + toggleAgentEnabled(idx); + } + } else if (deleteButton) { + const idx = parseInt(deleteButton.getAttribute('data-index'), 10); if (!isNaN(idx) && Array.isArray(agents)) { // Confirm delete if (confirm(`Are you sure you want to delete agent '${agents[idx].name}'?`)) { @@ -328,6 +350,35 @@ async function deleteAgent(idx) { } } +async function toggleAgentEnabled(idx) { + const agent = agents[idx]; + const nextEnabledState = agent.is_enabled === false; + + try { + const resp = await fetch(`/api/admin/agents/${encodeURIComponent(agent.name)}/enabled`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ is_enabled: nextEnabledState }), + }); + + const data = await resp.json(); + if (!resp.ok) { + showToast(data.error || 'Failed to update agent state.', 'danger'); + return; + } + + await loadAllAdminAgentData(); + if (data.fallback_agent_name) { + showToast(`Agent ${nextEnabledState ? 'enabled' : 'disabled'}. Selected agent switched to ${data.fallback_agent_name}.`, 'success'); + return; + } + + showToast(`Agent ${nextEnabledState ? 'enabled' : 'disabled'}!`, 'success'); + } catch (err) { + showToast('Failed to update agent state.', 'danger'); + } +} + // Helper to (re)attach change handler to dropdown function attachSelectedAgentDropdownHandler() { const oldDropdown = document.getElementById('default-agent-select'); diff --git a/application/single_app/static/js/admin/admin_plugins.js b/application/single_app/static/js/admin/admin_plugins.js index 97dc39ec..080c4f4a 100644 --- a/application/single_app/static/js/admin/admin_plugins.js +++ b/application/single_app/static/js/admin/admin_plugins.js @@ -2,6 +2,8 @@ import { showToast } from "../chat/chat-toast.js" import { renderPluginsTable as sharedRenderPluginsTable, validatePluginManifest as sharedValidatePluginManifest, getErrorMessageFromResponse } from "../plugin_common.js"; +let adminPlugins = []; + // Main logic document.addEventListener('DOMContentLoaded', function () { if (!document.getElementById('actions-configuration')) return; @@ -19,13 +21,14 @@ async function loadPlugins() { try { const res = await fetch('/api/admin/plugins'); if (!res.ok) throw new Error('Failed to load actions'); - const plugins = await res.json(); + adminPlugins = await res.json(); sharedRenderPluginsTable({ - plugins, + plugins: adminPlugins, tbodySelector: '#admin-plugins-table-body', onEdit: name => editPlugin(name), onDelete: name => deletePlugin(name), + onToggleEnabled: name => togglePluginEnabled(name), ensureTable: false, isAdmin: true }); @@ -128,10 +131,7 @@ async function savePlugin(pluginData, existingPlugin = null) { // Edit plugin modal logic async function editPlugin(name) { try { - const res = await fetch('/api/admin/plugins'); - if (!res.ok) throw new Error('Failed to load actions'); - const plugins = await res.json(); - const plugin = plugins.find(p => p.name === name); + const plugin = adminPlugins.find(p => p.name === name); if (plugin) { openPluginModal(plugin); @@ -144,6 +144,35 @@ async function editPlugin(name) { } } +async function togglePluginEnabled(name) { + const plugin = adminPlugins.find(item => item.name === name); + if (!plugin) { + showToast(`Action "${name}" not found`, 'danger'); + return; + } + + const nextEnabledState = plugin.is_enabled === false; + + try { + const res = await fetch(`/api/admin/plugins/${encodeURIComponent(name)}/enabled`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ is_enabled: nextEnabledState }) + }); + + if (!res.ok) { + const errorMessage = await getErrorMessageFromResponse(res, 'Failed to update action state'); + throw new Error(errorMessage); + } + + await loadPlugins(); + showToast(`Action "${name}" ${nextEnabledState ? 'enabled' : 'disabled'} successfully`, 'success'); + } catch (error) { + console.error('Error updating action enabled state:', error); + showToast('Error updating action state: ' + error.message, 'danger'); + } +} + async function deletePlugin(name) { if (!confirm(`Are you sure you want to delete action "${name}"?`)) return; diff --git a/application/single_app/static/js/admin/admin_settings.js b/application/single_app/static/js/admin/admin_settings.js index 7861b801..c58db54e 100644 --- a/application/single_app/static/js/admin/admin_settings.js +++ b/application/single_app/static/js/admin/admin_settings.js @@ -96,6 +96,7 @@ document.addEventListener('DOMContentLoaded', () => { updateImageHiddenInput(); setupToggles(); // This function will be extended below + setupLandingPageLogoScaleControl(); // Initialize tooltips initializeTooltips(); @@ -4921,6 +4922,23 @@ function initializeTooltips() { } } +function setupLandingPageLogoScaleControl() { + const slider = document.getElementById('landing_page_logo_scale_percent'); + const valueDisplay = document.getElementById('landing-page-logo-scale-value'); + + if (!slider || !valueDisplay) { + return; + } + + const updateValue = () => { + valueDisplay.textContent = `${slider.value}%`; + }; + + slider.addEventListener('input', updateValue); + slider.addEventListener('change', updateValue); + updateValue(); +} + /** * Check if optional features are enabled and configured for a specific step * @param {number} stepNumber - The step to check diff --git a/application/single_app/static/js/agent_modal_stepper.js b/application/single_app/static/js/agent_modal_stepper.js index f66586d7..e4e7d0c1 100644 --- a/application/single_app/static/js/agent_modal_stepper.js +++ b/application/single_app/static/js/agent_modal_stepper.js @@ -4,6 +4,164 @@ import { showToast } from "./chat/chat-toast.js"; import * as agentsCommon from "./agents_common.js"; import { getModelSupportedLevels } from "./chat/chat-reasoning.js"; +const ACTION_CAPABILITIES_KEY = 'action_capabilities'; +const SIMPLECHAT_CAPABILITY_DEFINITIONS = [ + { + key: 'create_group', + label: 'Create groups', + description: 'Allow the agent to create new group workspaces as the current user.' + }, + { + key: 'add_group_member', + label: 'Add users to groups', + description: 'Allow the agent to add members directly to groups using the current user\'s permissions.' + }, + { + key: 'make_group_inactive', + label: 'Make groups inactive', + description: 'Allow the agent to mark a group inactive when the current user has Control Center admin access.' + }, + { + key: 'create_group_conversation', + label: 'Create group multi-user conversations', + description: 'Allow the agent to create invite-managed group multi-user conversations and then add current group members as participants.' + }, + { + key: 'invite_group_conversation_members', + label: 'Invite group conversation members', + description: 'Allow the agent to invite current group members into an existing invite-managed group multi-user conversation.' + }, + { + key: 'create_personal_conversation', + label: 'Create personal conversations', + description: 'Allow the agent to create standard one-user personal conversations.' + }, + { + key: 'create_personal_workflow', + label: 'Create personal workflows', + description: 'Allow the agent to create personal workflows using the current user\'s own workflow permissions.' + }, + { + key: 'add_conversation_message', + label: 'Add conversation messages', + description: 'Allow the agent to add a user-authored message to an existing personal or collaborative conversation.' + }, + { + key: 'upload_markdown_document', + label: 'Upload markdown documents', + description: 'Allow the agent to create and upload Markdown documents into the current user\'s personal or allowed group workspaces.' + }, + { + key: 'create_personal_collaboration_conversation', + label: 'Create personal collaborative conversations', + description: 'Allow the agent to create personal collaborative conversations and invite participants.' + } +]; +const MSGRAPH_CAPABILITY_DEFINITIONS = [ + { + key: 'get_my_profile', + label: 'Read my profile', + description: 'Allow the agent to read the signed-in user\'s Microsoft 365 profile details.' + }, + { + key: 'get_my_timezone', + label: 'Read my mailbox timezone', + description: 'Allow the agent to read mailbox time zone and time formatting settings.' + }, + { + key: 'get_my_events', + label: 'Read my calendar events', + description: 'Allow the agent to read upcoming calendar events for the signed-in user.' + }, + { + key: 'create_calendar_invite', + label: 'Create calendar invites', + description: 'Allow the agent to create calendar invites, add current group members as attendees, and create Microsoft Teams meetings.' + }, + { + key: 'get_my_messages', + label: 'Read my mail', + description: 'Allow the agent to read recent mail messages for the signed-in user.' + }, + { + key: 'mark_message_as_read', + label: 'Update message read state', + description: 'Allow the agent to mark mail messages as read or unread.' + }, + { + key: 'search_users', + label: 'Search directory users', + description: 'Allow the agent to search Microsoft 365 directory users by name or email prefix.' + }, + { + key: 'get_user_by_email', + label: 'Lookup user by email', + description: 'Allow the agent to look up a directory user by exact email address or UPN.' + }, + { + key: 'list_drive_items', + label: 'List OneDrive items', + description: 'Allow the agent to list items from the signed-in user\'s OneDrive.' + }, + { + key: 'get_my_security_alerts', + label: 'Read my security alerts', + description: 'Allow the agent to read recent security alerts available to the signed-in user.' + } +]; +const CHART_CAPABILITY_DEFINITIONS = [ + { + key: 'line', + label: 'Line charts', + description: 'Allow the agent to generate line charts.' + }, + { + key: 'bar', + label: 'Bar charts', + description: 'Allow the agent to generate bar charts.' + }, + { + key: 'pie', + label: 'Pie charts', + description: 'Allow the agent to generate pie charts.' + }, + { + key: 'doughnut', + label: 'Doughnut charts', + description: 'Allow the agent to generate doughnut charts.' + }, + { + key: 'scatter', + label: 'Scatter plots', + description: 'Allow the agent to generate scatter plots.' + }, + { + key: 'area', + label: 'Area charts', + description: 'Allow the agent to generate area charts.' + }, + { + key: 'bubble', + label: 'Bubble charts', + description: 'Allow the agent to generate bubble charts.' + }, + { + key: 'radar', + label: 'Radar charts', + description: 'Allow the agent to generate radar charts.' + }, + { + key: 'stacked_bar', + label: 'Stacked bar charts', + description: 'Allow the agent to generate stacked bar charts.' + }, + { + key: 'stacked_line', + label: 'Stacked line charts', + description: 'Allow the agent to generate stacked line charts.' + } +]; + export class AgentModalStepper { constructor(isAdmin = false, options = {}) { this.currentStep = 1; @@ -15,6 +173,7 @@ export class AgentModalStepper { this.currentAgentType = 'local'; this.originalAgent = null; // Track original state for change detection this.actionsToSelect = null; // Store actions to select when they're loaded + this.availableActions = []; this.updateStepIndicatorTimeout = null; // For debouncing step indicator updates this.templateSubmitButton = document.getElementById('agent-modal-submit-template-btn'); this.foundryPlaceholderInstructions = 'Placeholder instructions: Azure AI Foundry agent manages its own prompt.'; @@ -572,6 +731,7 @@ export class AgentModalStepper { const foundryActivityApiVersionInput = document.getElementById('agent-new-foundry-activity-api-version'); const foundryNotesInput = document.getElementById('agent-foundry-notes'); const foundryStatus = document.getElementById('agent-foundry-fetch-status'); + const additionalSettings = document.getElementById('agent-additional-settings'); if (displayName) displayName.value = ''; if (generatedName) generatedName.value = ''; @@ -595,16 +755,22 @@ export class AgentModalStepper { if (foundryActivityApiVersionInput) foundryActivityApiVersionInput.value = ''; if (foundryNotesInput) foundryNotesInput.value = ''; if (foundryStatus) foundryStatus.textContent = ''; + if (additionalSettings) additionalSettings.value = '{}'; // Clear any selected actions this.clearSelectedActions(); } clearSelectedActions() { - const actionCards = document.querySelectorAll('.action-card.border-primary'); + const actionCards = document.querySelectorAll('.action-card'); actionCards.forEach(card => { - card.classList.remove('border-primary', 'bg-primary-subtle'); + card.classList.remove('border-primary', 'bg-light'); + const checkIcon = card.querySelector('.action-check-icon'); + if (checkIcon) { + checkIcon.classList.add('d-none'); + } }); + this.updateSelectedActionsDisplay(); } async loadModelsForModal() { @@ -1298,6 +1464,7 @@ export class AgentModalStepper { const nameB = (b.display_name || b.name || '').toLowerCase(); return nameA.localeCompare(nameB); }); + this.availableActions = filteredActions; // Clear container container.innerHTML = ''; @@ -1638,6 +1805,390 @@ export class AgentModalStepper { } else { if (summaryDiv) summaryDiv.classList.add('d-none'); } + + this.renderSimpleChatCapabilitySections(); + this.renderMsGraphCapabilitySections(); + this.renderChartCapabilitySections(); + } + + getDefaultSimpleChatCapabilities(actionId = '', actionName = '') { + const defaults = {}; + SIMPLECHAT_CAPABILITY_DEFINITIONS.forEach(definition => { + defaults[definition.key] = true; + }); + + const action = (this.availableActions || []).find(candidate => { + const candidateId = String(candidate?.id || candidate?.name || '').trim(); + const candidateName = String(candidate?.name || candidate?.display_name || '').trim(); + return (actionId && candidateId === actionId) || (actionName && candidateName === actionName); + }); + + const rawCapabilities = action?.additionalFields?.simplechat_capabilities + || action?.additional_fields?.simplechat_capabilities + || action?.simplechat_capabilities; + + if (rawCapabilities && typeof rawCapabilities === 'object' && !Array.isArray(rawCapabilities)) { + SIMPLECHAT_CAPABILITY_DEFINITIONS.forEach(definition => { + if (Object.prototype.hasOwnProperty.call(rawCapabilities, definition.key)) { + defaults[definition.key] = Boolean(rawCapabilities[definition.key]); + } + }); + } + + return defaults; + } + + getParsedAdditionalSettings() { + const settingsField = document.getElementById('agent-additional-settings'); + const rawValue = settingsField?.value?.trim() || '{}'; + + try { + const parsed = JSON.parse(rawValue); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}; + } catch (error) { + console.warn('Unable to parse agent additional settings while rendering capabilities:', error); + return {}; + } + } + + setParsedAdditionalSettings(settings) { + const settingsField = document.getElementById('agent-additional-settings'); + if (!settingsField) { + return; + } + settingsField.value = JSON.stringify(settings || {}, null, 2); + } + + getActionCapabilityMap() { + const otherSettings = this.getParsedAdditionalSettings(); + const capabilityMap = otherSettings[ACTION_CAPABILITIES_KEY]; + return capabilityMap && typeof capabilityMap === 'object' && !Array.isArray(capabilityMap) + ? capabilityMap + : {}; + } + + getSimpleChatCapabilitiesForAction(actionId, actionName) { + const defaults = this.getDefaultSimpleChatCapabilities(actionId, actionName); + const capabilityMap = this.getActionCapabilityMap(); + const storedCapabilities = capabilityMap[actionId] || capabilityMap[actionName] || {}; + + SIMPLECHAT_CAPABILITY_DEFINITIONS.forEach(definition => { + if (Object.prototype.hasOwnProperty.call(storedCapabilities, definition.key)) { + defaults[definition.key] = Boolean(storedCapabilities[definition.key]); + } + }); + + return defaults; + } + + updateSimpleChatCapabilities(actionId, actionName, nextCapabilities) { + const otherSettings = this.getParsedAdditionalSettings(); + const capabilityMap = this.getActionCapabilityMap(); + capabilityMap[actionId || actionName] = { ...nextCapabilities }; + otherSettings[ACTION_CAPABILITIES_KEY] = capabilityMap; + this.setParsedAdditionalSettings(otherSettings); + } + + renderSimpleChatCapabilitySections() { + const container = document.getElementById('agent-simplechat-capabilities'); + const list = document.getElementById('agent-simplechat-capabilities-list'); + if (!container || !list) { + return; + } + + const selectedSimpleChatCards = Array.from(document.querySelectorAll('.action-card.border-primary')).filter(card => { + return (card.getAttribute('data-action-type') || '').toLowerCase() === 'simplechat'; + }); + + if (!selectedSimpleChatCards.length || this.isAnyFoundryType()) { + container.classList.add('d-none'); + list.innerHTML = ''; + return; + } + + container.classList.remove('d-none'); + list.innerHTML = ''; + + selectedSimpleChatCards.forEach(card => { + const actionId = card.getAttribute('data-action-id') || card.getAttribute('data-action-name') || ''; + const actionName = card.getAttribute('data-action-name') || actionId; + const capabilities = this.getSimpleChatCapabilitiesForAction(actionId, actionName); + + const section = document.createElement('div'); + section.className = 'border rounded p-3 bg-light'; + + const heading = document.createElement('div'); + heading.className = 'fw-semibold mb-1'; + heading.textContent = actionName; + section.appendChild(heading); + + const helperText = document.createElement('div'); + helperText.className = 'text-muted small mb-3'; + helperText.textContent = 'These capability toggles apply only to this agent assignment.'; + section.appendChild(helperText); + + SIMPLECHAT_CAPABILITY_DEFINITIONS.forEach(definition => { + const wrapper = document.createElement('div'); + wrapper.className = 'form-check mb-2'; + + const checkbox = document.createElement('input'); + checkbox.className = 'form-check-input'; + checkbox.type = 'checkbox'; + checkbox.id = `simplechat-capability-${actionId}-${definition.key}`; + checkbox.checked = Boolean(capabilities[definition.key]); + + const label = document.createElement('label'); + label.className = 'form-check-label'; + label.setAttribute('for', checkbox.id); + label.innerHTML = `${this.escapeHtml(definition.label)}
${this.escapeHtml(definition.description)}`; + + checkbox.addEventListener('change', () => { + const updatedCapabilities = this.getSimpleChatCapabilitiesForAction(actionId, actionName); + updatedCapabilities[definition.key] = checkbox.checked; + this.updateSimpleChatCapabilities(actionId, actionName, updatedCapabilities); + }); + + wrapper.appendChild(checkbox); + wrapper.appendChild(label); + section.appendChild(wrapper); + }); + + list.appendChild(section); + }); + } + + getDefaultMsGraphCapabilities(actionId = '', actionName = '') { + const defaults = {}; + MSGRAPH_CAPABILITY_DEFINITIONS.forEach(definition => { + defaults[definition.key] = true; + }); + + const action = (this.availableActions || []).find(candidate => { + const candidateId = String(candidate?.id || candidate?.name || '').trim(); + const candidateName = String(candidate?.name || candidate?.display_name || '').trim(); + return (actionId && candidateId === actionId) || (actionName && candidateName === actionName); + }); + + const rawCapabilities = action?.additionalFields?.msgraph_capabilities + || action?.additional_fields?.msgraph_capabilities + || action?.msgraph_capabilities; + + if (rawCapabilities && typeof rawCapabilities === 'object' && !Array.isArray(rawCapabilities)) { + MSGRAPH_CAPABILITY_DEFINITIONS.forEach(definition => { + if (Object.prototype.hasOwnProperty.call(rawCapabilities, definition.key)) { + defaults[definition.key] = Boolean(rawCapabilities[definition.key]); + } + }); + } + + return defaults; + } + + getMsGraphCapabilitiesForAction(actionId, actionName) { + const defaults = this.getDefaultMsGraphCapabilities(actionId, actionName); + const capabilityMap = this.getActionCapabilityMap(); + const storedCapabilities = capabilityMap[actionId] || capabilityMap[actionName] || {}; + + MSGRAPH_CAPABILITY_DEFINITIONS.forEach(definition => { + if (Object.prototype.hasOwnProperty.call(storedCapabilities, definition.key)) { + defaults[definition.key] = Boolean(storedCapabilities[definition.key]); + } + }); + + return defaults; + } + + updateMsGraphCapabilities(actionId, actionName, nextCapabilities) { + const otherSettings = this.getParsedAdditionalSettings(); + const capabilityMap = this.getActionCapabilityMap(); + capabilityMap[actionId || actionName] = { ...nextCapabilities }; + otherSettings[ACTION_CAPABILITIES_KEY] = capabilityMap; + this.setParsedAdditionalSettings(otherSettings); + } + + renderMsGraphCapabilitySections() { + const container = document.getElementById('agent-msgraph-capabilities'); + const list = document.getElementById('agent-msgraph-capabilities-list'); + if (!container || !list) { + return; + } + + const selectedMsGraphCards = Array.from(document.querySelectorAll('.action-card.border-primary')).filter(card => { + return (card.getAttribute('data-action-type') || '').toLowerCase() === 'msgraph'; + }); + + if (!selectedMsGraphCards.length || this.isAnyFoundryType()) { + container.classList.add('d-none'); + list.innerHTML = ''; + return; + } + + container.classList.remove('d-none'); + list.innerHTML = ''; + + selectedMsGraphCards.forEach(card => { + const actionId = card.getAttribute('data-action-id') || card.getAttribute('data-action-name') || ''; + const actionName = card.getAttribute('data-action-name') || actionId; + const capabilities = this.getMsGraphCapabilitiesForAction(actionId, actionName); + + const section = document.createElement('div'); + section.className = 'border rounded p-3 bg-light'; + + const heading = document.createElement('div'); + heading.className = 'fw-semibold mb-1'; + heading.textContent = actionName; + section.appendChild(heading); + + const helperText = document.createElement('div'); + helperText.className = 'text-muted small mb-3'; + helperText.textContent = 'These capability toggles apply only to this agent assignment.'; + section.appendChild(helperText); + + MSGRAPH_CAPABILITY_DEFINITIONS.forEach(definition => { + const wrapper = document.createElement('div'); + wrapper.className = 'form-check mb-2'; + + const checkbox = document.createElement('input'); + checkbox.className = 'form-check-input'; + checkbox.type = 'checkbox'; + checkbox.id = `msgraph-capability-${actionId}-${definition.key}`; + checkbox.checked = Boolean(capabilities[definition.key]); + + const label = document.createElement('label'); + label.className = 'form-check-label'; + label.setAttribute('for', checkbox.id); + label.innerHTML = `${this.escapeHtml(definition.label)}
${this.escapeHtml(definition.description)}`; + + checkbox.addEventListener('change', () => { + const updatedCapabilities = this.getMsGraphCapabilitiesForAction(actionId, actionName); + updatedCapabilities[definition.key] = checkbox.checked; + this.updateMsGraphCapabilities(actionId, actionName, updatedCapabilities); + }); + + wrapper.appendChild(checkbox); + wrapper.appendChild(label); + section.appendChild(wrapper); + }); + + list.appendChild(section); + }); + } + + getDefaultChartCapabilities(actionId = '', actionName = '') { + const defaults = {}; + CHART_CAPABILITY_DEFINITIONS.forEach(definition => { + defaults[definition.key] = true; + }); + + const action = (this.availableActions || []).find(candidate => { + const candidateId = String(candidate?.id || candidate?.name || '').trim(); + const candidateName = String(candidate?.name || candidate?.display_name || '').trim(); + return (actionId && candidateId === actionId) || (actionName && candidateName === actionName); + }); + + const rawCapabilities = action?.additionalFields?.chart_capabilities + || action?.additional_fields?.chart_capabilities + || action?.chart_capabilities; + + if (rawCapabilities && typeof rawCapabilities === 'object' && !Array.isArray(rawCapabilities)) { + CHART_CAPABILITY_DEFINITIONS.forEach(definition => { + if (Object.prototype.hasOwnProperty.call(rawCapabilities, definition.key)) { + defaults[definition.key] = Boolean(rawCapabilities[definition.key]); + } + }); + } + + return defaults; + } + + getChartCapabilitiesForAction(actionId, actionName) { + const defaults = this.getDefaultChartCapabilities(actionId, actionName); + const capabilityMap = this.getActionCapabilityMap(); + const storedCapabilities = capabilityMap[actionId] || capabilityMap[actionName] || {}; + + CHART_CAPABILITY_DEFINITIONS.forEach(definition => { + if (Object.prototype.hasOwnProperty.call(storedCapabilities, definition.key)) { + defaults[definition.key] = Boolean(storedCapabilities[definition.key]); + } + }); + + return defaults; + } + + updateChartCapabilities(actionId, actionName, nextCapabilities) { + const otherSettings = this.getParsedAdditionalSettings(); + const capabilityMap = this.getActionCapabilityMap(); + capabilityMap[actionId || actionName] = { ...nextCapabilities }; + otherSettings[ACTION_CAPABILITIES_KEY] = capabilityMap; + this.setParsedAdditionalSettings(otherSettings); + } + + renderChartCapabilitySections() { + const container = document.getElementById('agent-chart-capabilities'); + const list = document.getElementById('agent-chart-capabilities-list'); + if (!container || !list) { + return; + } + + const selectedChartCards = Array.from(document.querySelectorAll('.action-card.border-primary')).filter(card => { + return (card.getAttribute('data-action-type') || '').toLowerCase() === 'chart'; + }); + + if (!selectedChartCards.length || this.isAnyFoundryType()) { + container.classList.add('d-none'); + list.innerHTML = ''; + return; + } + + container.classList.remove('d-none'); + list.innerHTML = ''; + + selectedChartCards.forEach(card => { + const actionId = card.getAttribute('data-action-id') || card.getAttribute('data-action-name') || ''; + const actionName = card.getAttribute('data-action-name') || actionId; + const capabilities = this.getChartCapabilitiesForAction(actionId, actionName); + + const section = document.createElement('div'); + section.className = 'border rounded p-3 bg-light'; + + const heading = document.createElement('div'); + heading.className = 'fw-semibold mb-1'; + heading.textContent = actionName; + section.appendChild(heading); + + const helperText = document.createElement('div'); + helperText.className = 'text-muted small mb-3'; + helperText.textContent = 'These chart type toggles apply only to this agent assignment.'; + section.appendChild(helperText); + + CHART_CAPABILITY_DEFINITIONS.forEach(definition => { + const wrapper = document.createElement('div'); + wrapper.className = 'form-check mb-2'; + + const checkbox = document.createElement('input'); + checkbox.className = 'form-check-input'; + checkbox.type = 'checkbox'; + checkbox.id = `chart-capability-${actionId}-${definition.key}`; + checkbox.checked = Boolean(capabilities[definition.key]); + + const label = document.createElement('label'); + label.className = 'form-check-label'; + label.setAttribute('for', checkbox.id); + label.innerHTML = `${this.escapeHtml(definition.label)}
${this.escapeHtml(definition.description)}`; + + checkbox.addEventListener('change', () => { + const updatedCapabilities = this.getChartCapabilitiesForAction(actionId, actionName); + updatedCapabilities[definition.key] = checkbox.checked; + this.updateChartCapabilities(actionId, actionName, updatedCapabilities); + }); + + wrapper.appendChild(checkbox); + wrapper.appendChild(label); + section.appendChild(wrapper); + }); + + list.appendChild(section); + }); } initializeActionSearch(actions) { diff --git a/application/single_app/static/js/chat/chat-agents.js b/application/single_app/static/js/chat/chat-agents.js index 22dffc6c..2983dd98 100644 --- a/application/single_app/static/js/chat/chat-agents.js +++ b/application/single_app/static/js/chat/chat-agents.js @@ -6,7 +6,7 @@ import { setUserSetting } from '../agents_common.js'; import { createSearchableSingleSelect } from './chat-searchable-select.js'; -import { getEffectiveScopes, setEffectiveScopes } from './chat-documents.js'; +import { getEffectiveScopes, isScopeLocked, setEffectiveScopes } from './chat-documents.js'; import { getConversationFilteringContext } from './chat-conversation-scope.js'; const enableAgentsBtn = document.getElementById("enable-agents-btn"); @@ -99,16 +99,20 @@ function getAgentOptionLabel(agent, duplicateCounts) { return `${displayName} (${agent.name || agent.id || 'agent'})`; } +function shouldUseConversationScopeGuard(filteringContext) { + return !filteringContext.isNewConversation && isScopeLocked() !== false; +} + function isAgentEnabledForContext(agent, scopes, filteringContext) { - if (!filteringContext.isNewConversation && filteringContext.conversationScope === 'group') { + if (shouldUseConversationScopeGuard(filteringContext) && filteringContext.conversationScope === 'group') { return agent.is_global || String(agent.group_id || '') === String(filteringContext.groupId || ''); } - if (!filteringContext.isNewConversation && filteringContext.conversationScope === 'public') { + if (shouldUseConversationScopeGuard(filteringContext) && filteringContext.conversationScope === 'public') { return agent.is_global; } - if (!filteringContext.isNewConversation && filteringContext.conversationScope === 'personal') { + if (shouldUseConversationScopeGuard(filteringContext) && filteringContext.conversationScope === 'personal') { return !agent.is_group; } @@ -202,7 +206,7 @@ function rebuildAgentOptions(sections, selectedAgentObj, filteringContext) { agentSelect.innerHTML = ''; - const hideUnavailableOptions = !filteringContext.isNewConversation; + const hideUnavailableOptions = shouldUseConversationScopeGuard(filteringContext); const renderedSections = sections .map(section => ({ ...section, diff --git a/application/single_app/static/js/chat/chat-citations.js b/application/single_app/static/js/chat/chat-citations.js index 9d751ffd..0d0e666a 100644 --- a/application/single_app/static/js/chat/chat-citations.js +++ b/application/single_app/static/js/chat/chat-citations.js @@ -473,16 +473,20 @@ function renderAgentCitationResult(toolResultEl, toolResultSummaryEl, toolResult }); } -async function fetchAgentCitationArtifact(conversationId, artifactId) { +export async function fetchAgentCitationArtifact(conversationId, artifactId) { if (!conversationId || !artifactId) { return null; } const response = await fetch( - `/api/conversation/${encodeURIComponent(conversationId)}/agent-citation/${encodeURIComponent(artifactId)}`, + `/api/conversation/${encodeURIComponent(conversationId)}/agent-citation/${encodeURIComponent(artifactId)}?ts=${Date.now()}`, { method: "GET", - headers: { "Content-Type": "application/json" }, + cache: "no-store", + headers: { + "Content-Type": "application/json", + "Cache-Control": "no-cache", + }, } ); diff --git a/application/single_app/static/js/chat/chat-collaboration.js b/application/single_app/static/js/chat/chat-collaboration.js new file mode 100644 index 00000000..b310be40 --- /dev/null +++ b/application/single_app/static/js/chat/chat-collaboration.js @@ -0,0 +1,1844 @@ +// chat-collaboration.js + +import { appendMessage, getCollaborativeTagSuggestions, updateSendButtonVisibility, updateUserMessageId, userInput } from './chat-messages.js'; +import { applyConversationMetadataUpdate } from './chat-conversations.js'; +import { loadUserSettings, saveUserSetting } from './chat-layout.js'; +import { showToast } from './chat-toast.js'; +import { sendMessageWithStreaming } from './chat-streaming.js'; + +const RECENT_COLLABORATORS_KEY = 'recentCollaborators'; +const MAX_RECENT_COLLABORATORS = 12; +const DEFAULT_SUGGESTION_LIMIT = 8; + +const mentionMenu = document.getElementById('collaboration-mention-menu'); +const participantModalEl = document.getElementById('collaboration-participant-modal'); +const participantSearchInput = document.getElementById('collaboration-participant-search-input'); +const participantResults = document.getElementById('collaboration-participant-results'); +const participantConversationIdInput = document.getElementById('collaboration-participant-conversation-id'); +const confirmModalEl = document.getElementById('collaboration-confirm-modal'); +const confirmMessageEl = document.getElementById('collaboration-confirm-message'); +const confirmAddBtn = document.getElementById('collaboration-confirm-add-btn'); +const replyPreviewEl = document.getElementById('collaboration-reply-preview'); +const replyPreviewLabelEl = document.getElementById('collaboration-reply-preview-label'); +const replyPreviewTextEl = document.getElementById('collaboration-reply-preview-text'); +const replyCancelBtn = document.getElementById('collaboration-reply-cancel-btn'); +const sendBtn = document.getElementById('send-btn'); + +let cachedUserSettingsPromise = null; +let activeCollaborativeConversationId = null; +let activeCollaborationEventSource = null; +let activeSubscriptionStartedAt = 0; +let activeReplyContext = null; +let typingUsers = new Map(); +let lastTypingState = false; +let typingStopHandle = null; +let mentionSearchToken = 0; +let activeMentionState = null; +let pendingParticipantConfirmation = null; +const notifiedPendingInviteConversationIds = new Set(); +const promptedPendingInviteConversationIds = new Set(); +const seenCollaborationEventKeys = new Set(); +const collaborationMessageCache = new Map(); +const collaborationConversationCache = new Map(); +const collaborationMarkReadRequests = new Map(); + +function isCollaborationEnabled() { + return Boolean(window.appSettings?.enable_collaborative_conversations); +} + +function getConversationDomItem(conversationId) { + if (!conversationId) { + return null; + } + + return document.querySelector(`.conversation-item[data-conversation-id="${conversationId}"]`) + || document.querySelector(`.sidebar-conversation-item[data-conversation-id="${conversationId}"]`); +} + +function getConversationKind(conversationId) { + const item = getConversationDomItem(conversationId); + return item?.dataset?.conversationKind || null; +} + +function isCollaborationConversation(conversationId) { + return getConversationKind(conversationId) === 'collaborative'; +} + +function getConversationChatType(conversationId) { + const item = getConversationDomItem(conversationId); + return item?.getAttribute('data-chat-type') || null; +} + +function getConversationGroupId(conversationId) { + const item = getConversationDomItem(conversationId); + const groupId = String(item?.getAttribute('data-group-id') || item?.dataset?.groupId || '').trim(); + if (groupId) { + return groupId; + } + + const cachedConversation = getCachedCollaborationConversation(conversationId); + return String(cachedConversation?.group_id || cachedConversation?.scope?.group_id || '').trim() || null; +} + +function markCollaborationConversationRead(conversationId, options = {}) { + const { suppressErrorToast = false } = options; + if (!conversationId) { + return Promise.resolve(null); + } + + if (collaborationMarkReadRequests.has(conversationId)) { + return collaborationMarkReadRequests.get(conversationId); + } + + const request = fetch(`/api/collaboration/conversations/${conversationId}/mark-read`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }) + .then(async response => { + const payload = await response.json().catch(() => ({})); + if (!response.ok || payload.success === false) { + throw new Error(payload.error || 'Failed to clear shared conversation notifications'); + } + return payload; + }) + .catch(error => { + if (!suppressErrorToast) { + showToast(`Failed to clear shared conversation notifications: ${error.message}`, 'danger'); + } + throw error; + }) + .finally(() => { + collaborationMarkReadRequests.delete(conversationId); + }); + + collaborationMarkReadRequests.set(conversationId, request); + return request; +} + +function setConversationDataset(conversationId, metadata = {}) { + const conversationSelectors = [ + `.conversation-item[data-conversation-id="${conversationId}"]`, + `.sidebar-conversation-item[data-conversation-id="${conversationId}"]`, + ]; + + conversationSelectors.forEach(selector => { + const element = document.querySelector(selector); + if (!element) { + return; + } + + if (metadata.conversation_kind) { + element.dataset.conversationKind = metadata.conversation_kind; + } + if (metadata.membership_status) { + element.dataset.membershipStatus = metadata.membership_status; + } + if (Object.prototype.hasOwnProperty.call(metadata, 'can_manage_members')) { + element.dataset.canManageMembers = metadata.can_manage_members ? 'true' : 'false'; + } + if (Object.prototype.hasOwnProperty.call(metadata, 'can_manage_roles')) { + element.dataset.canManageRoles = metadata.can_manage_roles ? 'true' : 'false'; + } + if (Object.prototype.hasOwnProperty.call(metadata, 'can_accept_invite')) { + element.dataset.canAcceptInvite = metadata.can_accept_invite ? 'true' : 'false'; + } + if (Object.prototype.hasOwnProperty.call(metadata, 'can_post_messages')) { + element.dataset.canPostMessages = metadata.can_post_messages ? 'true' : 'false'; + } + if (Object.prototype.hasOwnProperty.call(metadata, 'can_delete_conversation')) { + element.dataset.canDeleteConversation = metadata.can_delete_conversation ? 'true' : 'false'; + } + if (Object.prototype.hasOwnProperty.call(metadata, 'can_leave_conversation')) { + element.dataset.canLeaveConversation = metadata.can_leave_conversation ? 'true' : 'false'; + } + if (Object.prototype.hasOwnProperty.call(metadata, 'current_user_role')) { + element.dataset.currentUserRole = metadata.current_user_role || ''; + } + }); +} + +function normalizeCollaborator(rawUser) { + if (!rawUser || typeof rawUser !== 'object') { + return null; + } + + const userId = String(rawUser.user_id || rawUser.userId || rawUser.id || '').trim(); + if (!userId) { + return null; + } + + const displayName = String(rawUser.display_name || rawUser.displayName || rawUser.name || rawUser.email || '').trim(); + const email = String(rawUser.email || rawUser.mail || '').trim(); + return { + user_id: userId, + display_name: displayName || email || 'Unknown User', + email, + }; +} + +function normalizeCollaborationConversation(rawConversation = {}) { + const normalizedParticipants = Array.isArray(rawConversation.participants) + ? rawConversation.participants + .map(participant => { + const normalizedParticipant = normalizeCollaborator(participant); + if (!normalizedParticipant) { + return null; + } + + return { + ...participant, + ...normalizedParticipant, + role: String(participant?.role || '').trim(), + status: String(participant?.status || '').trim().toLowerCase(), + }; + }) + .filter(Boolean) + : []; + + return { + ...rawConversation, + conversation_kind: rawConversation.conversation_kind || 'collaborative', + last_updated: rawConversation.last_updated || rawConversation.updated_at || rawConversation.last_message_at || rawConversation.created_at || new Date().toISOString(), + classification: Array.isArray(rawConversation.classification) ? rawConversation.classification : [], + tags: Array.isArray(rawConversation.tags) ? rawConversation.tags : [], + context: Array.isArray(rawConversation.context) ? rawConversation.context : [], + participants: normalizedParticipants, + is_pinned: Boolean(rawConversation.is_pinned), + is_hidden: Boolean(rawConversation.is_hidden), + has_unread_assistant_response: Boolean(rawConversation.has_unread_assistant_response), + }; +} + +function cacheCollaborationConversation(rawConversation = {}) { + const normalizedConversation = normalizeCollaborationConversation(rawConversation); + if (!normalizedConversation.id) { + return normalizedConversation; + } + + collaborationConversationCache.set(normalizedConversation.id, normalizedConversation); + return normalizedConversation; +} + +function getCachedCollaborationConversation(conversationId) { + if (!conversationId) { + return null; + } + + return collaborationConversationCache.get(conversationId) || null; +} + +function getConversationParticipants(conversationId, options = {}) { + const currentUserId = getCurrentUserId(); + const conversation = getCachedCollaborationConversation(conversationId); + const participants = Array.isArray(conversation?.participants) ? conversation.participants : []; + + return participants.filter(participant => { + const participantUserId = String(participant?.user_id || '').trim(); + if (!participantUserId) { + return false; + } + if (!options.includeCurrentUser && participantUserId === currentUserId) { + return false; + } + + const participantStatus = String(participant?.status || '').trim().toLowerCase(); + return !participantStatus || participantStatus === 'accepted'; + }); +} + +function escapeRegExp(value) { + return String(value || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function matchesMentionQuery(collaborator, query = '') { + const normalizedQuery = String(query || '').trim().toLowerCase(); + if (!normalizedQuery) { + return true; + } + + const haystack = `${collaborator?.display_name || ''} ${collaborator?.email || ''}`.trim().toLowerCase(); + return haystack.includes(normalizedQuery); +} + +function buildMentionSuggestionsFromParticipants(conversationId, query = '') { + return getConversationParticipants(conversationId).filter(participant => matchesMentionQuery(participant, query)).map(participant => ({ + ...participant, + action: 'tag', + source: 'participant', + })); +} + +async function loadMentionSuggestions(conversationId, query = '') { + const participantSuggestions = buildMentionSuggestionsFromParticipants(conversationId, query); + const targetSuggestions = getCollaborativeTagSuggestions(query); + const seenUserIds = new Set(participantSuggestions.map(participant => participant.user_id)); + + if (!canUseParticipantFlow(conversationId)) { + return [...participantSuggestions, ...targetSuggestions].slice(0, DEFAULT_SUGGESTION_LIMIT); + } + + const collaboratorSuggestions = await searchConversationParticipantCandidates( + conversationId, + query, + { + recentOnly: false, + limit: DEFAULT_SUGGESTION_LIMIT, + } + ); + const inviteSuggestions = collaboratorSuggestions + .map(collaborator => { + const normalizedCollaborator = normalizeCollaborator(collaborator); + if (!normalizedCollaborator) { + return null; + } + + return { + ...normalizedCollaborator, + source: collaborator.source || 'local', + }; + }) + .filter(Boolean) + .filter(collaborator => !seenUserIds.has(collaborator.user_id)) + .map(collaborator => ({ + ...collaborator, + action: 'invite', + })); + + return [...participantSuggestions, ...targetSuggestions, ...inviteSuggestions].slice(0, DEFAULT_SUGGESTION_LIMIT); +} + +function replaceComposerMention(mentionState, replacementText) { + if (!userInput || !mentionState) { + return; + } + + const beforeMention = userInput.value.slice(0, mentionState.startIndex); + const afterMention = userInput.value.slice(mentionState.endIndex); + const trailingSpacer = afterMention.startsWith(' ') || !afterMention ? '' : ' '; + const nextValue = `${beforeMention}${replacementText} ${trailingSpacer}${afterMention}`.replace(/\s{2,}/g, ' '); + const nextCaretIndex = beforeMention.length + replacementText.length + 1; + + userInput.value = nextValue; + userInput.setSelectionRange(nextCaretIndex, nextCaretIndex); + updateSendButtonVisibility(); + hideMentionMenu(); + userInput.focus(); +} + +function insertParticipantMention(collaborator, mentionState) { + const normalizedCollaborator = normalizeCollaborator(collaborator); + if (!normalizedCollaborator) { + return; + } + + replaceComposerMention(mentionState, `@${normalizedCollaborator.display_name}`); +} + +function insertInvocationTargetMention(target, mentionState) { + const mentionText = String(target?.mention_text || `@${target?.display_name || ''}`).trim(); + if (!mentionText) { + return; + } + + replaceComposerMention(mentionState, mentionText); +} + +function extractMentionedParticipantsFromMessage(messageText, conversationId) { + const normalizedMessageText = String(messageText || ''); + if (!normalizedMessageText.trim()) { + return []; + } + + const participants = getConversationParticipants(conversationId, { includeCurrentUser: true }) + .slice() + .sort((left, right) => String(right?.display_name || '').length - String(left?.display_name || '').length); + + const mentionedParticipants = []; + const seenUserIds = new Set(); + participants.forEach(participant => { + const displayName = String(participant?.display_name || '').trim(); + if (!displayName || seenUserIds.has(participant.user_id)) { + return; + } + + const mentionPattern = new RegExp(`(^|\\s)@${escapeRegExp(displayName)}(?=$|\\s|[.,!?;:])`, 'i'); + if (!mentionPattern.test(normalizedMessageText)) { + return; + } + + seenUserIds.add(participant.user_id); + mentionedParticipants.push({ + user_id: participant.user_id, + display_name: participant.display_name, + email: participant.email || '', + }); + }); + + return mentionedParticipants; +} + +function isCurrentUserMentioned(message = {}) { + const currentUserId = getCurrentUserId(); + if (!currentUserId) { + return false; + } + + const mentionedUserIds = Array.isArray(message?.metadata?.mentioned_user_ids) + ? message.metadata.mentioned_user_ids.map(userId => String(userId || '').trim()) + : []; + return mentionedUserIds.includes(currentUserId); +} + +function escapeHtml(value) { + return String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function buildMessagePreview(content, maxLength = 140) { + const plainText = String(content ?? '').replace(/\s+/g, ' ').trim(); + if (!plainText) { + return 'No message content'; + } + if (plainText.length <= maxLength) { + return plainText; + } + return `${plainText.slice(0, maxLength - 3)}...`; +} + +function buildReplyContext(message = {}) { + const sender = normalizeCollaborator(message.sender || message.metadata?.sender || {}) || {}; + return { + message_id: String(message.id || '').trim(), + sender_display_name: sender.display_name || 'Participant', + content_preview: buildMessagePreview(message.content || ''), + }; +} + +function renderReplyPreview() { + if (!replyPreviewEl || !replyPreviewLabelEl || !replyPreviewTextEl) { + return; + } + + if (!activeReplyContext) { + replyPreviewEl.classList.add('d-none'); + replyPreviewLabelEl.textContent = ''; + replyPreviewTextEl.textContent = ''; + return; + } + + replyPreviewLabelEl.textContent = `Replying to ${activeReplyContext.sender_display_name || 'Participant'}`; + replyPreviewTextEl.textContent = activeReplyContext.content_preview || 'No message content'; + replyPreviewEl.classList.remove('d-none'); +} + +function clearReplyTarget(options = {}) { + activeReplyContext = null; + renderReplyPreview(); + if (options.focusComposer !== false) { + userInput?.focus(); + } +} + +function replyToMessage(message = {}) { + const messageId = String(message.id || '').trim(); + if (!messageId) { + return; + } + + if (!canPostMessages(window.chatConversations?.getCurrentConversationId?.())) { + showToast('Accept the invite before replying in this shared conversation.', 'warning'); + return; + } + + activeReplyContext = buildReplyContext(message); + renderReplyPreview(); + userInput?.focus(); +} + +function getPendingMessageContext(options = {}) { + const conversationId = window.chatConversations?.getCurrentConversationId?.(); + const mentionedParticipants = extractMentionedParticipantsFromMessage(userInput?.value || '', conversationId); + const invocationTarget = options.invocationTarget && typeof options.invocationTarget === 'object' + ? options.invocationTarget + : null; + if (!activeReplyContext && mentionedParticipants.length === 0 && !invocationTarget) { + return null; + } + + const metadata = {}; + if (activeReplyContext) { + metadata.reply_context = { + ...activeReplyContext, + }; + } + if (mentionedParticipants.length > 0) { + metadata.mentioned_participants = mentionedParticipants; + metadata.mentioned_user_ids = mentionedParticipants.map(participant => participant.user_id); + } + if (invocationTarget) { + metadata.ai_invocation_target = { ...invocationTarget }; + metadata.explicit_ai_invocation = true; + } + + return { + reply_to_message_id: activeReplyContext?.message_id || null, + metadata, + }; +} + +function cacheCollaborationMessage(message = {}) { + const messageId = String(message.id || '').trim(); + if (!messageId) { + return; + } + + collaborationMessageCache.set(messageId, { + ...message, + metadata: message.metadata || {}, + sender: message.sender || {}, + }); +} + +function removeCollaborationMessage(messageId) { + const normalizedMessageId = String(messageId || '').trim(); + if (!normalizedMessageId) { + return; + } + + collaborationMessageCache.delete(normalizedMessageId); + + const messageElement = document.querySelector(`[data-message-id="${normalizedMessageId}"]`); + if (messageElement) { + messageElement.remove(); + } + + if (activeReplyContext?.message_id === normalizedMessageId) { + clearReplyTarget({ focusComposer: false }); + } +} + +function clearMessageCache() { + collaborationMessageCache.clear(); +} + +function decorateReplyMessage(message = {}) { + const replyToMessageId = String(message.reply_to_message_id || '').trim(); + if (!replyToMessageId) { + return message; + } + + const replyMessage = collaborationMessageCache.get(replyToMessageId); + if (!replyMessage) { + return message; + } + + return { + ...message, + reply_message: replyMessage, + }; +} + +function buildEventKey(eventEnvelope = {}) { + const payload = eventEnvelope.payload || {}; + return [ + eventEnvelope.conversation_id || payload.conversation?.id || '', + eventEnvelope.event_type || '', + payload.message?.id || payload.message_id || payload.participant?.user_id || payload.user?.user_id || payload.deleted_by_user_id || '', + eventEnvelope.occurred_at || '', + ].join('|'); +} + +function parseCollaborationEventTimestamp(timestamp) { + const normalizedTimestamp = String(timestamp || '').trim(); + if (!normalizedTimestamp) { + return Number.NaN; + } + + if (/(?:Z|[+-]\d{2}:\d{2})$/i.test(normalizedTimestamp)) { + return Date.parse(normalizedTimestamp); + } + + const utcTimestamp = Date.parse(`${normalizedTimestamp}Z`); + if (!Number.isNaN(utcTimestamp)) { + return utcTimestamp; + } + + return Date.parse(normalizedTimestamp); +} + +function isReplayEvent(eventEnvelope = {}) { + if (!activeSubscriptionStartedAt) { + return false; + } + + const occurredAt = parseCollaborationEventTimestamp(eventEnvelope.occurred_at || ''); + if (Number.isNaN(occurredAt)) { + return false; + } + + return occurredAt < (activeSubscriptionStartedAt - 1000); +} + +async function getCachedUserSettings() { + if (!cachedUserSettingsPromise) { + cachedUserSettingsPromise = loadUserSettings().then(settings => settings || {}); + } + return cachedUserSettingsPromise; +} + +function setCachedUserSettings(settings = {}) { + cachedUserSettingsPromise = Promise.resolve(settings || {}); +} + +async function rememberRecentCollaborator(collaborator) { + const normalizedCollaborator = normalizeCollaborator(collaborator); + if (!normalizedCollaborator) { + return; + } + + const userSettings = await getCachedUserSettings(); + const existing = Array.isArray(userSettings[RECENT_COLLABORATORS_KEY]) + ? userSettings[RECENT_COLLABORATORS_KEY] + : []; + + const updatedCollaborators = [ + { + ...normalizedCollaborator, + last_used_at: new Date().toISOString(), + }, + ...existing.filter(item => String(item?.user_id || item?.userId || item?.id || '').trim() !== normalizedCollaborator.user_id), + ].slice(0, MAX_RECENT_COLLABORATORS); + + const nextSettings = { + ...userSettings, + [RECENT_COLLABORATORS_KEY]: updatedCollaborators, + }; + setCachedUserSettings(nextSettings); + saveUserSetting({ [RECENT_COLLABORATORS_KEY]: updatedCollaborators }); +} + +async function fetchJson(url, options = {}) { + const response = await fetch(url, { + credentials: 'same-origin', + ...options, + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(payload.error || `Request failed (${response.status})`); + } + return payload; +} + +async function searchLocalCollaborators(query = '', options = {}) { + const search = new URLSearchParams(); + search.set('query', query); + search.set('limit', String(options.limit || DEFAULT_SUGGESTION_LIMIT)); + if (options.recentOnly) { + search.set('recent_only', 'true'); + } + + const payload = await fetchJson(`/api/user/collaboration-suggestions?${search.toString()}`); + return Array.isArray(payload.results) ? payload.results : []; +} + +function filterParticipantCandidates(candidates, conversationId, limit = DEFAULT_SUGGESTION_LIMIT) { + const normalizedConversationId = String(conversationId || '').trim(); + const currentUserId = getCurrentUserId(); + const conversation = normalizedConversationId + ? getCachedCollaborationConversation(normalizedConversationId) + : null; + const existingParticipantIds = new Set( + Array.isArray(conversation?.participants) + ? conversation.participants + .map(participant => String(participant?.user_id || '').trim()) + .filter(Boolean) + : [] + ); + + return (Array.isArray(candidates) ? candidates : []) + .map(candidate => { + const normalizedCandidate = normalizeCollaborator(candidate); + return normalizedCandidate ? { ...candidate, ...normalizedCandidate } : candidate; + }) + .filter(candidate => { + const candidateUserId = String(candidate?.user_id || candidate?.userId || candidate?.id || '').trim(); + if (!candidateUserId || candidateUserId === currentUserId) { + return false; + } + return !existingParticipantIds.has(candidateUserId); + }) + .slice(0, limit); +} + +async function searchConversationParticipantCandidates(conversationId, query = '', options = {}) { + const limit = Number(options.limit || DEFAULT_SUGGESTION_LIMIT) || DEFAULT_SUGGESTION_LIMIT; + const chatType = getConversationChatType(conversationId); + + if (['group-single-user', 'group_multi_user'].includes(chatType || '')) { + const groupId = getConversationGroupId(conversationId); + if (!groupId) { + return []; + } + + const search = new URLSearchParams(); + if (query) { + search.set('search', query); + } + const payload = await fetchJson(`/api/groups/${groupId}/members?${search.toString()}`); + const groupMembers = Array.isArray(payload) ? payload : []; + const normalizedMembers = groupMembers.map(member => ({ + user_id: member.userId || member.user_id || member.id || '', + display_name: member.displayName || member.display_name || member.name || member.email || '', + email: member.email || '', + source: 'group', + group_role: member.role || '', + })); + return filterParticipantCandidates(normalizedMembers, conversationId, limit); + } + + const collaborators = await searchLocalCollaborators(query, options); + return filterParticipantCandidates(collaborators, conversationId, limit); +} + +function ensureTypingIndicator() { + let typingIndicator = document.getElementById('collaboration-typing-indicator'); + if (typingIndicator) { + return typingIndicator; + } + + const chatbox = document.getElementById('chatbox'); + if (!chatbox || !chatbox.parentElement) { + return null; + } + + typingIndicator = document.createElement('div'); + typingIndicator.id = 'collaboration-typing-indicator'; + typingIndicator.className = 'collaboration-typing-indicator d-none'; + chatbox.insertAdjacentElement('afterend', typingIndicator); + return typingIndicator; +} + +function renderTypingIndicator() { + const typingIndicator = ensureTypingIndicator(); + if (!typingIndicator) { + return; + } + + const now = Date.now(); + typingUsers.forEach((entry, userId) => { + const expiresAt = entry?.expiresAt ? Date.parse(entry.expiresAt) : 0; + if (!expiresAt || expiresAt <= now) { + typingUsers.delete(userId); + } + }); + + if (typingUsers.size === 0) { + typingIndicator.textContent = ''; + typingIndicator.classList.add('d-none'); + return; + } + + const names = Array.from(typingUsers.values()) + .map(entry => entry.displayName) + .filter(Boolean); + + if (names.length === 1) { + typingIndicator.textContent = `${names[0]} is typing...`; + } else if (names.length === 2) { + typingIndicator.textContent = `${names[0]} and ${names[1]} are typing...`; + } else { + typingIndicator.textContent = `${names[0]} and ${names.length - 1} others are typing...`; + } + + typingIndicator.classList.remove('d-none'); +} + +function clearTypingState() { + typingUsers = new Map(); + renderTypingIndicator(); +} + +function updateComposerAvailability(metadata = null) { + if (!userInput || !sendBtn) { + return; + } + + if (!metadata || metadata.conversation_kind !== 'collaborative') { + userInput.disabled = false; + sendBtn.disabled = false; + userInput.placeholder = 'Type your message...'; + return; + } + + const canPostMessages = metadata.can_post_messages !== false; + userInput.disabled = !canPostMessages; + sendBtn.disabled = !canPostMessages; + + if (canPostMessages) { + userInput.placeholder = 'Type a shared message...'; + return; + } + + if (metadata.membership_status === 'pending') { + userInput.placeholder = 'Accept the invite before posting messages...'; + } else { + userInput.placeholder = 'You cannot post messages in this conversation.'; + } +} + +function resolveMessageSenderType(message) { + if (message.role === 'assistant') { + return 'AI'; + } + + if (message.role === 'image') { + return 'image'; + } + + if (message.role === 'file') { + return 'File'; + } + + if (message.role === 'safety') { + return 'safety'; + } + + const senderUserId = message.sender?.user_id || message.metadata?.sender?.user_id || null; + if (senderUserId && senderUserId === getCurrentUserId()) { + return 'You'; + } + + return 'Collaborator'; +} + +function getCurrentUserId() { + return String(window.currentUser?.id || window.currentUser?.user_id || '').trim(); +} + +function getLatestPendingCollaborativeMessageId() { + const pendingMessages = Array.from(document.querySelectorAll('[data-message-id^="temp_user_"]')); + if (pendingMessages.length === 0) { + return null; + } + + return pendingMessages[pendingMessages.length - 1].getAttribute('data-message-id'); +} + +function reconcilePendingCollaborativeUserMessage(message, preferredTempId = null) { + const senderUserId = String(message?.sender?.user_id || message?.metadata?.sender?.user_id || '').trim(); + if (!senderUserId || senderUserId !== getCurrentUserId()) { + return false; + } + + const realMessageId = String(message?.id || '').trim(); + if (!realMessageId) { + return false; + } + + const pendingMessageId = preferredTempId || getLatestPendingCollaborativeMessageId(); + const existingRealMessage = document.querySelector(`[data-message-id="${realMessageId}"]`); + + if (existingRealMessage && pendingMessageId) { + const pendingMessage = document.querySelector(`[data-message-id="${pendingMessageId}"]`); + if (pendingMessage) { + pendingMessage.remove(); + } + return true; + } + + if (pendingMessageId) { + updateUserMessageId(pendingMessageId, realMessageId); + return true; + } + + return Boolean(existingRealMessage); +} + +function renderCollaborationMessage(message, options = {}) { + if (!message || !message.id) { + return; + } + + if (document.querySelector(`[data-message-id="${message.id}"]`)) { + return; + } + + const senderType = resolveMessageSenderType(message); + appendMessage( + senderType, + message.content || '', + message.model_deployment_name || null, + message.id, + Boolean(message.augmented), + Array.isArray(message.hybrid_citations) ? message.hybrid_citations : [], + Array.isArray(message.web_search_citations) ? message.web_search_citations : [], + Array.isArray(message.agent_citations) ? message.agent_citations : [], + message.agent_display_name || null, + message.agent_name || null, + { + ...message, + metadata: message.metadata || {}, + sender: message.sender || {}, + }, + Boolean(options.isNewMessage) + ); +} + +async function loadConversationMessages(conversationId) { + const payload = await fetchJson(`/api/collaboration/conversations/${conversationId}/messages`); + const chatbox = document.getElementById('chatbox'); + if (!chatbox) { + return []; + } + + chatbox.innerHTML = ''; + clearTypingState(); + clearMessageCache(); + + const messages = Array.isArray(payload.messages) ? payload.messages : []; + messages.forEach(message => { + const decoratedMessage = decorateReplyMessage(message); + renderCollaborationMessage(decoratedMessage); + cacheCollaborationMessage(message); + }); + return messages; +} + +function handleTypingEvent(payload = {}) { + const currentUserId = getCurrentUserId(); + const typingUser = normalizeCollaborator(payload.user); + if (!typingUser || typingUser.user_id === currentUserId) { + return; + } + + if (payload.is_typing === false) { + typingUsers.delete(typingUser.user_id); + renderTypingIndicator(); + return; + } + + typingUsers.set(typingUser.user_id, { + displayName: typingUser.display_name, + expiresAt: payload.expires_at, + }); + renderTypingIndicator(); +} + +function disconnectConversationEvents() { + const previousConversationId = activeCollaborativeConversationId; + + if (activeCollaborationEventSource) { + activeCollaborationEventSource.close(); + activeCollaborationEventSource = null; + } + + activeSubscriptionStartedAt = 0; + + if (typingStopHandle) { + window.clearTimeout(typingStopHandle); + typingStopHandle = null; + } + + setTypingState(false, { + force: true, + conversationId: previousConversationId, + }); + + activeCollaborativeConversationId = null; + clearTypingState(); + lastTypingState = false; +} + +function handleConversationEvent(eventEnvelope = {}) { + if (!eventEnvelope || !eventEnvelope.event_type) { + return; + } + + const eventKey = buildEventKey(eventEnvelope); + if (eventKey && seenCollaborationEventKeys.has(eventKey)) { + return; + } + if (eventKey) { + seenCollaborationEventKeys.add(eventKey); + } + + if (isReplayEvent(eventEnvelope)) { + return; + } + + const payload = eventEnvelope.payload || {}; + if (payload.conversation) { + const normalizedConversation = cacheCollaborationConversation(payload.conversation); + setConversationDataset(normalizedConversation.id, normalizedConversation); + applyConversationMetadataUpdate(normalizedConversation.id, normalizedConversation); + if (!['collaboration.message.created', 'collaboration.typing.updated'].includes(eventEnvelope.event_type)) { + void fetchConversationMetadata(normalizedConversation.id).catch(() => {}); + } + } + + if (eventEnvelope.event_type === 'collaboration.message.created' && payload.message) { + const senderUserId = String(payload.message?.sender?.user_id || payload.message?.metadata?.sender?.user_id || '').trim(); + const shouldClearNotifications = Boolean(senderUserId && senderUserId !== getCurrentUserId()); + if (senderUserId && senderUserId !== getCurrentUserId() && isCurrentUserMentioned(payload.message)) { + const senderName = normalizeCollaborator(payload.message.sender || payload.message.metadata?.sender || {})?.display_name || 'A participant'; + showToast(`${senderName} tagged you in a shared message.`, 'info'); + } + + const decoratedMessage = decorateReplyMessage(payload.message); + cacheCollaborationMessage(payload.message); + if (reconcilePendingCollaborativeUserMessage(payload.message)) { + if (shouldClearNotifications) { + void markCollaborationConversationRead(eventEnvelope.conversation_id || payload.message.conversation_id, { + suppressErrorToast: true, + }).catch(() => {}); + } + return; + } + renderCollaborationMessage(decoratedMessage, { isNewMessage: true }); + if (shouldClearNotifications) { + void markCollaborationConversationRead(eventEnvelope.conversation_id || payload.message.conversation_id, { + suppressErrorToast: true, + }).catch(() => {}); + } + return; + } + + if (eventEnvelope.event_type === 'collaboration.typing.updated') { + handleTypingEvent(payload); + return; + } + + if (eventEnvelope.event_type === 'collaboration.message.deleted' && payload.message_id) { + removeCollaborationMessage(payload.message_id); + if (payload.deleted_by_user_id && payload.deleted_by_user_id !== getCurrentUserId()) { + showToast('A shared message was deleted.', 'info'); + } + return; + } + + if (eventEnvelope.event_type === 'collaboration.member.invited' && Array.isArray(payload.participants)) { + return; + } + + if (eventEnvelope.event_type === 'collaboration.member.removed' && payload.participant?.display_name) { + if (String(payload.participant.user_id || '').trim() === getCurrentUserId()) { + showToast('You no longer have access to this shared conversation.', 'warning'); + window.chatConversations?.removeConversationFromUi?.(eventEnvelope.conversation_id || payload.conversation?.id, { + refreshList: true, + skipToast: true, + }); + return; + } + showToast(`${payload.participant.display_name} was removed from the conversation.`, 'info'); + return; + } + + if (eventEnvelope.event_type === 'collaboration.member.role_updated' && payload.participant?.display_name) { + const roleLabel = payload.participant.role === 'admin' ? 'admin' : 'member'; + showToast(`${payload.participant.display_name} is now ${roleLabel}.`, 'success'); + return; + } + + if (eventEnvelope.event_type === 'collaboration.invite.accepted' && payload.participant?.display_name) { + showToast(`${payload.participant.display_name} accepted the invite.`, 'success'); + return; + } + + if (eventEnvelope.event_type === 'collaboration.deleted') { + showToast('This shared conversation was deleted.', 'warning'); + window.chatConversations?.removeConversationFromUi?.(eventEnvelope.conversation_id || payload.conversation?.id, { + refreshList: true, + skipToast: true, + }); + } +} + +function subscribeToConversationEvents(conversationId) { + if (!isCollaborationEnabled() || !conversationId || typeof EventSource === 'undefined') { + return; + } + + disconnectConversationEvents(); + activeCollaborativeConversationId = conversationId; + activeSubscriptionStartedAt = Date.now(); + activeCollaborationEventSource = new EventSource(`/api/collaboration/conversations/${encodeURIComponent(conversationId)}/events`); + activeCollaborationEventSource.onmessage = event => { + if (!event?.data) { + return; + } + + try { + handleConversationEvent(JSON.parse(event.data)); + } catch (error) { + console.warn('Failed to parse collaboration event:', error); + } + }; + activeCollaborationEventSource.onerror = () => { + console.warn('Collaboration event stream disconnected.'); + }; +} + +async function fetchConversationMetadata(conversationId) { + const payload = await fetchJson(`/api/collaboration/conversations/${conversationId}`); + const normalizedConversation = cacheCollaborationConversation(payload.conversation || {}); + setConversationDataset(conversationId, normalizedConversation); + applyConversationMetadataUpdate(conversationId, normalizedConversation); + return normalizedConversation; +} + +function showPendingInviteToast(conversation) { + if (!conversation?.id || !conversation.can_accept_invite) { + return; + } + + if (notifiedPendingInviteConversationIds.has(conversation.id)) { + return; + } + + notifiedPendingInviteConversationIds.add(conversation.id); + const actionId = `collaboration-invite-review-${conversation.id}-${Date.now()}`; + showToast( + `You were invited to ${escapeHtml(conversation.title || 'a collaborative conversation')}. `, + 'warning' + ); + + window.setTimeout(() => { + const actionButton = document.getElementById(actionId); + if (!actionButton) { + return; + } + + actionButton.addEventListener('click', async event => { + event.preventDefault(); + if (window.chatConversations?.selectConversation) { + await window.chatConversations.selectConversation(conversation.id); + } + if (window.showConversationDetails) { + window.showConversationDetails(conversation.id); + } + }, { once: true }); + }, 0); +} + +function notifyPendingInvites(conversations = []) { + conversations.forEach(conversation => { + if (conversation?.can_accept_invite) { + showPendingInviteToast(conversation); + } + }); +} + +async function fetchCollaborationConversationList() { + if (!isCollaborationEnabled()) { + return []; + } + + const payload = await fetchJson('/api/collaboration/conversations?include_pending=true'); + const conversations = Array.isArray(payload.conversations) ? payload.conversations : []; + const normalizedConversations = conversations.map(conversation => cacheCollaborationConversation(conversation)); + notifyPendingInvites(normalizedConversations); + return normalizedConversations; +} + +async function activateConversation(conversationId, metadata = null) { + const conversationMetadata = metadata + ? cacheCollaborationConversation(metadata) + : await fetchConversationMetadata(conversationId); + updateComposerAvailability(conversationMetadata); + clearReplyTarget({ focusComposer: false }); + await loadConversationMessages(conversationId); + markCollaborationConversationRead(conversationId, { suppressErrorToast: true }).catch(error => { + console.warn('Failed to clear shared conversation notifications:', error); + }); + subscribeToConversationEvents(conversationId); + + if (conversationMetadata.can_accept_invite && !promptedPendingInviteConversationIds.has(conversationId)) { + promptedPendingInviteConversationIds.add(conversationId); + showPendingInviteToast(conversationMetadata); + if (window.showConversationDetails) { + window.setTimeout(() => { + window.showConversationDetails(conversationId); + }, 0); + } + } + + return conversationMetadata; +} + +function deactivateConversation() { + disconnectConversationEvents(); + updateComposerAvailability(null); + clearReplyTarget({ focusComposer: false }); + hideMentionMenu(); +} + +async function sendCollaborativeMessage(messageText, tempMessageId = null) { + const conversationId = window.chatConversations?.getCurrentConversationId?.(); + if (!conversationId) { + return null; + } + + const mentionedParticipants = extractMentionedParticipantsFromMessage(messageText, conversationId); + + const payload = await fetchJson(`/api/collaboration/conversations/${conversationId}/messages`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + content: messageText, + reply_to_message_id: activeReplyContext?.message_id || null, + mentioned_participants: mentionedParticipants, + }), + }); + + if (payload.conversation) { + const normalizedConversation = cacheCollaborationConversation(payload.conversation); + setConversationDataset(conversationId, normalizedConversation); + applyConversationMetadataUpdate(conversationId, normalizedConversation); + } + + if (payload.message) { + cacheCollaborationMessage(payload.message); + if (!reconcilePendingCollaborativeUserMessage(payload.message, tempMessageId)) { + renderCollaborationMessage(decorateReplyMessage(payload.message), { isNewMessage: true }); + } + } + + setTypingState(false, { force: true }); + clearReplyTarget(); + return payload; +} + +async function sendCollaborativeAiMessage(messageText, tempMessageId = null, messageData = {}, pendingContext = null) { + const conversationId = window.chatConversations?.getCurrentConversationId?.(); + if (!conversationId) { + throw new Error('No collaborative conversation is active.'); + } + + const mentionedParticipants = extractMentionedParticipantsFromMessage(messageText, conversationId); + const invocationTarget = pendingContext?.metadata?.ai_invocation_target || null; + const requestBody = { + ...messageData, + content: messageText, + reply_to_message_id: activeReplyContext?.message_id || null, + mentioned_participants: mentionedParticipants, + invocation_target: invocationTarget, + }; + + sendMessageWithStreaming( + requestBody, + tempMessageId, + conversationId, + { + endpoint: `/api/collaboration/conversations/${encodeURIComponent(conversationId)}/stream`, + allowRecovery: false, + onError: (errorMessage, errorData = null) => { + if (errorData?.user_message_id && tempMessageId) { + updateUserMessageId(tempMessageId, errorData.user_message_id); + } + + if (errorData?.message_persisted === true) { + return; + } + + const tempMessage = document.querySelector(`[data-message-id="${tempMessageId}"]`); + if (tempMessage) { + tempMessage.remove(); + } + }, + }, + ); + + setTypingState(false, { force: true }); + clearReplyTarget(); + return { started: true }; +} + +function setTypingState(isTyping, options = {}) { + const conversationId = options.conversationId || window.chatConversations?.getCurrentConversationId?.(); + if (!conversationId || !isCollaborationConversation(conversationId) || !canPostMessages(conversationId)) { + return; + } + + if (!options.force && lastTypingState === isTyping) { + return; + } + + lastTypingState = isTyping; + fetch(`/api/collaboration/conversations/${conversationId}/typing`, { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ is_typing: isTyping }), + }).catch(error => { + console.warn('Failed to update collaboration typing state:', error); + }); +} + +function scheduleTypingState() { + if (!isCollaborationConversation(window.chatConversations?.getCurrentConversationId?.())) { + return; + } + + const hasContent = Boolean(userInput?.value?.trim()); + setTypingState(hasContent); + + if (typingStopHandle) { + window.clearTimeout(typingStopHandle); + } + typingStopHandle = window.setTimeout(() => { + setTypingState(false); + }, 3000); +} + +function getMentionMatch() { + if (!userInput) { + return null; + } + + const selectionStart = typeof userInput.selectionStart === 'number' + ? userInput.selectionStart + : userInput.value.length; + const beforeCursor = userInput.value.slice(0, selectionStart); + const match = beforeCursor.match(/(^|\s)@([^\s@]*)$/); + if (!match) { + return null; + } + + const startIndex = selectionStart - match[2].length - 1; + return { + query: match[2] || '', + startIndex, + endIndex: selectionStart, + }; +} + +function buildSuggestionItemHtml(suggestion) { + const subtitle = suggestion.action === 'ai_tag' + ? `
${escapeHtml(suggestion.subtitle || (suggestion.target_type === 'agent' ? 'AI agent' : 'Model deployment'))}
` + : suggestion.email + ? `
${escapeHtml(suggestion.email)}
` + : '
No email recorded
'; + const sourceLabel = suggestion.action === 'ai_tag' + ? suggestion.target_type === 'agent' + ? 'Agent' + : 'Model' + : suggestion.action === 'tag' + ? 'Tag' + : suggestion.source === 'recent' + ? 'Recent' + : 'Invite'; + + return ` +
+
+
${escapeHtml(suggestion.display_name)}
+ ${subtitle} +
+ ${sourceLabel} +
+ `; +} + +function hideMentionMenu() { + if (!mentionMenu) { + return; + } + + mentionMenu.innerHTML = ''; + mentionMenu.classList.add('d-none'); + activeMentionState = null; +} + +function renderMentionMenu(results, mentionState) { + if (!mentionMenu) { + return; + } + + if (!Array.isArray(results) || results.length === 0) { + mentionMenu.innerHTML = '
No matching participants, agents, models, or collaborators found.
'; + mentionMenu.classList.remove('d-none'); + activeMentionState = { + ...mentionState, + results: [], + activeIndex: -1, + }; + return; + } + + activeMentionState = { + ...mentionState, + results, + activeIndex: 0, + }; + + mentionMenu.innerHTML = ''; + results.forEach((result, index) => { + const button = document.createElement('button'); + button.type = 'button'; + button.className = `list-group-item list-group-item-action collaboration-mention-item${index === 0 ? ' active' : ''}`; + button.innerHTML = buildSuggestionItemHtml(result); + button.setAttribute('data-index', String(index)); + button.addEventListener('mousedown', event => { + event.preventDefault(); + if (result.action === 'tag') { + insertParticipantMention(result, mentionState); + return; + } + + if (result.action === 'ai_tag') { + insertInvocationTargetMention(result, mentionState); + return; + } + + openParticipantConfirmation(result, { + conversationId: window.chatConversations?.getCurrentConversationId?.(), + source: 'mention', + mentionState, + }); + }); + mentionMenu.appendChild(button); + }); + mentionMenu.classList.remove('d-none'); +} + +function updateMentionMenuActiveItem() { + if (!mentionMenu || !activeMentionState) { + return; + } + + const items = mentionMenu.querySelectorAll('.collaboration-mention-item'); + items.forEach((item, index) => { + item.classList.toggle('active', index === activeMentionState.activeIndex); + }); +} + +async function refreshMentionSuggestions() { + const conversationId = window.chatConversations?.getCurrentConversationId?.(); + if (!conversationId) { + hideMentionMenu(); + return; + } + + const mentionState = getMentionMatch(); + if (!mentionState) { + hideMentionMenu(); + return; + } + + const searchToken = ++mentionSearchToken; + try { + const results = await loadMentionSuggestions(conversationId, mentionState.query); + if (searchToken !== mentionSearchToken) { + return; + } + renderMentionMenu(results, mentionState); + } catch (error) { + if (searchToken !== mentionSearchToken) { + return; + } + hideMentionMenu(); + console.warn('Failed to load mention suggestions:', error); + } +} + +function removeMentionFromComposer(mentionState) { + if (!userInput || !mentionState) { + return; + } + + const beforeMention = userInput.value.slice(0, mentionState.startIndex); + const afterMention = userInput.value.slice(mentionState.endIndex); + const nextValue = `${beforeMention}${afterMention}`.replace(/\s{2,}/g, ' ').trimStart(); + userInput.value = nextValue; + updateSendButtonVisibility(); + userInput.focus(); +} + +function renderParticipantResults(results, emptyMessage = 'No collaborators found.') { + if (!participantResults) { + return; + } + + if (!Array.isArray(results) || results.length === 0) { + participantResults.innerHTML = `
${emptyMessage}
`; + return; + } + + participantResults.innerHTML = ''; + results.forEach(result => { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'list-group-item list-group-item-action'; + button.innerHTML = buildSuggestionItemHtml(result); + button.addEventListener('click', () => { + openParticipantConfirmation(result, { + conversationId: participantConversationIdInput?.value || window.chatConversations?.getCurrentConversationId?.(), + source: 'picker', + }); + }); + participantResults.appendChild(button); + }); +} + +async function refreshParticipantPickerResults(query = '') { + try { + const conversationId = participantConversationIdInput?.value || window.chatConversations?.getCurrentConversationId?.(); + const results = await searchConversationParticipantCandidates(conversationId, query, { recentOnly: false, limit: 12 }); + renderParticipantResults(results); + } catch (error) { + renderParticipantResults([], 'Failed to load collaborators.'); + console.warn('Failed to refresh participant picker results:', error); + } +} + +function openParticipantPicker(options = {}) { + const conversationId = options.conversationId || window.chatConversations?.getCurrentConversationId?.(); + if (!conversationId) { + showToast('Select a conversation first.', 'warning'); + return; + } + + if (!canUseParticipantFlow(conversationId)) { + showToast('Participants can only be added to eligible conversations you manage.', 'warning'); + return; + } + + if (!participantModalEl || !participantSearchInput || !participantConversationIdInput) { + return; + } + + participantConversationIdInput.value = conversationId; + participantSearchInput.value = ''; + renderParticipantResults([], 'Loading collaborators...'); + bootstrap.Modal.getOrCreateInstance(participantModalEl).show(); + refreshParticipantPickerResults(''); +} + +function openParticipantConfirmation(userSummary, context = {}) { + const collaborator = normalizeCollaborator(userSummary); + if (!collaborator || !confirmModalEl || !confirmMessageEl) { + return; + } + + pendingParticipantConfirmation = { + collaborator, + context, + }; + confirmMessageEl.innerHTML = `Are you sure you want to add ${collaborator.display_name}${collaborator.email ? ` (${collaborator.email})` : ''} to this conversation?`; + bootstrap.Modal.getOrCreateInstance(confirmModalEl).show(); +} + +function canUseParticipantFlow(conversationId) { + const chatType = getConversationChatType(conversationId); + if (!chatType || !['personal_single_user', 'personal_multi_user', 'group-single-user', 'group_multi_user'].includes(chatType)) { + return false; + } + + if (!isCollaborationConversation(conversationId)) { + return true; + } + + const item = getConversationDomItem(conversationId); + return item?.dataset?.canManageMembers === 'true'; +} + +function canPostMessages(conversationId) { + if (!isCollaborationConversation(conversationId)) { + return true; + } + + const item = getConversationDomItem(conversationId); + return item?.dataset?.canPostMessages !== 'false'; +} + +async function addParticipantToConversation(conversationId, collaborator) { + const isCollaborative = isCollaborationConversation(conversationId); + const chatType = getConversationChatType(conversationId); + let endpoint = `/api/collaboration/conversations/from-personal/${conversationId}/members`; + if (isCollaborative) { + endpoint = `/api/collaboration/conversations/${conversationId}/members`; + } else if (chatType === 'group-single-user') { + endpoint = `/api/collaboration/conversations/from-group/${conversationId}/members`; + } + + const payload = await fetchJson(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + participants: [collaborator], + }), + }); + + const normalizedConversation = cacheCollaborationConversation(payload.conversation || {}); + await rememberRecentCollaborator(collaborator); + + if (window.chatConversations?.loadConversations) { + await window.chatConversations.loadConversations(); + } + + if (normalizedConversation.id && window.chatConversations?.selectConversation) { + await window.chatConversations.selectConversation(normalizedConversation.id); + } + + setConversationDataset(normalizedConversation.id, normalizedConversation); + return { + ...payload, + conversation: normalizedConversation, + }; +} + +async function confirmPendingParticipant() { + if (!pendingParticipantConfirmation) { + return; + } + + const { collaborator, context } = pendingParticipantConfirmation; + const conversationId = context.conversationId || window.chatConversations?.getCurrentConversationId?.(); + if (!conversationId) { + return; + } + + confirmAddBtn.disabled = true; + try { + const payload = await addParticipantToConversation(conversationId, collaborator); + bootstrap.Modal.getOrCreateInstance(confirmModalEl).hide(); + bootstrap.Modal.getOrCreateInstance(participantModalEl)?.hide(); + + if (context.source === 'mention' && context.mentionState) { + removeMentionFromComposer(context.mentionState); + hideMentionMenu(); + } + + showToast( + payload.created + ? 'Conversation converted to a multi-user chat and participant invited.' + : 'Participant invited to the conversation.', + 'success' + ); + + const detailsModalVisible = document.getElementById('conversation-details-modal')?.classList.contains('show'); + if (detailsModalVisible && window.showConversationDetails && payload.conversation?.id) { + window.showConversationDetails(payload.conversation.id); + } + } catch (error) { + showToast(error.message || 'Failed to add participant.', 'danger'); + } finally { + confirmAddBtn.disabled = false; + pendingParticipantConfirmation = null; + } +} + +async function respondToInvite(conversationId, action) { + const payload = await fetchJson(`/api/collaboration/conversations/${conversationId}/invite-response`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ action }), + }); + + if (window.chatConversations?.loadConversations) { + await window.chatConversations.loadConversations(); + } + + if (action === 'accept') { + notifiedPendingInviteConversationIds.delete(conversationId); + promptedPendingInviteConversationIds.delete(conversationId); + } + + if (action === 'accept' && payload.conversation?.id && window.chatConversations?.selectConversation) { + await window.chatConversations.selectConversation(payload.conversation.id); + } + + window.hideConversationDetails?.(); + showToast(action === 'accept' ? 'Invite accepted.' : 'Invite declined.', 'success'); + return payload; +} + +async function removeParticipant(conversationId, memberUserId) { + const payload = await fetchJson(`/api/collaboration/conversations/${conversationId}/members/${encodeURIComponent(memberUserId)}`, { + method: 'DELETE', + }); + + if (window.chatConversations?.loadConversations) { + await window.chatConversations.loadConversations(); + } + if (window.chatConversations?.selectConversation) { + await window.chatConversations.selectConversation(conversationId); + } + + showToast('Participant removed from the conversation.', 'success'); + if (window.showConversationDetails) { + window.showConversationDetails(conversationId); + } + return payload; +} + +async function updateParticipantRole(conversationId, memberUserId, role) { + const payload = await fetchJson(`/api/collaboration/conversations/${conversationId}/members/${encodeURIComponent(memberUserId)}/role`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ role }), + }); + + if (window.chatConversations?.loadConversations) { + await window.chatConversations.loadConversations(); + } + if (window.chatConversations?.selectConversation) { + await window.chatConversations.selectConversation(conversationId); + } + + showToast(role === 'admin' ? 'Participant promoted to admin.' : 'Participant admin access removed.', 'success'); + if (window.showConversationDetails) { + window.showConversationDetails(conversationId); + } + return payload; +} + +function handleComposerInput() { + if (!isCollaborationEnabled()) { + return; + } + + scheduleTypingState(); + void refreshMentionSuggestions(); +} + +function handleComposerKeydown(event) { + if (!activeMentionState || mentionMenu?.classList.contains('d-none')) { + if (event.key === 'Escape' && activeReplyContext) { + clearReplyTarget(); + return true; + } + return false; + } + + if (event.key === 'ArrowDown') { + event.preventDefault(); + activeMentionState.activeIndex = Math.min(activeMentionState.activeIndex + 1, Math.max(activeMentionState.results.length - 1, 0)); + updateMentionMenuActiveItem(); + return true; + } + + if (event.key === 'ArrowUp') { + event.preventDefault(); + activeMentionState.activeIndex = Math.max(activeMentionState.activeIndex - 1, 0); + updateMentionMenuActiveItem(); + return true; + } + + if (event.key === 'Enter' && activeMentionState.activeIndex >= 0) { + event.preventDefault(); + const collaborator = activeMentionState.results[activeMentionState.activeIndex]; + if (collaborator) { + if (collaborator.action === 'tag') { + insertParticipantMention(collaborator, activeMentionState); + } else if (collaborator.action === 'ai_tag') { + insertInvocationTargetMention(collaborator, activeMentionState); + } else { + openParticipantConfirmation(collaborator, { + conversationId: window.chatConversations?.getCurrentConversationId?.(), + source: 'mention', + mentionState: activeMentionState, + }); + } + } + return true; + } + + if (event.key === 'Escape') { + hideMentionMenu(); + return true; + } + + return false; +} + +function handleComposerBlur() { + window.setTimeout(() => { + hideMentionMenu(); + }, 100); +} + +function initializeUi() { + if (!isCollaborationEnabled()) { + return; + } + + if (participantSearchInput) { + participantSearchInput.addEventListener('input', event => { + void refreshParticipantPickerResults(event.target.value || ''); + }); + } + + if (participantModalEl) { + participantModalEl.addEventListener('shown.bs.modal', () => { + participantSearchInput?.focus(); + }); + } + + if (confirmAddBtn) { + confirmAddBtn.addEventListener('click', () => { + void confirmPendingParticipant(); + }); + } + + if (replyCancelBtn) { + replyCancelBtn.addEventListener('click', () => { + clearReplyTarget(); + }); + } + + document.addEventListener('click', event => { + if (!mentionMenu || mentionMenu.classList.contains('d-none')) { + return; + } + + const withinMentionMenu = mentionMenu.contains(event.target); + if (!withinMentionMenu && event.target !== userInput) { + hideMentionMenu(); + } + }); +} + +window.chatCollaboration = { + activateConversation, + clearReplyTarget, + deactivateConversation, + fetchCollaborationConversationList, + fetchConversationMetadata, + getPendingMessageContext, + handleComposerBlur, + handleComposerInput, + handleComposerKeydown, + isCollaborationConversation, + openParticipantPicker, + removeParticipant, + replyToMessage, + respondToInvite, + sendCollaborativeAiMessage, + sendCollaborativeMessage, + updateParticipantRole, + canUseParticipantFlow, + markConversationRead: markCollaborationConversationRead, +}; + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initializeUi); +} else { + initializeUi(); +} \ No newline at end of file diff --git a/application/single_app/static/js/chat/chat-conversation-details.js b/application/single_app/static/js/chat/chat-conversation-details.js index c8438617..dac9ad1c 100644 --- a/application/single_app/static/js/chat/chat-conversation-details.js +++ b/application/single_app/static/js/chat/chat-conversation-details.js @@ -5,20 +5,105 @@ import { isColorLight } from "./chat-utils.js"; +function getConversationDetailsModalElements() { + return { + modal: document.getElementById('conversation-details-modal'), + modalTitle: document.getElementById('conversationDetailsModalLabel'), + content: document.getElementById('conversation-details-content'), + actionContainer: document.getElementById('conversation-details-actions'), + }; +} + +function cleanupConversationDetailsModalState() { + const anyVisibleModal = document.querySelector('.modal.show'); + if (anyVisibleModal) { + return; + } + + document.querySelectorAll('.modal-backdrop').forEach(backdrop => backdrop.remove()); + document.body.classList.remove('modal-open'); + document.body.style.removeProperty('padding-right'); +} + +function getConversationDetailsModalInstance() { + const { modal } = getConversationDetailsModalElements(); + if (!modal || !window.bootstrap?.Modal) { + return null; + } + return bootstrap.Modal.getOrCreateInstance(modal); +} + +export function hideConversationDetails() { + const { modal } = getConversationDetailsModalElements(); + const modalInstance = getConversationDetailsModalInstance(); + if (!modal || !modalInstance) { + cleanupConversationDetailsModalState(); + return; + } + + if (modal.classList.contains('show')) { + modalInstance.hide(); + window.setTimeout(cleanupConversationDetailsModalState, 200); + return; + } + + cleanupConversationDetailsModalState(); +} + +function renderConversationDetailsActions(metadata, conversationId) { + const { actionContainer } = getConversationDetailsModalElements(); + if (!actionContainer) { + return; + } + + if (!metadata || !conversationId) { + actionContainer.innerHTML = ''; + return; + } + + const actionButtons = []; + + if (window.chatExport?.openExportWizard) { + actionButtons.push(` + + `); + } + + const isCollaborativeConversation = metadata.conversation_kind === 'collaborative'; + const canShowDeleteAction = isCollaborativeConversation + ? Boolean(metadata.can_delete_conversation || metadata.can_leave_conversation) + : true; + + if (canShowDeleteAction) { + const deleteLabel = isCollaborativeConversation + ? (metadata.can_delete_conversation ? 'Delete / Leave' : 'Leave') + : 'Delete'; + actionButtons.push(` + + `); + } + + actionContainer.innerHTML = actionButtons.join(''); +} + /** * Show conversation details in a modal * @param {string} conversationId - The conversation ID to show details for */ export async function showConversationDetails(conversationId) { - const modal = document.getElementById('conversation-details-modal'); - const modalTitle = document.getElementById('conversationDetailsModalLabel'); - const content = document.getElementById('conversation-details-content'); + const { modal, modalTitle, content } = getConversationDetailsModalElements(); if (!modal || !content) { console.error('Conversation details modal not found'); return; } + renderConversationDetailsActions(null, null); + // Show loading state content.innerHTML = `
@@ -30,19 +115,29 @@ export async function showConversationDetails(conversationId) { `; // Show the modal - const bsModal = new bootstrap.Modal(modal); - bsModal.show(); + const bsModal = getConversationDetailsModalInstance(); + if (bsModal && !modal.classList.contains('show')) { + bsModal.show(); + } try { - // Fetch conversation metadata - const response = await fetch(`/api/conversations/${conversationId}/metadata`); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); + const conversationItem = document.querySelector(`.conversation-item[data-conversation-id="${conversationId}"]`) + || document.querySelector(`.sidebar-conversation-item[data-conversation-id="${conversationId}"]`); + const isCollaborativeConversation = conversationItem?.dataset?.conversationKind === 'collaborative'; + let metadata = null; + + if (isCollaborativeConversation && window.chatCollaboration?.fetchConversationMetadata) { + metadata = await window.chatCollaboration.fetchConversationMetadata(conversationId); + } else { + const response = await fetch(`/api/conversations/${conversationId}/metadata`); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + metadata = await response.json(); } - const metadata = await response.json(); - // Update modal title with conversation title, pin icon, and hidden icon const pinIcon = metadata.is_pinned ? '' : ''; const hiddenIcon = metadata.is_hidden ? '' : ''; @@ -53,9 +148,12 @@ export async function showConversationDetails(conversationId) { // Render the metadata content.innerHTML = renderConversationMetadata(metadata, conversationId); + renderConversationDetailsActions(metadata, conversationId); + attachConversationDetailActions(metadata, conversationId); } catch (error) { console.error('Error fetching conversation details:', error); + renderConversationDetailsActions(null, null); content.innerHTML = `
@@ -75,7 +173,32 @@ export async function showConversationDetails(conversationId) { * @returns {string} HTML string */ function renderConversationMetadata(metadata, conversationId) { - const { context = [], tags = [], strict = false, classification = [], last_updated, chat_type = 'personal', is_pinned = false, is_hidden = false, scope_locked, locked_contexts = [], summary = null } = metadata; + const { + context = [], + tags = [], + strict = false, + classification = [], + last_updated, + updated_at, + chat_type = 'personal', + is_pinned = false, + is_hidden = false, + scope_locked, + locked_contexts = [], + summary = null, + conversation_kind = null, + participants = [], + membership_status = null, + can_manage_members = false, + can_manage_roles = false, + can_accept_invite = false, + can_post_messages = true, + can_delete_conversation = false, + can_leave_conversation = false, + current_user_role = '', + pending_invite_count = 0, + } = metadata; + const resolvedLastUpdated = last_updated || updated_at; // Organize tags by category const tagsByCategory = { @@ -94,6 +217,13 @@ function renderConversationMetadata(metadata, conversationId) { } }); + const participantRecords = Array.isArray(participants) && participants.length > 0 + ? participants + : tagsByCategory.participant; + const collaborationStatusHtml = conversation_kind === 'collaborative' + ? renderCollaborationMembershipStatus(membership_status, can_post_messages, pending_invite_count) + : `${is_pinned ? 'Pinned' : ''} ${is_hidden ? 'Hidden' : ''}${!is_pinned && !is_hidden ? 'Normal' : ''}`; + // Build HTML sections let html = `
@@ -121,7 +251,7 @@ function renderConversationMetadata(metadata, conversationId) { Conversation ID: ${conversationId}
- Last Updated: ${formatDate(last_updated)} + Last Updated: ${formatDate(resolvedLastUpdated)}
Strict Mode: ${strict ? 'Enabled' : 'Disabled'} @@ -133,11 +263,16 @@ function renderConversationMetadata(metadata, conversationId) { Classifications: ${formatClassifications(classification)}
- Status: ${is_pinned ? 'Pinned' : ''} ${is_hidden ? 'Hidden' : ''}${!is_pinned && !is_hidden ? 'Normal' : ''} + Status: ${collaborationStatusHtml}
Scope Lock: ${formatScopeLockStatus(scope_locked, locked_contexts)}
+ ${conversation_kind === 'collaborative' ? ` +
+ Your Role: ${formatCollaborationRole(current_user_role, can_delete_conversation, can_leave_conversation)} +
+ ` : ''}
@@ -161,15 +296,20 @@ function renderConversationMetadata(metadata, conversationId) { } // Participants Section - if (tagsByCategory.participant.length > 0) { + if (participantRecords.length > 0 || can_manage_members || can_accept_invite) { html += `
-
+
Participants
+ ${renderCollaborationActionButtons(conversationId, metadata)}
- ${renderParticipantsSection(tagsByCategory.participant)} + ${renderParticipantsSection(participantRecords, { + canManageMembers: can_manage_members, + canManageRoles: can_manage_roles, + conversationKind: conversation_kind, + })}
@@ -244,6 +384,70 @@ function renderConversationMetadata(metadata, conversationId) { return html; } +function renderCollaborationMembershipStatus(membershipStatus, canPostMessages, pendingInviteCount) { + if (!membershipStatus) { + return 'Normal'; + } + + const badges = []; + if (membershipStatus === 'accepted' || membershipStatus === 'group_member') { + badges.push('Active member'); + } + if (membershipStatus === 'pending') { + badges.push('Invite pending'); + } + if (!canPostMessages) { + badges.push('Read-only'); + } + if (pendingInviteCount > 0) { + badges.push(`${pendingInviteCount} pending`); + } + + return badges.join(' ') || 'Normal'; +} + +function formatCollaborationRole(currentUserRole, canDeleteConversation, canLeaveConversation) { + if (!currentUserRole) { + return 'Participant'; + } + + if (currentUserRole === 'owner') { + return `Owner${canDeleteConversation ? ' Can delete for everyone' : ''}`; + } + if (currentUserRole === 'admin') { + return 'Admin Can invite members'; + } + if (canLeaveConversation) { + return 'Member'; + } + return 'Participant'; +} + +function renderCollaborationActionButtons(conversationId, metadata) { + if (metadata.can_accept_invite) { + return ` +
+ + +
+ `; + } + + if (metadata.can_manage_members) { + return ` + + `; + } + + return ''; +} + /** * Render context section */ @@ -255,7 +459,7 @@ function renderContextSection(context) { if (primary) { const displayName = primary.name || primary.id; - const isGroupChat = primary.scope === 'group'; + const groupContextBadge = primary.scope === 'group' ? 'group' : ''; html += `
@@ -263,7 +467,7 @@ function renderContextSection(context) {
${primary.scope} - ${isGroupChat ? 'single-user' : ''} + ${groupContextBadge} ${displayName}
${primary.name ? `
ID: ${primary.id}
` : ''} @@ -299,22 +503,68 @@ function renderContextSection(context) { /** * Render participants section */ -function renderParticipantsSection(participants) { +function renderParticipantsSection(participants, options = {}) { let html = ''; participants.forEach(participant => { - const initials = (participant.name || 'U').slice(0, 2).toUpperCase(); + const displayName = participant.display_name || participant.name || 'Unknown User'; + const participantStatus = participant.status || null; + const participantRole = participant.role || null; + const initials = displayName.slice(0, 2).toUpperCase(); const avatarId = `participant-avatar-${participant.user_id}`; + const canRemoveParticipant = Boolean(options.canManageMembers) + && options.conversationKind === 'collaborative' + && participantRole !== 'owner'; + const canToggleAdmin = Boolean(options.canManageRoles) + && options.conversationKind === 'collaborative' + && participantRole !== 'owner' + && participantStatus === 'accepted'; + + let statusBadgesHtml = ''; + if (participantRole === 'owner') { + statusBadgesHtml += 'Owner'; + } + if (participantRole === 'admin') { + statusBadgesHtml += 'Admin'; + } + if (participantStatus === 'pending') { + statusBadgesHtml += 'Pending'; + } + if (participantStatus === 'removed') { + statusBadgesHtml += 'Removed'; + } + if (participantStatus === 'declined') { + statusBadgesHtml += 'Declined'; + } + + const participantActions = []; + if (canToggleAdmin) { + const nextRole = participantRole === 'admin' ? 'member' : 'admin'; + const roleActionLabel = participantRole === 'admin' ? 'Remove admin' : 'Make admin'; + participantActions.push(` + + `); + } + if (canRemoveParticipant) { + participantActions.push(` + + `); + } html += ` -
+
${initials}
-
-
${participant.name || 'Unknown User'}
+
+
${displayName}${statusBadgesHtml}
${participant.email || ''}
+ ${participantActions.length > 0 ? `
${participantActions.join('')}
` : ''}
`; }); @@ -329,6 +579,67 @@ function renderParticipantsSection(participants) { return html; } +function attachConversationDetailActions(metadata, conversationId) { + const addParticipantBtn = document.querySelector('[data-collaboration-action="add-participant"]'); + const acceptInviteBtn = document.querySelector('[data-collaboration-action="accept-invite"]'); + const declineInviteBtn = document.querySelector('[data-collaboration-action="decline-invite"]'); + const removeParticipantButtons = document.querySelectorAll('[data-collaboration-action="remove-participant"]'); + const roleButtons = document.querySelectorAll('[data-collaboration-action="toggle-participant-role"]'); + const exportConversationBtn = document.querySelector('[data-conversation-action="export"]'); + const deleteConversationBtn = document.querySelector('[data-conversation-action="delete"]'); + + if (addParticipantBtn) { + addParticipantBtn.addEventListener('click', () => { + window.chatCollaboration?.openParticipantPicker?.({ conversationId }); + }); + } + + if (acceptInviteBtn) { + acceptInviteBtn.addEventListener('click', () => { + window.chatCollaboration?.respondToInvite?.(conversationId, 'accept'); + }); + } + + if (declineInviteBtn) { + declineInviteBtn.addEventListener('click', () => { + window.chatCollaboration?.respondToInvite?.(conversationId, 'decline'); + }); + } + + removeParticipantButtons.forEach(button => { + button.addEventListener('click', () => { + const memberUserId = button.getAttribute('data-member-user-id'); + if (!memberUserId) { + return; + } + window.chatCollaboration?.removeParticipant?.(conversationId, memberUserId); + }); + }); + + roleButtons.forEach(button => { + button.addEventListener('click', () => { + const memberUserId = button.getAttribute('data-member-user-id'); + const nextRole = button.getAttribute('data-next-role'); + if (!memberUserId || !nextRole) { + return; + } + window.chatCollaboration?.updateParticipantRole?.(conversationId, memberUserId, nextRole); + }); + }); + + if (exportConversationBtn) { + exportConversationBtn.addEventListener('click', () => { + window.chatExport?.openExportWizard?.([conversationId], true); + }); + } + + if (deleteConversationBtn) { + deleteConversationBtn.addEventListener('click', () => { + window.chatConversations?.deleteConversation?.(conversationId); + }); + } +} + /** * Load profile image for a participant */ @@ -751,3 +1062,20 @@ document.addEventListener('click', function(e) { // Export functions for external use window.showConversationDetails = showConversationDetails; +window.hideConversationDetails = hideConversationDetails; + +function initializeConversationDetailsModal() { + const { modal } = getConversationDetailsModalElements(); + if (!modal || modal.dataset.initialized === 'true') { + return; + } + + modal.dataset.initialized = 'true'; + modal.addEventListener('hidden.bs.modal', cleanupConversationDetailsModalState); +} + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initializeConversationDetailsModal); +} else { + initializeConversationDetailsModal(); +} diff --git a/application/single_app/static/js/chat/chat-conversation-info-button.js b/application/single_app/static/js/chat/chat-conversation-info-button.js index dfb5e0b7..07aa27a8 100644 --- a/application/single_app/static/js/chat/chat-conversation-info-button.js +++ b/application/single_app/static/js/chat/chat-conversation-info-button.js @@ -36,7 +36,7 @@ export function toggleConversationInfoButton(hasActiveConversation) { const infoButton = document.getElementById('conversation-info-btn'); if (infoButton) { - infoButton.style.display = hasActiveConversation ? 'inline-block' : 'none'; + infoButton.classList.toggle('d-none', !hasActiveConversation); } } @@ -53,3 +53,34 @@ export function showConversationInfoButton() { export function hideConversationInfoButton() { toggleConversationInfoButton(false); } + +function buildWorkflowActivityUrl(conversationId, workflowId) { + if (!conversationId) { + return ''; + } + + const url = new URL('/workflow-activity', window.location.origin); + url.searchParams.set('conversationId', conversationId); + if (workflowId) { + url.searchParams.set('workflowId', workflowId); + } + return url.toString(); +} + +export function updateWorkflowActivityButton(conversationId, metadata = {}) { + const activityButton = document.getElementById('workflow-activity-btn'); + if (!activityButton) { + return; + } + + const workflowId = String(metadata.workflow_id || '').trim(); + const isWorkflowConversation = String(metadata.chat_type || '').trim().toLowerCase() === 'workflow' || Boolean(workflowId); + const activityUrl = isWorkflowConversation ? buildWorkflowActivityUrl(conversationId, workflowId) : ''; + + activityButton.classList.toggle('d-none', !activityUrl); + activityButton.href = activityUrl || '#'; +} + +export function hideWorkflowActivityButton() { + updateWorkflowActivityButton('', {}); +} diff --git a/application/single_app/static/js/chat/chat-conversations.js b/application/single_app/static/js/chat/chat-conversations.js index 560571e9..a566405a 100644 --- a/application/single_app/static/js/chat/chat-conversations.js +++ b/application/single_app/static/js/chat/chat-conversations.js @@ -9,7 +9,11 @@ import { setActiveConversation as setSidebarActiveConversation, setConversationUnreadState as setSidebarConversationUnreadState, } from "./chat-sidebar-conversations.js"; -import { toggleConversationInfoButton } from "./chat-conversation-info-button.js"; +import { + hideWorkflowActivityButton, + toggleConversationInfoButton, + updateWorkflowActivityButton, +} from "./chat-conversation-info-button.js"; import { restoreScopeLockState, resetScopeLock } from "./chat-documents.js"; import { loadUserSettings } from "./chat-layout.js"; import { setUserSetting } from "../agents_common.js"; @@ -23,6 +27,15 @@ const conversationsList = document.getElementById("conversations-list"); const currentConversationTitleEl = document.getElementById("current-conversation-title"); const currentConversationClassificationsEl = document.getElementById("current-conversation-classifications"); const chatbox = document.getElementById("chatbox"); +const deleteConversationModalEl = document.getElementById("delete-conversation-modal"); +const deleteConversationMessageEl = document.getElementById("delete-conversation-message"); +const deleteConversationSharedWarningEl = document.getElementById("delete-conversation-shared-warning"); +const deleteConversationOwnerOptionsEl = document.getElementById("delete-conversation-owner-options"); +const deleteConversationTransferOptionEl = document.getElementById("delete-conversation-transfer-option"); +const deleteConversationOwnerSelectContainerEl = document.getElementById("delete-conversation-owner-select-container"); +const deleteConversationNewOwnerSelectEl = document.getElementById("delete-conversation-new-owner-select"); +const deleteConversationImpactNoteEl = document.getElementById("delete-conversation-impact-note"); +const confirmDeleteConversationBtn = document.getElementById("confirm-delete-conversation-btn"); // Track selected conversations let selectedConversations = new Set(); @@ -94,6 +107,7 @@ let showQuickSearch = false; // Track if quick search input is visible let quickSearchTerm = ""; // Current search term let pendingConversationCreation = null; // Reuse a single in-flight create request const markConversationReadRequests = new Map(); +let pendingDeleteConversationContext = null; function createUnreadDotElement() { const unreadDot = document.createElement("span"); @@ -226,16 +240,49 @@ export function applyConversationMetadataUpdate(conversationId, updates = {}) { convoItem.setAttribute('data-conversation-title', updates.title); const titleElement = convoItem.querySelector('.conversation-title'); if (titleElement) { - const pinIcon = titleElement.querySelector('.bi-pin-angle'); + const existingIcons = Array.from(titleElement.querySelectorAll('i')).map(icon => icon.cloneNode(true)); titleElement.innerHTML = ''; - if (pinIcon) { - titleElement.appendChild(pinIcon); - } + existingIcons.forEach(icon => titleElement.appendChild(icon)); titleElement.appendChild(document.createTextNode(updates.title)); titleElement.title = updates.title; } } + if (updates.conversation_kind) { + convoItem.dataset.conversationKind = updates.conversation_kind; + } + if (Object.prototype.hasOwnProperty.call(updates, 'membership_status') && updates.membership_status) { + convoItem.dataset.membershipStatus = updates.membership_status; + } + if (Object.prototype.hasOwnProperty.call(updates, 'can_manage_members')) { + convoItem.dataset.canManageMembers = updates.can_manage_members ? 'true' : 'false'; + } + if (Object.prototype.hasOwnProperty.call(updates, 'can_manage_roles')) { + convoItem.dataset.canManageRoles = updates.can_manage_roles ? 'true' : 'false'; + } + if (Object.prototype.hasOwnProperty.call(updates, 'can_accept_invite')) { + convoItem.dataset.canAcceptInvite = updates.can_accept_invite ? 'true' : 'false'; + } + if (Object.prototype.hasOwnProperty.call(updates, 'can_post_messages')) { + convoItem.dataset.canPostMessages = updates.can_post_messages === false ? 'false' : 'true'; + } + if (Object.prototype.hasOwnProperty.call(updates, 'can_delete_conversation')) { + convoItem.dataset.canDeleteConversation = updates.can_delete_conversation ? 'true' : 'false'; + } + if (Object.prototype.hasOwnProperty.call(updates, 'can_leave_conversation')) { + convoItem.dataset.canLeaveConversation = updates.can_leave_conversation ? 'true' : 'false'; + } + if (Object.prototype.hasOwnProperty.call(updates, 'current_user_role')) { + convoItem.dataset.currentUserRole = updates.current_user_role || ''; + } + if (Object.prototype.hasOwnProperty.call(updates, 'workflow_id')) { + if (updates.workflow_id) { + convoItem.dataset.workflowId = updates.workflow_id; + } else { + delete convoItem.dataset.workflowId; + } + } + if (Array.isArray(updates.classification)) { convoItem.dataset.classifications = JSON.stringify(updates.classification); } @@ -252,6 +299,16 @@ export function applyConversationMetadataUpdate(conversationId, updates = {}) { classification: updates.classification, context: updates.context, chat_type: updates.chat_type, + conversation_kind: updates.conversation_kind, + membership_status: updates.membership_status, + can_manage_members: updates.can_manage_members, + can_manage_roles: updates.can_manage_roles, + can_accept_invite: updates.can_accept_invite, + can_post_messages: updates.can_post_messages, + can_delete_conversation: updates.can_delete_conversation, + can_leave_conversation: updates.can_leave_conversation, + current_user_role: updates.current_user_role, + workflow_id: updates.workflow_id, }); applySidebarConversationMetadataUpdate(conversationId, updates); @@ -269,6 +326,10 @@ export function applyConversationMetadataUpdate(conversationId, updates = {}) { } renderConversationHeaderBadges(convoItem); + updateWorkflowActivityButton(conversationId, { + chat_type: updates.chat_type || convoItem.getAttribute('data-chat-type') || '', + workflow_id: updates.workflow_id || convoItem.dataset.workflowId || '', + }); if (hasContextUpdate) { void refreshAgentsAndModelsForActiveConversation(); @@ -377,6 +438,21 @@ document.addEventListener('DOMContentLoaded', () => { if (deleteSelectedBtn) { deleteSelectedBtn.style.display = "none"; } + + if (confirmDeleteConversationBtn) { + confirmDeleteConversationBtn.addEventListener('click', () => { + void executeDeleteConversationAction(); + }); + } + + if (deleteConversationModalEl) { + deleteConversationModalEl.addEventListener('hidden.bs.modal', () => { + resetDeleteConversationModalState(); + }); + deleteConversationModalEl.querySelectorAll('input[name="delete-conversation-action"]').forEach(input => { + input.addEventListener('change', toggleDeleteConversationTransferInputs); + }); + } // Set up quick search event listeners const searchBtn = document.getElementById('sidebar-search-btn'); @@ -582,7 +658,9 @@ function clearQuickSearch() { loadConversations(); } -export function loadConversations() { +export function loadConversations(options = {}) { + const { syncSidebar = true } = options; + if (!conversationsList) return; // Prevent concurrent loads @@ -594,11 +672,24 @@ export function loadConversations() { isLoadingConversations = true; conversationsList.innerHTML = '
Loading conversations...
'; // Loading state - return fetch("/api/get_conversations") - .then(response => response.ok ? response.json() : response.json().then(err => Promise.reject(err))) - .then(data => { + const legacyConversationsRequest = fetch("/api/get_conversations") + .then(response => response.ok ? response.json() : response.json().then(err => Promise.reject(err))); + const collaborationConversationsRequest = window.chatCollaboration?.fetchCollaborationConversationList + ? window.chatCollaboration.fetchCollaborationConversationList().catch(error => { + console.warn('Failed to load collaborative conversations:', error); + return []; + }) + : Promise.resolve([]); + + return Promise.all([legacyConversationsRequest, collaborationConversationsRequest]) + .then(([data, collaborationConversations]) => { conversationsList.innerHTML = ""; // Clear loading state - if (!data.conversations || data.conversations.length === 0) { + const mergedConversations = [ + ...(Array.isArray(data.conversations) ? data.conversations : []), + ...(Array.isArray(collaborationConversations) ? collaborationConversations : []), + ]; + + if (mergedConversations.length === 0) { conversationsList.innerHTML = '
No conversations yet.
'; allConversations = []; updateHiddenToggleButton(); @@ -606,7 +697,7 @@ export function loadConversations() { } // Store all conversations for client-side operations - allConversations = data.conversations; + allConversations = mergedConversations; // Sort conversations: pinned first (by last_updated), then unpinned (by last_updated) const sortedConversations = [...allConversations].sort((a, b) => { @@ -646,8 +737,8 @@ export function loadConversations() { updateHiddenToggleButton(); // Also load sidebar conversations if the sidebar exists - if (window.chatSidebarConversations && window.chatSidebarConversations.loadSidebarConversations) { - window.chatSidebarConversations.loadSidebarConversations(); + if (syncSidebar && window.chatSidebarConversations && window.chatSidebarConversations.loadSidebarConversations) { + window.chatSidebarConversations.loadSidebarConversations({ conversations: mergedConversations }); } // Reset loading flag @@ -671,18 +762,27 @@ export async function ensureConversationPresent(conversationId) { if (existing) return existing; // Fetch metadata to validate ownership and get details + let metadata = null; const res = await fetch(`/api/conversations/${conversationId}/metadata`); - if (!res.ok) { + if (res.ok) { + metadata = await res.json(); + } else if (window.chatCollaboration?.fetchConversationMetadata) { + try { + metadata = await window.chatCollaboration.fetchConversationMetadata(conversationId); + } catch (error) { + const err = await res.json().catch(() => ({})); + throw new Error(error.message || err.error || `Failed to load conversation ${conversationId}`); + } + } else { const err = await res.json().catch(() => ({})); throw new Error(err.error || `Failed to load conversation ${conversationId}`); } - const metadata = await res.json(); // Build a conversation object compatible with createConversationItem const convo = { id: conversationId, title: metadata.title || 'Conversation', - last_updated: metadata.last_updated || new Date().toISOString(), + last_updated: metadata.last_updated || metadata.updated_at || new Date().toISOString(), classification: metadata.classification || [], context: metadata.context || [], chat_type: metadata.chat_type || null, @@ -691,6 +791,15 @@ export async function ensureConversationPresent(conversationId) { has_unread_assistant_response: metadata.has_unread_assistant_response || false, last_unread_assistant_message_id: metadata.last_unread_assistant_message_id || null, last_unread_assistant_at: metadata.last_unread_assistant_at || null, + conversation_kind: metadata.conversation_kind || null, + membership_status: metadata.membership_status || null, + can_manage_members: metadata.can_manage_members || false, + can_manage_roles: metadata.can_manage_roles || false, + can_accept_invite: metadata.can_accept_invite || false, + can_post_messages: metadata.can_post_messages !== false, + can_delete_conversation: metadata.can_delete_conversation || false, + can_leave_conversation: metadata.can_leave_conversation || false, + current_user_role: metadata.current_user_role || '', }; // Keep allConversations in sync @@ -712,7 +821,36 @@ export function createConversationItem(convo) { convoItem.classList.add("list-group-item", "list-group-item-action", "conversation-item", "d-flex", "align-items-center"); // Use action class convoItem.setAttribute("data-conversation-id", convo.id); convoItem.setAttribute("data-conversation-title", convo.title); // Store title too + if (convo.workflow_id) { + convoItem.dataset.workflowId = convo.workflow_id; + } convoItem.dataset.hasUnreadAssistantResponse = convo.has_unread_assistant_response ? "true" : "false"; + const isCollaborativeConversation = convo.conversation_kind === 'collaborative'; + const conversationChatType = convo.chat_type === 'personal' ? 'personal_single_user' : convo.chat_type; + const canManageMembers = isCollaborativeConversation + ? Boolean(convo.can_manage_members) + : ['personal_single_user', 'group-single-user'].includes(conversationChatType || ''); + const canManageRoles = isCollaborativeConversation ? Boolean(convo.can_manage_roles) : false; + const canEditCollaborativeTitle = !isCollaborativeConversation || canManageRoles; + const canShowAddParticipants = ['personal_single_user', 'personal_multi_user', 'group-single-user', 'group_multi_user'].includes(conversationChatType || '') + && canManageMembers; + const canDeleteCollaborativeConversation = Boolean(convo.can_delete_conversation); + const canLeaveCollaborativeConversation = Boolean(convo.can_leave_conversation); + const collaborativeDeleteLabel = canDeleteCollaborativeConversation ? 'Delete / Leave' : 'Leave'; + + if (isCollaborativeConversation) { + convoItem.dataset.conversationKind = 'collaborative'; + } + if (convo.membership_status) { + convoItem.dataset.membershipStatus = convo.membership_status; + } + convoItem.dataset.canManageMembers = canManageMembers ? 'true' : 'false'; + convoItem.dataset.canManageRoles = canManageRoles ? 'true' : 'false'; + convoItem.dataset.canAcceptInvite = convo.can_accept_invite ? 'true' : 'false'; + convoItem.dataset.canPostMessages = convo.can_post_messages === false ? 'false' : 'true'; + convoItem.dataset.canDeleteConversation = canDeleteCollaborativeConversation ? 'true' : 'false'; + convoItem.dataset.canLeaveConversation = canLeaveCollaborativeConversation ? 'true' : 'false'; + convoItem.dataset.currentUserRole = convo.current_user_role || ''; // *** Store classification data as stringified JSON *** convoItem.dataset.classifications = JSON.stringify(convo.classification || []); @@ -826,6 +964,13 @@ export function createConversationItem(convo) { pinIcon.classList.add("bi", "bi-pin-angle", "me-1"); titleSpan.appendChild(pinIcon); } + + if (isCollaborativeConversation) { + const collaborationIcon = document.createElement("i"); + collaborationIcon.classList.add("bi", "bi-people", "me-1"); + collaborationIcon.title = "Collaborative conversation"; + titleSpan.appendChild(collaborationIcon); + } titleSpan.appendChild(document.createTextNode(convo.title)); titleSpan.title = convo.title; // Tooltip for full title @@ -868,6 +1013,17 @@ export function createConversationItem(convo) { detailsA.innerHTML = 'Details'; detailsLi.appendChild(detailsA); + let addParticipantsA = null; + let addParticipantsLi = null; + if (canShowAddParticipants) { + addParticipantsLi = document.createElement("li"); + addParticipantsA = document.createElement("a"); + addParticipantsA.classList.add("dropdown-item", "add-participants-btn"); + addParticipantsA.href = "#"; + addParticipantsA.innerHTML = 'Add participants'; + addParticipantsLi.appendChild(addParticipantsA); + } + // Add Pin option const pinLi = document.createElement("li"); const pinA = document.createElement("a"); @@ -915,17 +1071,32 @@ export function createConversationItem(convo) { const deleteA = document.createElement("a"); deleteA.classList.add("dropdown-item", "delete-btn", "text-danger"); deleteA.href = "#"; - deleteA.innerHTML = 'Delete'; + deleteA.innerHTML = `${isCollaborativeConversation ? collaborativeDeleteLabel : 'Delete'}`; deleteLi.appendChild(deleteA); dropdownMenu.appendChild(detailsLi); - dropdownMenu.appendChild(pinLi); - dropdownMenu.appendChild(hideLi); - dropdownMenu.appendChild(selectLi); - dropdownMenu.appendChild(exportLi); - - dropdownMenu.appendChild(editLi); - dropdownMenu.appendChild(deleteLi); + if (addParticipantsLi) { + dropdownMenu.appendChild(addParticipantsLi); + } + if (isCollaborativeConversation) { + dropdownMenu.appendChild(pinLi); + dropdownMenu.appendChild(hideLi); + dropdownMenu.appendChild(selectLi); + dropdownMenu.appendChild(exportLi); + if (canEditCollaborativeTitle) { + dropdownMenu.appendChild(editLi); + } + if (canDeleteCollaborativeConversation || canLeaveCollaborativeConversation) { + dropdownMenu.appendChild(deleteLi); + } + } else { + dropdownMenu.appendChild(pinLi); + dropdownMenu.appendChild(hideLi); + dropdownMenu.appendChild(selectLi); + dropdownMenu.appendChild(exportLi); + dropdownMenu.appendChild(editLi); + dropdownMenu.appendChild(deleteLi); + } rightDiv.appendChild(dropdownBtn); rightDiv.appendChild(dropdownMenu); @@ -951,6 +1122,15 @@ export function createConversationItem(convo) { selectConversation(convo.id); }); + if (addParticipantsA) { + addParticipantsA.addEventListener("click", event => { + event.preventDefault(); + event.stopPropagation(); + closeDropdownMenu(dropdownBtn); + window.chatCollaboration?.openParticipantPicker?.({ conversationId: convo.id }); + }); + } + editA.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); @@ -1124,7 +1304,13 @@ export function exitEditMode(convoItem, convo, dropdownBtn, rightDiv, dateSpan, const newSpan = document.createElement("span"); newSpan.classList.add("conversation-title", "text-truncate"); - newSpan.textContent = convo.title; + if (convoItem.dataset.conversationKind === 'collaborative') { + const collaborationIcon = document.createElement("i"); + collaborationIcon.classList.add("bi", "bi-people", "me-1"); + collaborationIcon.title = "Collaborative conversation"; + newSpan.appendChild(collaborationIcon); + } + newSpan.appendChild(document.createTextNode(convo.title)); newSpan.title = convo.title; // Add tooltip back input.replaceWith(newSpan); // Replace input with updated span @@ -1137,7 +1323,10 @@ export function exitEditMode(convoItem, convo, dropdownBtn, rightDiv, dateSpan, } export async function updateConversationTitle(conversationId, newTitle) { - const response = await fetch(`/api/conversations/${conversationId}`, { + const convoItem = document.querySelector(`.conversation-item[data-conversation-id="${conversationId}"]`) + || document.querySelector(`.sidebar-conversation-item[data-conversation-id="${conversationId}"]`); + const isCollaborativeConversation = convoItem?.dataset?.conversationKind === 'collaborative'; + const response = await fetch(isCollaborativeConversation ? `/api/collaboration/conversations/${conversationId}` : `/api/conversations/${conversationId}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title: newTitle }), @@ -1204,16 +1393,26 @@ export async function selectConversation(conversationId) { if (chatbox) chatbox.innerHTML = '
Conversation not found.
'; highlightSelectedConversation(null); // Deselect all visually toggleConversationInfoButton(false); // Hide the info button + hideWorkflowActivityButton(); return; } const conversationTitle = convoItem.getAttribute("data-conversation-title") || "Conversation"; // Use stored title + const isCollaborativeConversation = convoItem.dataset.conversationKind === 'collaborative'; + let metadata = null; // Fetch the latest conversation metadata to get accurate chat_type, pin, and hide status try { - const response = await fetch(`/api/conversations/${conversationId}/metadata`); - if (response.ok) { - const metadata = await response.json(); + if (isCollaborativeConversation && window.chatCollaboration?.fetchConversationMetadata) { + metadata = await window.chatCollaboration.fetchConversationMetadata(conversationId); + } else { + const response = await fetch(`/api/conversations/${conversationId}/metadata`); + if (response.ok) { + metadata = await response.json(); + } + } + + if (metadata) { // Update Header Title with pin icon and hidden status if (currentConversationTitleEl) { @@ -1324,6 +1523,21 @@ export async function selectConversation(conversationId) { const metaScopeLocked = metadata.scope_locked !== undefined ? metadata.scope_locked : null; const metaLockedContexts = metadata.locked_contexts || []; restoreScopeLockState(metaScopeLocked, metaLockedContexts); + + convoItem.dataset.canManageMembers = metadata.can_manage_members ? 'true' : 'false'; + convoItem.dataset.canAcceptInvite = metadata.can_accept_invite ? 'true' : 'false'; + convoItem.dataset.canPostMessages = metadata.can_post_messages === false ? 'false' : 'true'; + if (metadata.membership_status) { + convoItem.dataset.membershipStatus = metadata.membership_status; + } + if (metadata.conversation_kind) { + convoItem.dataset.conversationKind = metadata.conversation_kind; + } + if (metadata.workflow_id) { + convoItem.dataset.workflowId = metadata.workflow_id; + } else { + delete convoItem.dataset.workflowId; + } } } catch (error) { console.warn('Failed to fetch conversation metadata:', error); @@ -1335,20 +1549,29 @@ export async function selectConversation(conversationId) { renderConversationHeaderBadges(convoItem); } - await loadMessages(conversationId); - try { - const streamingModule = await import('./chat-streaming.js'); - await streamingModule.reattachStreamingConversation(conversationId); - } catch (error) { - console.warn('Failed to reattach active stream for conversation:', error); + if (isCollaborativeConversation && window.chatCollaboration?.activateConversation) { + await window.chatCollaboration.activateConversation(conversationId, metadata); + } else { + window.chatCollaboration?.deactivateConversation?.(); + await loadMessages(conversationId); + try { + const streamingModule = await import('./chat-streaming.js'); + await streamingModule.reattachStreamingConversation(conversationId); + } catch (error) { + console.warn('Failed to reattach active stream for conversation:', error); + } + markConversationRead(conversationId, { force: true, suppressErrorToast: true }).catch(error => { + console.warn('Failed to clear unread state for conversation:', error); + }); } - markConversationRead(conversationId, { force: true, suppressErrorToast: true }).catch(error => { - console.warn('Failed to clear unread state for conversation:', error); - }); highlightSelectedConversation(conversationId); // Show the conversation info button since we have an active conversation toggleConversationInfoButton(true); + updateWorkflowActivityButton(conversationId, { + chat_type: metadata?.chat_type || convoItem.getAttribute('data-chat-type') || '', + workflow_id: metadata?.workflow_id || convoItem.dataset.workflowId || '', + }); // Update sidebar active conversation if sidebar exists if (setSidebarActiveConversation) { @@ -1388,45 +1611,261 @@ export function highlightSelectedConversation(conversationId) { }); } -// Delete a conversation -export function deleteConversation(conversationId) { - if (!confirm("Are you sure you want to delete this conversation? This action cannot be undone.")) { +function getConversationFromCache(conversationId) { + return allConversations.find(conversation => conversation.id === conversationId) || null; +} + +function toggleDeleteConversationTransferInputs() { + if (!deleteConversationOwnerSelectContainerEl || !deleteConversationModalEl || !confirmDeleteConversationBtn) { return; } - // Optionally show loading state on the item being deleted + const selectedAction = deleteConversationModalEl.querySelector('input[name="delete-conversation-action"]:checked')?.value; + deleteConversationOwnerSelectContainerEl.classList.toggle('d-none', selectedAction !== 'leave'); - fetch(`/api/conversations/${conversationId}`, { method: "DELETE" }) - .then(response => { - if (response.ok) { - const convoItem = document.querySelector(`.conversation-item[data-conversation-id="${conversationId}"]`); - if (convoItem) convoItem.remove(); - - // If the deleted conversation was the current one, reset the chat view - if (currentConversationId === conversationId) { - currentConversationId = null; - if (currentConversationTitleEl) currentConversationTitleEl.textContent = "Select or start a conversation"; - if (currentConversationClassificationsEl) currentConversationClassificationsEl.innerHTML = ""; // Clear classifications - if (chatbox) chatbox.innerHTML = '
Select a conversation to view messages.
'; // Reset chatbox - highlightSelectedConversation(null); // Deselect all - toggleConversationInfoButton(false); // Hide the info button - } - - // Also reload sidebar conversations if the sidebar exists - if (window.chatSidebarConversations && window.chatSidebarConversations.loadSidebarConversations) { - window.chatSidebarConversations.loadSidebarConversations(); + if (selectedAction === 'leave') { + confirmDeleteConversationBtn.classList.remove('btn-danger'); + confirmDeleteConversationBtn.classList.add('btn-warning'); + confirmDeleteConversationBtn.innerHTML = 'Assign Owner & Leave'; + return; + } + + confirmDeleteConversationBtn.classList.remove('btn-warning'); + confirmDeleteConversationBtn.classList.add('btn-danger'); + confirmDeleteConversationBtn.innerHTML = 'Delete Conversation'; +} + +function resetDeleteConversationModalState() { + pendingDeleteConversationContext = null; + + if (deleteConversationMessageEl) { + deleteConversationMessageEl.textContent = 'Are you sure you want to delete this conversation?'; + } + if (deleteConversationSharedWarningEl) { + deleteConversationSharedWarningEl.classList.add('d-none'); + } + if (deleteConversationOwnerOptionsEl) { + deleteConversationOwnerOptionsEl.classList.add('d-none'); + } + if (deleteConversationTransferOptionEl) { + deleteConversationTransferOptionEl.classList.add('d-none'); + } + if (deleteConversationOwnerSelectContainerEl) { + deleteConversationOwnerSelectContainerEl.classList.add('d-none'); + } + if (deleteConversationNewOwnerSelectEl) { + deleteConversationNewOwnerSelectEl.innerHTML = ''; + } + if (deleteConversationImpactNoteEl) { + deleteConversationImpactNoteEl.textContent = 'This action cannot be undone.'; + } + if (confirmDeleteConversationBtn) { + confirmDeleteConversationBtn.disabled = false; + confirmDeleteConversationBtn.innerHTML = 'Delete Conversation'; + confirmDeleteConversationBtn.classList.remove('btn-warning'); + confirmDeleteConversationBtn.classList.add('btn-danger'); + } + + const deleteRadio = document.getElementById('delete-conversation-action-delete'); + if (deleteRadio) { + deleteRadio.checked = true; + } +} + +async function buildDeleteConversationContext(conversationId) { + const cachedConversation = getConversationFromCache(conversationId) || {}; + const isCollaborativeConversation = cachedConversation.conversation_kind === 'collaborative' + || document.querySelector(`.conversation-item[data-conversation-id="${conversationId}"]`)?.dataset?.conversationKind === 'collaborative' + || document.querySelector(`.sidebar-conversation-item[data-conversation-id="${conversationId}"]`)?.dataset?.conversationKind === 'collaborative'; + + if (isCollaborativeConversation && window.chatCollaboration?.fetchConversationMetadata) { + return window.chatCollaboration.fetchConversationMetadata(conversationId); + } + + const response = await fetch(`/api/conversations/${conversationId}/metadata`); + if (!response.ok) { + const errorPayload = await response.json().catch(() => ({})); + throw new Error(errorPayload.error || 'Failed to load conversation metadata'); + } + return response.json(); +} + +function configureDeleteConversationModal(conversationId, metadata = {}) { + resetDeleteConversationModalState(); + + const isCollaborativeConversation = metadata.conversation_kind === 'collaborative'; + const currentUserId = String(window.currentUser?.id || window.currentUser?.user_id || '').trim(); + const activeParticipants = Array.isArray(metadata.participants) + ? metadata.participants.filter(participant => participant?.status === 'accepted') + : []; + const transferableParticipants = activeParticipants.filter(participant => participant?.user_id && participant.user_id !== currentUserId); + + pendingDeleteConversationContext = { + conversationId, + isCollaborativeConversation, + metadata, + transferableParticipants, + }; + + if (!isCollaborativeConversation) { + if (deleteConversationMessageEl) { + deleteConversationMessageEl.textContent = 'Are you sure you want to delete this conversation?'; + } + if (deleteConversationImpactNoteEl) { + deleteConversationImpactNoteEl.textContent = 'This action cannot be undone.'; + } + if (confirmDeleteConversationBtn) { + confirmDeleteConversationBtn.innerHTML = 'Delete Conversation'; + } + return; + } + + if (deleteConversationSharedWarningEl) { + deleteConversationSharedWarningEl.classList.remove('d-none'); + } + + if (metadata.can_delete_conversation) { + if (deleteConversationMessageEl) { + deleteConversationMessageEl.textContent = 'This is a multi-user conversation. Do you want to delete it for everyone, or assign another owner and leave the conversation?'; + } + if (deleteConversationOwnerOptionsEl) { + deleteConversationOwnerOptionsEl.classList.remove('d-none'); + } + if (deleteConversationImpactNoteEl) { + deleteConversationImpactNoteEl.textContent = 'Deleting removes the shared conversation for every participant.'; + } + if (deleteConversationTransferOptionEl && transferableParticipants.length > 0) { + deleteConversationTransferOptionEl.classList.remove('d-none'); + } + if (deleteConversationNewOwnerSelectEl) { + deleteConversationNewOwnerSelectEl.innerHTML = transferableParticipants + .map(participant => ``) + .join(''); + } + return; + } + + if (deleteConversationMessageEl) { + deleteConversationMessageEl.textContent = 'Are you sure you want to leave this shared conversation?'; + } + if (deleteConversationImpactNoteEl) { + deleteConversationImpactNoteEl.textContent = 'Leaving removes this conversation from your list, but other participants will keep it.'; + } + if (confirmDeleteConversationBtn) { + confirmDeleteConversationBtn.classList.remove('btn-danger'); + confirmDeleteConversationBtn.classList.add('btn-warning'); + confirmDeleteConversationBtn.innerHTML = 'Leave Conversation'; + } +} + +export function removeConversationFromUi(conversationId, options = {}) { + if (!conversationId) { + return; + } + + allConversations = allConversations.filter(conversation => conversation.id !== conversationId); + document.querySelector(`.conversation-item[data-conversation-id="${conversationId}"]`)?.remove(); + document.querySelector(`.sidebar-conversation-item[data-conversation-id="${conversationId}"]`)?.remove(); + + if (currentConversationId === conversationId || window.currentConversationId === conversationId) { + currentConversationId = null; + window.currentConversationId = null; + if (currentConversationTitleEl) currentConversationTitleEl.textContent = "Select or start a conversation"; + if (currentConversationClassificationsEl) currentConversationClassificationsEl.innerHTML = ""; + if (chatbox) { + chatbox.innerHTML = '
Select a conversation to view messages.
'; + } + highlightSelectedConversation(null); + toggleConversationInfoButton(false); + hideWorkflowActivityButton(); + window.chatCollaboration?.deactivateConversation?.(); + setSidebarActiveConversation(null); + } + + window.hideConversationDetails?.(); + + if (!options.skipToast) { + showToast(options.toastMessage || 'Conversation removed.', 'success'); + } + + if (options.refreshList !== false) { + loadConversations(); + } +} + +async function executeDeleteConversationAction() { + if (!pendingDeleteConversationContext || !confirmDeleteConversationBtn) { + return; + } + + const { conversationId, isCollaborativeConversation, metadata } = pendingDeleteConversationContext; + confirmDeleteConversationBtn.disabled = true; + + try { + if (!isCollaborativeConversation) { + const response = await fetch(`/api/conversations/${conversationId}`, { method: 'DELETE' }); + if (!response.ok) { + const errorPayload = await response.json().catch(() => ({})); + throw new Error(errorPayload.error || 'Failed to delete conversation'); + } + + bootstrap.Modal.getOrCreateInstance(deleteConversationModalEl)?.hide(); + removeConversationFromUi(conversationId, { toastMessage: 'Conversation deleted.' }); + return; + } + + let action = metadata.can_delete_conversation ? 'delete' : 'leave'; + let newOwnerUserId = null; + if (metadata.can_delete_conversation) { + action = deleteConversationModalEl?.querySelector('input[name="delete-conversation-action"]:checked')?.value || 'delete'; + if (action === 'leave') { + newOwnerUserId = deleteConversationNewOwnerSelectEl?.value || null; + if (!newOwnerUserId) { + throw new Error('Choose a new owner before leaving the shared conversation.'); } - - showToast("Conversation deleted.", "success"); - } else { - return response.json().then(err => Promise.reject(err)); // Pass error details } - }) - .catch(error => { - console.error("Error deleting conversation:", error); - showToast(`Error deleting conversation: ${error.error || 'Unknown error'}`, "danger"); - // Re-enable button if loading state was shown + } + + const response = await fetch(`/api/collaboration/conversations/${conversationId}/delete-action`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'same-origin', + body: JSON.stringify({ + action, + new_owner_user_id: newOwnerUserId, + }), + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(payload.error || 'Failed to update the shared conversation'); + } + + bootstrap.Modal.getOrCreateInstance(deleteConversationModalEl)?.hide(); + removeConversationFromUi(conversationId, { + toastMessage: action === 'delete' ? 'Shared conversation deleted for all participants.' : 'You left the shared conversation.', }); + } catch (error) { + showToast(error.message || 'Failed to update the conversation.', 'danger'); + } finally { + confirmDeleteConversationBtn.disabled = false; + } +} + +// Delete a conversation +export async function deleteConversation(conversationId) { + if (!conversationId || !deleteConversationModalEl) { + return; + } + + try { + const metadata = await buildDeleteConversationContext(conversationId); + configureDeleteConversationModal(conversationId, metadata); + bootstrap.Modal.getOrCreateInstance(deleteConversationModalEl).show(); + } catch (error) { + showToast(error.message || 'Failed to prepare the delete dialog.', 'danger'); + } } // Create a new conversation via API @@ -1686,6 +2125,7 @@ async function deleteSelectedConversations() { if (chatbox) chatbox.innerHTML = '
Select a conversation to view messages.
'; highlightSelectedConversation(null); toggleConversationInfoButton(false); // Hide the info button + hideWorkflowActivityButton(); } }); @@ -1711,7 +2151,10 @@ async function deleteSelectedConversations() { // Toggle conversation pin status async function toggleConversationPin(conversationId) { try { - const response = await fetch(`/api/conversations/${conversationId}/pin`, { + const convoItem = document.querySelector(`.conversation-item[data-conversation-id="${conversationId}"]`) + || document.querySelector(`.sidebar-conversation-item[data-conversation-id="${conversationId}"]`); + const isCollaborativeConversation = convoItem?.dataset?.conversationKind === 'collaborative'; + const response = await fetch(isCollaborativeConversation ? `/api/collaboration/conversations/${conversationId}/pin` : `/api/conversations/${conversationId}/pin`, { method: 'POST', headers: { 'Content-Type': 'application/json' @@ -1743,7 +2186,10 @@ async function toggleConversationPin(conversationId) { // Toggle conversation hide status async function toggleConversationHide(conversationId) { try { - const response = await fetch(`/api/conversations/${conversationId}/hide`, { + const convoItem = document.querySelector(`.conversation-item[data-conversation-id="${conversationId}"]`) + || document.querySelector(`.sidebar-conversation-item[data-conversation-id="${conversationId}"]`); + const isCollaborativeConversation = convoItem?.dataset?.conversationKind === 'collaborative'; + const response = await fetch(isCollaborativeConversation ? `/api/collaboration/conversations/${conversationId}/hide` : `/api/conversations/${conversationId}/hide`, { method: 'POST', headers: { 'Content-Type': 'application/json' @@ -1882,7 +2328,7 @@ export function setShowHiddenConversations(value) { updateHiddenToggleButton(); - if (window.chatSidebarConversations && window.chatSidebarConversations.loadSidebarConversations) { + if (syncSidebar && window.chatSidebarConversations && window.chatSidebarConversations.loadSidebarConversations) { window.chatSidebarConversations.loadSidebarConversations(); } } else { @@ -1900,6 +2346,7 @@ window.chatConversations = { markConversationRead, setConversationUnreadState, deleteConversation, + removeConversationFromUi, toggleConversationSelection, deleteSelectedConversations, bulkPinConversations, @@ -2016,6 +2463,13 @@ function addChatTypeBadges(convoItem, classificationsEl) { // Don't show badges for Model-only conversations if (chatType === 'personal' || chatType === 'personal_single_user') { return; + } else if (chatType === 'personal_multi_user') { + const sharedBadge = document.createElement("span"); + sharedBadge.classList.add("badge", "bg-primary-subtle", "text-primary-emphasis"); + sharedBadge.textContent = 'shared'; + + appendBadgeSpacer(); + classificationsEl.appendChild(sharedBadge); } else if (chatType && chatType.startsWith('group')) { // Group workspace was used const groupBadge = document.createElement("span"); diff --git a/application/single_app/static/js/chat/chat-documents.js b/application/single_app/static/js/chat/chat-documents.js index d0792db7..f4b78aa3 100644 --- a/application/single_app/static/js/chat/chat-documents.js +++ b/application/single_app/static/js/chat/chat-documents.js @@ -160,8 +160,10 @@ export async function toggleScopeLock(conversationId, newState) { updateHeaderLockIcon(); - // Reload docs for the new scope - loadAllDocs().then(() => { loadTagsForScope(); }); + // Reload scope-dependent UI and notify listeners like the agent picker. + runScopeRefreshPipeline('scope-lock').catch(error => { + console.error('Failed to refresh scope-dependent UI after toggling scope lock:', error); + }); } /** @@ -1703,6 +1705,11 @@ export function handleDocumentSelectChange() { // Sync button text from current hidden select state syncDropdownButtonText(); + window.dispatchEvent(new CustomEvent('chat:document-selection-changed', { + detail: { + documentIds: Array.from(docSelectEl.selectedOptions).map(option => option.value).filter(Boolean), + }, + })); } diff --git a/application/single_app/static/js/chat/chat-export.js b/application/single_app/static/js/chat/chat-export.js index f42e0123..03d5f20a 100644 --- a/application/single_app/static/js/chat/chat-export.js +++ b/application/single_app/static/js/chat/chat-export.js @@ -114,10 +114,25 @@ function prevStep() { async function _loadConversationTitles() { try { - const response = await fetch('/api/get_conversations'); - if (!response.ok) throw new Error('Failed to fetch conversations'); - const data = await response.json(); - const conversations = data.conversations || []; + const legacyConversationsRequest = fetch('/api/get_conversations') + .then(response => { + if (!response.ok) { + throw new Error('Failed to fetch conversations'); + } + return response.json(); + }); + const collaborationConversationsRequest = window.chatCollaboration?.fetchCollaborationConversationList + ? window.chatCollaboration.fetchCollaborationConversationList().catch(() => []) + : Promise.resolve([]); + + const [legacyData, collaborationConversations] = await Promise.all([ + legacyConversationsRequest, + collaborationConversationsRequest, + ]); + const conversations = [ + ...(legacyData?.conversations || []), + ...(Array.isArray(collaborationConversations) ? collaborationConversations : []), + ]; exportConversationTitles = {}; conversations.forEach(c => { if (exportConversationIds.includes(c.id)) { diff --git a/application/single_app/static/js/chat/chat-inline-charts.js b/application/single_app/static/js/chat/chat-inline-charts.js new file mode 100644 index 00000000..2bd050eb --- /dev/null +++ b/application/single_app/static/js/chat/chat-inline-charts.js @@ -0,0 +1,441 @@ +// chat-inline-charts.js + +const INLINE_CHART_LANGUAGE = 'simplechart'; +const INLINE_CHART_REGEX = new RegExp(`\`\`\`${INLINE_CHART_LANGUAGE}\\s*([\\s\\S]*?)\`\`\``, 'gi'); +const ALLOWED_KINDS = new Set(['line', 'bar', 'pie', 'doughnut', 'scatter', 'area', 'bubble', 'radar', 'stacked_bar', 'stacked_line', 'polar_area']); +const DEFAULT_PALETTE = [ + { background: 'rgba(28, 110, 164, 0.18)', border: '#1c6ea4' }, + { background: 'rgba(215, 91, 53, 0.18)', border: '#d75b35' }, + { background: 'rgba(39, 123, 84, 0.18)', border: '#277b54' }, + { background: 'rgba(153, 92, 32, 0.18)', border: '#995c20' }, + { background: 'rgba(126, 77, 140, 0.18)', border: '#7e4d8c' }, + { background: 'rgba(191, 66, 112, 0.18)', border: '#bf4270' }, + { background: 'rgba(58, 141, 121, 0.18)', border: '#3a8d79' }, + { background: 'rgba(101, 120, 48, 0.18)', border: '#657830' } +]; + +function escapeHtml(value) { + return String(value ?? '').replace(/[&<>"']/g, character => ( + { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[character] + )); +} + +function getPalette(index) { + return DEFAULT_PALETTE[index % DEFAULT_PALETTE.length]; +} + +function sanitizeText(value, maxLength = 240) { + return String(value ?? '').trim().slice(0, maxLength); +} + +function sanitizeNumber(value) { + if (value === null || value === undefined || value === '') { + return null; + } + + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function sanitizeColor(value, fallback) { + if (typeof value !== 'string') { + return fallback; + } + + const trimmed = value.trim(); + if (!trimmed || trimmed.length > 40) { + return fallback; + } + + if (trimmed.startsWith('#') || trimmed.startsWith('rgb(') || trimmed.startsWith('rgba(') || trimmed.startsWith('hsl(') || trimmed.startsWith('hsla(')) { + return trimmed; + } + + return fallback; +} + +function getBaseChartType(kind) { + if (kind === 'area' || kind === 'stacked_line') { + return 'line'; + } + if (kind === 'stacked_bar') { + return 'bar'; + } + if (kind === 'polar_area') { + return 'polarArea'; + } + return kind; +} + +function normalizePoint(point, kind) { + if (!point || typeof point !== 'object') { + return null; + } + + const normalized = { + x: sanitizeNumber(point.x), + y: sanitizeNumber(point.y) + }; + + if (normalized.x === null || normalized.y === null) { + return null; + } + + if (kind === 'bubble') { + normalized.r = sanitizeNumber(point.r); + if (normalized.r === null) { + return null; + } + } + + return normalized; +} + +function normalizeDatasets(kind, rawDatasets, labels) { + if (!Array.isArray(rawDatasets) || rawDatasets.length === 0) { + return []; + } + + return rawDatasets.slice(0, 20).map((dataset, datasetIndex) => { + const palette = getPalette(datasetIndex); + const normalized = { + label: sanitizeText(dataset?.label || `Series ${datasetIndex + 1}`, 80), + borderColor: sanitizeColor(dataset?.borderColor, palette.border), + backgroundColor: sanitizeColor(dataset?.backgroundColor, palette.background), + borderWidth: 2 + }; + + if (kind === 'scatter' || kind === 'bubble') { + normalized.data = Array.isArray(dataset?.data) + ? dataset.data.map(point => normalizePoint(point, kind)).filter(Boolean) + : []; + } else { + normalized.data = Array.isArray(dataset?.data) + ? dataset.data.slice(0, 200).map(value => sanitizeNumber(value)) + : []; + } + + if (kind === 'line' || kind === 'area' || kind === 'stacked_line') { + normalized.fill = dataset?.fill === true || kind === 'area'; + normalized.tension = dataset?.tension === 0 ? 0 : 0.35; + } + + if (kind === 'radar') { + normalized.fill = dataset?.fill === true; + } + + if ((kind === 'pie' || kind === 'doughnut' || kind === 'polar_area') && Array.isArray(labels) && labels.length) { + normalized.backgroundColor = labels.map((_, colorIndex) => getPalette(colorIndex).background); + normalized.borderColor = labels.map((_, colorIndex) => getPalette(colorIndex).border); + } + + if (dataset?.type === 'line' || dataset?.type === 'bar') { + normalized.type = dataset.type; + } + + return normalized; + }).filter(dataset => Array.isArray(dataset.data) && dataset.data.length > 0); +} + +function normalizeTable(rawTable) { + if (!rawTable || typeof rawTable !== 'object') { + return null; + } + + const columns = Array.isArray(rawTable.columns) + ? rawTable.columns.slice(0, 12).map(column => sanitizeText(column, 80)).filter(Boolean) + : []; + const rows = Array.isArray(rawTable.rows) + ? rawTable.rows.slice(0, 500).map(row => Array.isArray(row) ? row.slice(0, columns.length || 12) : []).filter(row => row.length > 0) + : []; + + if (!columns.length || !rows.length) { + return null; + } + + return { columns, rows }; +} + +function normalizeChartSpec(rawSpec) { + if (!rawSpec || typeof rawSpec !== 'object' || Array.isArray(rawSpec)) { + return null; + } + + const kind = sanitizeText(rawSpec.kind || rawSpec.chartType, 40).toLowerCase(); + if (!ALLOWED_KINDS.has(kind)) { + return null; + } + + const rawData = rawSpec.data; + if (!rawData || typeof rawData !== 'object' || Array.isArray(rawData)) { + return null; + } + + const labels = Array.isArray(rawData.labels) + ? rawData.labels.slice(0, 200).map(label => sanitizeText(label, 80)) + : []; + const datasets = normalizeDatasets(kind, rawData.datasets, labels); + if (!datasets.length) { + return null; + } + + const rawOptions = rawSpec.options && typeof rawSpec.options === 'object' && !Array.isArray(rawSpec.options) + ? rawSpec.options + : {}; + + const legendPosition = sanitizeText(rawOptions.legendPosition || 'top', 10).toLowerCase(); + const normalizedOptions = { + legendPosition: ['top', 'bottom', 'left', 'right'].includes(legendPosition) ? legendPosition : 'top', + showLegend: rawOptions.showLegend !== false, + showDataTable: rawOptions.showDataTable !== false, + beginAtZero: rawOptions.beginAtZero !== false, + horizontal: Boolean(rawOptions.horizontal) && (kind === 'bar' || kind === 'stacked_bar'), + fill: Boolean(rawOptions.fill) || kind === 'area', + smooth: rawOptions.smooth !== false, + stacked: Boolean(rawOptions.stacked) || kind === 'stacked_bar' || kind === 'stacked_line', + xAxisLabel: sanitizeText(rawOptions.xAxisLabel, 80), + yAxisLabel: sanitizeText(rawOptions.yAxisLabel, 80), + cutout: sanitizeText(rawOptions.cutout || '60%', 20) + }; + + return { + version: Number(rawSpec.version) || 1, + chartId: sanitizeText(rawSpec.chartId || '', 40), + kind, + chartType: getBaseChartType(kind), + title: sanitizeText(rawSpec.title, 160), + subtitle: sanitizeText(rawSpec.subtitle, 160), + description: sanitizeText(rawSpec.description, 320), + summary: sanitizeText(rawSpec.summary, 220), + data: { + labels, + datasets + }, + options: normalizedOptions, + table: normalizeTable(rawSpec.table) + }; +} + +function buildTableHtml(spec) { + if (!spec.table || spec.options.showDataTable === false) { + return ''; + } + + const tableId = `chart-table-${spec.chartId || Math.random().toString(36).slice(2, 10)}`; + const headHtml = spec.table.columns.map(column => `${escapeHtml(column)}`).join(''); + const bodyHtml = spec.table.rows.map(row => ` + ${row.map(cell => `${escapeHtml(cell ?? '')}`).join('')} + `).join(''); + + return ` +
+ +
+ + ${headHtml} + ${bodyHtml} +
+
+
+ `; +} + +function buildPlaceholderHtml(block, index) { + const encodedSpec = encodeURIComponent(JSON.stringify(block.spec)); + const captionParts = [block.spec.description, block.spec.summary].filter(Boolean); + const captionHtml = captionParts.length + ? `
${escapeHtml(captionParts.join(' '))}
` + : ''; + + return ` +
+
+
+ ${block.spec.title ? `
${escapeHtml(block.spec.title)}
` : ''} + ${block.spec.subtitle ? `
${escapeHtml(block.spec.subtitle)}
` : ''} +
+
+ +
+ ${captionHtml} + ${buildTableHtml(block.spec)} +
+
+ `; +} + +function replaceAllOccurrences(source, target, replacement) { + return source.split(target).join(replacement); +} + +function buildChartJsConfig(spec) { + const baseType = getBaseChartType(spec.kind); + const config = { + type: baseType, + data: { + datasets: spec.data.datasets.map(dataset => ({ ...dataset })) + }, + options: { + responsive: true, + maintainAspectRatio: false, + interaction: { + mode: 'nearest', + intersect: false + }, + plugins: { + legend: { + display: spec.options.showLegend, + position: spec.options.legendPosition + }, + title: { + display: Boolean(spec.title), + text: spec.title + }, + subtitle: { + display: Boolean(spec.subtitle), + text: spec.subtitle + } + } + } + }; + + if (spec.data.labels.length) { + config.data.labels = [...spec.data.labels]; + } + + if (['bar', 'line', 'scatter', 'bubble'].includes(baseType)) { + config.options.scales = { + x: { + stacked: spec.options.stacked, + title: { + display: Boolean(spec.options.xAxisLabel), + text: spec.options.xAxisLabel + } + }, + y: { + stacked: spec.options.stacked, + beginAtZero: spec.options.beginAtZero, + title: { + display: Boolean(spec.options.yAxisLabel), + text: spec.options.yAxisLabel + } + } + }; + + if (spec.options.horizontal && baseType === 'bar') { + config.options.indexAxis = 'y'; + } + } + + if (baseType === 'doughnut') { + config.options.cutout = spec.options.cutout || '60%'; + } + + if (baseType === 'radar') { + config.options.scales = { + r: { + beginAtZero: spec.options.beginAtZero + } + }; + } + + return config; +} + +export function extractInlineChartBlocks(markdownText = '') { + const blocks = []; + const markdown = String(markdownText ?? '').replace(INLINE_CHART_REGEX, (match, payload) => { + try { + const parsed = JSON.parse(String(payload || '').trim()); + const spec = normalizeChartSpec(parsed); + if (!spec) { + return match; + } + + const token = `SIMPLECHAT_INLINE_CHART_TOKEN_${blocks.length}__`; + blocks.push({ token, spec, originalBlock: match }); + return `\n\n${token}\n\n`; + } catch (error) { + console.warn('Failed to parse inline chart block:', error); + return match; + } + }); + + return { markdown, blocks }; +} + +export function restoreInlineChartTokens(markdownText = '', blocks = []) { + let restored = String(markdownText ?? ''); + blocks.forEach(block => { + restored = replaceAllOccurrences(restored, block.token, block.originalBlock || ''); + }); + return restored; +} + +export function injectInlineChartHtml(html = '', blocks = []) { + let renderedHtml = String(html ?? ''); + + blocks.forEach((block, index) => { + const placeholderHtml = buildPlaceholderHtml(block, index); + renderedHtml = replaceAllOccurrences(renderedHtml, `

${block.token}

`, placeholderHtml); + renderedHtml = replaceAllOccurrences(renderedHtml, block.token, placeholderHtml); + }); + + return renderedHtml; +} + +export function hydrateInlineCharts(root = document) { + const chartContainers = root.querySelectorAll('.sc-inline-chart[data-chart-hydrated="false"]'); + chartContainers.forEach(container => { + const specText = container.getAttribute('data-chart-spec'); + const stage = container.querySelector('.sc-inline-chart-stage'); + const canvas = container.querySelector('canvas'); + if (!specText || !stage || !canvas) { + return; + } + + stage.style.height = '320px'; + + if (typeof window.Chart === 'undefined') { + stage.innerHTML = '
Chart library is unavailable for this message.
'; + container.setAttribute('data-chart-hydrated', 'true'); + return; + } + + try { + const spec = normalizeChartSpec(JSON.parse(decodeURIComponent(specText))); + if (!spec) { + throw new Error('Invalid inline chart specification.'); + } + + const chartConfig = buildChartJsConfig(spec); + if (container._chartInstance) { + container._chartInstance.destroy(); + } + container._chartInstance = new window.Chart(canvas.getContext('2d'), chartConfig); + container.setAttribute('data-chart-hydrated', 'true'); + + const toggleButton = container.querySelector('.sc-inline-chart-table-toggle'); + if (toggleButton && !toggleButton.dataset.bound) { + toggleButton.dataset.bound = 'true'; + toggleButton.addEventListener('click', () => { + const targetId = toggleButton.getAttribute('data-target-id'); + const target = targetId ? container.querySelector(`#${targetId}`) : null; + if (!target) { + return; + } + const isHidden = target.classList.contains('d-none'); + target.classList.toggle('d-none', !isHidden); + toggleButton.setAttribute('aria-expanded', isHidden ? 'true' : 'false'); + toggleButton.textContent = isHidden ? 'Hide data table' : 'Show data table'; + }); + } + } catch (error) { + console.warn('Failed to hydrate inline chart:', error); + stage.innerHTML = `
Unable to render chart: ${escapeHtml(error.message || 'invalid data')}
`; + container.setAttribute('data-chart-hydrated', 'true'); + } + }); +} \ No newline at end of file diff --git a/application/single_app/static/js/chat/chat-inline-images.js b/application/single_app/static/js/chat/chat-inline-images.js new file mode 100644 index 00000000..8937ce01 --- /dev/null +++ b/application/single_app/static/js/chat/chat-inline-images.js @@ -0,0 +1,730 @@ +// chat-inline-images.js +import { + fetchAgentCitationArtifact, + parseDocIdAndPage, + showImagePopup, +} from "./chat-citations.js"; +import { escapeHtml } from "./chat-utils.js"; + +const INLINE_IMAGE_GALLERY_RENDER_TYPE = "inline_image_gallery"; +const MAX_INLINE_IMAGE_ITEMS = 5; +const IMAGE_FILE_NAME_PATTERN = /\.(?:avif|bmp|gif|heic|heif|ico|jpe?g|png|svg|tiff?|webp)(?:$|[?#])/i; + +function toNonEmptyString(value) { + return typeof value === "string" ? value.trim() : ""; +} + +function parseJsonValue(value) { + if (value === null || value === undefined || value === "") { + return null; + } + + if (typeof value === "object") { + return value; + } + + try { + return JSON.parse(value); + } catch (error) { + return null; + } +} + +function getCitationResult(candidate) { + if (!candidate || typeof candidate !== "object") { + return null; + } + + if (candidate.render_type && (candidate.image_gallery || candidate.image_url || candidate.images)) { + return candidate; + } + + const parsedResult = parseJsonValue(candidate.function_result); + if (parsedResult && typeof parsedResult === "object") { + return parsedResult; + } + + return null; +} + +function resolveImageUrlValue(candidate) { + if (typeof candidate === "string") { + return candidate.trim(); + } + + if (!candidate || typeof candidate !== "object") { + return ""; + } + + return toNonEmptyString(candidate.url || candidate.image_url || candidate.src); +} + +function buildWorkspaceImageUrl(docId) { + const normalizedDocId = toNonEmptyString(docId); + if (!normalizedDocId) { + return ""; + } + + return `/api/enhanced_citations/image?doc_id=${encodeURIComponent(normalizedDocId)}`; +} + +function getUrlHostname(urlValue) { + const normalizedValue = toNonEmptyString(urlValue); + if (!normalizedValue) { + return ""; + } + + try { + return new URL(normalizedValue, window.location.origin).hostname; + } catch (error) { + return ""; + } +} + +function deriveSourceLabel(docId, imageUrl, explicitLabel = "") { + const normalizedLabel = toNonEmptyString(explicitLabel); + if (normalizedLabel) { + return normalizedLabel; + } + + if (toNonEmptyString(docId)) { + return "Workspace image"; + } + + if (toNonEmptyString(imageUrl).startsWith("data:image/")) { + return "Embedded image"; + } + + const hostname = getUrlHostname(imageUrl); + if (hostname) { + return `External image (${hostname})`; + } + + return "Image"; +} + +function isLikelyImageFileName(fileName) { + return IMAGE_FILE_NAME_PATTERN.test(toNonEmptyString(fileName)); +} + +function isLikelyImageUrl(urlValue) { + const normalizedUrl = toNonEmptyString(urlValue); + if (!normalizedUrl) { + return false; + } + + if (normalizedUrl.startsWith("data:image/")) { + return true; + } + + return IMAGE_FILE_NAME_PATTERN.test(normalizedUrl); +} + +function getItemIdentityKey(item) { + if (!item || typeof item !== "object") { + return ""; + } + + return toNonEmptyString( + item.doc_id + || item.source_url + || item.full_image_url + || item.preview_image_url + || item.title + ); +} + +function pushUniqueImageItem(targetItems, seenKeys, item) { + if (!item) { + return; + } + + const identityKey = getItemIdentityKey(item); + if (!identityKey || seenKeys.has(identityKey)) { + return; + } + + seenKeys.add(identityKey); + targetItems.push(item); +} + +function normalizeImageItem(rawItem, index) { + const item = typeof rawItem === "string" + ? { image_url: rawItem } + : rawItem; + + if (!item || typeof item !== "object" || Array.isArray(item)) { + return null; + } + + const docId = toNonEmptyString(item.doc_id || item.docId || item.document_id || item.documentId); + const imageUrl = resolveImageUrlValue(item.image_url || item.url || item.src || item.full_url || item.fullUrl); + const thumbnailUrl = resolveImageUrlValue( + item.thumbnail_url || item.thumbnailUrl || item.preview_url || item.previewUrl + ) || imageUrl; + + if (!docId && !imageUrl) { + return null; + } + + const fallbackTitle = docId + ? toNonEmptyString(item.file_name || item.fileName) || `Workspace image ${index + 1}` + : `Image ${index + 1}`; + const title = toNonEmptyString(item.title || item.label || item.name || item.caption) || fallbackTitle; + const description = toNonEmptyString(item.description || item.summary || item.caption || item.details); + const fileName = toNonEmptyString(item.file_name || item.fileName); + const previewImageUrl = docId ? buildWorkspaceImageUrl(docId) : thumbnailUrl; + const fullImageUrl = docId ? buildWorkspaceImageUrl(docId) : imageUrl; + const sourceUrl = toNonEmptyString(item.source_url || item.sourceUrl || item.link || item.href); + + return { + id: toNonEmptyString(item.id) || `inline-image-${index + 1}`, + title, + description, + file_name: fileName, + doc_id: docId, + preview_image_url: previewImageUrl, + full_image_url: fullImageUrl, + source_label: deriveSourceLabel(docId, imageUrl || previewImageUrl, item.source_label || item.sourceLabel), + source_url: sourceUrl, + alt_text: toNonEmptyString(item.alt_text || item.altText) || title, + }; +} + +function normalizeWorkspaceCitationImageItem(rawCitation, index) { + if (!rawCitation || typeof rawCitation !== "object" || rawCitation.metadata_type) { + return null; + } + + const fileName = toNonEmptyString(rawCitation.file_name || rawCitation.fileName || rawCitation.title); + if (!isLikelyImageFileName(fileName)) { + return null; + } + + const citationId = toNonEmptyString(rawCitation.citation_id || rawCitation.chunk_id); + const { docId } = citationId ? parseDocIdAndPage(citationId) : { docId: "" }; + const normalizedDocId = toNonEmptyString(docId || rawCitation.doc_id || rawCitation.docId); + if (!normalizedDocId) { + return null; + } + + const locationLabel = toNonEmptyString(rawCitation.location_label || (rawCitation.sheet_name ? "Sheet" : "Page")); + const locationValue = toNonEmptyString(rawCitation.location_value || rawCitation.sheet_name || rawCitation.page_number); + const description = locationValue && locationValue !== "N/A" + ? `${locationLabel || "Location"}: ${locationValue}` + : "Workspace image cited in this response."; + + return { + id: `workspace-image-${normalizedDocId}-${index + 1}`, + title: fileName || `Workspace image ${index + 1}`, + description, + file_name: fileName, + doc_id: normalizedDocId, + preview_image_url: buildWorkspaceImageUrl(normalizedDocId), + full_image_url: buildWorkspaceImageUrl(normalizedDocId), + source_label: "Workspace image", + source_url: "", + alt_text: fileName || `Workspace image ${index + 1}`, + }; +} + +function extractWorkspaceCitationImageItems(hybridCitations = [], seenKeys = new Set()) { + const items = []; + if (!Array.isArray(hybridCitations) || hybridCitations.length === 0) { + return items; + } + + hybridCitations.forEach((citation, index) => { + pushUniqueImageItem(items, seenKeys, normalizeWorkspaceCitationImageItem(citation, index)); + }); + + return items; +} + +function normalizeWebCitationImageItem(rawCitation, index) { + if (!rawCitation || typeof rawCitation !== "object") { + return null; + } + + const imageUrl = resolveImageUrlValue(rawCitation.image_url || rawCitation.url || rawCitation.src); + if (!isLikelyImageUrl(imageUrl)) { + return null; + } + + const title = toNonEmptyString(rawCitation.title || rawCitation.label || rawCitation.name) + || `Linked image ${index + 1}`; + const description = toNonEmptyString(rawCitation.description || rawCitation.summary); + + return { + id: `linked-image-${index + 1}`, + title, + description, + file_name: toNonEmptyString(rawCitation.file_name || rawCitation.fileName), + doc_id: "", + preview_image_url: imageUrl, + full_image_url: imageUrl, + source_label: deriveSourceLabel("", imageUrl, rawCitation.source_label || rawCitation.sourceLabel), + source_url: toNonEmptyString(rawCitation.source_url || rawCitation.sourceUrl || rawCitation.url), + alt_text: toNonEmptyString(rawCitation.alt_text || rawCitation.altText) || title, + }; +} + +function extractLinkedImageItems(webCitations = [], seenKeys = new Set()) { + const items = []; + if (!Array.isArray(webCitations) || webCitations.length === 0) { + return items; + } + + webCitations.forEach((citation, index) => { + pushUniqueImageItem(items, seenKeys, normalizeWebCitationImageItem(citation, index)); + }); + + return items; +} + +function buildImageGalleryResult(title, summary, items, sourceActionName, totalCount = items.length) { + const renderedItems = Array.isArray(items) ? items.slice(0, MAX_INLINE_IMAGE_ITEMS) : []; + if (renderedItems.length === 0) { + return null; + } + + return { + success: true, + render_type: INLINE_IMAGE_GALLERY_RENDER_TYPE, + image_gallery: { + title, + summary, + items: renderedItems, + total_count: Number(totalCount) || renderedItems.length, + rendered_count: renderedItems.length, + source_action_name: sourceActionName, + }, + }; +} + +function extractRawImageItems(candidate) { + if (!candidate || typeof candidate !== "object") { + return []; + } + + if (candidate.image_gallery && typeof candidate.image_gallery === "object") { + const galleryItems = candidate.image_gallery.items; + return Array.isArray(galleryItems) ? galleryItems : []; + } + + if (Array.isArray(candidate.items)) { + return candidate.items; + } + + if (Array.isArray(candidate.images)) { + return candidate.images; + } + + if (Array.isArray(candidate.image_urls)) { + return candidate.image_urls; + } + + const directImageUrl = resolveImageUrlValue(candidate.image_url); + const directMime = toNonEmptyString(candidate.mime); + const directType = toNonEmptyString(candidate.type).toLowerCase(); + if (directImageUrl || directType === "image_url" || directMime.startsWith("image/")) { + return [{ + image_url: directImageUrl, + title: candidate.title, + description: candidate.description || candidate.summary, + file_name: candidate.file_name || candidate.fileName, + source_label: candidate.source_label || candidate.sourceLabel, + source_url: candidate.source_url || candidate.sourceUrl, + }]; + } + + return []; +} + +function normalizeImageGalleryResult(result, maxItems = MAX_INLINE_IMAGE_ITEMS) { + if (!result || typeof result !== "object" || result.success === false || maxItems <= 0) { + return null; + } + + const galleryCandidate = result.image_gallery && typeof result.image_gallery === "object" + ? result.image_gallery + : result; + const rawItems = extractRawImageItems(result); + if (!Array.isArray(rawItems) || rawItems.length === 0) { + return null; + } + + const normalizedItems = rawItems + .map((item, index) => normalizeImageItem(item, index)) + .filter(Boolean); + if (normalizedItems.length === 0) { + return null; + } + + const renderedItems = normalizedItems.slice(0, Math.max(0, maxItems)); + if (renderedItems.length === 0) { + return null; + } + const totalCount = Number.isFinite(Number(galleryCandidate.total_count || galleryCandidate.totalCount)) + ? Number(galleryCandidate.total_count || galleryCandidate.totalCount) + : normalizedItems.length; + const galleryTitle = toNonEmptyString(galleryCandidate.title || galleryCandidate.label || result.title) + || (totalCount === 1 ? "Image result" : "Image results"); + const gallerySummary = toNonEmptyString(galleryCandidate.summary || galleryCandidate.description || result.summary) + || (renderedItems.length === 1 + ? "Relevant image returned for this result." + : "Relevant images returned for this result."); + + return { + ...result, + render_type: result.render_type || INLINE_IMAGE_GALLERY_RENDER_TYPE, + image_gallery: { + title: galleryTitle, + summary: gallerySummary, + items: renderedItems, + total_count: totalCount, + rendered_count: renderedItems.length, + source_action_name: toNonEmptyString(galleryCandidate.source_action_name || galleryCandidate.sourceActionName || result.source_action_name), + }, + }; +} + +async function hydrateInlineImageGalleryCitation(conversationId, artifactId) { + try { + const hydratedCitation = await fetchAgentCitationArtifact(conversationId, artifactId); + return normalizeImageGalleryResult(getCitationResult(hydratedCitation)); + } catch (error) { + console.warn("Failed to hydrate inline image gallery citation artifact", error); + return null; + } +} + +async function resolveInlineImageGallery(citation, conversationId, maxItems = MAX_INLINE_IMAGE_ITEMS) { + const shouldPreferArtifact = Boolean( + citation?.raw_payload_externalized + && citation?.artifact_id + && conversationId + ); + + if (shouldPreferArtifact) { + const hydratedResult = await hydrateInlineImageGalleryCitation(conversationId, citation.artifact_id); + const normalizedHydratedResult = normalizeImageGalleryResult(hydratedResult, maxItems); + if (normalizedHydratedResult) { + return normalizedHydratedResult; + } + + if (hydratedResult) { + return hydratedResult; + } + } + + const localResult = normalizeImageGalleryResult(getCitationResult(citation), maxItems); + if (localResult) { + return localResult; + } + + if (!citation?.artifact_id || !conversationId || shouldPreferArtifact) { + return null; + } + + const fallbackHydratedResult = await hydrateInlineImageGalleryCitation(conversationId, citation.artifact_id); + return normalizeImageGalleryResult(fallbackHydratedResult, maxItems); +} + +function buildImageDetailsRows(item) { + const rows = []; + + if (item.source_label) { + rows.push(` +
+ Source + ${escapeHtml(item.source_label)} +
+ `); + } + + if (item.file_name) { + rows.push(` +
+ File + ${escapeHtml(item.file_name)} +
+ `); + } + + if (item.doc_id) { + rows.push(` +
+ Document ID + ${escapeHtml(item.doc_id)} +
+ `); + } + + if (item.source_url) { + rows.push(` + + `); + } + + return rows.join(""); +} + +function getInlineImageDetailsModal() { + let modalContainer = document.getElementById("inline-image-details-modal"); + if (modalContainer) { + return modalContainer; + } + + modalContainer = document.createElement("div"); + modalContainer.id = "inline-image-details-modal"; + modalContainer.className = "modal fade"; + modalContainer.tabIndex = -1; + modalContainer.setAttribute("aria-hidden", "true"); + modalContainer.innerHTML = ` + + `; + document.body.appendChild(modalContainer); + return modalContainer; +} + +function showInlineImageDetailsModal(item) { + const modalContainer = getInlineImageDetailsModal(); + const titleEl = modalContainer.querySelector("#inline-image-details-title"); + const previewEl = modalContainer.querySelector("#inline-image-details-preview"); + const descriptionEl = modalContainer.querySelector("#inline-image-details-description"); + const metaEl = modalContainer.querySelector("#inline-image-details-meta"); + + if (titleEl) { + titleEl.textContent = item.title || "Image details"; + } + + if (previewEl) { + previewEl.src = item.full_image_url || item.preview_image_url; + previewEl.alt = item.alt_text || item.title || "Inline image"; + } + + if (descriptionEl) { + const hasDescription = Boolean(item.description); + descriptionEl.classList.toggle("d-none", !hasDescription); + descriptionEl.textContent = item.description || ""; + } + + if (metaEl) { + metaEl.innerHTML = buildImageDetailsRows(item); + } + + const modal = new bootstrap.Modal(modalContainer); + modal.show(); +} + +function createBadge(label, value) { + const badge = document.createElement("span"); + badge.className = "inline-image-gallery-badge"; + badge.textContent = `${label}: ${value}`; + return badge; +} + +function createImageTile(item) { + const tile = document.createElement("article"); + tile.className = "inline-image-gallery-item"; + tile.innerHTML = ` + + + `; + + const imageEl = tile.querySelector(".inline-image-gallery-item-image"); + const infoButton = tile.querySelector(".inline-image-gallery-info-btn"); + if (imageEl) { + imageEl.src = item.preview_image_url || item.full_image_url; + imageEl.addEventListener("click", () => { + showImagePopup(item.full_image_url || item.preview_image_url); + }); + imageEl.addEventListener("error", () => { + imageEl.src = "/static/images/image-error.png"; + imageEl.alt = "Failed to load image"; + }); + } + + if (infoButton) { + infoButton.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + showInlineImageDetailsModal(item); + }); + } + + return tile; +} + +function createImageGalleryCard(result, messageId, index) { + const payload = result.image_gallery || {}; + const card = document.createElement("section"); + card.className = "inline-image-gallery-card"; + + const renderedCount = Number(payload.rendered_count || (payload.items || []).length || 0); + const totalCount = Number(payload.total_count || renderedCount || 0); + const summaryText = payload.summary || result.summary || "Relevant image results."; + + card.innerHTML = ` + + + + `; + + const badgesContainer = card.querySelector(".inline-image-gallery-badges"); + if (badgesContainer) { + badgesContainer.appendChild(createBadge("Images", renderedCount)); + if (totalCount > renderedCount) { + badgesContainer.appendChild(createBadge("Showing", `${renderedCount} of ${totalCount}`)); + } + } + + const grid = card.querySelector(".inline-image-gallery-grid"); + if (grid) { + (payload.items || []).forEach((item) => { + grid.appendChild(createImageTile(item)); + }); + } + + return { card }; +} + +export async function renderInlineImageGalleries( + messageElement, + hybridCitations = [], + webCitations = [], + agentCitations = [], + messageId = "", + conversationId = "" +) { + if (!messageElement) { + return; + } + + const container = messageElement.querySelector(".inline-visualizations-container"); + if (!container) { + return; + } + + container.querySelectorAll(".inline-image-gallery-card").forEach((card) => card.remove()); + + const hasHybridCitations = Array.isArray(hybridCitations) && hybridCitations.length > 0; + const hasWebCitations = Array.isArray(webCitations) && webCitations.length > 0; + const hasAgentCitations = Array.isArray(agentCitations) && agentCitations.length > 0; + if (!hasHybridCitations && !hasWebCitations && !hasAgentCitations) { + container.classList.toggle("d-none", container.children.length === 0); + return; + } + + let remainingSlots = MAX_INLINE_IMAGE_ITEMS; + let galleryIndex = 0; + const seenImageKeys = new Set(); + + const workspaceItems = extractWorkspaceCitationImageItems(hybridCitations, seenImageKeys); + if (workspaceItems.length > 0 && remainingSlots > 0) { + const workspaceGallery = buildImageGalleryResult( + "Workspace images", + "Image sources cited from workspace content.", + workspaceItems.slice(0, remainingSlots), + "Workspace citations", + workspaceItems.length + ); + if (workspaceGallery) { + const { card } = createImageGalleryCard(workspaceGallery, messageId, galleryIndex); + container.appendChild(card); + remainingSlots -= workspaceGallery.image_gallery.rendered_count || 0; + galleryIndex += 1; + } + } + + const linkedItems = extractLinkedImageItems(webCitations, seenImageKeys); + if (linkedItems.length > 0 && remainingSlots > 0) { + const linkedGallery = buildImageGalleryResult( + "Linked images", + "Direct image links returned with this response.", + linkedItems.slice(0, remainingSlots), + "Linked sources", + linkedItems.length + ); + if (linkedGallery) { + const { card } = createImageGalleryCard(linkedGallery, messageId, galleryIndex); + container.appendChild(card); + remainingSlots -= linkedGallery.image_gallery.rendered_count || 0; + galleryIndex += 1; + } + } + + for (let index = 0; index < agentCitations.length && remainingSlots > 0; index += 1) { + const citation = agentCitations[index]; + const result = await resolveInlineImageGallery(citation, conversationId, remainingSlots); + if (!result) { + continue; + } + + const normalizedItems = Array.isArray(result?.image_gallery?.items) + ? result.image_gallery.items.filter((item) => { + const identityKey = getItemIdentityKey(item); + if (!identityKey || seenImageKeys.has(identityKey)) { + return false; + } + + seenImageKeys.add(identityKey); + return true; + }) + : []; + if (normalizedItems.length === 0) { + continue; + } + + result.image_gallery.items = normalizedItems; + result.image_gallery.rendered_count = normalizedItems.length; + + const { card } = createImageGalleryCard(result, messageId, galleryIndex); + container.appendChild(card); + remainingSlots -= normalizedItems.length; + galleryIndex += 1; + } + + container.classList.toggle("d-none", container.children.length === 0); +} \ No newline at end of file diff --git a/application/single_app/static/js/chat/chat-inline-maps.js b/application/single_app/static/js/chat/chat-inline-maps.js new file mode 100644 index 00000000..87ec16d7 --- /dev/null +++ b/application/single_app/static/js/chat/chat-inline-maps.js @@ -0,0 +1,578 @@ +// chat-inline-maps.js +import { fetchAgentCitationArtifact } from "./chat-citations.js"; +import { escapeHtml } from "./chat-utils.js"; + +const AZURE_MAPS_RENDER_TYPE = "azure_maps_openlayers"; + +function toFiniteNumber(value) { + const numericValue = Number(value); + return Number.isFinite(numericValue) ? numericValue : null; +} + +function normalizeCoordinatePair(rawCoordinate) { + if (!Array.isArray(rawCoordinate) || rawCoordinate.length < 2) { + return null; + } + + const longitude = toFiniteNumber(rawCoordinate[0]); + const latitude = toFiniteNumber(rawCoordinate[1]); + if (longitude === null || latitude === null) { + return null; + } + + return [longitude, latitude]; +} + +function parseJsonValue(value) { + if (value === null || value === undefined || value === "") { + return null; + } + + if (typeof value === "object") { + return value; + } + + try { + return JSON.parse(value); + } catch (error) { + return null; + } +} + +function getCitationResult(candidate) { + if (!candidate || typeof candidate !== "object") { + return null; + } + + if (candidate.render_type && candidate.map_payload) { + return candidate; + } + + const parsedResult = parseJsonValue(candidate.function_result); + if (parsedResult && typeof parsedResult === "object") { + return parsedResult; + } + + return null; +} + +function isAzureMapsVisualization(result) { + return Boolean( + result + && result.success !== false + && result.render_type === AZURE_MAPS_RENDER_TYPE + && result.map_payload + && typeof result.map_payload === "object" + && result.map_payload.tile_url_template + ); +} + +function normalizeMarkers(rawMarkers = []) { + if (!Array.isArray(rawMarkers)) { + return []; + } + + return rawMarkers + .map((marker) => { + if (!marker || typeof marker !== "object" || Array.isArray(marker)) { + return null; + } + + const longitude = toFiniteNumber(marker.longitude ?? marker.lon ?? marker.lng); + const latitude = toFiniteNumber(marker.latitude ?? marker.lat); + if (longitude === null || latitude === null) { + return null; + } + + return { + ...marker, + longitude, + latitude, + label: typeof marker.label === "string" && marker.label.trim() ? marker.label.trim() : "Location", + description: typeof marker.description === "string" ? marker.description : "", + }; + }) + .filter(Boolean); +} + +function normalizeAreas(rawAreas = []) { + if (!Array.isArray(rawAreas)) { + return []; + } + + return rawAreas + .map((area) => { + if (!area || typeof area !== "object" || Array.isArray(area)) { + return null; + } + + let rawCoordinates = area.coordinates; + if ( + Array.isArray(rawCoordinates) + && Array.isArray(rawCoordinates[0]) + && Array.isArray(rawCoordinates[0][0]) + ) { + rawCoordinates = rawCoordinates[0]; + } + + const coordinates = Array.isArray(rawCoordinates) + ? rawCoordinates.map((coordinate) => normalizeCoordinatePair(coordinate)).filter(Boolean) + : []; + + if (coordinates.length < 3) { + return null; + } + + const firstCoordinate = coordinates[0]; + const lastCoordinate = coordinates[coordinates.length - 1]; + if (!lastCoordinate || firstCoordinate[0] !== lastCoordinate[0] || firstCoordinate[1] !== lastCoordinate[1]) { + coordinates.push([...firstCoordinate]); + } + + return { + ...area, + coordinates, + label: typeof area.label === "string" && area.label.trim() ? area.label.trim() : "Area", + description: typeof area.description === "string" ? area.description : "", + }; + }) + .filter(Boolean); +} + +function normalizePaths(rawPaths = []) { + if (!Array.isArray(rawPaths)) { + return []; + } + + return rawPaths + .map((path) => { + if (!path || typeof path !== "object" || Array.isArray(path)) { + return null; + } + + let rawCoordinates = path.coordinates; + if ( + Array.isArray(rawCoordinates) + && Array.isArray(rawCoordinates[0]) + && Array.isArray(rawCoordinates[0][0]) + ) { + rawCoordinates = rawCoordinates[0]; + } + + const coordinates = Array.isArray(rawCoordinates) + ? rawCoordinates.map((coordinate) => normalizeCoordinatePair(coordinate)).filter(Boolean) + : []; + + if (coordinates.length < 2) { + return null; + } + + const lineWidth = toFiniteNumber(path.line_width ?? path.lineWidth ?? path.width); + return { + ...path, + coordinates, + label: typeof path.label === "string" && path.label.trim() ? path.label.trim() : "Path", + description: typeof path.description === "string" ? path.description : "", + line_width: lineWidth === null ? 4 : lineWidth, + }; + }) + .filter(Boolean); +} + +function buildFallbackCenter(markers, areas, paths) { + if (markers.length > 0) { + return [markers[0].longitude, markers[0].latitude]; + } + + if (areas.length > 0 && Array.isArray(areas[0].coordinates) && areas[0].coordinates.length > 0) { + return [...areas[0].coordinates[0]]; + } + + if (paths.length > 0 && Array.isArray(paths[0].coordinates) && paths[0].coordinates.length > 0) { + return [...paths[0].coordinates[0]]; + } + + return [0, 20]; +} + +function normalizeView(rawView = {}, markers = [], areas = [], paths = []) { + const fallbackCenter = buildFallbackCenter(markers, areas, paths); + const parsedCenter = normalizeCoordinatePair(rawView?.center); + const zoom = toFiniteNumber(rawView?.zoom); + const maxZoom = toFiniteNumber(rawView?.max_zoom); + + return { + center: parsedCenter || fallbackCenter, + zoom: zoom === null ? (markers.length === 1 && areas.length === 0 && paths.length === 0 ? 14 : 10) : zoom, + max_zoom: maxZoom === null ? 15 : maxZoom, + fit_to_features: rawView?.fit_to_features !== false, + }; +} + +function normalizeAzureMapsResult(result) { + if (!isAzureMapsVisualization(result)) { + return null; + } + + const payload = result.map_payload || {}; + const markers = normalizeMarkers(payload.markers); + const areas = normalizeAreas(payload.areas); + const paths = normalizePaths(payload.paths); + if (markers.length === 0 && areas.length === 0 && paths.length === 0) { + return null; + } + + return { + ...result, + map_payload: { + ...payload, + markers, + paths, + areas, + view: normalizeView(payload.view || {}, markers, areas, paths), + }, + }; +} + +async function hydrateAzureMapsCitation(conversationId, artifactId) { + try { + const hydratedCitation = await fetchAgentCitationArtifact(conversationId, artifactId); + return normalizeAzureMapsResult(getCitationResult(hydratedCitation)); + } catch (error) { + console.warn("Failed to hydrate Azure Maps citation artifact", error); + return null; + } +} + +async function resolveAzureMapsVisualization(citation, conversationId) { + const shouldPreferArtifact = Boolean(citation?.artifact_id && conversationId); + + if (shouldPreferArtifact) { + const hydratedResult = await hydrateAzureMapsCitation(conversationId, citation.artifact_id); + if (hydratedResult) { + return hydratedResult; + } + } + + const localResult = normalizeAzureMapsResult(getCitationResult(citation)); + if (localResult) { + return localResult; + } + + if (!citation?.artifact_id || !conversationId || shouldPreferArtifact) { + return null; + } + + return hydrateAzureMapsCitation(conversationId, citation.artifact_id); +} + +function createBadge(label, value) { + const badge = document.createElement("span"); + badge.className = "inline-map-badge"; + badge.textContent = `${label}: ${value}`; + return badge; +} + +function buildPopupHtml(properties) { + const label = escapeHtml(properties?.label || "Map item"); + const description = properties?.description + ? `
${escapeHtml(properties.description)}
` + : ""; + + return ` +
${label}
+ ${description} + `; +} + +function createMarkerFeature(olRef, marker) { + const feature = new olRef.Feature({ + geometry: new olRef.geom.Point(olRef.proj.fromLonLat([ + Number(marker.longitude), + Number(marker.latitude), + ])), + featureType: "marker", + label: marker.label || "Location", + description: marker.description || "", + }); + + feature.setStyle(new olRef.style.Style({ + image: new olRef.style.Circle({ + radius: 8, + fill: new olRef.style.Fill({ + color: marker.color || "#0d6efd", + }), + stroke: new olRef.style.Stroke({ + color: "#ffffff", + width: 2, + }), + }), + })); + + return feature; +} + +function createAreaFeature(olRef, area) { + const ring = (area.coordinates || []).map((coordinate) => olRef.proj.fromLonLat([ + Number(coordinate[0]), + Number(coordinate[1]), + ])); + + const feature = new olRef.Feature({ + geometry: new olRef.geom.Polygon([ring]), + featureType: "area", + label: area.label || "Area", + description: area.description || "", + }); + + feature.setStyle(new olRef.style.Style({ + stroke: new olRef.style.Stroke({ + color: area.stroke_color || "#b02a37", + width: 2, + }), + fill: new olRef.style.Fill({ + color: area.fill_color || "rgba(176, 42, 55, 0.20)", + }), + })); + + return feature; +} + +function createPathFeature(olRef, path) { + const line = (path.coordinates || []).map((coordinate) => olRef.proj.fromLonLat([ + Number(coordinate[0]), + Number(coordinate[1]), + ])); + + const feature = new olRef.Feature({ + geometry: new olRef.geom.LineString(line), + featureType: "path", + label: path.label || "Path", + description: path.description || "", + }); + + feature.setStyle(new olRef.style.Style({ + stroke: new olRef.style.Stroke({ + color: path.stroke_color || "#0b5ed7", + width: Number(path.line_width || 4), + }), + })); + + return feature; +} + +function fitViewToFeatures(olRef, vectorSource, view, payload, featureCount) { + if (!payload.view?.fit_to_features || featureCount === 0) { + return; + } + + const extent = vectorSource.getExtent(); + const extentIsEmpty = typeof olRef.extent?.isEmpty === "function" + ? olRef.extent.isEmpty(extent) + : false; + if (extentIsEmpty) { + return; + } + + view.fit(extent, { + padding: [48, 48, 48, 48], + maxZoom: payload.view?.max_zoom || 15, + duration: 0, + }); +} + +function initializeOpenLayersMap(mapElement, popupElement, payload) { + const olRef = window.ol; + if (!olRef?.Map || !olRef?.layer?.Tile || !olRef?.source?.XYZ) { + throw new Error("OpenLayers is not available."); + } + + const tileLayer = new olRef.layer.Tile({ + source: new olRef.source.XYZ({ + url: payload.tile_url_template, + attributions: payload.tile_attribution || "", + crossOrigin: "anonymous", + maxZoom: payload.view?.max_zoom || 15, + }), + }); + + const vectorSource = new olRef.source.Vector(); + const features = []; + + (payload.markers || []).forEach((marker) => { + features.push(createMarkerFeature(olRef, marker)); + }); + + (payload.paths || []).forEach((path) => { + features.push(createPathFeature(olRef, path)); + }); + + (payload.areas || []).forEach((area) => { + features.push(createAreaFeature(olRef, area)); + }); + + vectorSource.addFeatures(features); + + const vectorLayer = new olRef.layer.Vector({ + source: vectorSource, + }); + + const overlay = new olRef.Overlay({ + element: popupElement, + positioning: "bottom-center", + stopEvent: false, + offset: [0, -14], + }); + + const view = new olRef.View({ + center: olRef.proj.fromLonLat(payload.view?.center || [0, 20]), + zoom: payload.view?.zoom || 10, + maxZoom: payload.view?.max_zoom || 15, + }); + + const map = new olRef.Map({ + target: mapElement, + layers: [tileLayer, vectorLayer], + overlays: [overlay], + view, + controls: typeof olRef.control?.defaults === "function" + ? olRef.control.defaults({ attributionOptions: { collapsible: true } }) + : undefined, + }); + + map.on("click", (event) => { + const feature = map.forEachFeatureAtPixel(event.pixel, (selectedFeature) => selectedFeature); + if (!feature) { + popupElement.classList.remove("is-visible"); + overlay.setPosition(undefined); + return; + } + + popupElement.innerHTML = buildPopupHtml(feature.getProperties()); + popupElement.classList.add("is-visible"); + overlay.setPosition(event.coordinate); + }); + + map.on("pointermove", (event) => { + map.getTargetElement().style.cursor = map.hasFeatureAtPixel(event.pixel) ? "pointer" : ""; + }); + + requestAnimationFrame(() => { + map.updateSize(); + fitViewToFeatures(olRef, vectorSource, view, payload, features.length); + requestAnimationFrame(() => { + map.updateSize(); + fitViewToFeatures(olRef, vectorSource, view, payload, features.length); + }); + }); +} + +function createFallbackNotice(message) { + const fallback = document.createElement("div"); + fallback.className = "inline-map-fallback"; + fallback.textContent = message; + return fallback; +} + +function createMapCard(result, messageId, index) { + const payload = result.map_payload || {}; + const card = document.createElement("section"); + card.className = "inline-map-card"; + + const safeMessageId = String(messageId || "map").replace(/[^a-zA-Z0-9_-]/g, "-"); + const mapId = `inline-map-${safeMessageId}-${index}`; + const markerCount = Array.isArray(payload.markers) ? payload.markers.length : 0; + const pathCount = Array.isArray(payload.paths) ? payload.paths.length : 0; + const areaCount = Array.isArray(payload.areas) ? payload.areas.length : 0; + const summaryText = payload.summary || result.summary || "Interactive Azure Maps visualization."; + + card.innerHTML = ` +
+
+
+ +
${escapeHtml(payload.title || "Interactive Map")}
+
+

${escapeHtml(summaryText)}

+
+
+
+
+
+
+
+ + `; + + const badgesContainer = card.querySelector(".inline-map-badges"); + if (badgesContainer) { + badgesContainer.appendChild(createBadge("Markers", markerCount)); + if (pathCount > 0) { + badgesContainer.appendChild(createBadge("Paths", pathCount)); + } + badgesContainer.appendChild(createBadge("Areas", areaCount)); + if (payload.tileset_id) { + badgesContainer.appendChild(createBadge("Tiles", payload.tileset_id)); + } + } + + return { + card, + mapElement: card.querySelector(".inline-map-canvas"), + popupElement: card.querySelector(".inline-map-popup"), + payload, + }; +} + +export async function renderInlineAzureMaps(messageElement, agentCitations = [], messageId = "", conversationId = "") { + if (!messageElement) { + return; + } + + const container = messageElement.querySelector(".inline-visualizations-container"); + if (!container) { + return; + } + + container.querySelectorAll(".inline-map-card").forEach((card) => card.remove()); + + if (!Array.isArray(agentCitations) || agentCitations.length === 0) { + container.classList.toggle("d-none", container.children.length === 0); + return; + } + + const pendingMaps = []; + let renderedCount = 0; + for (let index = 0; index < agentCitations.length; index += 1) { + const citation = agentCitations[index]; + const result = await resolveAzureMapsVisualization(citation, conversationId); + if (!result) { + continue; + } + + const { card, mapElement, popupElement, payload } = createMapCard(result, messageId, index); + container.appendChild(card); + pendingMaps.push({ card, mapElement, popupElement, payload }); + + renderedCount += 1; + } + + container.classList.toggle("d-none", container.children.length === 0); + + pendingMaps.forEach(({ card, mapElement, popupElement, payload }) => { + try { + initializeOpenLayersMap(mapElement, popupElement, payload); + } catch (error) { + console.warn("Failed to initialize inline Azure Maps visualization", error); + const mapShell = card.querySelector(".inline-map-shell"); + if (mapShell) { + mapShell.innerHTML = ""; + mapShell.appendChild(createFallbackNotice("The map data is available, but OpenLayers could not be initialized in this browser session.")); + } + } + }); +} \ No newline at end of file diff --git a/application/single_app/static/js/chat/chat-inline-videos.js b/application/single_app/static/js/chat/chat-inline-videos.js new file mode 100644 index 00000000..0e9a931b --- /dev/null +++ b/application/single_app/static/js/chat/chat-inline-videos.js @@ -0,0 +1,796 @@ +// chat-inline-videos.js +import { + fetchAgentCitationArtifact, + parseDocIdAndPage, +} from "./chat-citations.js"; +import { escapeHtml } from "./chat-utils.js"; + +const INLINE_VIDEO_GALLERY_RENDER_TYPE = "inline_video_gallery"; +const MAX_INLINE_VIDEO_ITEMS = 5; +const VIDEO_FILE_NAME_PATTERN = /\.(?:3gp|avi|flv|m4v|mkv|mov|mp4|mpe?g|ogv|webm|wmv)(?:$|[?#])/i; + +function toNonEmptyString(value) { + return typeof value === "string" ? value.trim() : ""; +} + +function parseJsonValue(value) { + if (value === null || value === undefined || value === "") { + return null; + } + + if (typeof value === "object") { + return value; + } + + try { + return JSON.parse(value); + } catch (error) { + return null; + } +} + +function getCitationResult(candidate) { + if (!candidate || typeof candidate !== "object") { + return null; + } + + if (candidate.render_type && (candidate.video_gallery || candidate.video_url || candidate.videos)) { + return candidate; + } + + const parsedResult = parseJsonValue(candidate.function_result); + if (parsedResult && typeof parsedResult === "object") { + return parsedResult; + } + + return null; +} + +function resolveVideoUrlValue(candidate) { + if (typeof candidate === "string") { + return candidate.trim(); + } + + if (!candidate || typeof candidate !== "object") { + return ""; + } + + return toNonEmptyString(candidate.url || candidate.video_url || candidate.src); +} + +function buildWorkspaceVideoUrl(docId) { + const normalizedDocId = toNonEmptyString(docId); + if (!normalizedDocId) { + return ""; + } + + return `/api/enhanced_citations/video?doc_id=${encodeURIComponent(normalizedDocId)}`; +} + +function getUrlHostname(urlValue) { + const normalizedValue = toNonEmptyString(urlValue); + if (!normalizedValue) { + return ""; + } + + try { + return new URL(normalizedValue, window.location.origin).hostname; + } catch (error) { + return ""; + } +} + +function deriveSourceLabel(docId, videoUrl, explicitLabel = "") { + const normalizedLabel = toNonEmptyString(explicitLabel); + if (normalizedLabel) { + return normalizedLabel; + } + + if (toNonEmptyString(docId)) { + return "Workspace video"; + } + + if (toNonEmptyString(videoUrl).startsWith("data:video/")) { + return "Embedded video"; + } + + const hostname = getUrlHostname(videoUrl); + if (hostname) { + return `External video (${hostname})`; + } + + return "Video"; +} + +function isLikelyVideoFileName(fileName) { + return VIDEO_FILE_NAME_PATTERN.test(toNonEmptyString(fileName)); +} + +function isLikelyVideoUrl(urlValue) { + const normalizedUrl = toNonEmptyString(urlValue); + if (!normalizedUrl) { + return false; + } + + if (normalizedUrl.startsWith("data:video/")) { + return true; + } + + return VIDEO_FILE_NAME_PATTERN.test(normalizedUrl); +} + +function getItemIdentityKey(item) { + if (!item || typeof item !== "object") { + return ""; + } + + return toNonEmptyString( + item.doc_id + || item.source_url + || item.full_video_url + || item.preview_video_url + || item.title + ); +} + +function pushUniqueVideoItem(targetItems, seenKeys, item) { + if (!item) { + return; + } + + const identityKey = getItemIdentityKey(item); + if (!identityKey || seenKeys.has(identityKey)) { + return; + } + + seenKeys.add(identityKey); + targetItems.push(item); +} + +function normalizeVideoItem(rawItem, index = 0) { + const normalizedRawItem = typeof rawItem === "string" ? { video_url: rawItem } : rawItem; + if (!normalizedRawItem || typeof normalizedRawItem !== "object") { + return null; + } + + const citationReference = parseDocIdAndPage( + toNonEmptyString(normalizedRawItem.citation_id || normalizedRawItem.citationId) + ); + const docId = toNonEmptyString(normalizedRawItem.doc_id || normalizedRawItem.docId || citationReference.docId); + const fileName = toNonEmptyString(normalizedRawItem.file_name || normalizedRawItem.fileName); + const previewVideoUrl = resolveVideoUrlValue(normalizedRawItem.preview_video_url || normalizedRawItem.previewVideoUrl); + const fullVideoUrl = resolveVideoUrlValue( + normalizedRawItem.full_video_url + || normalizedRawItem.fullVideoUrl + || normalizedRawItem.video_url + || normalizedRawItem.videoUrl + || normalizedRawItem.url + ); + const workspaceVideoUrl = buildWorkspaceVideoUrl(docId); + const resolvedPreviewVideoUrl = previewVideoUrl || fullVideoUrl || workspaceVideoUrl; + const resolvedFullVideoUrl = fullVideoUrl || previewVideoUrl || workspaceVideoUrl; + const mimeType = toNonEmptyString(normalizedRawItem.mime).toLowerCase(); + const resultType = toNonEmptyString(normalizedRawItem.type).toLowerCase(); + const hasVideoHint = Boolean( + isLikelyVideoUrl(resolvedPreviewVideoUrl) + || isLikelyVideoUrl(resolvedFullVideoUrl) + || isLikelyVideoFileName(fileName) + || mimeType.startsWith("video/") + || resultType === "video_url" + ); + + if (!hasVideoHint || (!docId && !resolvedPreviewVideoUrl && !resolvedFullVideoUrl)) { + return null; + } + + const title = toNonEmptyString(normalizedRawItem.title || normalizedRawItem.name || fileName) + || `Video ${index + 1}`; + + return { + id: toNonEmptyString( + normalizedRawItem.id + || normalizedRawItem.citation_id + || normalizedRawItem.citationId + || docId + || resolvedFullVideoUrl + || title + ) || `video-${index + 1}`, + title, + description: toNonEmptyString( + normalizedRawItem.description + || normalizedRawItem.summary + || normalizedRawItem.caption + ), + file_name: fileName, + doc_id: docId, + poster_url: resolveVideoUrlValue( + normalizedRawItem.poster_url + || normalizedRawItem.posterUrl + || normalizedRawItem.thumbnail_url + || normalizedRawItem.thumbnailUrl + ), + preview_video_url: resolvedPreviewVideoUrl, + full_video_url: resolvedFullVideoUrl, + source_label: deriveSourceLabel( + docId, + resolvedFullVideoUrl || resolvedPreviewVideoUrl, + normalizedRawItem.source_label || normalizedRawItem.sourceLabel + ), + source_url: toNonEmptyString( + normalizedRawItem.source_url + || normalizedRawItem.sourceUrl + || normalizedRawItem.url + ), + }; +} + +function normalizeWorkspaceCitationVideoItem(rawCitation, index = 0) { + if (!rawCitation || typeof rawCitation !== "object") { + return null; + } + + const fileName = toNonEmptyString(rawCitation.file_name || rawCitation.fileName); + if (!isLikelyVideoFileName(fileName)) { + return null; + } + + const citationReference = parseDocIdAndPage( + toNonEmptyString(rawCitation.citation_id || rawCitation.citationId) + ); + const docId = toNonEmptyString(rawCitation.doc_id || rawCitation.docId || citationReference.docId); + if (!docId) { + return null; + } + + const workspaceVideoUrl = buildWorkspaceVideoUrl(docId); + + return { + id: docId || `workspace-video-${index + 1}`, + title: fileName || `Workspace video ${index + 1}`, + description: "", + file_name: fileName, + doc_id: docId, + poster_url: "", + preview_video_url: workspaceVideoUrl, + full_video_url: workspaceVideoUrl, + source_label: deriveSourceLabel( + docId, + workspaceVideoUrl, + rawCitation.source_label || rawCitation.sourceLabel + ), + source_url: "", + }; +} + +function extractWorkspaceCitationVideoItems(hybridCitations = [], seenKeys = new Set()) { + const items = []; + if (!Array.isArray(hybridCitations) || hybridCitations.length === 0) { + return items; + } + + hybridCitations.forEach((citation, index) => { + pushUniqueVideoItem(items, seenKeys, normalizeWorkspaceCitationVideoItem(citation, index)); + }); + + return items; +} + +function normalizeWebCitationVideoItem(rawCitation, index = 0) { + const normalizedCitation = typeof rawCitation === "string" ? { url: rawCitation } : rawCitation; + if (!normalizedCitation || typeof normalizedCitation !== "object") { + return null; + } + + const videoUrl = resolveVideoUrlValue( + normalizedCitation.video_url + || normalizedCitation.videoUrl + || normalizedCitation.url + ); + const fileName = toNonEmptyString(normalizedCitation.file_name || normalizedCitation.fileName); + if (!isLikelyVideoUrl(videoUrl) && !isLikelyVideoFileName(fileName)) { + return null; + } + + const title = toNonEmptyString(normalizedCitation.title || normalizedCitation.name || fileName) + || `Video ${index + 1}`; + + return { + id: toNonEmptyString(normalizedCitation.id || videoUrl || title) || `linked-video-${index + 1}`, + title, + description: toNonEmptyString( + normalizedCitation.description + || normalizedCitation.summary + || normalizedCitation.snippet + ), + file_name: fileName, + doc_id: "", + poster_url: resolveVideoUrlValue( + normalizedCitation.poster_url + || normalizedCitation.posterUrl + || normalizedCitation.thumbnail_url + || normalizedCitation.thumbnailUrl + ), + preview_video_url: videoUrl, + full_video_url: videoUrl, + source_label: deriveSourceLabel( + "", + videoUrl, + normalizedCitation.source_label || normalizedCitation.sourceLabel + ), + source_url: toNonEmptyString( + normalizedCitation.source_url + || normalizedCitation.sourceUrl + || normalizedCitation.url + ), + }; +} + +function extractLinkedVideoItems(webCitations = [], seenKeys = new Set()) { + const items = []; + if (!Array.isArray(webCitations) || webCitations.length === 0) { + return items; + } + + webCitations.forEach((citation, index) => { + pushUniqueVideoItem(items, seenKeys, normalizeWebCitationVideoItem(citation, index)); + }); + + return items; +} + +function buildVideoGalleryResult(title, summary, items, sourceActionName, totalCount = items.length) { + const renderedItems = Array.isArray(items) ? items.slice(0, MAX_INLINE_VIDEO_ITEMS) : []; + if (renderedItems.length === 0) { + return null; + } + + return { + success: true, + render_type: INLINE_VIDEO_GALLERY_RENDER_TYPE, + video_gallery: { + title, + summary, + items: renderedItems, + total_count: Number(totalCount) || renderedItems.length, + rendered_count: renderedItems.length, + source_action_name: sourceActionName, + }, + }; +} + +function extractRawVideoItems(candidate) { + if (!candidate || typeof candidate !== "object") { + return []; + } + + if (candidate.video_gallery && typeof candidate.video_gallery === "object") { + const galleryItems = candidate.video_gallery.items; + return Array.isArray(galleryItems) ? galleryItems : []; + } + + if (Array.isArray(candidate.items)) { + return candidate.items; + } + + if (Array.isArray(candidate.videos)) { + return candidate.videos; + } + + if (Array.isArray(candidate.video_urls)) { + return candidate.video_urls; + } + + const directVideoUrl = resolveVideoUrlValue(candidate.video_url || candidate.url); + const directMime = toNonEmptyString(candidate.mime).toLowerCase(); + const directType = toNonEmptyString(candidate.type).toLowerCase(); + if (directVideoUrl || directType === "video_url" || directMime.startsWith("video/")) { + return [{ + video_url: directVideoUrl, + title: candidate.title, + description: candidate.description || candidate.summary, + file_name: candidate.file_name || candidate.fileName, + source_label: candidate.source_label || candidate.sourceLabel, + source_url: candidate.source_url || candidate.sourceUrl || candidate.url, + mime: candidate.mime, + type: candidate.type, + poster_url: candidate.poster_url || candidate.posterUrl || candidate.thumbnail_url || candidate.thumbnailUrl, + }]; + } + + return []; +} + +function normalizeVideoGalleryResult(result, maxItems = MAX_INLINE_VIDEO_ITEMS) { + if (!result || typeof result !== "object" || result.success === false || maxItems <= 0) { + return null; + } + + const galleryCandidate = result.video_gallery && typeof result.video_gallery === "object" + ? result.video_gallery + : result; + const rawItems = extractRawVideoItems(result); + if (!Array.isArray(rawItems) || rawItems.length === 0) { + return null; + } + + const normalizedItems = rawItems + .map((item, index) => normalizeVideoItem(item, index)) + .filter(Boolean); + if (normalizedItems.length === 0) { + return null; + } + + const renderedItems = normalizedItems.slice(0, Math.max(0, maxItems)); + if (renderedItems.length === 0) { + return null; + } + + const totalCount = Number.isFinite(Number(galleryCandidate.total_count || galleryCandidate.totalCount)) + ? Number(galleryCandidate.total_count || galleryCandidate.totalCount) + : normalizedItems.length; + const galleryTitle = toNonEmptyString(galleryCandidate.title || galleryCandidate.label || result.title) + || (totalCount === 1 ? "Video result" : "Video results"); + const gallerySummary = toNonEmptyString(galleryCandidate.summary || galleryCandidate.description || result.summary) + || (renderedItems.length === 1 + ? "Relevant video returned for this result." + : "Relevant videos returned for this result."); + + return { + ...result, + render_type: result.render_type || INLINE_VIDEO_GALLERY_RENDER_TYPE, + video_gallery: { + title: galleryTitle, + summary: gallerySummary, + items: renderedItems, + total_count: totalCount, + rendered_count: renderedItems.length, + source_action_name: toNonEmptyString( + galleryCandidate.source_action_name || galleryCandidate.sourceActionName || result.source_action_name + ), + }, + }; +} + +async function hydrateInlineVideoGalleryCitation(conversationId, artifactId) { + try { + const hydratedCitation = await fetchAgentCitationArtifact(conversationId, artifactId); + return normalizeVideoGalleryResult(getCitationResult(hydratedCitation)); + } catch (error) { + console.warn("Failed to hydrate inline video gallery citation artifact", error); + return null; + } +} + +async function resolveInlineVideoGallery(citation, conversationId, maxItems = MAX_INLINE_VIDEO_ITEMS) { + const shouldPreferArtifact = Boolean( + citation?.raw_payload_externalized + && citation?.artifact_id + && conversationId + ); + + if (shouldPreferArtifact) { + const hydratedResult = await hydrateInlineVideoGalleryCitation(conversationId, citation.artifact_id); + const normalizedHydratedResult = normalizeVideoGalleryResult(hydratedResult, maxItems); + if (normalizedHydratedResult) { + return normalizedHydratedResult; + } + + if (hydratedResult) { + return hydratedResult; + } + } + + const localResult = normalizeVideoGalleryResult(getCitationResult(citation), maxItems); + if (localResult) { + return localResult; + } + + if (!citation?.artifact_id || !conversationId || shouldPreferArtifact) { + return null; + } + + const fallbackHydratedResult = await hydrateInlineVideoGalleryCitation(conversationId, citation.artifact_id); + return normalizeVideoGalleryResult(fallbackHydratedResult, maxItems); +} + +function buildVideoDetailsRows(item) { + const rows = []; + + if (item.source_label) { + rows.push(` +
+ Source + ${escapeHtml(item.source_label)} +
+ `); + } + + if (item.file_name) { + rows.push(` +
+ File + ${escapeHtml(item.file_name)} +
+ `); + } + + if (item.doc_id) { + rows.push(` +
+ Document ID + ${escapeHtml(item.doc_id)} +
+ `); + } + + if (item.source_url) { + rows.push(` + + `); + } + + return rows.join(""); +} + +function getInlineVideoDetailsModal() { + let modalContainer = document.getElementById("inline-video-details-modal"); + if (modalContainer) { + return modalContainer; + } + + modalContainer = document.createElement("div"); + modalContainer.id = "inline-video-details-modal"; + modalContainer.className = "modal fade"; + modalContainer.tabIndex = -1; + modalContainer.setAttribute("aria-hidden", "true"); + modalContainer.innerHTML = ` + + `; + document.body.appendChild(modalContainer); + return modalContainer; +} + +function showInlineVideoDetailsModal(item) { + const modalContainer = getInlineVideoDetailsModal(); + const titleEl = modalContainer.querySelector("#inline-video-details-title"); + const previewEl = modalContainer.querySelector("#inline-video-details-preview"); + const descriptionEl = modalContainer.querySelector("#inline-video-details-description"); + const metaEl = modalContainer.querySelector("#inline-video-details-meta"); + + if (titleEl) { + titleEl.textContent = item.title || "Video details"; + } + + if (previewEl) { + previewEl.pause(); + previewEl.src = item.full_video_url || item.preview_video_url; + if (item.poster_url) { + previewEl.setAttribute("poster", item.poster_url); + } else { + previewEl.removeAttribute("poster"); + } + previewEl.load(); + } + + if (descriptionEl) { + const hasDescription = Boolean(item.description); + descriptionEl.classList.toggle("d-none", !hasDescription); + descriptionEl.textContent = item.description || ""; + } + + if (metaEl) { + metaEl.innerHTML = buildVideoDetailsRows(item); + } + + modalContainer.addEventListener("hidden.bs.modal", () => { + if (previewEl) { + previewEl.pause(); + previewEl.currentTime = 0; + } + }, { once: true }); + + const modal = new bootstrap.Modal(modalContainer); + modal.show(); +} + +function createBadge(label, value) { + const badge = document.createElement("span"); + badge.className = "inline-video-gallery-badge"; + badge.textContent = `${label}: ${value}`; + return badge; +} + +function createVideoTile(item) { + const tile = document.createElement("article"); + tile.className = "inline-video-gallery-item"; + tile.innerHTML = ` + + + `; + + const videoEl = tile.querySelector(".inline-video-gallery-item-video"); + const infoButton = tile.querySelector(".inline-video-gallery-info-btn"); + if (videoEl) { + videoEl.src = item.preview_video_url || item.full_video_url; + if (item.poster_url) { + videoEl.setAttribute("poster", item.poster_url); + } + } + + if (infoButton) { + infoButton.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + showInlineVideoDetailsModal(item); + }); + } + + return tile; +} + +function createVideoGalleryCard(result) { + const payload = result.video_gallery || {}; + const card = document.createElement("section"); + card.className = "inline-video-gallery-card"; + + const renderedCount = Number(payload.rendered_count || (payload.items || []).length || 0); + const totalCount = Number(payload.total_count || renderedCount || 0); + const summaryText = payload.summary || result.summary || "Relevant video results."; + + card.innerHTML = ` + + + + `; + + const badgesContainer = card.querySelector(".inline-video-gallery-badges"); + if (badgesContainer) { + badgesContainer.appendChild(createBadge("Videos", renderedCount)); + if (totalCount > renderedCount) { + badgesContainer.appendChild(createBadge("Showing", `${renderedCount} of ${totalCount}`)); + } + } + + const grid = card.querySelector(".inline-video-gallery-grid"); + if (grid) { + (payload.items || []).forEach((item) => { + grid.appendChild(createVideoTile(item)); + }); + } + + return { card }; +} + +export async function renderInlineVideoGalleries( + messageElement, + hybridCitations = [], + webCitations = [], + agentCitations = [], + conversationId = "" +) { + if (!messageElement) { + return; + } + + const container = messageElement.querySelector(".inline-visualizations-container"); + if (!container) { + return; + } + + container.querySelectorAll(".inline-video-gallery-card").forEach((card) => card.remove()); + + const hasHybridCitations = Array.isArray(hybridCitations) && hybridCitations.length > 0; + const hasWebCitations = Array.isArray(webCitations) && webCitations.length > 0; + const hasAgentCitations = Array.isArray(agentCitations) && agentCitations.length > 0; + if (!hasHybridCitations && !hasWebCitations && !hasAgentCitations) { + container.classList.toggle("d-none", container.children.length === 0); + return; + } + + let remainingSlots = MAX_INLINE_VIDEO_ITEMS; + const seenVideoKeys = new Set(); + + const workspaceItems = extractWorkspaceCitationVideoItems(hybridCitations, seenVideoKeys); + if (workspaceItems.length > 0 && remainingSlots > 0) { + const workspaceGallery = buildVideoGalleryResult( + "Workspace videos", + "Video sources cited from workspace content.", + workspaceItems.slice(0, remainingSlots), + "Workspace citations", + workspaceItems.length + ); + if (workspaceGallery) { + const { card } = createVideoGalleryCard(workspaceGallery); + container.appendChild(card); + remainingSlots -= workspaceGallery.video_gallery.rendered_count || 0; + } + } + + const linkedItems = extractLinkedVideoItems(webCitations, seenVideoKeys); + if (linkedItems.length > 0 && remainingSlots > 0) { + const linkedGallery = buildVideoGalleryResult( + "Linked videos", + "Direct video links returned with this response.", + linkedItems.slice(0, remainingSlots), + "Linked sources", + linkedItems.length + ); + if (linkedGallery) { + const { card } = createVideoGalleryCard(linkedGallery); + container.appendChild(card); + remainingSlots -= linkedGallery.video_gallery.rendered_count || 0; + } + } + + for (let index = 0; index < agentCitations.length && remainingSlots > 0; index += 1) { + const citation = agentCitations[index]; + const result = await resolveInlineVideoGallery(citation, conversationId, remainingSlots); + if (!result) { + continue; + } + + const normalizedItems = Array.isArray(result?.video_gallery?.items) + ? result.video_gallery.items.filter((item) => { + const identityKey = getItemIdentityKey(item); + if (!identityKey || seenVideoKeys.has(identityKey)) { + return false; + } + + seenVideoKeys.add(identityKey); + return true; + }) + : []; + if (normalizedItems.length === 0) { + continue; + } + + result.video_gallery.items = normalizedItems; + result.video_gallery.rendered_count = normalizedItems.length; + + const { card } = createVideoGalleryCard(result); + container.appendChild(card); + remainingSlots -= normalizedItems.length; + } + + container.classList.toggle("d-none", container.children.length === 0); +} \ No newline at end of file diff --git a/application/single_app/static/js/chat/chat-messages.js b/application/single_app/static/js/chat/chat-messages.js index d350eb3a..a13b78a3 100644 --- a/application/single_app/static/js/chat/chat-messages.js +++ b/application/single_app/static/js/chat/chat-messages.js @@ -21,6 +21,10 @@ import { sendMessageWithStreaming } from "./chat-streaming.js"; import { getCurrentReasoningEffort, isReasoningEffortEnabled } from './chat-reasoning.js'; import { areAgentsEnabled } from './chat-agents.js'; import { createThoughtsToggleHtml, attachThoughtsToggleListener } from './chat-thoughts.js'; +import { extractInlineChartBlocks, hydrateInlineCharts, injectInlineChartHtml, restoreInlineChartTokens } from './chat-inline-charts.js'; +import { renderInlineVideoGalleries } from './chat-inline-videos.js'; +import { renderInlineImageGalleries } from './chat-inline-images.js'; +import { renderInlineAzureMaps } from './chat-inline-maps.js'; // Conditionally import TTS if enabled let ttsModule = null; @@ -34,6 +38,68 @@ if (typeof window.appSettings !== 'undefined' && window.appSettings.enable_text_ }); } +const documentActionSelect = document.getElementById('document-action-select'); +const documentComparisonLeftContainer = document.getElementById('document-comparison-left-container'); +const documentComparisonLeftSelect = document.getElementById('document-comparison-left-select'); +const DOCUMENT_ACTION_NONE = 'none'; +const DOCUMENT_ACTION_EXHAUSTIVE_REVIEW = 'exhaustive_review'; +const DOCUMENT_ACTION_COMPARISON = 'comparison'; +const CHAT_EXHAUSTIVE_REVIEW_MAX_DOCUMENTS = 3; +const WORKFLOW_EXHAUSTIVE_REVIEW_MAX_DOCUMENTS = 10; + +function getSelectedDocumentIds() { + const docSel = document.getElementById('document-select'); + if (!docSel) { + return []; + } + + return Array.from(docSel.selectedOptions) + .map(option => option.value) + .filter(value => value); +} + +function getDocumentActionType() { + return String(documentActionSelect?.value || DOCUMENT_ACTION_NONE).trim() || DOCUMENT_ACTION_NONE; +} + +function syncComparisonLeftOptions() { + if (!documentComparisonLeftSelect) { + return; + } + + const selectedDocumentIds = getSelectedDocumentIds(); + const previousSelection = String(documentComparisonLeftSelect.value || '').trim(); + documentComparisonLeftSelect.innerHTML = ''; + + selectedDocumentIds.forEach((documentId, index) => { + const metadata = getDocumentMetadata(documentId) || {}; + const option = document.createElement('option'); + option.value = documentId; + option.textContent = metadata.name || metadata.file_name || metadata.filename || documentId; + if ((previousSelection && previousSelection === documentId) || (!previousSelection && index === 0)) { + option.selected = true; + } + documentComparisonLeftSelect.appendChild(option); + }); +} + +function updateDocumentActionControls() { + const actionType = getDocumentActionType(); + const selectedDocumentIds = getSelectedDocumentIds(); + const showComparisonLeftSelector = actionType === DOCUMENT_ACTION_COMPARISON && selectedDocumentIds.length > 0; + + if (documentComparisonLeftContainer) { + documentComparisonLeftContainer.classList.toggle('d-none', !showComparisonLeftSelector); + } + + if (showComparisonLeftSelector) { + syncComparisonLeftOptions(); + } +} + +documentActionSelect?.addEventListener('change', updateDocumentActionControls); +window.addEventListener('chat:document-selection-changed', updateDocumentActionControls); + /** * Unwraps markdown tables that are mistakenly wrapped in code blocks. * This fixes the issue where AI responses contain tables in code blocks, @@ -359,11 +425,31 @@ export function updateSendButtonVisibility() { // Make function available globally for inline oninput handler window.handleInputChange = updateSendButtonVisibility; +function resolveMessageConversationId(fullMessageObject = null) { + const conversationCandidates = [ + fullMessageObject?.conversation_id, + fullMessageObject?.metadata?.source_conversation_id, + fullMessageObject?.source_conversation_id, + window.chatConversations?.getCurrentConversationId?.(), + window.currentConversationId, + ]; + + for (const candidate of conversationCandidates) { + const normalizedConversationId = String(candidate || '').trim(); + if (normalizedConversationId) { + return normalizedConversationId; + } + } + + return ''; +} + function createCitationsHtml( hybridCitations = [], webCitations = [], agentCitations = [], - messageId + messageId, + messageConversationId = "" ) { let citationsHtml = ""; let hasCitations = false; @@ -449,7 +535,7 @@ function createCitationsHtml( data-tool-args="${escapeHtml(toolArgs)}" data-tool-result="${escapeHtml(toolResult)}" data-artifact-id="${escapeHtml(cite.artifact_id || '')}" - data-conversation-id="${escapeHtml(window.currentConversationId || '')}" + data-conversation-id="${escapeHtml(messageConversationId)}" title="Agent tool: ${escapeHtml(displayText)} - Click to view details"> ${escapeHtml(displayText)} `; @@ -470,7 +556,12 @@ export function loadMessages(conversationId) { // Clear search highlights when loading a different conversation clearSearchHighlight(); - return fetch(`/conversation/${conversationId}/messages`) + return fetch(`/conversation/${conversationId}/messages?ts=${Date.now()}`, { + cache: "no-store", + headers: { + "Cache-Control": "no-cache", + }, + }) .then((response) => response.json()) .then((data) => { const chatbox = document.getElementById("chatbox"); @@ -554,6 +645,382 @@ export function loadMessages(conversationId) { }); } +const collaboratorProfileImageCache = new Map(); + +function stripHtmlTags(value) { + const tempElement = document.createElement("div"); + tempElement.innerHTML = String(value ?? ""); + return tempElement.textContent || tempElement.innerText || ""; +} + +function buildPlainTextPreview(value, maxLength = 160) { + const normalizedValue = String(value ?? "").replace(/\s+/g, " ").trim(); + if (!normalizedValue) { + return "No message content"; + } + if (normalizedValue.length <= maxLength) { + return normalizedValue; + } + return `${normalizedValue.slice(0, maxLength - 3)}...`; +} + +function getMessageSenderUserId(fullMessageObject = null) { + const senderUserId = String( + fullMessageObject?.sender?.user_id || fullMessageObject?.metadata?.sender?.user_id || "" + ).trim(); + return senderUserId || null; +} + +function getMessageSenderDisplayName(fullMessageObject = null, fallbackLabel = "Participant") { + const senderDisplayName = String( + fullMessageObject?.sender?.display_name + || fullMessageObject?.metadata?.sender?.display_name + || fallbackLabel + ).trim(); + return senderDisplayName || fallbackLabel; +} + +function getInitials(name) { + const words = String(name ?? "") + .trim() + .split(/\s+/) + .filter(Boolean); + + if (words.length === 0) { + return "?"; + } + + return words + .slice(0, 2) + .map(word => word.charAt(0).toUpperCase()) + .join(""); +} + +function createCollaboratorAvatarHtml(fullMessageObject, senderLabel) { + const senderUserId = getMessageSenderUserId(fullMessageObject); + const cachedProfileImage = senderUserId ? collaboratorProfileImageCache.get(senderUserId) : null; + const altText = `${senderLabel} Avatar`; + + if (cachedProfileImage) { + return `${escapeHtml(altText)}`; + } + + return ` +
+ ${escapeHtml(getInitials(senderLabel))} +
`; +} + +function hydrateCollaboratorAvatar(messageDiv, senderUserId, senderLabel) { + if (!messageDiv || !senderUserId) { + return; + } + + const avatarElement = messageDiv.querySelector(".collaborator-avatar"); + if (!avatarElement) { + return; + } + + const cachedProfileImage = collaboratorProfileImageCache.get(senderUserId); + if (cachedProfileImage) { + if (avatarElement.tagName === "IMG") { + avatarElement.src = cachedProfileImage; + avatarElement.alt = `${senderLabel} Avatar`; + } else { + const imageElement = document.createElement("img"); + imageElement.src = cachedProfileImage; + imageElement.alt = `${senderLabel} Avatar`; + imageElement.className = "avatar collaborator-avatar"; + imageElement.dataset.avatarUserId = senderUserId; + avatarElement.replaceWith(imageElement); + } + return; + } + + fetch(`/api/user/profile-image/${encodeURIComponent(senderUserId)}`, { + credentials: "same-origin", + }) + .then(response => { + if (!response.ok) { + throw new Error("Failed to load user profile image"); + } + return response.json(); + }) + .then(userData => { + const profileImage = String(userData?.profile_image || "").trim(); + if (!profileImage) { + return; + } + + collaboratorProfileImageCache.set(senderUserId, profileImage); + if (avatarElement.tagName === "IMG") { + avatarElement.src = profileImage; + avatarElement.alt = `${senderLabel} Avatar`; + } else { + const imageElement = document.createElement("img"); + imageElement.src = profileImage; + imageElement.alt = `${senderLabel} Avatar`; + imageElement.className = "avatar collaborator-avatar"; + imageElement.dataset.avatarUserId = senderUserId; + avatarElement.replaceWith(imageElement); + } + }) + .catch(() => { + console.debug("Could not load profile image for collaborator:", senderUserId); + }); +} + +function buildReplyContextFromMessage(message = null) { + if (!message) { + return null; + } + + const messageId = String(message.id || "").trim(); + if (!messageId) { + return null; + } + + return { + message_id: messageId, + sender_display_name: getMessageSenderDisplayName( + message, + message.role === "assistant" ? "AI" : "Participant" + ), + content_preview: buildPlainTextPreview( + message.content || message.metadata?.last_message_preview || "" + ), + }; +} + +function resolveReplyContextFromDom(messageId) { + if (!messageId) { + return null; + } + + const replyElement = document.querySelector(`[data-message-id="${messageId}"]`); + if (!replyElement) { + return null; + } + + const senderDisplayName = String( + replyElement.dataset.replySenderName + || replyElement.querySelector(".message-sender")?.textContent + || "Participant" + ) + .replace(/\s+/g, " ") + .trim(); + const contentPreview = String( + replyElement.dataset.replyPreviewText + || buildPlainTextPreview(replyElement.querySelector(".message-text")?.textContent || "") + ).trim(); + + return { + message_id: messageId, + sender_display_name: senderDisplayName || "Participant", + content_preview: contentPreview || "No message content", + }; +} + +function resolveReplyContext(fullMessageObject = null) { + const replyMessageContext = buildReplyContextFromMessage(fullMessageObject?.reply_message); + if (replyMessageContext) { + return replyMessageContext; + } + + const metadataReplyContext = fullMessageObject?.metadata?.reply_context; + if (metadataReplyContext) { + return { + message_id: String(metadataReplyContext.message_id || "").trim(), + sender_display_name: String(metadataReplyContext.sender_display_name || "Participant").trim() || "Participant", + content_preview: buildPlainTextPreview(metadataReplyContext.content_preview || ""), + }; + } + + const replyToMessageId = String(fullMessageObject?.reply_to_message_id || "").trim(); + if (!replyToMessageId) { + return null; + } + + return resolveReplyContextFromDom(replyToMessageId); +} + +function renderReplyQuoteHtml(fullMessageObject = null) { + const replyContext = resolveReplyContext(fullMessageObject); + if (!replyContext) { + return ""; + } + + return ` +
+
Replying to ${escapeHtml(replyContext.sender_display_name || "Participant")}
+
${escapeHtml(replyContext.content_preview || "No message content")}
+
`; +} + + function escapeMentionPattern(value) { + return String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + } + + function buildAtMentionPattern(displayName) { + return new RegExp( + `(^|\\s)@${escapeMentionPattern(displayName)}(?=$|\\s|[.,!?;:])`, + "gi" + ); + } + + function normalizeStructuredMessageContent(messageContent) { + return String(messageContent ?? "") + .replace(/[ \t]{2,}/g, " ") + .replace(/\s+\n/g, "\n") + .replace(/\n\s+/g, "\n") + .replace(/\s+([.,!?;:])/g, "$1") + .trim(); + } + + function stripInlineAzureMapsBlocks(messageContent) { + const normalizedContent = String(messageContent ?? ""); + if (!normalizedContent.includes("{{map:")) { + return normalizedContent; + } + + return normalizeStructuredMessageContent( + normalizedContent + .replace(/\n?\{\{map:[\s\S]*?\}\}\n?/g, "\n") + .replace(/\n{3,}/g, "\n\n") + ); + } + + function getMentionedParticipants(fullMessageObject = null) { + const rawMentions = Array.isArray(fullMessageObject?.metadata?.mentioned_participants) + ? fullMessageObject.metadata.mentioned_participants + : []; + + return rawMentions + .map(participant => ({ + user_id: String(participant?.user_id || "").trim(), + display_name: String(participant?.display_name || participant?.name || participant?.email || "").trim(), + email: String(participant?.email || "").trim(), + })) + .filter(participant => participant.user_id && participant.display_name); + } + + function stripMentionTextFromMessageContent(messageContent, fullMessageObject = null) { + let normalizedMessageContent = String(messageContent ?? ""); + if (!normalizedMessageContent.trim()) { + return normalizedMessageContent; + } + + const mentions = getMentionedParticipants(fullMessageObject) + .slice() + .sort((left, right) => right.display_name.length - left.display_name.length); + if (mentions.length === 0) { + return normalizedMessageContent; + } + + mentions.forEach(participant => { + const displayName = String(participant.display_name || "").trim(); + if (!displayName) { + return; + } + + const mentionPattern = buildAtMentionPattern(displayName); + normalizedMessageContent = normalizedMessageContent.replace( + mentionPattern, + (match, leadingWhitespace) => leadingWhitespace || "" + ); + }); + + const invocationTarget = getInvocationTarget(fullMessageObject); + if (invocationTarget?.display_name) { + normalizedMessageContent = normalizedMessageContent.replace( + buildAtMentionPattern(invocationTarget.display_name), + (match, leadingWhitespace) => leadingWhitespace || "" + ); + } + + return normalizeStructuredMessageContent(normalizedMessageContent); + } + + function renderMentionTagsHtml(fullMessageObject = null) { + const mentions = getMentionedParticipants(fullMessageObject); + if (mentions.length === 0) { + return ""; + } + + const currentUserId = String(window.currentUser?.id || window.currentUser?.user_id || "").trim(); + const mentionChipsHtml = mentions.map(participant => { + const isCurrentUser = currentUserId && participant.user_id === currentUserId; + const currentUserClass = isCurrentUser ? " collaboration-mention-chip-current-user" : ""; + return `@${escapeHtml(participant.display_name)}`; + }).join(""); + + return ` +
+
Tagged
+
${mentionChipsHtml}
+
`; + } + + function getInvocationTarget(fullMessageObject = null) { + const target = fullMessageObject?.metadata?.ai_invocation_target; + if (!target || typeof target !== "object") { + return null; + } + + const displayName = String(target.display_name || target.label || "").trim(); + if (!displayName) { + return null; + } + + const targetType = String(target.target_type || target.type || "model").trim() || "model"; + const sourceMode = String(target.source_mode || target.mode || "").trim() || null; + return { + target_type: targetType, + display_name: displayName, + mention_text: String(target.mention_text || `@${displayName}`).trim() || `@${displayName}`, + source_mode: sourceMode, + }; + } + + function renderInvocationTargetHtml(fullMessageObject = null) { + const invocationTarget = getInvocationTarget(fullMessageObject); + if (!invocationTarget) { + return ""; + } + + const targetTypeClass = ` collaboration-mention-chip-target-${String(invocationTarget.target_type || "model") + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]/g, "") || "model"}`; + + return ` +
+
+ ${escapeHtml(invocationTarget.mention_text)} +
+
`; + } + + export function renderAiMessageContent(messageContent) { + let cleaned = stripInlineAzureMapsBlocks(messageContent).trim().replace(/\n{3,}/g, "\n\n"); + cleaned = cleaned.replace(/(\bhttps?:\/\/\S+)(%5D|\])+/gi, (_, url) => url); + + const chartExtraction = extractInlineChartBlocks(cleaned); + const withInlineCitations = parseCitations(chartExtraction.markdown); + const withUnwrappedTables = unwrapTablesFromCodeBlocks(withInlineCitations); + const withMarkdownTables = convertUnicodeTableToMarkdown(withUnwrappedTables); + const withPSVTables = convertPSVCodeBlockToMarkdown(withMarkdownTables); + const withASCIITables = convertASCIIDashTableToMarkdown(withPSVTables); + const sanitizedHtml = DOMPurify.sanitize(marked.parse(withASCIITables)); + const htmlWithCharts = injectInlineChartHtml(sanitizedHtml, chartExtraction.blocks); + + return { + htmlContent: addTargetBlankToExternalLinks(htmlWithCharts), + copyMarkdown: restoreInlineChartTokens(withInlineCitations, chartExtraction.blocks), + previewMarkdown: chartExtraction.markdown, + }; + } + export function appendMessage( sender, messageContent, @@ -576,6 +1043,7 @@ export function appendMessage( let avatarImg = ""; let avatarAltText = ""; + let avatarHtml = ""; let messageClass = ""; // <<< ENSURE THIS IS DECLARED HERE let senderLabel = ""; let messageContentHtml = ""; @@ -615,16 +1083,10 @@ export function appendMessage( senderLabel = "AI"; } - // Parse content with comprehensive table processing - let cleaned = messageContent.trim().replace(/\n{3,}/g, "\n\n"); - cleaned = cleaned.replace(/(\bhttps?:\/\/\S+)(%5D|\])+/gi, (_, url) => url); - const withInlineCitations = parseCitations(cleaned); - const withUnwrappedTables = unwrapTablesFromCodeBlocks(withInlineCitations); - const withMarkdownTables = convertUnicodeTableToMarkdown(withUnwrappedTables); - const withPSVTables = convertPSVCodeBlockToMarkdown(withMarkdownTables); - const withASCIITables = convertASCIIDashTableToMarkdown(withPSVTables); - const sanitizedHtml = DOMPurify.sanitize(marked.parse(withASCIITables)); - const htmlContent = addTargetBlankToExternalLinks(sanitizedHtml); + const messageConversationId = resolveMessageConversationId(fullMessageObject); + + const renderedAiContent = renderAiMessageContent(messageContent); + const htmlContent = renderedAiContent.htmlContent; const mainMessageHtml = `
${htmlContent}
`; // Renamed for clarity @@ -652,7 +1114,7 @@ export function appendMessage( `; @@ -692,7 +1154,8 @@ export function appendMessage( hybridCitations, webCitations, agentCitations, - messageId + messageId, + messageConversationId ); console.log( `Generated citationsButtonsHtml (length ${ @@ -773,6 +1236,7 @@ export function appendMessage(
${senderLabel}
${mainMessageHtml} +
${citationContentContainerHtml} ${thoughtsHtml.containerHtml} ${metadataContainerHtml} @@ -780,13 +1244,41 @@ export function appendMessage(
`; + messageDiv.dataset.replySenderName = stripHtmlTags(senderLabel).replace(/\s+/g, " ").trim() || "AI"; + messageDiv.dataset.replyPreviewText = buildPlainTextPreview(renderedAiContent.previewMarkdown); + messageDiv.classList.add(messageClass); // Add AI message class chatbox.appendChild(messageDiv); // Append AI message // Auto-play TTS if enabled (only for new messages, not when loading history) if (isNewMessage && typeof autoplayTTSIfEnabled === 'function') { - autoplayTTSIfEnabled(messageId, messageContent); + autoplayTTSIfEnabled(messageId, renderedAiContent.previewMarkdown || messageContent); } + + hydrateInlineCharts(messageDiv); + void (async () => { + await renderInlineVideoGalleries( + messageDiv, + hybridCitations || [], + webCitations || [], + agentCitations || [], + messageConversationId + ); + await renderInlineImageGalleries( + messageDiv, + hybridCitations || [], + webCitations || [], + agentCitations || [], + messageId, + messageConversationId + ); + await renderInlineAzureMaps( + messageDiv, + agentCitations || [], + messageId, + messageConversationId + ); + })(); // Highlight code blocks in the messages messageDiv.querySelectorAll('pre code[class^="language-"]').forEach((block) => { @@ -799,10 +1291,7 @@ export function appendMessage( // Apply masked state if message has masking if (fullMessageObject?.metadata) { - console.log('Applying masked state for AI message:', messageId, fullMessageObject.metadata); applyMaskedState(messageDiv, fullMessageObject.metadata); - } else { - console.log('No metadata found for AI message:', messageId, 'fullMessageObject:', fullMessageObject); } // --- Attach Event Listeners specifically for AI message --- @@ -1035,11 +1524,24 @@ export function appendMessage( } else { avatarImg = "/static/images/user-avatar.png"; } - + + const renderedMessageContent = stripMentionTextFromMessageContent(messageContent, fullMessageObject); const sanitizedUserHtml = DOMPurify.sanitize( - marked.parse(escapeHtml(messageContent)) + marked.parse(escapeHtml(renderedMessageContent)) ); messageContentHtml = addTargetBlankToExternalLinks(sanitizedUserHtml); + } else if (sender === "Collaborator") { + messageClass = "collaborator-message"; + senderLabel = fullMessageObject?.sender?.display_name + || fullMessageObject?.metadata?.sender?.display_name + || "Participant"; + avatarAltText = `${senderLabel} Avatar`; + avatarHtml = createCollaboratorAvatarHtml(fullMessageObject, senderLabel); + const renderedMessageContent = stripMentionTextFromMessageContent(messageContent, fullMessageObject); + const sanitizedCollaboratorHtml = DOMPurify.sanitize( + marked.parse(escapeHtml(renderedMessageContent)) + ); + messageContentHtml = addTargetBlankToExternalLinks(sanitizedCollaboratorHtml); } else if (sender === "File") { messageClass = "file-message"; senderLabel = "File Added"; @@ -1121,6 +1623,17 @@ export function appendMessage( // Create message footer for user, image, and file messages let messageFooterHtml = ""; let metadataContainerHtml = ""; + const replyQuoteHtml = (sender === "You" || sender === "Collaborator") + ? renderReplyQuoteHtml(fullMessageObject) + : ""; + const invocationTargetHtml = (sender === "You" || sender === "Collaborator") + ? renderInvocationTargetHtml(fullMessageObject) + : ""; + const mentionTagsHtml = (sender === "You" || sender === "Collaborator") + ? renderMentionTagsHtml(fullMessageObject) + : ""; + const hasVisibleMessageText = sender === "image" + || Boolean(stripHtmlTags(messageContentHtml || "").replace(/\s+/g, " ").trim()); if (sender === "You") { const metadataContainerId = `metadata-${messageId || Date.now()}`; const isMasked = fullMessageObject?.metadata?.masked || (fullMessageObject?.metadata?.masked_ranges && fullMessageObject.metadata.masked_ranges.length > 0); @@ -1166,6 +1679,28 @@ export function appendMessage(
`; metadataContainerHtml = ``; + } else if (sender === "Collaborator") { + const metadataContainerId = `metadata-${messageId || Date.now()}`; + messageFooterHtml = ` + `; + metadataContainerHtml = ``; } else if (sender === "image" || sender === "File") { // Image and file messages get mask button on left, metadata button on right side const metadataContainerId = `metadata-${messageId || Date.now()}`; @@ -1213,9 +1748,11 @@ export function appendMessage( sender === "You" || sender === "File" ? "flex-row-reverse" : "" }"> ${ - avatarImg - ? `${avatarAltText}` - : "" + avatarHtml + ? avatarHtml + : avatarImg + ? `${avatarAltText}` + : "" }
@@ -1223,12 +1760,20 @@ export function appendMessage( ${fullMessageObject?.metadata?.edited ? 'Edited' : ''} ${fullMessageObject?.metadata?.retried ? 'Retried' : ''}
-
${messageContentHtml}
+ ${replyQuoteHtml} + ${invocationTargetHtml} + ${mentionTagsHtml} + ${hasVisibleMessageText ? `
${messageContentHtml}
` : ""} ${metadataContainerHtml} ${messageFooterHtml}
`; + messageDiv.dataset.replySenderName = stripHtmlTags(senderLabel).replace(/\s+/g, " ").trim() || "Participant"; + if (typeof messageContent === "string") { + messageDiv.dataset.replyPreviewText = buildPlainTextPreview(messageContent); + } + // Append and scroll (common actions for non-AI) chatbox.appendChild(messageDiv); @@ -1255,6 +1800,11 @@ export function appendMessage( } } + if (sender === "Collaborator") { + attachCollaboratorMessageEventListeners(messageDiv, fullMessageObject, messageContent); + hydrateCollaboratorAvatar(messageDiv, getMessageSenderUserId(fullMessageObject), senderLabel); + } + // Add event listener for image info button (uploaded images) if (sender === "image" && fullMessageObject?.metadata?.is_user_upload) { const imageInfoBtn = messageDiv.querySelector('.image-info-btn'); @@ -1407,21 +1957,12 @@ export function sendMessage() { userInput.focus(); } -export function actuallySendMessage(finalMessageToSend) { - // Generate a temporary message ID for the user message - const tempUserMessageId = `temp_user_${Date.now()}`; - - // Append user message first with temporary ID - appendMessage("You", finalMessageToSend, null, tempUserMessageId); - userInput.value = ""; - userInput.style.height = ""; - // Update send button visibility after clearing input - updateSendButtonVisibility(); - +function getCurrentModelSelection() { let modelDeployment = modelSelect?.value; let modelId = null; let modelEndpointId = null; let modelProvider = null; + if (window.appSettings?.enable_multi_model_endpoints && modelSelect) { const selectedOption = modelSelect.options[modelSelect.selectedIndex]; modelId = selectedOption?.dataset?.modelId || selectedOption?.value || null; @@ -1430,35 +1971,323 @@ export function actuallySendMessage(finalMessageToSend) { modelDeployment = selectedOption?.dataset?.deploymentName || null; } - // ... (keep existing logic for hybridSearchEnabled, selectedDocumentId, classificationsToSend, imageGenEnabled) + return { + modelDeployment, + modelId, + modelEndpointId, + modelProvider, + modelDisplayName: String( + modelSelect?.options?.[modelSelect.selectedIndex]?.dataset?.displayName + || modelSelect?.options?.[modelSelect.selectedIndex]?.textContent + || modelDeployment + || 'Model' + ).trim() || 'Model', + }; +} + +function getCurrentAgentSelection() { + const agentSelectContainer = document.getElementById('agent-select-container'); + const agentSelect = document.getElementById('agent-select'); + if (!areAgentsEnabled() || !agentSelectContainer || agentSelectContainer.style.display === 'none' || !agentSelect) { + return null; + } + + const selectedAgentOption = agentSelect.options[agentSelect.selectedIndex]; + if (!selectedAgentOption) { + return null; + } + + return { + id: selectedAgentOption.dataset.agentId || null, + name: selectedAgentOption.dataset.name || selectedAgentOption.value || '', + display_name: selectedAgentOption.dataset.displayName || selectedAgentOption.textContent, + is_global: selectedAgentOption.dataset.isGlobal === 'true', + is_group: selectedAgentOption.dataset.isGroup === 'true', + group_id: selectedAgentOption.dataset.groupId || null, + group_name: selectedAgentOption.dataset.groupName || null, + }; +} + +function normalizeCollaborativeTargetLabel(value) { + return String(value || '') + .replace(/\s+/g, ' ') + .trim() + .toLowerCase(); +} + +function getCollaborativeModelDisplayName(option = {}) { + const dataset = option?.dataset || {}; + return String( + dataset.displayName + || option.display_name + || option.model_id + || dataset.modelId + || dataset.deploymentName + || option.deployment_name + || option.textContent + || option.label + || option.value + || '' + ).trim(); +} + +function getCollaborativeAgentDisplayName(option = {}) { + const dataset = option?.dataset || {}; + return String( + dataset.displayName + || option.display_name + || option.displayName + || option.textContent + || option.label + || option.name + || option.value + || '' + ).trim(); +} + +function buildCollaborativeModelTarget(option = {}) { + const dataset = option?.dataset || {}; + const displayName = getCollaborativeModelDisplayName(option); + const hasModelIdentity = Boolean( + dataset.modelId + || option.model_id + || dataset.deploymentName + || option.deployment_name + || dataset.endpointId + || option.endpoint_id + || option.display_name + ); + if (!displayName) { + return null; + } + + if (!hasModelIdentity && String(option.value || '').trim() === '') { + return null; + } + + const modelDeployment = String(dataset.deploymentName || option.deployment_name || option.value || '').trim() || null; + const modelId = String(dataset.modelId || option.model_id || option.value || '').trim() || null; + const modelEndpointId = String(dataset.endpointId || option.endpoint_id || '').trim() || null; + const modelProvider = String(dataset.provider || option.provider || '').trim() || null; + const selectionKey = String( + dataset.selectionKey + || option.selection_key + || modelDeployment + || modelId + || displayName + ).trim(); + + return { + action: 'ai_tag', + target_type: 'model', + display_name: displayName, + mention_text: `@${displayName}`, + source_mode: 'explicit_tag', + selection_key: selectionKey, + model_deployment: modelDeployment, + model_id: modelId, + model_endpoint_id: modelEndpointId, + model_provider: modelProvider, + subtitle: modelDeployment && modelDeployment !== displayName + ? modelDeployment + : modelProvider + ? `${modelProvider} model` + : 'Model deployment', + search_text: [displayName, modelDeployment, modelId, modelProvider].filter(Boolean).join(' '), + }; +} + +function buildCollaborativeAgentTarget(option = {}) { + const dataset = option?.dataset || {}; + const displayName = getCollaborativeAgentDisplayName(option); + const agentId = String(dataset.agentId || option.id || '').trim() || null; + const agentName = String(dataset.name || option.name || option.value || '').trim() || null; + if (!displayName || (!agentId && !agentName)) { + return null; + } + + const isGlobal = String(dataset.isGlobal || option.is_global || '').trim() === 'true' || option.is_global === true; + const isGroup = String(dataset.isGroup || option.is_group || '').trim() === 'true' || option.is_group === true; + const groupName = String(dataset.groupName || option.group_name || '').trim() || null; + + return { + action: 'ai_tag', + target_type: 'agent', + display_name: displayName, + mention_text: `@${displayName}`, + source_mode: 'explicit_tag', + agent_info: { + id: agentId, + name: agentName || displayName, + display_name: displayName, + is_global: isGlobal, + is_group: isGroup, + group_id: String(dataset.groupId || option.group_id || '').trim() || null, + group_name: groupName, + }, + subtitle: isGroup && groupName + ? `Group agent · ${groupName}` + : isGlobal + ? 'Global agent' + : 'Personal agent', + search_text: [displayName, agentName, agentId, groupName].filter(Boolean).join(' '), + }; +} + +function getAvailableCollaborativeModelTargets() { + const modelOptions = modelSelect?.options ? Array.from(modelSelect.options) : []; + const mappedSelectTargets = modelOptions + .map(option => buildCollaborativeModelTarget(option)) + .filter(Boolean); + + if (mappedSelectTargets.length > 0) { + return mappedSelectTargets; + } + + return (Array.isArray(window.chatModelOptions) ? window.chatModelOptions : []) + .map(option => buildCollaborativeModelTarget(option)) + .filter(Boolean); +} + +function getAvailableCollaborativeAgentTargets() { + const agentSelect = document.getElementById('agent-select'); + const agentOptions = agentSelect?.options ? Array.from(agentSelect.options) : []; + const mappedSelectTargets = agentOptions + .map(option => buildCollaborativeAgentTarget(option)) + .filter(Boolean); + + if (mappedSelectTargets.length > 0) { + return mappedSelectTargets; + } + + return (Array.isArray(window.chatAgentOptions) ? window.chatAgentOptions : []) + .map(option => buildCollaborativeAgentTarget(option)) + .filter(Boolean); +} + +export function getCollaborativeTagSuggestions(query = '') { + const normalizedQuery = normalizeCollaborativeTargetLabel(query); + const matchesQuery = target => { + if (!normalizedQuery) { + return true; + } + + const haystack = normalizeCollaborativeTargetLabel([ + target.display_name, + target.subtitle, + target.search_text, + target.mention_text, + ].filter(Boolean).join(' ')); + return haystack.includes(normalizedQuery); + }; + + return [ + ...getAvailableCollaborativeAgentTargets().filter(matchesQuery), + ...getAvailableCollaborativeModelTargets().filter(matchesQuery), + ]; +} + +function resolveCollaborativeExplicitInvocationTarget(messageText = '') { + const normalizedMessageText = String(messageText || ''); + if (!normalizedMessageText.includes('@')) { + return null; + } + + const targets = getCollaborativeTagSuggestions('') + .slice() + .sort((leftTarget, rightTarget) => String(rightTarget.display_name || '').length - String(leftTarget.display_name || '').length); + + for (const target of targets) { + const displayName = String(target.display_name || '').trim(); + if (!displayName) { + continue; + } + + if (buildAtMentionPattern(displayName).test(normalizedMessageText)) { + return target; + } + } + + return null; +} + +function stripExplicitCollaborativeTargetText(messageText = '', invocationTarget = null) { + if (!invocationTarget?.display_name) { + return String(messageText || ''); + } + + return normalizeStructuredMessageContent( + String(messageText || '').replace( + buildAtMentionPattern(invocationTarget.display_name), + (match, leadingWhitespace) => leadingWhitespace || '' + ) + ); +} + +function buildCollaborativeSendContext(finalMessageToSend, conversationId = currentConversationId) { + const messageText = String(finalMessageToSend ?? ''); + const explicitInvocationTarget = resolveCollaborativeExplicitInvocationTarget(messageText); + const messageData = buildChatRequestPayload(messageText, conversationId); + + if (explicitInvocationTarget?.target_type === 'agent' && explicitInvocationTarget.agent_info) { + messageData.image_generation = false; + messageData.agent_info = { ...explicitInvocationTarget.agent_info }; + } + + if (explicitInvocationTarget?.target_type === 'model') { + messageData.image_generation = false; + messageData.agent_info = null; + messageData.model_deployment = explicitInvocationTarget.model_deployment || messageData.model_deployment; + messageData.model_id = explicitInvocationTarget.model_id || messageData.model_id; + messageData.model_endpoint_id = explicitInvocationTarget.model_endpoint_id || messageData.model_endpoint_id; + messageData.model_provider = explicitInvocationTarget.model_provider || messageData.model_provider; + } + + const invocationTarget = buildCollaborativeInvocationTarget(messageData, explicitInvocationTarget); + const displayMessageText = explicitInvocationTarget + ? stripExplicitCollaborativeTargetText(messageText, explicitInvocationTarget) + : messageText; + + messageData.message = displayMessageText; + + return { + messageData, + invocationTarget, + explicitInvocationTarget, + displayMessageText, + }; +} + +export function buildChatRequestPayload(finalMessageToSend, conversationId = currentConversationId) { + const { + modelDeployment, + modelId, + modelEndpointId, + modelProvider, + } = getCurrentModelSelection(); + let hybridSearchEnabled = false; - const sdbtn = document.getElementById("search-documents-btn"); - if (sdbtn && sdbtn.classList.contains("active")) { + const sdbtn = document.getElementById('search-documents-btn'); + if (sdbtn && sdbtn.classList.contains('active')) { hybridSearchEnabled = true; } let selectedDocumentId = null; let selectedDocumentIds = []; - const docSel = document.getElementById("document-select"); - - // Read all selected document IDs (multi-select support) + const docSel = document.getElementById('document-select'); if (docSel) { selectedDocumentIds = Array.from(docSel.selectedOptions) - .map(o => o.value) - .filter(v => v); // Filter out empty strings - // For backwards compat, set single ID to first selected or null + .map(option => option.value) + .filter(value => value); selectedDocumentId = selectedDocumentIds.length > 0 ? selectedDocumentIds[0] : null; } let imageGenEnabled = false; - const igbtn = document.getElementById("image-generate-btn"); - if (igbtn && igbtn.classList.contains("active")) { + const igbtn = document.getElementById('image-generate-btn'); + if (igbtn && igbtn.classList.contains('active')) { imageGenEnabled = true; } - // --- Robust chat_type/group_id logic --- - // Assume: window.activeChatTabType = 'user' | 'group', window.activeGroupId = group id if group tab - // If you add a group chat tab, set window.activeChatTabType and window.activeGroupId accordingly when switching tabs let chat_type = 'user'; let group_id = null; if (window.activeChatTabType === 'group' && window.activeGroupId) { @@ -1466,95 +2295,84 @@ export function actuallySendMessage(finalMessageToSend) { group_id = window.activeGroupId; } - // Collect prompt information if a prompt is selected let promptInfo = null; if ( - promptSelectionContainer && - promptSelectionContainer.style.display !== "none" && - promptSelect && - promptSelect.selectedIndex > 0 + promptSelectionContainer + && promptSelectionContainer.style.display !== 'none' + && promptSelect + && promptSelect.selectedIndex > 0 ) { const selectedOpt = promptSelect.options[promptSelect.selectedIndex]; if (selectedOpt) { promptInfo = { name: selectedOpt.textContent, id: selectedOpt.value, - content: selectedOpt.dataset?.promptContent || "" + content: selectedOpt.dataset?.promptContent || '', }; } } - // Collect agent information if agents are enabled - let agentInfo = null; - const agentSelectContainer = document.getElementById("agent-select-container"); - const agentSelect = document.getElementById("agent-select"); - if (agentSelectContainer && agentSelectContainer.style.display !== "none" && agentSelect) { - const selectedAgentOption = agentSelect.options[agentSelect.selectedIndex]; - if (selectedAgentOption) { - agentInfo = { - id: selectedAgentOption.dataset.agentId || null, - name: selectedAgentOption.dataset.name || selectedAgentOption.value || '', - display_name: selectedAgentOption.dataset.displayName || selectedAgentOption.textContent, - is_global: selectedAgentOption.dataset.isGlobal === 'true', - is_group: selectedAgentOption.dataset.isGroup === 'true', - group_id: selectedAgentOption.dataset.groupId || null, - group_name: selectedAgentOption.dataset.groupName || null - }; - } - } - - // Get effective scopes from multi-select scope dropdown + const agentInfo = getCurrentAgentSelection(); const scopes = getEffectiveScopes(); - // Determine the correct doc_scope based on selected scopes - let effectiveDocScope = "all"; + let effectiveDocScope = 'all'; if (scopes.personal && scopes.groupIds.length === 0 && scopes.publicWorkspaceIds.length === 0) { - effectiveDocScope = "personal"; + effectiveDocScope = 'personal'; } else if (!scopes.personal && scopes.groupIds.length > 0 && scopes.publicWorkspaceIds.length === 0) { - effectiveDocScope = "group"; + effectiveDocScope = 'group'; } else if (!scopes.personal && scopes.groupIds.length === 0 && scopes.publicWorkspaceIds.length > 0) { - effectiveDocScope = "public"; + effectiveDocScope = 'public'; } - // If documents are selected, determine the actual scope from the documents themselves if (selectedDocumentIds.length > 0) { const docScopes = new Set(); selectedDocumentIds.forEach(docId => { if (personalDocs.find(doc => doc.id === docId || doc.document_id === docId)) { - docScopes.add("personal"); + docScopes.add('personal'); } else if (groupDocs.find(doc => doc.id === docId || doc.document_id === docId)) { - docScopes.add("group"); + docScopes.add('group'); } else if (publicDocs.find(doc => doc.id === docId || doc.document_id === docId)) { - docScopes.add("public"); + docScopes.add('public'); } }); - // Only narrow scope if ALL selected docs are from the same scope if (docScopes.size === 1) { effectiveDocScope = docScopes.values().next().value; console.log(`All selected documents are from scope: ${effectiveDocScope}`); } else if (docScopes.size > 1) { - effectiveDocScope = "all"; + effectiveDocScope = 'all'; console.log(`Selected documents span ${docScopes.size} scopes (${[...docScopes].join(', ')}), keeping scope as "all"`); } } - // Use group IDs from scope selector; fall back to window.activeGroupId for backwards compat const finalGroupIds = scopes.groupIds.length > 0 ? scopes.groupIds : (window.activeGroupId ? [window.activeGroupId] : []); - const finalGroupId = finalGroupIds[0] || window.activeGroupId || null; - const webSearchToggle = document.getElementById("search-web-btn"); - const webSearchEnabled = webSearchToggle ? webSearchToggle.classList.contains("active") : false; - - // Prepare message data object - // Get public workspace IDs from scope selector; fall back to window.activePublicWorkspaceId + const finalGroupId = finalGroupIds[0] || group_id || null; + const webSearchToggle = document.getElementById('search-web-btn'); + const webSearchEnabled = webSearchToggle ? webSearchToggle.classList.contains('active') : false; const finalPublicWorkspaceId = scopes.publicWorkspaceIds[0] || window.activePublicWorkspaceId || null; - - // Get selected tags from chat-documents module const selectedTags = getSelectedTags(); + const documentActionType = getDocumentActionType(); + const comparisonLeftDocumentId = documentActionType === DOCUMENT_ACTION_COMPARISON + ? String(documentComparisonLeftSelect?.value || selectedDocumentId || '').trim() + : ''; + const comparisonRightDocumentIds = documentActionType === DOCUMENT_ACTION_COMPARISON + ? selectedDocumentIds.filter(documentId => documentId !== comparisonLeftDocumentId) + : []; + const documentAction = { + type: documentActionType, + document_ids: documentActionType === DOCUMENT_ACTION_EXHAUSTIVE_REVIEW ? selectedDocumentIds : [], + left_document_id: documentActionType === DOCUMENT_ACTION_COMPARISON ? comparisonLeftDocumentId : '', + right_document_ids: comparisonRightDocumentIds, + doc_scope: effectiveDocScope, + active_group_ids: finalGroupIds, + active_public_workspace_id: scopes.publicWorkspaceIds, + window_unit: 'pages', + max_retries_per_window: 1, + }; - const messageData = { + const requestPayload = { message: finalMessageToSend, - conversation_id: currentConversationId, + conversation_id: conversationId, hybrid_search: hybridSearchEnabled, web_search_enabled: webSearchEnabled, selected_document_id: selectedDocumentId, @@ -1563,7 +2381,7 @@ export function actuallySendMessage(finalMessageToSend) { tags: selectedTags, image_generation: imageGenEnabled, doc_scope: effectiveDocScope, - chat_type: chat_type, + chat_type, active_group_ids: finalGroupIds, active_group_id: finalGroupId, active_public_workspace_ids: scopes.publicWorkspaceIds, @@ -1574,12 +2392,180 @@ export function actuallySendMessage(finalMessageToSend) { model_provider: modelProvider, prompt_info: promptInfo, agent_info: agentInfo, - reasoning_effort: getCurrentReasoningEffort() + reasoning_effort: getCurrentReasoningEffort(), }; + + if (documentActionType !== DOCUMENT_ACTION_NONE) { + requestPayload.document_action = documentAction; + } + + if (documentActionType === DOCUMENT_ACTION_EXHAUSTIVE_REVIEW) { + requestPayload.exhaustive_review = { + enabled: true, + document_ids: selectedDocumentIds, + doc_scope: effectiveDocScope, + active_group_ids: finalGroupIds, + active_public_workspace_id: scopes.publicWorkspaceIds, + }; + } + + return requestPayload; +} + +export function buildCollaborativeInvocationTarget(messageData = {}, explicitInvocationTarget = null) { + if (!messageData || typeof messageData !== 'object') { + return null; + } + + if (explicitInvocationTarget?.target_type === 'agent' || explicitInvocationTarget?.target_type === 'model') { + return { + ...explicitInvocationTarget, + source_mode: 'explicit_tag', + mention_text: explicitInvocationTarget.mention_text || `@${explicitInvocationTarget.display_name}`, + }; + } + + const hasAgentTarget = Boolean( + messageData.agent_info + && (messageData.agent_info.id || messageData.agent_info.name || messageData.agent_info.display_name) + ); + const sourceMode = messageData.image_generation + ? 'image_generation' + : hasAgentTarget + ? 'agent' + : messageData.web_search_enabled + ? 'web_search' + : messageData.hybrid_search + ? 'workspace' + : messageData.prompt_info + ? 'prompt' + : null; + + if (!sourceMode) { + return null; + } + + if (messageData.image_generation) { + return { + target_type: 'image', + display_name: 'Image', + mention_text: '@Image', + source_mode: sourceMode, + }; + } + + if (hasAgentTarget) { + const agentLabel = String( + messageData.agent_info.display_name + || messageData.agent_info.name + || messageData.agent_info.id + || 'Agent' + ).trim() || 'Agent'; + return { + target_type: 'agent', + display_name: agentLabel, + mention_text: `@${agentLabel}`, + source_mode: sourceMode, + }; + } + + const { modelDisplayName } = getCurrentModelSelection(); + return { + target_type: 'model', + display_name: modelDisplayName, + mention_text: `@${modelDisplayName}`, + source_mode: sourceMode, + }; +} + +export function shouldUseCollaborativeAiWorkflow(messageData = {}, explicitInvocationTarget = null) { + return Boolean(buildCollaborativeInvocationTarget(messageData, explicitInvocationTarget)); +} + +export function actuallySendMessage(finalMessageToSend) { + const isCollaborativeConversation = Boolean( + currentConversationId + && window.chatCollaboration?.isCollaborationConversation?.(currentConversationId) + ); + + if (isCollaborativeConversation) { + const tempUserMessageId = `temp_user_${Date.now()}`; + const { + messageData: collaborativeMessageData, + invocationTarget, + explicitInvocationTarget, + displayMessageText, + } = buildCollaborativeSendContext(finalMessageToSend, currentConversationId); + if (invocationTarget && !String(displayMessageText || '').trim()) { + showToast('Add a message after the selected @agent or @model tag.', 'warning'); + return; + } + + const pendingCollaborativeContext = window.chatCollaboration?.getPendingMessageContext?.({ invocationTarget }) || null; + appendMessage("You", displayMessageText, null, tempUserMessageId, false, [], [], [], null, null, pendingCollaborativeContext); + userInput.value = ""; + userInput.style.height = ""; + updateSendButtonVisibility(); + + const collaborativeSendOperation = shouldUseCollaborativeAiWorkflow(collaborativeMessageData, explicitInvocationTarget) + ? window.chatCollaboration.sendCollaborativeAiMessage?.( + displayMessageText, + tempUserMessageId, + collaborativeMessageData, + pendingCollaborativeContext, + ) + : window.chatCollaboration.sendCollaborativeMessage(displayMessageText, tempUserMessageId); + + Promise.resolve(collaborativeSendOperation).catch(error => { + const tempMessage = document.querySelector(`[data-message-id="${tempUserMessageId}"]`); + if (tempMessage) { + tempMessage.remove(); + } + showToast(error.message || 'Failed to send shared message.', 'danger'); + }); + return; + } + + // Generate a temporary message ID for the user message + const tempUserMessageId = `temp_user_${Date.now()}`; + const messageData = buildChatRequestPayload(finalMessageToSend, currentConversationId); + const actionType = String(messageData.document_action?.type || DOCUMENT_ACTION_NONE).trim() || DOCUMENT_ACTION_NONE; + const useDocumentAction = actionType !== DOCUMENT_ACTION_NONE; + const totalSelectedDocuments = Array.isArray(messageData.selected_document_ids) ? messageData.selected_document_ids.length : 0; + + if (actionType === DOCUMENT_ACTION_EXHAUSTIVE_REVIEW && totalSelectedDocuments === 0) { + showToast('Select one or more documents before starting an exhaustive review.', 'warning'); + return; + } + if (actionType === DOCUMENT_ACTION_COMPARISON && totalSelectedDocuments < 2) { + showToast('Select at least two documents before starting a comparison.', 'warning'); + return; + } + if (actionType === DOCUMENT_ACTION_COMPARISON && (!messageData.document_action?.left_document_id || !Array.isArray(messageData.document_action?.right_document_ids) || messageData.document_action.right_document_ids.length === 0)) { + showToast('Choose one left document and at least one right document for comparison.', 'warning'); + return; + } + if (useDocumentAction && totalSelectedDocuments > CHAT_EXHAUSTIVE_REVIEW_MAX_DOCUMENTS) { + showToast( + `Chat document actions support up to ${CHAT_EXHAUSTIVE_REVIEW_MAX_DOCUMENTS} documents. Use workflows for up to ${WORKFLOW_EXHAUSTIVE_REVIEW_MAX_DOCUMENTS} documents.`, + 'warning' + ); + return; + } + + // Append user message first with temporary ID + appendMessage("You", finalMessageToSend, null, tempUserMessageId); + userInput.value = ""; + userInput.style.height = ""; + // Update send button visibility after clearing input + updateSendButtonVisibility(); sendMessageWithStreaming( messageData, tempUserMessageId, - currentConversationId + currentConversationId, + { + endpoint: useDocumentAction ? '/api/chat/document-action/stream' : '/api/chat/stream', + } ); return; @@ -1636,6 +2622,10 @@ if (sendBtn) { if (userInput) { userInput.addEventListener("keydown", function (e) { + if (window.chatCollaboration?.handleComposerKeydown?.(e)) { + return; + } + // Check if Enter key is pressed if (e.key === "Enter") { // Check if Shift key is NOT pressed @@ -1650,9 +2640,18 @@ if (userInput) { }); // Monitor input changes for send button visibility - userInput.addEventListener("input", updateSendButtonVisibility); - userInput.addEventListener("focus", updateSendButtonVisibility); - userInput.addEventListener("blur", updateSendButtonVisibility); + userInput.addEventListener("input", () => { + updateSendButtonVisibility(); + window.chatCollaboration?.handleComposerInput?.(); + }); + userInput.addEventListener("focus", () => { + updateSendButtonVisibility(); + window.chatCollaboration?.handleComposerInput?.(); + }); + userInput.addEventListener("blur", () => { + updateSendButtonVisibility(); + window.chatCollaboration?.handleComposerBlur?.(); + }); } // Monitor prompt selection changes @@ -1660,6 +2659,8 @@ if (promptSelect) { promptSelect.addEventListener("change", updateSendButtonVisibility); } +updateDocumentActionControls(); + // Helper function to update user message ID after backend response export function updateUserMessageId(tempId, realId) { console.log(`🔄 Updating message ID: ${tempId} -> ${realId}`); @@ -1720,7 +2721,12 @@ export function updateUserMessageId(tempId, realId) { console.error(`❌ ID update verification failed: ${realId} not found in DOM`); } } else { - console.error(`❌ Message div with temp ID ${tempId} not found for update`); + const existingRealMessageDiv = document.querySelector(`[data-message-id="${realId}"]`); + if (existingRealMessageDiv) { + console.info(`ℹ️ Message div for temp ID ${tempId} was already reconciled to ${realId}`); + } else { + console.warn(`⚠️ Message div with temp ID ${tempId} not found for update`); + } } } @@ -1892,6 +2898,59 @@ function attachUserMessageEventListeners(messageDiv, messageId, messageContent) } } +function attachCollaboratorMessageEventListeners(messageDiv, fullMessageObject, messageContent) { + const dropdownReplyBtn = messageDiv.querySelector(".dropdown-reply-btn"); + if (dropdownReplyBtn) { + dropdownReplyBtn.addEventListener("click", e => { + e.preventDefault(); + const currentMessageId = messageDiv.getAttribute("data-message-id"); + window.chatCollaboration?.replyToMessage?.({ + ...(fullMessageObject || {}), + id: currentMessageId, + content: messageContent, + sender: fullMessageObject?.sender || fullMessageObject?.metadata?.sender || { + display_name: messageDiv.dataset.replySenderName || "Participant", + }, + }); + }); + } + + const metadataToggleBtn = messageDiv.querySelector(".metadata-toggle-btn"); + if (metadataToggleBtn) { + metadataToggleBtn.addEventListener("click", () => { + const currentMessageId = messageDiv.getAttribute("data-message-id"); + toggleUserMessageMetadata(messageDiv, currentMessageId); + }); + } + + const dropdownToggle = messageDiv.querySelector(".message-footer .dropdown button[data-bs-toggle='dropdown']"); + const dropdownMenu = messageDiv.querySelector(".message-footer .dropdown-menu"); + if (dropdownToggle && dropdownMenu) { + dropdownToggle.addEventListener("show.bs.dropdown", () => { + const localChatbox = document.getElementById("chatbox"); + if (localChatbox) { + dropdownMenu.remove(); + localChatbox.appendChild(dropdownMenu); + + const rect = dropdownToggle.getBoundingClientRect(); + const chatboxRect = localChatbox.getBoundingClientRect(); + dropdownMenu.style.position = "absolute"; + dropdownMenu.style.top = `${rect.bottom - chatboxRect.top + localChatbox.scrollTop + 2}px`; + dropdownMenu.style.left = `${rect.left - chatboxRect.left}px`; + dropdownMenu.style.zIndex = "9999"; + } + }); + + dropdownToggle.addEventListener("hidden.bs.dropdown", () => { + const dropdown = messageDiv.querySelector(".message-footer .dropdown"); + if (dropdown && dropdownMenu.parentElement !== dropdown) { + dropdownMenu.remove(); + dropdown.appendChild(dropdownMenu); + } + }); + } +} + // Function to toggle user message metadata drawer function toggleUserMessageMetadata(messageDiv, messageId) { console.log(`🔀 Toggling metadata for message: ${messageId}`); @@ -2083,6 +3142,144 @@ function formatMetadataForDrawer(metadata) { return `${escapeHtml(classification)} (?)`; } } + + if (metadata.message_details) { + content += '
'; + content += '
Message Details
'; + content += '
'; + + if (metadata.message_details.message_id) { + content += `
Message ID: ${escapeHtml(metadata.message_details.message_id)}
`; + } + if (metadata.message_details.conversation_id) { + content += `
Conversation ID: ${escapeHtml(metadata.message_details.conversation_id)}
`; + } + if (metadata.message_details.role) { + content += `
Stored Role: ${createInfoBadge(metadata.message_details.role, 'primary')}
`; + } + if (metadata.message_details.display_role) { + content += `
Display Role: ${createInfoBadge(metadata.message_details.display_role, 'info')}
`; + } + if (metadata.message_details.message_kind) { + content += `
Message Kind: ${createInfoBadge(metadata.message_details.message_kind, 'secondary')}
`; + } + if (metadata.message_details.source_role) { + content += `
Original Role: ${createInfoBadge(metadata.message_details.source_role, 'warning')}
`; + } + if (metadata.message_details.timestamp) { + content += `
Timestamp: ${escapeHtml(new Date(metadata.message_details.timestamp).toLocaleString())}
`; + } + if (metadata.message_details.explicit_ai_invocation !== undefined) { + content += `
Explicit AI Invocation: ${createStatusBadge(Boolean(metadata.message_details.explicit_ai_invocation))}
`; + } + + content += '
'; + } + + if (metadata.reply_context) { + content += '
'; + content += '
Reply Context
'; + content += '
'; + if (metadata.reply_context.message_id) { + content += `
Reply Message ID: ${escapeHtml(metadata.reply_context.message_id)}
`; + } + if (metadata.reply_context.sender_display_name) { + content += `
Replying To: ${escapeHtml(metadata.reply_context.sender_display_name)}
`; + } + if (metadata.reply_context.content_preview) { + content += `
Preview:
${escapeHtml(metadata.reply_context.content_preview)}
`; + } + content += '
'; + } + + if (Array.isArray(metadata.mentions) && metadata.mentions.length > 0) { + content += '
'; + content += '
Tagged Participants
'; + content += '
'; + metadata.mentions.forEach(participant => { + content += `@${escapeHtml(participant.display_name || participant.email || participant.user_id || 'Participant')}`; + }); + content += '
'; + } + + if (metadata.collaboration) { + content += '
'; + content += '
Shared Conversation
'; + content += '
'; + if (metadata.collaboration.conversation_title) { + content += `
Conversation: ${escapeHtml(metadata.collaboration.conversation_title)}
`; + } + if (metadata.collaboration.chat_type) { + content += `
Collaboration Type: ${createInfoBadge(metadata.collaboration.chat_type, 'success')}
`; + } + if (metadata.collaboration.participant_count !== undefined) { + content += `
Participants: ${escapeHtml(metadata.collaboration.participant_count)}
`; + } + content += '
'; + } + + if (metadata.file_details) { + content += '
'; + content += '
File Details
'; + content += '
'; + if (metadata.file_details.filename) { + content += `
Filename: ${escapeHtml(metadata.file_details.filename)}
`; + } + if (metadata.file_details.source_message_id) { + content += `
Source Message ID: ${escapeHtml(metadata.file_details.source_message_id)}
`; + } + if (metadata.file_details.is_table !== undefined && metadata.file_details.is_table !== null) { + content += `
Table Data: ${createStatusBadge(Boolean(metadata.file_details.is_table))}
`; + } + content += '
'; + } + + if (metadata.image_details) { + content += '
'; + content += '
Image Details
'; + content += '
'; + if (metadata.image_details.filename) { + content += `
Filename: ${escapeHtml(metadata.image_details.filename)}
`; + } + if (metadata.image_details.image_url) { + content += `
Image URL: ${escapeHtml(metadata.image_details.image_url)}
`; + } + if (metadata.image_details.is_user_upload !== undefined) { + content += `
User Upload: ${createStatusBadge(Boolean(metadata.image_details.is_user_upload))}
`; + } + if (metadata.image_details.extracted_text) { + content += `
Extracted Text:
${escapeHtml(metadata.image_details.extracted_text)}
`; + } + if (metadata.image_details.vision_analysis) { + content += `
Vision Analysis:
${escapeHtml(metadata.image_details.vision_analysis)}
`; + } + content += '
'; + } + + if (metadata.generation_details) { + content += '
'; + content += '
Generation Details
'; + content += '
'; + if (metadata.generation_details.selected_model) { + content += `
Model: ${escapeHtml(metadata.generation_details.selected_model)}
`; + } + if (metadata.generation_details.agent_display_name || metadata.generation_details.agent_name) { + content += `
Agent: ${escapeHtml(metadata.generation_details.agent_display_name || metadata.generation_details.agent_name)}
`; + } + if (metadata.generation_details.augmented !== undefined) { + content += `
Augmented: ${createStatusBadge(Boolean(metadata.generation_details.augmented))}
`; + } + if (metadata.generation_details.document_citation_count !== undefined) { + content += `
Document Citations: ${escapeHtml(metadata.generation_details.document_citation_count)}
`; + } + if (metadata.generation_details.web_citation_count !== undefined) { + content += `
Web Citations: ${escapeHtml(metadata.generation_details.web_citation_count)}
`; + } + if (metadata.generation_details.agent_citation_count !== undefined) { + content += `
Agent Citations: ${escapeHtml(metadata.generation_details.agent_citation_count)}
`; + } + content += '
'; + } // User Information Section if (metadata.user_info) { @@ -2256,7 +3453,7 @@ function formatMetadataForDrawer(metadata) { content += '
'; metadata.uploaded_images.forEach((image, index) => { - const imageId = `image-${messageId || Date.now()}-${index}`; + const imageId = `image-${metadata.message_details?.message_id || Date.now()}-${index}`; content += `
+ +
+
+
+ Simple Chat Action Capabilities +
+
+

Enable only the Simple Chat operations this agent should expose. These settings are saved per agent in additional settings.

+
+
+
+
+ +
+
+
+ Microsoft Graph Action Capabilities +
+
+

Enable only the Microsoft Graph operations this agent should expose. These settings are saved per agent in additional settings.

+
+
+
+
+ +
+
+
+ Chart Action Types +
+
+

Enable only the chart types this agent should expose from the selected chart action. These settings are saved per agent in additional settings.

+
+
+
+
diff --git a/application/single_app/templates/_plugin_modal.html b/application/single_app/templates/_plugin_modal.html index 0a5b6f35..a1f02cbc 100644 --- a/application/single_app/templates/_plugin_modal.html +++ b/application/single_app/templates/_plugin_modal.html @@ -189,6 +189,204 @@
API Information
+ +
+
+ Built-in action: SimpleChat does not require a URL or external credentials. + The capabilities below become the default operations exposed by this action. Agents can narrow them further per assignment. +
+ +
+ +
+
+
+ +
+
+ Built-in action: Microsoft Graph uses the signed-in user's delegated permissions and the standard Graph endpoint. + The capabilities below become the default operations exposed by this action. Agents can narrow them further per assignment. +
+ +
+ +
+
+
+ +
+
+ Built-in action: This action prepares inline OpenLayers maps in chat and proxies Azure Maps raster tiles through SimpleChat. + Only the Azure Maps subscription key is required here. +
+ +
+
+ +
+ + +
The browser receives only a short-lived proxy token. The raw subscription key stays in the stored action configuration.
+
+
+
+
+ +
+
+ Built-in action: Charts are rendered with SimpleChat's internal Chart.js bundle and the current user's chat session. + The chart types below become the default visualization options exposed by this action. Agents can narrow them further per assignment. +
+ +
+ +
+
+
+ +
+
+ + This action uses SimpleChat's internal document search and the current user's access. No external URL or stored credentials are required. +
+ +
+
+ +
+
+ + +
Used when the action is called without an explicit scope filter.
+
+
+ + +
Maximum number of ranked results returned when a caller does not set top_n.
+
+
+
+
+ +
+
+ +

Chunk retrieval returns all chunks by default. Configure preferred windowing so downstream tools can plan comparisons and summaries consistently.

+
+
+ + +
+
+ + +
Optional fixed number of pages or chunks per window. Takes precedence over percent.
+
+
+ + +
Optional percent of the document per window when size is not set.
+
+
+
+
+ +
+
+ +
+ + +
Applied when a summarization call does not include its own focus instructions.
+
+
+
+ + +
Target length for first-pass summaries over each chunk window.
+
+
+ + +
Target length for the final summary output.
+
+
+
+
+
+ +
+
+ Container-scoped action: Configure one Azure Blob container and optionally narrow the action to a blob prefix. + The connection string can be stored in Key Vault through the normal action secret flow, while the capability toggles below define the default operations exposed by this action. +
+ +
+ +
+ + +
Use an Azure Storage account connection string with access to the target container.
+
+
+
+ + +
The action is scoped to this single container.
+
+
+ + +
Optional. Restrict list, read, and upload operations to blobs under this prefix.
+
+
+
+ +
+ +
+
+ +
+ +
+ +
+
+
+ +
+
+
+
@@ -315,8 +513,8 @@
API Information
@@ -443,6 +641,103 @@
API Information
+ + +
+ + +
+ + +
+ + +
Use the Azure Cosmos DB account endpoint for the API for NoSQL account.
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
Enter the container partition key path exactly as configured in Cosmos DB.
+
+
+ +
+ + +
+ + +
Managed identity avoids stored secrets. Account keys can be stored in Key Vault when app secret storage is enabled.
+
+ +
+ + +
If this action already uses a Key Vault-backed key, leave the stored value unchanged to preserve the existing secret.
+
+ +
+ Managed Identity uses Azure AD authentication without storing credentials. Assign an Azure Cosmos DB built-in data reader role to the application identity for the target account. +
+
+ +
+ + +
+ + +
Optional. Add one field name per line so the model knows which document properties are relevant.
+
+ +
+
+ + +
Hard cap for documents returned per query.
+
+
+ + +
Absolute timeout for Cosmos client requests.
+
+
+
+ +
+
+ +
+
+ +
+
+
@@ -615,6 +910,235 @@
+ + + + + + + + + + + + + +

Disable a global agent to keep it saved for admins while hiding it from runtime selection until it is re-enabled.

{% if settings.orchestration_type == "default_agent" %} @@ -1216,6 +1217,7 @@
Global Actions
+

Disable a global action to keep the configuration without exposing it to runtime action loading until it is re-enabled.

{% if settings.per_user_semantic_kernel %}
@@ -1462,15 +1464,39 @@
Logo Settings
+
+
+ + {{ settings.landing_page_logo_scale_percent | default(100) }}% +
+ +
+ 50% + 500% +
+ + Adjust the logo size on the home page only. This does not change the logo size in the top or sidebar navigation. + +
- This logo will be displayed in light mode. + This logo will be displayed in light mode and stored at up to 500px tall so the main page can render it sharply without keeping oversized assets in settings.
- This logo will be displayed in dark mode. If not provided, the light mode logo will be used in both themes. + This logo will be displayed in dark mode. If not provided, the light mode logo will be used in both themes. Dark logos are also stored at up to 500px tall.
@@ -3914,6 +3940,17 @@
+
+ + + +
diff --git a/application/single_app/templates/base.html b/application/single_app/templates/base.html index 16028a63..0ddaa980 100644 --- a/application/single_app/templates/base.html +++ b/application/single_app/templates/base.html @@ -283,6 +283,334 @@
+ {% if session.get('user') %} + + + {% endif %} diff --git a/application/single_app/templates/chats.html b/application/single_app/templates/chats.html index f47936a8..063ec5fc 100644 --- a/application/single_app/templates/chats.html +++ b/application/single_app/templates/chats.html @@ -5,6 +5,7 @@ {% block head %} + {% if app_settings.enable_speech_to_text_input %} {% endif %} @@ -266,7 +267,11 @@
Conversations
@@ -615,6 +620,19 @@
+
+ + +
+ + +
All other selected documents become right-side comparison targets.
+
+
@@ -632,9 +650,19 @@
{% endif %}
+
+
+
+
+
+ +
+
{% if app_settings.enable_speech_to_text_input %} @@ -874,6 +902,77 @@
+ + + + + + + +
aria-controls="endpoints-tab" aria-selected="false" > - Your Endpoints + Endpoints {% endif %} @@ -953,6 +1064,67 @@

Personal Workspace

+ {% endif %} + + {% if settings.allow_user_workflows %} + +
+
+
+
+ +
+ + + + +
+
+

Create personal workflows that run a selected agent or model manually or on an interval schedule.

+
+
+ +
+
+
+
+
+
+
+ + + + + + + + + + + + + + + +
NameRunnerTriggerLast RunActions
+
Loading...
+ Loading workflows... +
+
+
+
+
+
+
+
+ + {% endif %} + {% if settings.allow_user_custom_endpoints and settings.enable_multi_model_endpoints %}
Your Endpoints
{% endif %} - {% endif %} @@ -1025,6 +1196,266 @@ + {% if settings.allow_user_workflows %} + + + + + + {% endif %} +