diff --git a/application/single_app/app.py b/application/single_app/app.py index fdbaee55d..2b9073881 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 c978bf9ed..8af8aa344 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 000000000..f6c3d9d6b --- /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 7196cfe80..73269f133 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.039" SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production') @@ -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 987a3dec9..8313cde64 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_collaboration.py b/application/single_app/functions_collaboration.py new file mode 100644 index 000000000..f9441e248 --- /dev/null +++ b/application/single_app/functions_collaboration.py @@ -0,0 +1,2286 @@ +# 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_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': 'group' if is_group_collaboration_conversation(conversation_doc) else 'personal_single_user', + '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))), + '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_documents.py b/application/single_app/functions_documents.py index 7c6e4a272..85be6ffcc 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,134 @@ 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, + ) + select_fields = [ + 'id', + 'document_id', + 'chunk_text', + 'chunk_id', + 'file_name', + 'user_id', + 'group_id', + 'public_workspace_id', + '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'), + 'group_id': result.get('group_id'), + 'public_workspace_id': result.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 +2828,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_image_messages.py b/application/single_app/functions_image_messages.py new file mode 100644 index 000000000..175c7af21 --- /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_msgraph_operations.py b/application/single_app/functions_msgraph_operations.py new file mode 100644 index 000000000..8f8b17689 --- /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 80574160f..2a24f5f7b 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 000000000..b938c74aa --- /dev/null +++ b/application/single_app/functions_personal_workflows.py @@ -0,0 +1,584 @@ +# 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_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 _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')) + ) + 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, + '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 6851778f0..e7d117a64 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 000000000..f00fe0caf --- /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 8d09ee614..a16868df4 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, @@ -264,6 +265,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 000000000..cb3d6527b --- /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 7d20441b6..91df9b981 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 000000000..0caeadbf1 --- /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 000000000..5421417bb --- /dev/null +++ b/application/single_app/functions_workflow_runner.py @@ -0,0 +1,1144 @@ +# 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 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_keyvault import SecretReturnType, keyvault_model_endpoint_get_helper +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 _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 '') + + +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 _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 = str(workflow.get('name') or 'Workflow').strip() 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 + success = bool(run_record.get('success')) + title_prefix = f'{priority.capitalize()} priority workflow alert' + response_preview = str(run_record.get('response_preview') or '').strip() + error_text = str(run_record.get('error') or '').strip() + + if success: + title = f'{title_prefix}: {workflow_name}' + message = _summarize_workflow_alert_text( + response_preview or f'{workflow_name} completed from the {trigger_source} trigger.' + ) + else: + title = f'{title_prefix}: {workflow_name} failed' + message = _summarize_workflow_alert_text( + error_text or f'{workflow_name} failed from the {trigger_source} trigger.' + ) + + 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, + '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=title, + message=message, + 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()) + 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, + }, + '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 {} + 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'), + '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 {}, + }, + '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 _execute_model_workflow(workflow, settings, run_id=None, thought_tracker=None): + 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() + + 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', + ) + + if endpoint_id and model_id: + client, deployment_name, provider = _build_multi_endpoint_client(user_id, endpoint_id, model_id, settings) + else: + 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: + client, deployment_name, provider = _build_multi_endpoint_client(user_id, default_endpoint_id, default_model_id, settings) + else: + client, deployment_name, provider = _build_legacy_default_client(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_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) + 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, + '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', + ) + + if 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, + ) + + _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'), + '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 abfbcb901..c7d6432fc 100644 --- a/application/single_app/json_schema_validation.py +++ b/application/single_app/json_schema_validation.py @@ -6,6 +6,14 @@ from jsonschema import validate, ValidationError, Draft7Validator, Draft6Validator, RefResolver 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', + 'msgraph': 'https://graph.microsoft.com', + 'simplechat': 'simplechat://internal', + 'search': 'internal://document-search', + 'document_search': 'internal://document-search', +} PLUGIN_STORAGE_MANAGED_FIELDS = { '_attachments', @@ -45,20 +53,25 @@ 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 + + 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 +85,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 d59a8d80c..d5a76f273 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 561f1d181..c585c3f4d 100644 --- a/application/single_app/route_backend_agents.py +++ b/application/single_app/route_backend_agents.py @@ -1510,6 +1510,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 e16d72423..cbc559315 100644 --- a/application/single_app/route_backend_chats.py +++ b/application/single_app/route_backend_chats.py @@ -6935,24 +6935,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, @@ -9469,6 +9457,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 @@ -10417,7 +10416,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 +10506,9 @@ 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 + # Stream the response accumulated_content = "" token_usage_data = None # Will be populated from final stream chunk @@ -10902,6 +10919,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 +10942,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, diff --git a/application/single_app/route_backend_collaboration.py b/application/single_app/route_backend_collaboration.py new file mode 100644 index 000000000..48facc3aa --- /dev/null +++ b/application/single_app/route_backend_collaboration.py @@ -0,0 +1,1643 @@ +# 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_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, + ) + + try: + source_conversation_doc = cosmos_conversations_container.read_item( + item=source_conversation_id, + partition_key=source_conversation_id, + ) + updated_conversation_doc, _ = sync_collaboration_conversation_metadata_from_source( + updated_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( + updated_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(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 086019f1e..b3237bbbd 100644 --- a/application/single_app/route_backend_conversation_export.py +++ b/application/single_app/route_backend_conversation_export.py @@ -17,6 +17,13 @@ from functions_appinsights import log_event from functions_authentication import * from functions_chat import sort_messages_by_thread +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 +91,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 +275,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 +296,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 +377,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', []), diff --git a/application/single_app/route_backend_conversations.py b/application/single_app/route_backend_conversations.py index 23f1a0714..2d1aae589 100644 --- a/application/single_app/route_backend_conversations.py +++ b/application/single_app/route_backend_conversations.py @@ -5,10 +5,12 @@ 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 @@ -124,90 +126,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 +164,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 +222,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 +719,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 diff --git a/application/single_app/route_backend_groups.py b/application/single_app/route_backend_groups.py index 0e35d211b..a848ffd76 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 8fe8dd580..5787ccb27 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 115bf2828..5e58345ba 100644 --- a/application/single_app/route_backend_plugins.py +++ b/application/single_app/route_backend_plugins.py @@ -3,6 +3,9 @@ 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 @@ -43,6 +46,37 @@ log_action_update, log_action_deletion, ) +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']: + plugin_payload.setdefault('endpoint', f'sql://{plugin_type}') + elif plugin_type == MSGRAPH_PLUGIN_TYPE: + plugin_payload.setdefault('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']: + plugin_payload.setdefault('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 == SIMPLECHAT_PLUGIN_TYPE: + plugin_payload.setdefault('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,6 +179,20 @@ def get_plugin_types(): 'connection_string': ':memory:', 'metadata': {'description': 'Example SQL plugin'} } + 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 any(x in module_name.lower() for x in ['azure_function', 'blob_storage', 'queue_storage']): safe_manifest = { 'endpoint': 'https://example.azure.com', @@ -230,8 +278,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 +287,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 +315,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 +415,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 +596,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 +655,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) @@ -767,6 +806,7 @@ def add_plugin(): try: plugins = get_global_actions() new_plugin = request.json + _apply_plugin_runtime_defaults(new_plugin) # Strict validation with dynamic allowed types allowed_types = discover_plugin_types() @@ -822,6 +862,7 @@ def edit_plugin(plugin_name): try: plugins = get_global_actions() updated_plugin = request.json + _apply_plugin_runtime_defaults(updated_plugin) # Strict validation with dynamic allowed types allowed_types = discover_plugin_types() @@ -1193,3 +1234,148 @@ def test_sql_connection(): 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 000000000..5a5a98bc1 --- /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 69aed2710..406a59ae9 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: @@ -40,6 +64,8 @@ def api_get_message_thoughts(conversation_id, message_id): '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 +89,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 = [] @@ -78,6 +115,8 @@ def api_get_pending_thoughts(conversation_id): '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 459aa800c..80e26ae0b 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()) @@ -157,6 +238,7 @@ def user_settings(): 'ttsEnabled', 'ttsVoice', 'ttsSpeed', 'ttsAutoplay', # Tutorial visibility settings 'showTutorialButtons', + 'recentCollaborators', # 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 000000000..d708e69bd --- /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: 1500\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(1) + + +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_frontend_admin_settings.py b/application/single_app/route_frontend_admin_settings.py index 129dfcde6..7e9bf5324 100644 --- a/application/single_app/route_frontend_admin_settings.py +++ b/application/single_app/route_frontend_admin_settings.py @@ -229,6 +229,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: diff --git a/application/single_app/route_frontend_chats.py b/application/single_app/route_frontend_chats.py index 2679f57b4..a2854347c 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 d2b428fe2..24e72c0e4 100644 --- a/application/single_app/route_frontend_conversations.py +++ b/application/single_app/route_frontend_conversations.py @@ -1,9 +1,17 @@ # route_frontend_conversations.py from config import * +from functions_appinsights import log_event 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, +) +from functions_image_messages import hydrate_image_messages from functions_message_artifacts import ( build_message_artifact_payload_map, filter_assistant_artifact_items, @@ -126,68 +134,10 @@ 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: @@ -253,7 +203,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 +239,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 3a2ca4b54..187b5421e 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,6 +46,16 @@ 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_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 @@ -792,6 +803,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 +842,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 +926,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 +982,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 +1073,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 +1090,56 @@ 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') == 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) + + 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 +1402,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 +1646,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 +1670,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"], @@ -1692,6 +1844,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: 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 000000000..d760e28a3 --- /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 000000000..639671ad4 --- /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 928f78518..0d79e1077 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 e1b2023e8..2352a4c0c 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 4e64797ba..e901b6f5c 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,7 @@ from typing import Dict, Any, List, Optional, Tuple from semantic_kernel_plugins.base_plugin import BasePlugin from functions_appinsights import log_event +from functions_simplechat_operations import SIMPLECHAT_DEFAULT_ENDPOINT class PluginHealthChecker: @@ -60,11 +61,44 @@ 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'") 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 dea35f222..c070081ab 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 c400ed11e..35704cbaa 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 000000000..b7c374aae --- /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/static/css/chats.css b/application/single_app/static/css/chats.css index 1ab7b2829..74c5d45f0 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 */ diff --git a/application/single_app/static/css/sidebar.css b/application/single_app/static/css/sidebar.css index 1b3f7eb81..1c2f76e77 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 000000000..f7a379cdc --- /dev/null +++ b/application/single_app/static/css/workflow-activity.css @@ -0,0 +1,370 @@ +.workflow-activity-page { + --workflow-activity-accent: #1f6feb; + --workflow-activity-accent-soft: rgba(31, 111, 235, 0.12); + --workflow-activity-surface: rgba(255, 255, 255, 0.94); + --workflow-activity-border: rgba(15, 23, 42, 0.08); + --workflow-activity-shadow: 0 22px 50px rgba(15, 23, 42, 0.08); +} + +.workflow-activity-hero, +.workflow-activity-timeline-panel, +.workflow-activity-detail-panel { + background: + radial-gradient(circle at top right, rgba(31, 111, 235, 0.12), transparent 32%), + linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(247, 249, 252, 0.98)); + 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: rgba(255, 255, 255, 0.82); + border: 1px solid rgba(31, 41, 55, 0.08); +} + +.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: rgba(255, 255, 255, 0.8); + border: 1px solid rgba(31, 41, 55, 0.08); +} + +.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: start; +} + +.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, rgba(31, 111, 235, 0.28), rgba(15, 23, 42, 0.12)); +} + +.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: rgba(31, 111, 235, 0.18); +} + +.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: rgba(31, 111, 235, 0.18); +} + +.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 rgba(255, 255, 255, 0.95); + 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 rgba(15, 23, 42, 0.08); + border-radius: 1.15rem; + background: rgba(255, 255, 255, 0.92); + 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 rgba(15, 23, 42, 0.12); + border-radius: 1.25rem; + padding: 2.25rem 1.5rem; + text-align: center; + background: rgba(248, 250, 252, 0.8); +} + +.workflow-activity-empty-icon { + font-size: 2rem; + color: var(--workflow-activity-accent); + margin-bottom: 0.9rem; +} + +.workflow-activity-detail-sticky { + position: sticky; + top: 1rem; +} + +.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: rgba(248, 250, 252, 0.88); + border: 1px solid rgba(15, 23, 42, 0.07); +} + +.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; +} + +.workflow-activity-event-item { + border-radius: 0.85rem; + padding: 0.75rem 0.85rem; + background: rgba(255, 255, 255, 0.9); + border: 1px solid rgba(15, 23, 42, 0.06); +} + +.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) { + .workflow-activity-layout { + grid-template-columns: 1fr; + } + + .workflow-activity-detail-sticky { + position: static; + } +} + +@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/js/agent_modal_stepper.js b/application/single_app/static/js/agent_modal_stepper.js index f66586d78..41d4bfe2f 100644 --- a/application/single_app/static/js/agent_modal_stepper.js +++ b/application/single_app/static/js/agent_modal_stepper.js @@ -4,6 +4,112 @@ 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.' + } +]; + export class AgentModalStepper { constructor(isAdmin = false, options = {}) { this.currentStep = 1; @@ -15,6 +121,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 +679,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 +703,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 +1412,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 +1753,272 @@ export class AgentModalStepper { } else { if (summaryDiv) summaryDiv.classList.add('d-none'); } + + this.renderSimpleChatCapabilitySections(); + this.renderMsGraphCapabilitySections(); + } + + 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); + }); } initializeActionSearch(actions) { 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 000000000..b310be402 --- /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 c8438617a..dac9ad1c1 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 dfb5e0b72..07aa27a8b 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 560571e92..6adc27d9f 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'); @@ -594,11 +670,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 +695,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) => { @@ -671,18 +760,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 +789,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 +819,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 +962,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 +1011,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 +1069,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 +1120,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 +1302,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 +1321,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 +1391,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 +1521,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 +1547,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 +1609,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 +2123,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 +2149,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 +2184,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' @@ -1900,6 +2344,7 @@ window.chatConversations = { markConversationRead, setConversationUnreadState, deleteConversation, + removeConversationFromUi, toggleConversationSelection, deleteSelectedConversations, bulkPinConversations, @@ -2016,6 +2461,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-export.js b/application/single_app/static/js/chat/chat-export.js index f42e0123f..03d5f20af 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-messages.js b/application/single_app/static/js/chat/chat-messages.js index d350eb3ae..1bcfc3dfa 100644 --- a/application/single_app/static/js/chat/chat-messages.js +++ b/application/single_app/static/js/chat/chat-messages.js @@ -554,6 +554,349 @@ 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 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 appendMessage( sender, messageContent, @@ -576,6 +919,7 @@ export function appendMessage( let avatarImg = ""; let avatarAltText = ""; + let avatarHtml = ""; let messageClass = ""; // <<< ENSURE THIS IS DECLARED HERE let senderLabel = ""; let messageContentHtml = ""; @@ -780,6 +1124,9 @@ export function appendMessage(
`; + messageDiv.dataset.replySenderName = stripHtmlTags(senderLabel).replace(/\s+/g, " ").trim() || "AI"; + messageDiv.dataset.replyPreviewText = buildPlainTextPreview(messageContent); + messageDiv.classList.add(messageClass); // Add AI message class chatbox.appendChild(messageDiv); // Append AI message @@ -1035,11 +1382,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 +1481,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 +1537,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 +1606,11 @@ export function appendMessage( sender === "You" || sender === "File" ? "flex-row-reverse" : "" }"> ${ - avatarImg - ? `${avatarAltText}` - : "" + avatarHtml + ? avatarHtml + : avatarImg + ? `${avatarAltText}` + : "" }
@@ -1223,12 +1618,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 +1658,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 +1815,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 +1829,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 +2153,66 @@ 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 || "" - }; - } - } - - // 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 + content: selectedOpt.dataset?.promptContent || '', }; } } - // 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 messageData = { + return { message: finalMessageToSend, - conversation_id: currentConversationId, + conversation_id: conversationId, hybrid_search: hybridSearchEnabled, web_search_enabled: webSearchEnabled, selected_document_id: selectedDocumentId, @@ -1563,7 +2221,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,8 +2232,134 @@ export function actuallySendMessage(finalMessageToSend) { model_provider: modelProvider, prompt_info: promptInfo, agent_info: agentInfo, - reasoning_effort: getCurrentReasoningEffort() + reasoning_effort: getCurrentReasoningEffort(), + }; +} + +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()}`; + + // 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(); + const messageData = buildChatRequestPayload(finalMessageToSend, currentConversationId); sendMessageWithStreaming( messageData, tempUserMessageId, @@ -1636,6 +2420,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 +2438,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 @@ -1720,7 +2517,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 +2694,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 +2938,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 +3249,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.

+
+
+
+
diff --git a/application/single_app/templates/_plugin_modal.html b/application/single_app/templates/_plugin_modal.html index 0a5b6f35e..fb83ca2ce 100644 --- a/application/single_app/templates/_plugin_modal.html +++ b/application/single_app/templates/_plugin_modal.html @@ -189,6 +189,119 @@
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. +
+ +
+ +
+
+
+ +
+
+ + 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.
+
+
+
+
+
@@ -443,6 +556,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 +825,163 @@
+ + + + + + + + + diff --git a/application/single_app/templates/base.html b/application/single_app/templates/base.html index 16028a639..c29d41b6e 100644 --- a/application/single_app/templates/base.html +++ b/application/single_app/templates/base.html @@ -283,6 +283,333 @@
+ {% if session.get('user') %} + + + {% endif %} diff --git a/application/single_app/templates/chats.html b/application/single_app/templates/chats.html index f47936a83..e91d705b5 100644 --- a/application/single_app/templates/chats.html +++ b/application/single_app/templates/chats.html @@ -266,7 +266,11 @@
Conversations
@@ -632,9 +636,19 @@
{% endif %}
+
+
+
+
+
+ +
+
{% if app_settings.enable_speech_to_text_input %} @@ -874,6 +888,77 @@
+ + + + + + + +
+ {% endif %} + + {% if settings.allow_user_workflows %} + +
+
+
+
+
+
Your 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 +1137,183 @@ + {% if settings.allow_user_workflows %} + + + + + + {% endif %} +