diff --git a/.github/workflows/docker_image_publish.yml b/.github/workflows/docker_image_publish.yml index f9324f20c..94255a6e3 100644 --- a/.github/workflows/docker_image_publish.yml +++ b/.github/workflows/docker_image_publish.yml @@ -25,16 +25,6 @@ jobs: login-server: ${{ secrets.ACR_LOGIN_SERVER }} - uses: actions/checkout@v3 - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: 20 - - name: Install Ajv - run: npm install ajv@^8.0.0 ajv-cli@^5.0.0 - - name: Install Ajv - run: npm install ajv@^8.0.0 ajv-formats - - name: Generate standalone JSON schema validators - run: node scripts/generate-validators.mjs - name: Build the Docker image run: docker build . --file application/single_app/Dockerfile --tag ${{ secrets.ACR_LOGIN_SERVER }}/simple-chat:$(date +'%Y-%m-%d')_$GITHUB_RUN_NUMBER; diff --git a/.github/workflows/docker_image_publish_dev.yml b/.github/workflows/docker_image_publish_dev.yml index 9882527a6..e5fb31a05 100644 --- a/.github/workflows/docker_image_publish_dev.yml +++ b/.github/workflows/docker_image_publish_dev.yml @@ -26,14 +26,6 @@ jobs: login-server: ${{ secrets.ACR_LOGIN_SERVER }} - uses: actions/checkout@v3 - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: 20 - - name: Install Ajv - run: npm install ajv@^8.0.0 ajv-formats - - name: Generate standalone JSON schema validators - run: node scripts/generate-validators.mjs - name: Build the Docker image run: docker build . --file application/single_app/Dockerfile --tag ${{ secrets.ACR_LOGIN_SERVER }}/simple-chat-dev:$(date +'%Y-%m-%d')_$GITHUB_RUN_NUMBER; diff --git a/.github/workflows/docker_image_publish_nadoyle.yml b/.github/workflows/docker_image_publish_nadoyle.yml index 4edc6dbf2..aebfe45ae 100644 --- a/.github/workflows/docker_image_publish_nadoyle.yml +++ b/.github/workflows/docker_image_publish_nadoyle.yml @@ -5,7 +5,7 @@ on: push: branches: - nadoyle - - keyvaultForSecrets + - feature/group-agents-actions workflow_dispatch: @@ -27,14 +27,6 @@ jobs: login-server: ${{ secrets.ACR_LOGIN_SERVER_NADOYLE }} - uses: actions/checkout@v3 - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: 20 - - name: Install Ajv - run: npm install ajv@^8.0.0 ajv-formats - - name: Generate standalone JSON schema validators - run: node scripts/generate-validators.mjs - name: Build the Docker image run: docker build . --file application/single_app/Dockerfile --tag ${{ secrets.ACR_LOGIN_SERVER_NADOYLE }}/simple-chat-dev:$(date +'%Y-%m-%d')_$GITHUB_RUN_NUMBER; diff --git a/application/single_app/app.py b/application/single_app/app.py index e63932d3b..77078f752 100644 --- a/application/single_app/app.py +++ b/application/single_app/app.py @@ -159,7 +159,7 @@ def configure_sessions(settings): @app.before_first_request def before_first_request(): print("Initializing application...") - settings = get_settings() + settings = get_settings(use_cosmos=True) app_settings_cache.configure_app_cache(settings, get_redis_cache_infrastructure_endpoint(settings.get('redis_url', '').strip().split('.')[0])) app_settings_cache.update_settings_cache(settings) print(f"DEBUG:Application settings: {settings}") @@ -456,7 +456,7 @@ def list_semantic_kernel_plugins(): register_route_external_health(app) if __name__ == '__main__': - settings = get_settings() + settings = get_settings(use_cosmos=True) app_settings_cache.configure_app_cache(settings, get_redis_cache_infrastructure_endpoint(settings.get('redis_url', '').strip().split('.')[0])) app_settings_cache.update_settings_cache(settings) initialize_clients(settings) diff --git a/application/single_app/config.py b/application/single_app/config.py index 53c7495ad..2526b9493 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -88,8 +88,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.233.153" - +VERSION = "0.233.166" SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production') diff --git a/application/single_app/functions_appinsights.py b/application/single_app/functions_appinsights.py index 090101edc..41e535e5c 100644 --- a/application/single_app/functions_appinsights.py +++ b/application/single_app/functions_appinsights.py @@ -47,8 +47,7 @@ def log_event( try: try: cache = app_settings_cache.get_settings_cache() or None - except Exception as e: - print(f"[Log] Could not retrieve settings cache: {e}") + except Exception: cache = None # Get logger - use Azure Monitor logger if configured, otherwise standard logger diff --git a/application/single_app/functions_conversation_metadata.py b/application/single_app/functions_conversation_metadata.py index 5924a877b..262b09558 100644 --- a/application/single_app/functions_conversation_metadata.py +++ b/application/single_app/functions_conversation_metadata.py @@ -45,7 +45,7 @@ def collect_conversation_metadata(user_message, conversation_id, user_id, active document_scope=None, selected_document_id=None, model_deployment=None, hybrid_search_enabled=False, image_gen_enabled=False, selected_documents=None, - selected_agent=None, search_results=None, web_search_results=None, + selected_agent=None, selected_agent_details=None, search_results=None, web_search_results=None, conversation_item=None, additional_participants=None): """ Collect comprehensive metadata for a conversation based on the user's interaction. @@ -65,6 +65,7 @@ def collect_conversation_metadata(user_message, conversation_id, user_id, active search_results: Results from hybrid search conversation_item: Existing conversation item to update additional_participants: List of additional user IDs to include as participants + selected_agent_details: Detailed agent metadata (is_group, group_id, group_name) Returns: dict: Updated conversation metadata @@ -86,6 +87,25 @@ def collect_conversation_metadata(user_message, conversation_id, user_id, active if 'strict' not in conversation_item: conversation_item['strict'] = False + # Prepare agent-derived group context (used when agent is a group and no documents were used) + agent_primary_context = None + agent_primary_context_active = False + if selected_agent_details and selected_agent_details.get('is_group'): + group_id = selected_agent_details.get('group_id') + group_name = selected_agent_details.get('group_name') + + if group_id: + if not group_name: + group_info = find_group_by_id(group_id) + if group_info: + group_name = group_info.get('name') + agent_primary_context = { + "type": "primary", + "scope": "group", + "id": group_id, + "name": group_name or "Unknown Group" + } + # Process documents from search results first to determine primary context document_map = {} # Map of document_id -> {scope, chunks, classification} workspace_used = None # Track the first workspace used (becomes primary context) @@ -144,19 +164,30 @@ def collect_conversation_metadata(user_message, conversation_id, user_id, active "id": scope_id, "name": context_name } - # If no documents were used, we don't set a primary context yet - # This allows us to track conversations that only use model knowledge + # If no documents were used, fall back to agent-based primary context + if not primary_context and agent_primary_context: + primary_context = agent_primary_context + agent_primary_context_active = True # Update or add primary context only if we don't already have one existing_primary = next((ctx for ctx in conversation_item['context'] if ctx.get('type') == 'primary'), None) if primary_context: if existing_primary: - # Primary context already exists - check if this is the same workspace + # Primary context already exists - determine how to handle the new context if (existing_primary.get('scope') == primary_context.get('scope') and existing_primary.get('id') == primary_context.get('id')): # Same workspace - update existing primary context (e.g., refresh name) existing_primary.update(primary_context) debug_print(f"Updated existing primary context: {existing_primary}") + elif agent_primary_context_active: + # Promote the group agent context to become the new primary context + existing_primary.update({ + "scope": primary_context.get('scope'), + "id": primary_context.get('id'), + "name": primary_context.get('name') + }) + debug_print(f"Replaced existing primary context with agent group context: {existing_primary}") + primary_context = None else: # Different workspace - this should become a secondary context debug_print(f"Primary context already exists ({existing_primary.get('scope')}:{existing_primary.get('id')}), "f"treating new workspace ({primary_context.get('scope')}:{primary_context.get('id')}) as secondary") diff --git a/application/single_app/functions_global_agents.py b/application/single_app/functions_global_agents.py index e0595b832..720a1b6cc 100644 --- a/application/single_app/functions_global_agents.py +++ b/application/single_app/functions_global_agents.py @@ -40,6 +40,8 @@ def ensure_default_global_agent_exists(): "azure_agent_apim_gpt_api_version": "", "enable_agent_gpt_apim": False, "is_global": True, + "is_group": False, + "agent_type": "local", "instructions": ( "You are a highly capable research assistant. Your role is to help the user investigate academic, technical, and real-world topics by finding relevant information, summarizing key points, identifying knowledge gaps, and suggesting credible sources for further study.\n\n" "You must always:\n- Think step-by-step and work methodically.\n- Distinguish between fact, inference, and opinion.\n- Clearly state your assumptions when making inferences.\n- Cite authoritative sources when possible (e.g., peer-reviewed journals, academic publishers, government agencies).\n- Avoid speculation unless explicitly asked for.\n- When asked to summarize, preserve the intent, nuance, and technical accuracy of the original content.\n- When generating questions, aim for depth and clarity to guide rigorous inquiry.\n- Present answers in a clear, structured format using bullet points, tables, or headings when appropriate.\n\n" @@ -105,6 +107,9 @@ def get_global_agents(): for agent in agents: if agent.get('max_completion_tokens') is None: agent['max_completion_tokens'] = -1 + agent.setdefault('is_global', True) + agent.setdefault('is_group', False) + agent.setdefault('agent_type', 'local') return agents except Exception as e: log_event( @@ -135,6 +140,9 @@ def get_global_agent(agent_id): agent = keyvault_agent_get_helper(agent, agent_id, scope="global") if agent.get('max_completion_tokens') is None: agent['max_completion_tokens'] = -1 + agent.setdefault('is_global', True) + agent.setdefault('is_group', False) + agent.setdefault('agent_type', 'local') print(f"Found global agent: {agent_id}") return agent except Exception as e: @@ -165,6 +173,8 @@ def save_global_agent(agent_data): agent_data['id'] = str(uuid.uuid4()) # Add metadata agent_data['is_global'] = True + agent_data['is_group'] = False + agent_data.setdefault('agent_type', 'local') agent_data['created_at'] = datetime.utcnow().isoformat() agent_data['updated_at'] = datetime.utcnow().isoformat() log_event( diff --git a/application/single_app/functions_group.py b/application/single_app/functions_group.py index 2d1179f1f..195e268e3 100644 --- a/application/single_app/functions_group.py +++ b/application/single_app/functions_group.py @@ -3,6 +3,7 @@ from config import * from functions_authentication import * from functions_settings import * +from typing import Iterable def create_group(name, description): @@ -128,6 +129,32 @@ def get_user_role_in_group(group_doc, user_id): return None +def require_active_group(user_id: str) -> str: + """Return the active group id for a user or raise ValueError if missing.""" + settings = get_user_settings(user_id) + active_group_id = settings.get("settings", {}).get("activeGroupOid") + if not active_group_id: + raise ValueError("No active group selected") + return active_group_id + + +def assert_group_role(user_id: str, group_id: str, allowed_roles: Iterable[str] = ("Owner", "Admin")) -> str: + """Ensure the user holds one of the allowed roles for the group.""" + group_doc = find_group_by_id(group_id) + if not group_doc: + raise LookupError("Group not found") + + role = get_user_role_in_group(group_doc, user_id) + if not role: + raise PermissionError("User is not a member of this group") + + allowed = {r.lower() for r in allowed_roles} + if role.lower() not in allowed: + raise PermissionError("Insufficient permissions for this group") + + return role + + def map_group_list_for_frontend(groups, current_user_id): """ Utility to produce a simplified list of group data diff --git a/application/single_app/functions_group_actions.py b/application/single_app/functions_group_actions.py new file mode 100644 index 000000000..0dc0c3ddc --- /dev/null +++ b/application/single_app/functions_group_actions.py @@ -0,0 +1,209 @@ +# functions_group_actions.py + +"""Group-level plugin/action management helpers.""" + +import re +import uuid +from datetime import datetime +from typing import Any, Dict, List, Optional + +from azure.cosmos import exceptions +from flask import current_app + +from config import cosmos_group_actions_container +from functions_keyvault import ( + SecretReturnType, + keyvault_plugin_delete_helper, + keyvault_plugin_get_helper, + keyvault_plugin_save_helper, +) + + +_NAME_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$") + + +def get_group_actions( + group_id: str, return_type: SecretReturnType = SecretReturnType.TRIGGER +) -> List[Dict[str, Any]]: + """Return all actions/plugins scoped to the provided group.""" + try: + query = "SELECT * FROM c WHERE c.group_id = @group_id" + parameters = [ + {"name": "@group_id", "value": group_id}, + ] + results = list( + cosmos_group_actions_container.query_items( + query=query, + parameters=parameters, + partition_key=group_id, + ) + ) + return [_clean_action(action, group_id, return_type) for action in results] + except exceptions.CosmosResourceNotFoundError: + return [] + except Exception as exc: + current_app.logger.error( + "Error fetching group actions for %s: %s", group_id, exc + ) + return [] + + +def get_group_action( + group_id: str, action_id: str, return_type: SecretReturnType = SecretReturnType.TRIGGER +) -> Optional[Dict[str, Any]]: + """Fetch a single group action by id or name.""" + try: + action = cosmos_group_actions_container.read_item( + item=action_id, + partition_key=group_id, + ) + except exceptions.CosmosResourceNotFoundError: + query = "SELECT * FROM c WHERE c.group_id = @group_id AND c.name = @name" + parameters = [ + {"name": "@group_id", "value": group_id}, + {"name": "@name", "value": action_id}, + ] + actions = list( + cosmos_group_actions_container.query_items( + query=query, + parameters=parameters, + partition_key=group_id, + ) + ) + if not actions: + return None + action = actions[0] + except Exception as exc: + current_app.logger.error( + "Error fetching group action %s for %s: %s", action_id, group_id, exc + ) + return None + + return _clean_action(action, group_id, return_type) + + +def save_group_action(group_id: str, action_data: Dict[str, Any]) -> Dict[str, Any]: + """Create or update a group action entry.""" + payload = dict(action_data) + action_id = payload.get("id") or str(uuid.uuid4()) + + payload["id"] = action_id + payload["group_id"] = group_id + payload["last_updated"] = datetime.utcnow().isoformat() + + payload.setdefault("name", "") + payload.setdefault("displayName", payload.get("name", "")) + payload.setdefault("type", "") + payload.setdefault("description", "") + payload.setdefault("endpoint", "") + payload.setdefault("auth", {"type": "identity"}) + payload.setdefault("metadata", {}) + payload.setdefault("additionalFields", {}) + + if not isinstance(payload["auth"], dict): + payload["auth"] = {"type": "identity"} + elif "type" not in payload["auth"]: + payload["auth"]["type"] = "identity" + + payload.pop("user_id", None) + + payload = keyvault_plugin_save_helper(payload, scope_value=group_id, scope="group") + + try: + stored = cosmos_group_actions_container.upsert_item(body=payload) + return _clean_action(stored, group_id, SecretReturnType.TRIGGER) + except Exception as exc: + current_app.logger.error( + "Error saving group action %s for %s: %s", action_id, group_id, exc + ) + raise + + +def delete_group_action(group_id: str, action_id: str) -> bool: + """Remove a group action entry if it exists.""" + try: + action = cosmos_group_actions_container.read_item( + item=action_id, + partition_key=group_id, + ) + except exceptions.CosmosResourceNotFoundError: + return False + + try: + keyvault_plugin_delete_helper(action, scope_value=group_id, scope="group") + cosmos_group_actions_container.delete_item( + item=action_id, + partition_key=group_id, + ) + return True + except Exception as exc: + current_app.logger.error( + "Error deleting group action %s for %s: %s", action_id, group_id, exc + ) + raise + + +def validate_group_action_payload(payload: Dict[str, Any], partial: bool = False) -> None: + """Validate incoming payload data for group actions.""" + if not isinstance(payload, dict): + raise ValueError("Action payload must be an object") + + required_fields = ( + "name", + "displayName", + "type", + "description", + "endpoint", + "auth", + "metadata", + "additionalFields", + ) + + if not partial: + missing = [field for field in required_fields if field not in payload] + if missing: + raise ValueError(f"Missing required action fields: {', '.join(missing)}") + + if "name" in payload: + name = payload["name"] + if not isinstance(name, str) or not name or not _NAME_PATTERN.fullmatch(name): + raise ValueError("Action name must be alphanumeric with optional underscores or hyphens") + + if "displayName" in payload and not isinstance(payload["displayName"], str): + raise ValueError("displayName must be a string") + + if "type" in payload and not isinstance(payload["type"], str): + raise ValueError("type must be a string") + + if "description" in payload and not isinstance(payload["description"], str): + raise ValueError("description must be a string") + + if "endpoint" in payload and not isinstance(payload["endpoint"], str): + raise ValueError("endpoint must be a string") + + if "auth" in payload and not isinstance(payload["auth"], dict): + raise ValueError("auth must be an object") + + if "metadata" in payload and not isinstance(payload["metadata"], dict): + raise ValueError("metadata must be an object") + + if "additionalFields" in payload and not isinstance(payload["additionalFields"], dict): + raise ValueError("additionalFields must be an object") + + +def _clean_action( + action: Dict[str, Any], + group_id: str, + return_type: SecretReturnType, +) -> Dict[str, Any]: + cleaned = {k: v for k, v in action.items() if not k.startswith("_")} + cleaned = keyvault_plugin_get_helper( + cleaned, + scope_value=group_id, + scope="group", + return_type=return_type, + ) + cleaned.setdefault("is_global", False) + cleaned.setdefault("is_group", True) + cleaned.setdefault("scope", "group") + return cleaned diff --git a/application/single_app/functions_group_agents.py b/application/single_app/functions_group_agents.py new file mode 100644 index 000000000..92880ebce --- /dev/null +++ b/application/single_app/functions_group_agents.py @@ -0,0 +1,199 @@ +# functions_group_agents.py + +"""Group-level agent management helpers.""" + +import re +import uuid +from datetime import datetime +from typing import Any, Dict, List, Optional + +from azure.cosmos import exceptions +from flask import current_app + +from config import cosmos_group_agents_container +from functions_keyvault import ( + keyvault_agent_delete_helper, + keyvault_agent_get_helper, + keyvault_agent_save_helper, +) + + +_NAME_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$") + + +def get_group_agents(group_id: str) -> List[Dict[str, Any]]: + """Return all agents scoped to the provided group.""" + try: + query = "SELECT * FROM c WHERE c.group_id = @group_id" + parameters = [ + {"name": "@group_id", "value": group_id}, + ] + results = list( + cosmos_group_agents_container.query_items( + query=query, + parameters=parameters, + partition_key=group_id, + ) + ) + return [_clean_agent(agent) for agent in results] + except exceptions.CosmosResourceNotFoundError: + return [] + except Exception as exc: + current_app.logger.error( + "Error fetching group agents for %s: %s", group_id, exc + ) + return [] + + +def get_group_agent(group_id: str, agent_id: str) -> Optional[Dict[str, Any]]: + """Fetch a single group agent document.""" + try: + agent = cosmos_group_agents_container.read_item( + item=agent_id, + partition_key=group_id, + ) + return _clean_agent(agent) + except exceptions.CosmosResourceNotFoundError: + return None + except Exception as exc: + current_app.logger.error( + "Error fetching group agent %s for %s: %s", agent_id, group_id, exc + ) + return None + + +def save_group_agent(group_id: str, agent_data: Dict[str, Any]) -> Dict[str, Any]: + """Create or update a group agent entry.""" + agent_id = agent_data.get("id") or str(uuid.uuid4()) + payload = dict(agent_data) + payload["id"] = agent_id + payload["group_id"] = group_id + payload["last_updated"] = datetime.utcnow().isoformat() + payload["is_global"] = False + payload["is_group"] = True + + # Required/defaulted fields + payload.setdefault("name", "") + payload.setdefault("display_name", payload.get("name", "")) + payload.setdefault("description", "") + payload.setdefault("instructions", "") + payload.setdefault("actions_to_load", []) + payload.setdefault("other_settings", {}) + payload.setdefault("max_completion_tokens", -1) + payload.setdefault("enable_agent_gpt_apim", False) + payload.setdefault("agent_type", "local") + + # Ensure optional Azure fields exist + payload.setdefault("azure_openai_gpt_endpoint", "") + payload.setdefault("azure_openai_gpt_key", "") + payload.setdefault("azure_openai_gpt_deployment", "") + payload.setdefault("azure_openai_gpt_api_version", "") + payload.setdefault("azure_agent_apim_gpt_endpoint", "") + payload.setdefault("azure_agent_apim_gpt_subscription_key", "") + payload.setdefault("azure_agent_apim_gpt_deployment", "") + payload.setdefault("azure_agent_apim_gpt_api_version", "") + + # Remove user-specific residue if present + payload.pop("user_id", None) + + if payload.get("max_completion_tokens") is None: + payload["max_completion_tokens"] = -1 + + # Store sensitive values in Key Vault before persistence + payload = keyvault_agent_save_helper(payload, payload["id"], scope="group") + + try: + stored = cosmos_group_agents_container.upsert_item(body=payload) + return _clean_agent(stored) + except Exception as exc: + current_app.logger.error( + "Error saving group agent %s for %s: %s", agent_id, group_id, exc + ) + raise + + +def delete_group_agent(group_id: str, agent_id: str) -> bool: + """Remove a group agent entry if it exists.""" + try: + agent = cosmos_group_agents_container.read_item( + item=agent_id, + partition_key=group_id, + ) + except exceptions.CosmosResourceNotFoundError: + return False + + try: + keyvault_agent_delete_helper(agent, agent.get("id", agent_id), scope="group") + cosmos_group_agents_container.delete_item( + item=agent_id, + partition_key=group_id, + ) + return True + except Exception as exc: + current_app.logger.error( + "Error deleting group agent %s for %s: %s", agent_id, group_id, exc + ) + raise + + +def validate_group_agent_payload(payload: Dict[str, Any], partial: bool = False) -> None: + """Validate incoming payload data for group agents.""" + if not isinstance(payload, dict): + raise ValueError("Agent payload must be an object") + + required_fields = ( + "name", + "display_name", + "description", + "instructions", + "actions_to_load", + "other_settings", + "max_completion_tokens", + ) + + if not partial: + missing = [field for field in required_fields if field not in payload] + if missing: + raise ValueError(f"Missing required agent fields: {', '.join(missing)}") + + if "name" in payload: + name = payload["name"] + if not isinstance(name, str) or not name or not _NAME_PATTERN.fullmatch(name): + raise ValueError("Agent name must be alphanumeric with optional underscores or hyphens") + + if "display_name" in payload and not isinstance(payload["display_name"], str): + raise ValueError("display_name must be a string") + + if "description" in payload and not isinstance(payload["description"], str): + raise ValueError("description must be a string") + + if "instructions" in payload and not isinstance(payload["instructions"], str): + raise ValueError("instructions must be a string") + + if "actions_to_load" in payload: + actions = payload["actions_to_load"] + if not isinstance(actions, list) or not all(isinstance(a, str) for a in actions): + raise ValueError("actions_to_load must be a list of strings") + + if "other_settings" in payload and not isinstance(payload["other_settings"], dict): + raise ValueError("other_settings must be an object") + + if "max_completion_tokens" in payload: + tokens = payload["max_completion_tokens"] + if not isinstance(tokens, int): + raise ValueError("max_completion_tokens must be an integer") + + +def _clean_agent(agent: Dict[str, Any]) -> Dict[str, Any]: + cleaned = {k: v for k, v in agent.items() if not k.startswith("_")} + cleaned = keyvault_agent_get_helper( + cleaned, + cleaned.get("id", ""), + scope="group", + ) + if cleaned.get("max_completion_tokens") is None: + cleaned["max_completion_tokens"] = -1 + cleaned.setdefault("is_global", False) + cleaned.setdefault("is_group", True) + cleaned.setdefault("agent_type", "local") + return cleaned diff --git a/application/single_app/functions_keyvault.py b/application/single_app/functions_keyvault.py index 3d2ed1835..2094814fb 100644 --- a/application/single_app/functions_keyvault.py +++ b/application/single_app/functions_keyvault.py @@ -287,48 +287,6 @@ def keyvault_agent_get_helper(agent_dict, scope_value, scope="global", return_ty return updated return updated -def keyvault_agent_get_helper(agent_dict, scope_value, scope="global", return_actual_key=False): - """ - For agent dicts, retrieve sensitive keys from Key Vault if they are stored as Key Vault references. - Only processes 'azure_agent_apim_gpt_subscription_key' and 'azure_openai_gpt_key'. - - Args: - agent_dict (dict): The agent dictionary to process. - scope_value (str): The value for the scope (e.g., agent id). - scope (str): The scope (e.g., 'user', 'global'). - return_actual_key (bool): If True, retrieves the actual secret value from Key Vault. If False, replaces with ui_trigger_word. - - Returns: - dict: A new agent dict with sensitive values replaced by Key Vault references. - Raises: - Exception: If retrieving a key from Key Vault fails. - """ - settings = get_settings() - enable_key_vault_secret_storage = settings.get("enable_key_vault_secret_storage", False) - key_vault_name = settings.get("key_vault_name", None) - if not enable_key_vault_secret_storage or not key_vault_name: - return agent_dict - source = "agent" - updated = dict(agent_dict) - agent_name = updated.get('name', 'agent') - use_apim = updated.get('enable_agent_gpt_apim', False) - key = 'azure_agent_apim_gpt_subscription_key' if use_apim else 'azure_openai_gpt_key' - if key in updated and updated[key]: - value = updated[key] - if validate_secret_name_dynamic(value): - try: - if return_type == SecretReturnType.VALUE: - actual_key = retrieve_secret_from_key_vault_by_full_name(value) - updated[key] = actual_key - elif return_type == SecretReturnType.NAME: - updated[key] = value - else: - updated[key] = ui_trigger_word - except Exception as e: - logging.error(f"Failed to retrieve agent key '{key}' from Key Vault: {e}") - return updated - return updated - def keyvault_plugin_save_helper(plugin_dict, scope_value, scope="global"): """ For plugin dicts, store the auth.key in Key Vault if auth.type is 'key', 'servicePrincipal', 'basic', or 'connection_string', diff --git a/application/single_app/functions_personal_agents.py b/application/single_app/functions_personal_agents.py index 0017ae40b..284e2f250 100644 --- a/application/single_app/functions_personal_agents.py +++ b/application/single_app/functions_personal_agents.py @@ -46,6 +46,9 @@ def get_personal_agents(user_id): cleaned_agent = keyvault_agent_get_helper(cleaned_agent, cleaned_agent.get('id', ''), scope="user") if cleaned_agent.get('max_completion_tokens') is None: cleaned_agent['max_completion_tokens'] = -1 + cleaned_agent.setdefault('is_global', False) + cleaned_agent.setdefault('is_group', False) + cleaned_agent.setdefault('agent_type', 'local') cleaned_agents.append(cleaned_agent) return cleaned_agents @@ -78,6 +81,9 @@ def get_personal_agent(user_id, agent_id): # Ensure max_completion_tokens field exists if cleaned_agent.get('max_completion_tokens') is None: cleaned_agent['max_completion_tokens'] = -1 + cleaned_agent.setdefault('is_global', False) + cleaned_agent.setdefault('is_group', False) + cleaned_agent.setdefault('agent_type', 'local') return cleaned_agent except exceptions.CosmosResourceNotFoundError: current_app.logger.warning(f"Agent {agent_id} not found for user {user_id}") @@ -119,7 +125,9 @@ def save_personal_agent(user_id, agent_data): agent_data.setdefault('enable_agent_gpt_apim', False) agent_data.setdefault('actions_to_load', []) agent_data.setdefault('other_settings', {}) - agent_data.setdefault('is_global', False) + agent_data['is_global'] = False + agent_data['is_group'] = False + agent_data.setdefault('agent_type', 'local') # Store sensitive keys in Key Vault if enabled agent_data = keyvault_agent_save_helper(agent_data, agent_data.get('id', ''), scope="user") @@ -128,6 +136,9 @@ def save_personal_agent(user_id, agent_data): result = cosmos_personal_agents_container.upsert_item(body=agent_data) # Remove Cosmos metadata from response cleaned_result = {k: v for k, v in result.items() if not k.startswith('_')} + cleaned_result.setdefault('is_global', False) + cleaned_result.setdefault('is_group', False) + cleaned_result.setdefault('agent_type', 'local') return cleaned_result except Exception as e: diff --git a/application/single_app/functions_settings.py b/application/single_app/functions_settings.py index d0b970174..145eb721e 100644 --- a/application/single_app/functions_settings.py +++ b/application/single_app/functions_settings.py @@ -3,8 +3,9 @@ from config import * from functions_appinsights import log_event import app_settings_cache +import inspect -def get_settings(): +def get_settings(use_cosmos=False): import secrets default_settings = { # External health check @@ -241,10 +242,44 @@ def get_settings(): try: # Attempt to read the existing doc - settings_item = cosmos_settings_container.read_item( - item="app_settings", - partition_key="app_settings" - ) + if use_cosmos: + settings_item = cosmos_settings_container.read_item( + item="app_settings", + partition_key="app_settings" + ) + else: + settings_item = None + + cache_accessor = getattr(app_settings_cache, "get_settings_cache", None) + if callable(cache_accessor): + try: + settings_item = cache_accessor() + except Exception: + settings_item = None + + if not settings_item: + settings_item = cosmos_settings_container.read_item( + item="app_settings", + partition_key="app_settings" + ) + + frame = inspect.currentframe() + caller = frame.f_back # the function that called *this* code + + if caller is not None: + code = caller.f_code + caller_file = code.co_filename + caller_line = caller.f_lineno + caller_func = code.co_name + print( + "Warning: Failed to get settings from cache, read from Cosmos DB instead. " + f"Called from {caller_file}:{caller_line} in {caller_func}()." + ) + else: + print( + "Warning: Failed to get settings from cache, " + "read from Cosmos DB instead. (no caller frame)" + ) #print("Successfully retrieved settings from Cosmos DB.") # Merge default_settings in, to fill in any missing or nested keys @@ -274,7 +309,9 @@ def update_settings(new_settings): settings_item = get_settings() settings_item.update(new_settings) cosmos_settings_container.upsert_item(settings_item) - app_settings_cacheupdate_settings_cache(settings_item) # Update the in-memory cache as well + cache_updater = getattr(app_settings_cache, "update_settings_cache", None) + if callable(cache_updater): + cache_updater(settings_item) print("Settings updated successfully.") return True except Exception as e: @@ -558,8 +595,13 @@ def update_user_settings(user_id, settings_to_update): first_user_agent = doc['settings']['agents'][0] if first_user_agent: doc['settings']['selected_agent'] = { + 'id': first_user_agent.get('id'), 'name': first_user_agent['name'], + 'display_name': first_user_agent.get('display_name', first_user_agent['name']), 'is_global': False, + 'is_group': False, + 'group_id': None, + 'group_name': None, } else: settings = get_settings() @@ -571,24 +613,44 @@ def update_user_settings(user_id, settings_to_update): if global_agents: first_global_agent = global_agents[0] doc['settings']['selected_agent'] = { + 'id': first_global_agent.get('id'), 'name': first_global_agent['name'], + 'display_name': first_global_agent.get('display_name', first_global_agent['name']), 'is_global': True, + 'is_group': False, + 'group_id': None, + 'group_name': None, } else: doc['settings']['selected_agent'] = { + 'id': None, 'name': 'default_agent', + 'display_name': 'default_agent', 'is_global': True, + 'is_group': False, + 'group_id': None, + 'group_name': None, } except Exception: # Fallback if container access fails doc['settings']['selected_agent'] = { + 'id': None, 'name': 'default_agent', + 'display_name': 'default_agent', 'is_global': True, + 'is_group': False, + 'group_id': None, + 'group_name': None, } else: doc['settings']['selected_agent'] = { + 'id': None, 'name': 'researcher', + 'display_name': 'researcher', 'is_global': False, + 'is_group': False, + 'group_id': None, + 'group_name': None, } if doc['settings']['agents'] is not None and len(doc['settings']['agents']) > 0: diff --git a/application/single_app/route_backend_agents.py b/application/single_app/route_backend_agents.py index c5229bb1b..d2e812b1a 100644 --- a/application/single_app/route_backend_agents.py +++ b/application/single_app/route_backend_agents.py @@ -4,11 +4,19 @@ import uuid import logging import builtins -from flask import Blueprint, jsonify, request +from flask import Blueprint, jsonify, request, current_app from semantic_kernel_loader import get_agent_orchestration_types from functions_settings import get_settings, update_settings, get_user_settings, update_user_settings from functions_global_agents import get_global_agents, save_global_agent, delete_global_agent from functions_personal_agents import get_personal_agents, ensure_migration_complete, save_personal_agent, delete_personal_agent +from functions_group import require_active_group, assert_group_role +from functions_group_agents import ( + get_group_agents, + get_group_agent, + save_group_agent, + delete_group_agent, + validate_group_agent_payload, +) from functions_authentication import * from functions_appinsights import log_event from json_schema_validation import validate_agent @@ -43,6 +51,8 @@ def get_user_agents(): # Always mark user agents as is_global: False for agent in agents: agent['is_global'] = False + agent['is_group'] = False + agent.setdefault('agent_type', 'local') # Check global/merge toggles settings = get_settings() @@ -54,6 +64,8 @@ def get_user_agents(): # Mark global agents for agent in global_agents: agent['is_global'] = True + agent['is_group'] = False + agent.setdefault('agent_type', 'local') # Merge agents using ID as key to avoid name conflicts # This allows both personal and global agents with same name to coexist @@ -99,6 +111,7 @@ def set_user_agents(): if agent.get('is_global', False): continue # Skip global agents agent['is_global'] = False # Ensure user agents are not global + agent['is_group'] = False # --- Require at least one deployment field --- #if not (agent.get('azure_openai_gpt_deployment') or agent.get('azure_agent_apim_gpt_deployment')): # return jsonify({'error': f'Agent "{agent.get("name", "(unnamed)")}" must have either azure_openai_gpt_deployment or azure_agent_apim_gpt_deployment set.'}), 400 @@ -169,6 +182,171 @@ def delete_user_agent(agent_name): log_event("User agent deleted", extra={"user_id": user_id, "agent_name": agent_name}) return jsonify({'success': True}) + +# === GROUP AGENT ENDPOINTS === + +@bpa.route('/api/group/agents', methods=['GET']) +@swagger_route(security=get_auth_security()) +@login_required +@user_required +@enabled_required('enable_group_workspaces') +def get_group_agents_route(): + user_id = get_current_user_id() + try: + active_group = require_active_group(user_id) + assert_group_role( + user_id, + active_group, + allowed_roles=("Owner", "Admin", "DocumentManager", "User"), + ) + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except LookupError as exc: + return jsonify({'error': str(exc)}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + + agents = get_group_agents(active_group) + return jsonify({'agents': agents}), 200 + + +@bpa.route('/api/group/agents/', methods=['GET']) +@swagger_route(security=get_auth_security()) +@login_required +@user_required +@enabled_required('enable_group_workspaces') +def get_group_agent_route(agent_id): + user_id = get_current_user_id() + try: + active_group = require_active_group(user_id) + assert_group_role( + user_id, + active_group, + allowed_roles=("Owner", "Admin", "DocumentManager", "User"), + ) + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except LookupError as exc: + return jsonify({'error': str(exc)}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + + agent = get_group_agent(active_group, agent_id) + if not agent: + return jsonify({'error': 'Agent not found'}), 404 + return jsonify(agent), 200 + + +@bpa.route('/api/group/agents', methods=['POST']) +@swagger_route(security=get_auth_security()) +@login_required +@user_required +@enabled_required('enable_group_workspaces') +def create_group_agent_route(): + user_id = get_current_user_id() + try: + active_group = require_active_group(user_id) + assert_group_role(user_id, active_group) + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except LookupError as exc: + return jsonify({'error': str(exc)}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + + payload = request.get_json(silent=True) or {} + try: + validate_group_agent_payload(payload, partial=False) + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + + for key in ('group_id', 'last_updated', 'is_global', 'is_group'): + payload.pop(key, None) + + try: + saved = save_group_agent(active_group, payload) + except Exception as exc: + current_app.logger.error('Failed to save group agent: %s', exc) + return jsonify({'error': 'Unable to save agent'}), 500 + + return jsonify(saved), 201 + + +@bpa.route('/api/group/agents/', methods=['PATCH']) +@swagger_route(security=get_auth_security()) +@login_required +@user_required +@enabled_required('enable_group_workspaces') +def update_group_agent_route(agent_id): + user_id = get_current_user_id() + try: + active_group = require_active_group(user_id) + assert_group_role(user_id, active_group) + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except LookupError as exc: + return jsonify({'error': str(exc)}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + + existing = get_group_agent(active_group, agent_id) + if not existing: + return jsonify({'error': 'Agent not found'}), 404 + + updates = request.get_json(silent=True) or {} + for key in ('id', 'group_id', 'last_updated', 'is_global', 'is_group'): + updates.pop(key, None) + + try: + validate_group_agent_payload(updates, partial=True) + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + + merged = dict(existing) + merged.update(updates) + merged['id'] = agent_id + + try: + validate_group_agent_payload(merged, partial=False) + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + + try: + saved = save_group_agent(active_group, merged) + except Exception as exc: + current_app.logger.error('Failed to update group agent %s: %s', agent_id, exc) + return jsonify({'error': 'Unable to update agent'}), 500 + + return jsonify(saved), 200 + + +@bpa.route('/api/group/agents/', methods=['DELETE']) +@swagger_route(security=get_auth_security()) +@login_required +@user_required +@enabled_required('enable_group_workspaces') +def delete_group_agent_route(agent_id): + user_id = get_current_user_id() + try: + active_group = require_active_group(user_id) + assert_group_role(user_id, active_group) + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except LookupError as exc: + return jsonify({'error': str(exc)}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + + try: + removed = delete_group_agent(active_group, agent_id) + except Exception as exc: + current_app.logger.error('Failed to delete group agent %s: %s', agent_id, exc) + return jsonify({'error': 'Unable to delete agent'}), 500 + + if not removed: + return jsonify({'error': 'Agent not found'}), 404 + return jsonify({'message': 'Agent deleted'}), 200 + # User endpoint to set selected agent (new model, not legacy default_agent) @bpa.route('/api/user/settings/selected_agent', methods=['POST']) @swagger_route( @@ -183,9 +361,17 @@ def set_user_selected_agent(): return jsonify({'error': 'selected_agent is required.'}), 400 user_settings = get_user_settings(user_id) settings_to_update = user_settings.get('settings', {}) + agent_name = (selected_agent.get('name') or '').strip() + if not agent_name: + return jsonify({'error': 'selected_agent.name is required.'}), 400 agent = { - "name": selected_agent.get('name'), - "is_global": selected_agent.get('is_global', False) + "id": selected_agent.get('id'), + "name": agent_name, + "display_name": selected_agent.get('display_name'), + "is_global": selected_agent.get('is_global', False), + "is_group": selected_agent.get('is_group', False), + "group_id": selected_agent.get('group_id'), + "group_name": selected_agent.get('group_name') } settings_to_update['selected_agent'] = agent update_user_settings(user_id, settings_to_update) @@ -233,7 +419,7 @@ def set_selected_agent(): # Set global_selected_agent field only settings = get_settings() - settings['global_selected_agent'] = { 'name': agent_name, 'is_global': True } + settings['global_selected_agent'] = { 'name': agent_name, 'is_global': True, 'is_group': False } update_settings(settings) log_event("Global selected agent set", extra={"action": "set-global-selected", "agent_name": agent_name, "user": str(get_current_user_id())}) # --- HOT RELOAD TRIGGER --- @@ -261,6 +447,7 @@ def list_agents(): agent['actions_to_load'] = [] # Mark as global agents agent['is_global'] = True + agent['is_group'] = False log_event("List agents", extra={"action": "list", "user": str(get_current_user_id())}) return jsonify(agents) @@ -279,6 +466,7 @@ def add_agent(): agents = get_global_agents() new_agent = request.json.copy() if hasattr(request.json, 'copy') else dict(request.json) new_agent['is_global'] = True + new_agent['is_group'] = False validation_error = validate_agent(new_agent) if validation_error: log_event("Add agent failed: validation error", level=logging.WARNING, extra={"action": "add", "agent": new_agent, "error": validation_error}) @@ -388,6 +576,7 @@ def edit_agent(agent_name): agents = get_global_agents() updated_agent = request.json.copy() if hasattr(request.json, 'copy') else dict(request.json) updated_agent['is_global'] = True + updated_agent['is_group'] = False validation_error = validate_agent(updated_agent) if validation_error: log_event("Edit agent failed: validation error", level=logging.WARNING, extra={"action": "edit", "agent": updated_agent, "error": validation_error}) diff --git a/application/single_app/route_backend_chats.py b/application/single_app/route_backend_chats.py index fb1d14a5a..e095dc1a5 100644 --- a/application/single_app/route_backend_chats.py +++ b/application/single_app/route_backend_chats.py @@ -364,7 +364,11 @@ def chat_api(): user_metadata['agent_selection'] = { 'selected_agent': selected_agent_info.get('name'), 'agent_display_name': selected_agent_info.get('display_name'), - 'is_global': selected_agent_info.get('is_global', False) + 'is_global': selected_agent_info.get('is_global', False), + 'is_group': selected_agent_info.get('is_group', False), + 'group_id': selected_agent_info.get('group_id'), + 'group_name': selected_agent_info.get('group_name'), + 'agent_id': selected_agent_info.get('id') } except Exception as e: print(f"Error retrieving agent details: {e}") @@ -385,7 +389,11 @@ def chat_api(): user_metadata['agent_selection'] = { 'selected_agent': agent_info.get('name'), 'agent_display_name': agent_info.get('display_name'), - 'is_global': agent_info.get('is_global', False) + 'is_global': agent_info.get('is_global', False), + 'is_group': agent_info.get('is_group', False), + 'group_id': agent_info.get('group_id'), + 'group_name': agent_info.get('group_name'), + 'agent_id': agent_info.get('id') } # Model selection information @@ -1780,6 +1788,7 @@ def gpt_error(e): image_gen_enabled=image_gen_enabled, selected_documents=combined_documents if 'combined_documents' in locals() else None, selected_agent=selected_agent_name, + selected_agent_details=user_metadata.get('agent_selection'), search_results=search_results if 'search_results' in locals() else None, conversation_item=conversation_item ) diff --git a/application/single_app/route_backend_plugins.py b/application/single_app/route_backend_plugins.py index 5edcba1b6..51f0c6a04 100644 --- a/application/single_app/route_backend_plugins.py +++ b/application/single_app/route_backend_plugins.py @@ -18,6 +18,15 @@ from functions_global_actions import * from functions_personal_actions import * +from functions_group import require_active_group, assert_group_role +from functions_group_actions import ( + get_group_actions, + get_group_action, + save_group_action, + delete_group_action, + validate_group_action_payload, +) +from functions_keyvault import SecretReturnType #from functions_personal_actions import delete_personal_action from functions_debug import debug_print @@ -353,6 +362,190 @@ def delete_user_plugin(plugin_name): log_event("User plugin deleted", extra={"user_id": user_id, "plugin_name": plugin_name}) return jsonify({'success': True}) + +# === GROUP ACTION ENDPOINTS === + +@bpap.route('/api/group/plugins', methods=['GET']) +@swagger_route(security=get_auth_security()) +@login_required +@user_required +@enabled_required('enable_group_workspaces') +def get_group_actions_route(): + user_id = get_current_user_id() + try: + active_group = require_active_group(user_id) + assert_group_role( + user_id, + active_group, + allowed_roles=("Owner", "Admin", "DocumentManager", "User"), + ) + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except LookupError as exc: + return jsonify({'error': str(exc)}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + + actions = get_group_actions(active_group, return_type=SecretReturnType.TRIGGER) + + settings = get_settings() + merge_global = bool(settings.get('merge_global_semantic_kernel_with_workspace', False)) if settings else False + + if merge_global: + global_actions = get_global_actions(return_type=SecretReturnType.TRIGGER) + merged_actions = _merge_group_and_global_actions(actions, global_actions) + else: + merged_actions = [_normalize_group_action(action) for action in actions] + merged_actions.sort(key=lambda item: (item.get('displayName') or item.get('display_name') or item.get('name') or '').lower()) + + return jsonify({'actions': merged_actions}), 200 + + +@bpap.route('/api/group/plugins/', methods=['GET']) +@swagger_route(security=get_auth_security()) +@login_required +@user_required +@enabled_required('enable_group_workspaces') +def get_group_action_route(action_id): + user_id = get_current_user_id() + try: + active_group = require_active_group(user_id) + assert_group_role( + user_id, + active_group, + allowed_roles=("Owner", "Admin", "DocumentManager", "User"), + ) + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except LookupError as exc: + return jsonify({'error': str(exc)}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + + action = get_group_action(active_group, action_id, return_type=SecretReturnType.TRIGGER) + if not action: + return jsonify({'error': 'Action not found'}), 404 + return jsonify(action), 200 + + +@bpap.route('/api/group/plugins', methods=['POST']) +@swagger_route(security=get_auth_security()) +@login_required +@user_required +@enabled_required('enable_group_workspaces') +def create_group_action_route(): + user_id = get_current_user_id() + try: + active_group = require_active_group(user_id) + assert_group_role(user_id, active_group) + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except LookupError as exc: + return jsonify({'error': str(exc)}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + + payload = request.get_json(silent=True) or {} + try: + validate_group_action_payload(payload, partial=False) + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + + if payload.get('is_global'): + return jsonify({'error': 'Global actions are managed centrally and cannot be created within a group.'}), 400 + + for key in ('group_id', 'last_updated', 'user_id', 'is_global', 'is_group', 'scope'): + payload.pop(key, None) + + try: + saved = save_group_action(active_group, payload) + except Exception as exc: + current_app.logger.error('Failed to save group action: %s', exc) + return jsonify({'error': 'Unable to save action'}), 500 + + return jsonify(saved), 201 + + +@bpap.route('/api/group/plugins/', methods=['PATCH']) +@swagger_route(security=get_auth_security()) +@login_required +@user_required +@enabled_required('enable_group_workspaces') +def update_group_action_route(action_id): + user_id = get_current_user_id() + try: + active_group = require_active_group(user_id) + assert_group_role(user_id, active_group) + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except LookupError as exc: + return jsonify({'error': str(exc)}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + + existing = get_group_action(active_group, action_id, return_type=SecretReturnType.NAME) + if not existing: + return jsonify({'error': 'Action not found'}), 404 + + updates = request.get_json(silent=True) or {} + if updates.get('is_global'): + return jsonify({'error': 'Global actions cannot be modified within a group.'}), 400 + + for key in ('id', 'group_id', 'last_updated', 'user_id', 'is_global', 'is_group', 'scope'): + updates.pop(key, None) + + try: + validate_group_action_payload(updates, partial=True) + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + + merged = dict(existing) + merged.update(updates) + merged['is_global'] = False + merged['is_group'] = True + merged['id'] = existing.get('id', action_id) + + try: + validate_group_action_payload(merged, partial=False) + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + + try: + saved = save_group_action(active_group, merged) + except Exception as exc: + current_app.logger.error('Failed to update group action %s: %s', action_id, exc) + return jsonify({'error': 'Unable to update action'}), 500 + + return jsonify(saved), 200 + + +@bpap.route('/api/group/plugins/', methods=['DELETE']) +@swagger_route(security=get_auth_security()) +@login_required +@user_required +@enabled_required('enable_group_workspaces') +def delete_group_action_route(action_id): + user_id = get_current_user_id() + try: + active_group = require_active_group(user_id) + assert_group_role(user_id, active_group) + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except LookupError as exc: + return jsonify({'error': str(exc)}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + + try: + removed = delete_group_action(active_group, action_id) + except Exception as exc: + current_app.logger.error('Failed to delete group action %s: %s', action_id, exc) + return jsonify({'error': 'Unable to delete action'}), 500 + + if not removed: + return jsonify({'error': 'Action not found'}), 404 + return jsonify({'message': 'Action deleted'}), 200 + @bpap.route('/api/user/plugins/types', methods=['GET']) @swagger_route(security=get_auth_security()) @login_required @@ -624,3 +817,44 @@ def list_dynamic_plugins(): """ plugins = get_all_plugin_metadata() return jsonify(plugins) + +# Helper functions for group/global action merging +def _normalize_group_action(action: dict) -> dict: + normalized = dict(action) + normalized['is_global'] = False + normalized['is_group'] = True + normalized.setdefault('scope', 'group') + return normalized + + +def _normalize_global_action(action: dict) -> dict: + normalized = dict(action) + normalized['is_global'] = True + normalized['is_group'] = False + normalized.setdefault('scope', 'global') + return normalized + + +def _merge_group_and_global_actions(group_actions, global_actions): + normalized_actions = [] + seen_names = set() + + for action in group_actions: + normalized = _normalize_group_action(action) + action_name = (normalized.get('name') or '').lower() + if action_name: + seen_names.add(action_name) + normalized_actions.append(normalized) + + for action in global_actions: + normalized = _normalize_global_action(action) + action_name = (normalized.get('name') or '').lower() + if action_name and action_name in seen_names: + continue + normalized_actions.append(normalized) + + normalized_actions.sort(key=lambda item: (item.get('displayName') or item.get('display_name') or item.get('name') or '').lower()) + return normalized_actions + + + diff --git a/application/single_app/semantic_kernel_loader.py b/application/single_app/semantic_kernel_loader.py index 80bc2fd92..2d484e713 100644 --- a/application/single_app/semantic_kernel_loader.py +++ b/application/single_app/semantic_kernel_loader.py @@ -31,6 +31,9 @@ from functions_keyvault import validate_secret_name_dynamic, retrieve_secret_from_key_vault, retrieve_secret_from_key_vault_by_full_name, SecretReturnType from functions_global_actions import get_global_actions from functions_global_agents import get_global_agents +from functions_group_agents import get_group_agent, get_group_agents +from functions_group_actions import get_group_actions +from functions_group import require_active_group 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 semantic_kernel_plugins.plugin_loader import discover_plugins @@ -100,6 +103,9 @@ def resolve_agent_config(agent, settings): debug_print(f"[SK Loader] resolve_agent_config called for agent: {agent.get('name')}") debug_print(f"[SK Loader] Agent config: {agent}") debug_print(f"[SK Loader] Agent is_global flag: {agent.get('is_global')}") + debug_print(f"[SK Loader] Agent is_group flag: {agent.get('is_group')}") + agent_type = (agent.get('agent_type') or 'local').lower() + agent['agent_type'] = agent_type gpt_model_obj = settings.get('gpt_model', {}) selected_model = gpt_model_obj.get('selected', [{}])[0] if gpt_model_obj.get('selected') else {} @@ -112,9 +118,18 @@ def resolve_agent_config(agent, settings): per_user_enabled = settings.get('per_user_semantic_kernel', False) allow_user_custom_agent_endpoints = settings.get('allow_user_custom_agent_endpoints', False) allow_group_custom_agent_endpoints = settings.get('allow_group_custom_agent_endpoints', False) + is_group_agent = agent.get("is_group", False) + is_global_agent = agent.get("is_global", False) + + if is_group_agent: + allow_custom_agent_endpoints = allow_group_custom_agent_endpoints + elif is_global_agent: + allow_custom_agent_endpoints = False + else: + allow_custom_agent_endpoints = allow_user_custom_agent_endpoints debug_print(f"[SK Loader] user_apim_enabled: {user_apim_enabled}, global_apim_enabled: {global_apim_enabled}, per_user_enabled: {per_user_enabled}") - debug_print(f"[SK Loader] allow_user_custom_agent_endpoints: {allow_user_custom_agent_endpoints}, allow_group_custom_agent_endpoints: {allow_group_custom_agent_endpoints}") + debug_print(f"[SK Loader] allow_user_custom_agent_endpoints: {allow_user_custom_agent_endpoints}, allow_group_custom_agent_endpoints: {allow_group_custom_agent_endpoints}, allow_custom_agent_endpoints_resolved: {allow_custom_agent_endpoints}") debug_print(f"[SK Loader] Max completion tokens from agent: {agent.get('max_completion_tokens')}") def resolve_secret_value_if_needed(value, scope_value, source, scope): @@ -238,8 +253,12 @@ def merge_fields(primary, fallback): "id": agent.get("id", ""), "default_agent": agent.get("default_agent", False), "is_global": agent.get("is_global", False), + "is_group": agent.get("is_group", False), + "group_id": agent.get("group_id"), + "group_name": agent.get("group_name"), "enable_agent_gpt_apim": agent.get("enable_agent_gpt_apim", False), - "max_completion_tokens": agent.get("max_completion_tokens", -1) + "max_completion_tokens": agent.get("max_completion_tokens", -1), + "agent_type": agent_type or "local" } except Exception as e: log_event(f"[SK Loader] Error resolving agent config: {e}", level=logging.ERROR, exceptionTraceback=True) @@ -249,22 +268,24 @@ def merge_fields(primary, fallback): g_apim = get_global_apim() u_gpt = get_user_gpt() g_gpt = get_global_gpt() + can_use_agent_endpoints = allow_custom_agent_endpoints + user_apim_allowed = user_apim_enabled and can_use_agent_endpoints # 1. User APIM enabled and any user APIM values set: use user APIM (merge with global APIM if needed) - if user_apim_enabled and any_filled(*u_apim) and allow_user_custom_agent_endpoints: + if user_apim_allowed and any_filled(*u_apim): debug_print(f"[SK Loader] Using user APIM with global fallback") merged = merge_fields(u_apim, g_apim if global_apim_enabled and any_filled(*g_apim) else (None, None, None, None)) endpoint, key, deployment, api_version = merged # 2. User APIM enabled but no user APIM values, and global APIM enabled and present: use global APIM - elif user_apim_enabled and global_apim_enabled and any_filled(*g_apim) and allow_group_custom_agent_endpoints: + elif user_apim_enabled and global_apim_enabled and any_filled(*g_apim): debug_print(f"[SK Loader] Using global APIM (user APIM enabled but not present)") endpoint, key, deployment, api_version = g_apim # 3. User GPT config is FULLY filled: use user GPT (all fields filled) - elif all_filled(*u_gpt) and allow_user_custom_agent_endpoints: + elif all_filled(*u_gpt) and can_use_agent_endpoints: debug_print(f"[SK Loader] Using agent GPT config (all fields filled)") endpoint, key, deployment, api_version = u_gpt # 4. User GPT config is PARTIALLY filled, global APIM is NOT enabled: merge user GPT with global GPT - elif any_filled(*u_gpt) and not global_apim_enabled and allow_user_custom_agent_endpoints: + elif any_filled(*u_gpt) and not global_apim_enabled and can_use_agent_endpoints: debug_print(f"[SK Loader] Using agent GPT config (partially filled, merging with global GPT, global APIM not enabled)") endpoint, key, deployment, api_version = merge_fields(u_gpt, g_gpt) # 5. Global APIM enabled and present: use global APIM @@ -290,8 +311,12 @@ def merge_fields(primary, fallback): "id": agent.get("id", ""), "default_agent": agent.get("default_agent", False), # [Deprecated, use 'selected_agent' or 'global_selected_agent' in agent config] "is_global": agent.get("is_global", False), # Ensure we have this field + "is_group": agent.get("is_group", False), + "group_id": agent.get("group_id"), + "group_name": agent.get("group_name"), "enable_agent_gpt_apim": agent.get("enable_agent_gpt_apim", False), # Use this to check if APIM is enabled for the agent - "max_completion_tokens": agent.get("max_completion_tokens", -1) # -1 meant use model default determined by the service, 35-trubo is 4096, 4o is 16384, 4.1 is at least 32768 + "max_completion_tokens": agent.get("max_completion_tokens", -1), # -1 meant use model default determined by the service, 35-trubo is 4096, 4o is 16384, 4.1 is at least 32768 + "agent_type": agent_type or "local", } print(f"[SK Loader] Final resolved config for {agent.get('name')}: endpoint={bool(endpoint)}, key={bool(key)}, deployment={deployment}") @@ -450,7 +475,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): +def load_agent_specific_plugins(kernel, plugin_names, settings, mode_label="global", user_id=None, group_id=None): """ Load specific plugins by name for an agent with enhanced logging. @@ -459,6 +484,7 @@ def load_agent_specific_plugins(kernel, plugin_names, settings, mode_label="glob plugin_names: List of plugin names to load (from agent's actions_to_load) mode_label: 'per-user' or 'global' for logging user_id: User ID for per-user mode + group_id: Active group identifier when loading group-scoped plugins """ if not plugin_names: debug_print(f"[SK Loader] No plugin names provided to load_agent_specific_plugins") @@ -471,7 +497,18 @@ def load_agent_specific_plugins(kernel, plugin_names, settings, mode_label="glob # Create logged plugin loader for enhanced logging logged_loader = create_logged_plugin_loader(kernel) - if mode_label == "per-user": + if mode_label == "group": + if not group_id: + debug_print(f"[SK Loader] Warning: Group mode requested without group_id. Skipping plugin load.") + all_plugin_manifests = [] + else: + all_plugin_manifests = get_group_actions(group_id, return_type=SecretReturnType.NAME) + debug_print(f"[SK Loader] Retrieved {len(all_plugin_manifests)} group plugin manifests for group {group_id}") + if merge_global: + global_plugins = get_global_actions(return_type=SecretReturnType.NAME) + all_plugin_manifests.extend(global_plugins) + debug_print(f"[SK Loader] Merged global plugins for group mode. Total manifests: {len(all_plugin_manifests)}") + elif mode_label == "per-user": if user_id: all_plugin_manifests = get_personal_actions(user_id, return_type=SecretReturnType.NAME) if merge_global: @@ -484,7 +521,7 @@ def load_agent_specific_plugins(kernel, plugin_names, settings, mode_label="glob all_plugin_manifests = [] else: # Global mode - get from global actions container - all_plugin_manifests = get_global_plugins(return_type=SecretReturnType.NAME) + all_plugin_manifests = get_global_actions(return_type=SecretReturnType.NAME) print(f"[SK Loader] Retrieved {len(all_plugin_manifests)} global plugin manifests") # Filter manifests to only include requested plugins @@ -559,7 +596,15 @@ def load_agent_specific_plugins(kernel, plugin_names, settings, mode_label="glob # Fallback to original method try: # Get plugin manifests again for fallback - if mode_label == "per-user": + if mode_label == "group": + if group_id: + all_plugin_manifests = get_group_actions(group_id, return_type=SecretReturnType.NAME) + if merge_global: + global_plugins = get_global_actions(return_type=SecretReturnType.NAME) + all_plugin_manifests.extend(global_plugins) + else: + all_plugin_manifests = [] + elif mode_label == "per-user": if user_id: all_plugin_manifests = get_personal_actions(user_id, return_type=SecretReturnType.NAME) if merge_global: @@ -672,6 +717,7 @@ def load_single_agent_for_kernel(kernel, agent_cfg, settings, context_obj, redis context_obj.redis_client = redis_client agent_objs = {} agent_config = resolve_agent_config(agent_cfg, settings) + agent_type = (agent_config.get("agent_type") or agent_cfg.get("agent_type") or "local").lower() service_id = f"aoai-chat-{agent_config['name']}" chat_service = None apim_enabled = settings.get("enable_gpt_apim", False) @@ -757,12 +803,27 @@ def load_single_agent_for_kernel(kernel, agent_cfg, settings, context_obj, redis # Load agent-specific plugins into the kernel before creating the agent if agent_config.get("actions_to_load"): print(f"[SK Loader] Loading agent-specific plugins: {agent_config['actions_to_load']}") - # Determine plugin source based on agent's global status, not overall mode + # Determine plugin source based on agent scope agent_is_global = agent_config.get("is_global", False) - plugin_mode = "global" if agent_is_global else mode_label - user_id = get_current_user_id() if not agent_is_global else None - print(f"[SK Loader] Agent is_global: {agent_is_global}, using plugin_mode: {plugin_mode}") - load_agent_specific_plugins(kernel, agent_config["actions_to_load"], settings, plugin_mode, user_id=user_id) + agent_is_group = agent_config.get("is_group", False) + if agent_is_global: + plugin_mode = "global" + elif agent_is_group: + plugin_mode = "group" + else: + plugin_mode = mode_label + + resolved_user_id = None if agent_is_global else get_current_user_id() + group_id = agent_config.get("group_id") if agent_is_group else None + print(f"[SK Loader] Agent scope - is_global: {agent_is_global}, is_group: {agent_is_group}, plugin_mode: {plugin_mode}, group_id: {group_id}") + load_agent_specific_plugins( + kernel, + agent_config["actions_to_load"], + settings, + plugin_mode, + user_id=resolved_user_id, + group_id=group_id, + ) try: kwargs = { @@ -776,7 +837,7 @@ def load_single_agent_for_kernel(kernel, agent_cfg, settings, context_obj, redis "default_agent": agent_config.get("default_agent", False), "deployment_name": agent_config["deployment"], "azure_endpoint": agent_config["endpoint"], - "api_version": agent_config["api_version"] + "api_version": agent_config["api_version"], } # Don't pass plugins to agent since they're already loaded in kernel agent_obj = LoggingChatCompletionAgent(**kwargs) @@ -789,7 +850,9 @@ def load_single_agent_for_kernel(kernel, agent_cfg, settings, context_obj, redis "aoai_endpoint": agent_config["endpoint"], "aoai_key": f"{agent_config['key'][:3]}..." if agent_config["key"] else None, "aoai_deployment": agent_config["deployment"], - "agent_name": agent_config["name"] + "agent_name": agent_config["name"], + "max_completion_tokens": agent_config.get("max_completion_tokens", -1), + "agent_type": agent_type, }, level=logging.INFO ) @@ -1066,13 +1129,67 @@ def load_user_semantic_kernel(kernel: Kernel, settings, user_id: str, redis_clie # Ensure migration is complete (will migrate any remaining legacy data) ensure_agents_migration_complete(user_id) agents_cfg = get_personal_agents(user_id) - + print(f"[SK Loader] User settings found {len(agents_cfg)} agents for user '{user_id}'") - + # Always mark user agents as is_global: False for agent in agents_cfg: agent['is_global'] = False + # Append selected group agent (if any) to the candidate list so downstream selection logic can resolve it + selected_agent_data = selected_agent if isinstance(selected_agent, dict) else {} + selected_agent_is_group = selected_agent_data.get('is_group', False) + if selected_agent_is_group: + resolved_group_id = selected_agent_data.get('group_id') + try: + active_group_id = require_active_group(user_id) + if not resolved_group_id: + resolved_group_id = active_group_id + elif resolved_group_id != active_group_id: + debug_print( + f"[SK Loader] Selected group agent references group {resolved_group_id}, active group is {active_group_id}." + ) + except ValueError as err: + debug_print(f"[SK Loader] No active group available while loading group agent: {err}") + if not resolved_group_id: + log_event( + "[SK Loader] Group agent selected but no active group in settings.", + level=logging.WARNING + ) + + if resolved_group_id: + agent_identifier = selected_agent_data.get('id') or selected_agent_data.get('name') + group_agent_cfg = None + if agent_identifier: + group_agent_cfg = get_group_agent(resolved_group_id, agent_identifier) + if not group_agent_cfg: + # Fallback: search by name across group agents if ID lookup failed + for candidate in get_group_agents(resolved_group_id): + if candidate.get('name') == selected_agent_data.get('name'): + group_agent_cfg = candidate + break + + if group_agent_cfg: + group_agent_cfg['is_global'] = False + group_agent_cfg['is_group'] = True + group_agent_cfg.setdefault('group_id', resolved_group_id) + group_agent_cfg['group_name'] = selected_agent_data.get('group_name') + agents_cfg.append(group_agent_cfg) + log_event( + f"[SK Loader] Added group agent '{group_agent_cfg.get('name')}' from group {resolved_group_id} to candidate list.", + level=logging.INFO + ) + else: + log_event( + f"[SK Loader] Selected group agent '{selected_agent_data.get('name')}' not found for group {resolved_group_id}.", + level=logging.WARNING + ) + else: + log_event( + "[SK Loader] Unable to resolve group ID for selected group agent; skipping group agent load.", + level=logging.WARNING + ) + # PATCH: Merge global agents if enabled merge_global = settings.get('merge_global_semantic_kernel_with_workspace', False) print(f"[SK Loader] merge_global_semantic_kernel_with_workspace: {merge_global}") @@ -1092,9 +1209,11 @@ def load_user_semantic_kernel(kernel: Kernel, settings, user_id: str, redis_clie key = f"global_{agent['name']}" all_agents[key] = agent - # Add personal agents with 'personal_' prefix + # Add personal and group agents with scoped prefixes for agent in agents_cfg: - key = f"personal_{agent['name']}" + prefix = "group" if agent.get('is_group') else "personal" + scoped_name = agent.get('name') or agent.get('id') or 'unnamed' + key = f"{prefix}_{scoped_name}" all_agents[key] = agent agents_cfg = list(all_agents.values()) @@ -1253,7 +1372,17 @@ def load_user_semantic_kernel(kernel: Kernel, settings, user_id: str, redis_clie debug_print(f"[SK Loader] User {user_id} Agent azure_deployment: {agent_cfg.get('azure_deployment', 'NOT SET')}") print(f"[SK Loader] User {user_id} Loading agent: {agent_cfg.get('name')}") - kernel, agent_objs = load_single_agent_for_kernel(kernel, agent_cfg, settings, g, redis_client=redis_client, mode_label="per-user") + agent_type = (agent_cfg.get('agent_type') or 'local').lower() + agent_cfg['agent_type'] = agent_type + if agent_type == 'local': + kernel, agent_objs = load_single_agent_for_kernel(kernel, agent_cfg, settings, g, redis_client=redis_client, mode_label="per-user") + else: + log_event( + f"[SK Loader] Unsupported agent_type '{agent_type}' for agent '{agent_cfg.get('name')}'. Defaulting to local path.", + level=logging.WARNING, + extra={'agent_type': agent_type, 'agent_name': agent_cfg.get('name')} + ) + kernel, agent_objs = load_single_agent_for_kernel(kernel, agent_cfg, settings, g, redis_client=redis_client, mode_label="per-user") print(f"[SK Loader] User {user_id} Agent loading completed. Agent objects: {type(agent_objs)} with {len(agent_objs) if agent_objs else 0} items") return kernel, agent_objs @@ -1557,7 +1686,17 @@ def load_semantic_kernel(kernel: Kernel, settings): if global_selected_agent_cfg: log_event(f"[SK Loader] Using global_selected_agent: {global_selected_agent_cfg.get('name')}", level=logging.INFO) - kernel, agent_objs = load_single_agent_for_kernel(kernel, global_selected_agent_cfg, settings, builtins, redis_client=None, mode_label="global") + agent_type = (global_selected_agent_cfg.get('agent_type') or 'local').lower() + global_selected_agent_cfg['agent_type'] = agent_type + if agent_type == 'local': + kernel, agent_objs = load_single_agent_for_kernel(kernel, global_selected_agent_cfg, settings, builtins, redis_client=None, mode_label="global") + else: + log_event( + f"[SK Loader] Unsupported agent_type '{agent_type}' for global agent '{global_selected_agent_cfg.get('name')}'. Defaulting to local path.", + level=logging.WARNING, + extra={'agent_type': agent_type, 'agent_name': global_selected_agent_cfg.get('name')} + ) + kernel, agent_objs = load_single_agent_for_kernel(kernel, global_selected_agent_cfg, settings, builtins, redis_client=None, mode_label="global") log_event(f"[SK Loader] load_single_agent_for_kernel returned agent_objs: {type(agent_objs)} with {len(agent_objs) if agent_objs else 0} agents", level=logging.INFO) else: log_event("[SK Loader] No global_selected_agent found. Proceeding in kernel-only mode.", level=logging.WARNING) diff --git a/application/single_app/static/css/sidebar.css b/application/single_app/static/css/sidebar.css index 5ec8fead2..999b44c76 100644 --- a/application/single_app/static/css/sidebar.css +++ b/application/single_app/static/css/sidebar.css @@ -45,6 +45,18 @@ body.has-classification-banner #sidebar-nav { height: calc(100vh - 40px) !important; /* Adjust height to account for banner */ } +/* Chats top-nav layout: align the fixed sidebar just below the navbar */ +nav.navbar.fixed-top + #sidebar-nav { + top: 66px !important; + height: calc(100vh - 66px); +} + +/* Account for classification banner when present */ +body.has-classification-banner nav.navbar + #sidebar-nav { + top: 98px !important; + height: calc(100vh - 98px); +} + /* Floating expand button positioning when classification banner is present */ body.has-classification-banner #floating-expand-btn { top: calc(0.5rem + 40px) !important; /* Start below the classification banner */ diff --git a/application/single_app/static/js/agent_modal_stepper.js b/application/single_app/static/js/agent_modal_stepper.js index 81df7aa8c..eb5736240 100644 --- a/application/single_app/static/js/agent_modal_stepper.js +++ b/application/single_app/static/js/agent_modal_stepper.js @@ -1143,6 +1143,7 @@ export class AgentModalStepper { try { // Get agent data from form const agentData = this.getAgentFormData(); + agentData.agent_type = (this.originalAgent?.agent_type) || agentData.agent_type || 'local'; // Validate required fields if (!agentData.display_name || !agentData.name) { @@ -1195,23 +1196,6 @@ export class AgentModalStepper { } }); - // Validate with schema if available - try { - if (!window.validateAgent) { - window.validateAgent = (await import('/static/js/validateAgent.mjs')).default; - } - const valid = window.validateAgent(agentData); - if (!valid) { - let errorMsg = 'Validation error: Invalid agent data.'; - if (window.validateAgent.errors && window.validateAgent.errors.length) { - errorMsg += '\n' + window.validateAgent.errors.map(e => `${e.instancePath} ${e.message}`).join('\n'); - } - throw new Error(errorMsg); - } - } catch (e) { - console.warn('Schema validation failed:', e.message); - } - // Use appropriate endpoint and save method based on context let saveBtn = document.getElementById('agent-modal-save-btn'); const originalText = saveBtn.innerHTML; @@ -1252,7 +1236,8 @@ export class AgentModalStepper { model: document.getElementById('agent-global-model-select')?.value || '', custom_connection: document.getElementById('agent-custom-connection')?.checked || false, other_settings: document.getElementById('agent-additional-settings')?.value || '{}', - max_completion_tokens: parseInt(document.getElementById('agent-max-completion-tokens')?.value.trim()) || null + max_completion_tokens: parseInt(document.getElementById('agent-max-completion-tokens')?.value.trim()) || null, + agent_type: 'local' }; // Handle model and deployment configuration diff --git a/application/single_app/static/js/agents_common.js b/application/single_app/static/js/agents_common.js index 0157d5f5e..8ae1333be 100644 --- a/application/single_app/static/js/agents_common.js +++ b/application/single_app/static/js/agents_common.js @@ -98,7 +98,8 @@ export function getAgentModalFields(opts = {}) { instructions: root.getElementById('agent-instructions').value.trim(), max_completion_tokens: parseInt(root.getElementById('agent-max-completion-tokens').value.trim()) || null, actions_to_load: actions_to_load, - other_settings: additionalSettings + other_settings: additionalSettings, + agent_type: (opts.agent && opts.agent.agent_type) || 'local' }; } /** @@ -473,6 +474,31 @@ export async function fetchUserAgents() { return await res.json(); } +export async function fetchGroupAgentsForActiveGroup() { + if (typeof window === 'undefined' || !window.activeGroupId) { + return []; + } + try { + const res = await fetch('/api/group/agents'); + if (!res.ok) { + console.warn('Group agents request failed:', res.status, res.statusText); + return []; + } + const payload = await res.json().catch(() => ({ agents: [] })); + const agents = Array.isArray(payload.agents) ? payload.agents : []; + const activeGroupName = (typeof window !== 'undefined' && window.activeGroupName) ? window.activeGroupName : ''; + return agents.map(agent => ({ + ...agent, + is_group: true, + group_id: agent.group_id || window.activeGroupId, + group_name: agent.group_name || activeGroupName || null + })); + } catch (error) { + console.error('Failed to fetch group agents:', error); + return []; + } +} + /** * Fetch selected agent from user settings * @returns {Promise} Selected agent object or null @@ -505,23 +531,63 @@ export function populateAgentSelect(selectEl, agents, selectedAgentObj) { console.log('DEBUG: populateAgentSelect called with agents:', agents); console.log('DEBUG: Number of agents:', agents.length); agents.forEach((agent, index) => { - console.log(`DEBUG: Agent ${index}: name="${agent.name}", is_global=${agent.is_global}, display_name="${agent.display_name}"`); + console.log(`DEBUG: Agent ${index}: name="${agent.name}", is_global=${agent.is_global}, is_group=${agent.is_group}, display_name="${agent.display_name}"`); }); + const getDisplayLabel = (agent) => (agent.display_name || agent.displayName || agent.name || '').trim(); + const displayLabelCounts = agents.reduce((acc, agent) => { + const label = getDisplayLabel(agent).toLowerCase(); + if (!label) { + return acc; + } + acc[label] = (acc[label] || 0) + 1; + return acc; + }, {}); + let selectedAgentName = typeof selectedAgentObj === 'object' ? selectedAgentObj.name : selectedAgentObj; + const selectedAgentId = typeof selectedAgentObj === 'object' ? (selectedAgentObj.id || selectedAgentObj.agent_id) : null; + const selectedAgentIsGlobal = typeof selectedAgentObj === 'object' ? !!selectedAgentObj.is_global : false; + const selectedAgentIsGroup = typeof selectedAgentObj === 'object' ? !!selectedAgentObj.is_group : false; + const selectedAgentGroupId = typeof selectedAgentObj === 'object' ? (selectedAgentObj.group_id || selectedAgentObj.groupId || null) : null; console.log('DEBUG: Selected agent name:', selectedAgentName); agents.forEach(agent => { let opt = document.createElement('option'); - // Use unique value that combines name and global status to distinguish between personal and global agents with same name - opt.value = agent.is_global ? `global_${agent.name}` : `personal_${agent.name}`; - opt.textContent = (agent.display_name || agent.name) + (agent.is_global ? ' (Global)' : ''); - // For selection matching, check if this agent matches the selected agent (by name and global status) + const agentId = agent.id || agent.agent_id || agent.name; + const contextPrefix = agent.is_group ? 'group' : (agent.is_global ? 'global' : 'personal'); + opt.value = `${contextPrefix}_${agentId}`; + const groupName = agent.group_name || agent.groupName || ''; + const displayLabel = getDisplayLabel(agent); + const labelKey = displayLabel.toLowerCase(); + const hasDuplicateLabel = labelKey && displayLabelCounts[labelKey] > 1; + let labelSuffix = ''; + if (agent.is_group) { + if (hasDuplicateLabel) { + labelSuffix = ` (Group${groupName ? `: ${groupName}` : ''})`; + } + } else if (agent.is_global) { + labelSuffix = ' (Global)'; + } + opt.textContent = `${displayLabel}${labelSuffix}`; + opt.dataset.name = agent.name || ''; + opt.dataset.displayName = displayLabel; + opt.dataset.agentId = agentId || ''; + opt.dataset.isGlobal = agent.is_global ? 'true' : 'false'; + opt.dataset.isGroup = agent.is_group ? 'true' : 'false'; + opt.dataset.groupId = agent.group_id || agent.groupId || ''; + opt.dataset.groupName = groupName || ''; + // For selection matching, prefer ID if available, otherwise fallback to name/context if (selectedAgentObj && typeof selectedAgentObj === 'object') { - if (agent.name === selectedAgentObj.name && agent.is_global === selectedAgentObj.is_global) { + const candidateIds = [agentId, agent.id, agent.agent_id].filter(Boolean).map(String); + const selectedIds = [selectedAgentId].filter(Boolean).map(String); + const idMatches = selectedIds.length > 0 && selectedIds.some(selId => candidateIds.includes(selId)); + const nameMatches = agent.name === selectedAgentObj.name; + const contextMatches = (!!agent.is_global === selectedAgentIsGlobal) && (!!agent.is_group === selectedAgentIsGroup); + const groupMatches = !selectedAgentIsGroup || selectedAgentGroupId === null || String(agent.group_id || agent.groupId || '') === String(selectedAgentGroupId || ''); + if ((idMatches || nameMatches) && contextMatches && groupMatches) { opt.selected = true; } - } else if (agent.name === selectedAgentName && !agent.is_global) { + } else if (agent.name === selectedAgentName && !agent.is_global && !agent.is_group) { // Default to personal agent if just name is provided opt.selected = true; } diff --git a/application/single_app/static/js/chat/chat-agents.js b/application/single_app/static/js/chat/chat-agents.js index ace05642d..015c3fbc1 100644 --- a/application/single_app/static/js/chat/chat-agents.js +++ b/application/single_app/static/js/chat/chat-agents.js @@ -1,5 +1,13 @@ // chat-agents.js -import { fetchUserAgents, fetchSelectedAgent, populateAgentSelect, setSelectedAgent, getUserSetting, setUserSetting } from '../agents_common.js'; +import { + fetchUserAgents, + fetchGroupAgentsForActiveGroup, + fetchSelectedAgent, + populateAgentSelect, + setSelectedAgent, + getUserSetting, + setUserSetting +} from '../agents_common.js'; const enableAgentsBtn = document.getElementById("enable-agents-btn"); const agentSelectContainer = document.getElementById("agent-select-container"); @@ -43,40 +51,38 @@ export async function initializeAgentInteractions() { export async function populateAgentDropdown() { const agentSelect = agentSelectContainer.querySelector('select'); try { - const agents = await fetchUserAgents(); - const selectedAgent = await fetchSelectedAgent(); - populateAgentSelect(agentSelect, agents, selectedAgent); - agentSelect.onchange = async function() { - const selectedValue = agentSelect.value; - console.log('DEBUG: Agent dropdown changed to:', selectedValue); - console.log('DEBUG: Available agents:', agents); - - // Parse the selected value to extract name and global status - let selectedAgentObj = null; - if (selectedValue.startsWith('global_')) { - const agentName = selectedValue.substring(7); // Remove 'global_' prefix - selectedAgentObj = agents.find(a => a.name === agentName && a.is_global === true); - } else if (selectedValue.startsWith('personal_')) { - const agentName = selectedValue.substring(9); // Remove 'personal_' prefix - selectedAgentObj = agents.find(a => a.name === agentName && a.is_global === false); - } else { - // Fallback for agents without prefix (backwards compatibility) - selectedAgentObj = agents.find(a => a.name === selectedValue); + const [userAgents, selectedAgent] = await Promise.all([ + fetchUserAgents(), + fetchSelectedAgent() + ]); + const groupAgents = await fetchGroupAgentsForActiveGroup(); + const combinedAgents = [...userAgents, ...groupAgents]; + const personalAgents = combinedAgents.filter(agent => !agent.is_global && !agent.is_group); + const activeGroupAgents = combinedAgents.filter(agent => agent.is_group); + const globalAgents = combinedAgents.filter(agent => agent.is_global); + const orderedAgents = [...personalAgents, ...activeGroupAgents, ...globalAgents]; + populateAgentSelect(agentSelect, orderedAgents, selectedAgent); + agentSelect.onchange = async function () { + const selectedOption = agentSelect.options[agentSelect.selectedIndex]; + if (!selectedOption) { + return; } - - console.log('DEBUG: Found agent object:', selectedAgentObj); - - if (selectedAgentObj) { - const payload = { name: selectedAgentObj.name, is_global: !!selectedAgentObj.is_global }; - console.log('DEBUG: Setting selected agent payload:', payload); - console.log('DEBUG: Agent is_global flag:', selectedAgentObj.is_global); - console.log('DEBUG: !!selectedAgentObj.is_global:', !!selectedAgentObj.is_global); - - await setSelectedAgent(payload); - console.log('DEBUG: Agent selection saved successfully'); - } else { - console.log('DEBUG: No agent found with value:', selectedValue); + const payload = { + name: selectedOption.dataset.name || '', + display_name: selectedOption.dataset.displayName || selectedOption.textContent || '', + id: selectedOption.dataset.agentId || null, + is_global: selectedOption.dataset.isGlobal === 'true', + is_group: selectedOption.dataset.isGroup === 'true', + group_id: selectedOption.dataset.groupId || null, + group_name: selectedOption.dataset.groupName || (window.activeGroupName || null) + }; + console.log('DEBUG: Agent dropdown changed with payload:', payload); + if (!payload.name) { + console.warn('Selected agent is missing a name, skipping settings update.'); + return; } + await setSelectedAgent(payload); + console.log('DEBUG: Agent selection saved successfully'); }; } catch (e) { console.error('Error loading agents:', e); diff --git a/application/single_app/static/js/chat/chat-messages.js b/application/single_app/static/js/chat/chat-messages.js index b5419eeed..61b52e2dc 100644 --- a/application/single_app/static/js/chat/chat-messages.js +++ b/application/single_app/static/js/chat/chat-messages.js @@ -993,11 +993,15 @@ export function actuallySendMessage(finalMessageToSend) { const agentSelect = document.getElementById("agent-select"); if (agentSelectContainer && agentSelectContainer.style.display !== "none" && agentSelect) { const selectedAgentOption = agentSelect.options[agentSelect.selectedIndex]; - if (selectedAgentOption && selectedAgentOption.value) { + if (selectedAgentOption) { agentInfo = { - name: selectedAgentOption.value, - display_name: selectedAgentOption.textContent, - is_global: selectedAgentOption.textContent.includes("(Global)") + 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 }; } } diff --git a/application/single_app/static/js/chat/chat-sidebar-conversations.js b/application/single_app/static/js/chat/chat-sidebar-conversations.js index bfbba5c6b..a1d6f70ba 100644 --- a/application/single_app/static/js/chat/chat-sidebar-conversations.js +++ b/application/single_app/static/js/chat/chat-sidebar-conversations.js @@ -50,6 +50,19 @@ function createSidebarConversationItem(convo) { const convoItem = document.createElement("div"); convoItem.classList.add("sidebar-conversation-item"); convoItem.setAttribute("data-conversation-id", convo.id); + if (convo.chat_type) { + convoItem.setAttribute("data-chat-type", convo.chat_type); + } + let groupName = null; + if (Array.isArray(convo.context)) { + const primaryGroupContext = convo.context.find(ctx => ctx.type === "primary" && ctx.scope === "group"); + if (primaryGroupContext) { + groupName = primaryGroupContext.name || null; + } + } + if (groupName) { + convoItem.setAttribute("data-group-name", groupName); + } convoItem.innerHTML = `
@@ -67,6 +80,32 @@ function createSidebarConversationItem(convo) {
`; + + const headerRow = convoItem.querySelector(".d-flex.justify-content-between.align-items-center"); + const dropdownElement = headerRow ? headerRow.querySelector('.conversation-dropdown') : null; + const originalTitleElement = headerRow ? headerRow.querySelector('.sidebar-conversation-title') : null; + + if (headerRow && dropdownElement && originalTitleElement) { + const titleWrapper = document.createElement('div'); + titleWrapper.classList.add('sidebar-conversation-header', 'd-flex', 'align-items-center', 'flex-grow-1', 'overflow-hidden', 'gap-2'); + + // Ensure the title can truncate correctly within the new wrapper + originalTitleElement.classList.add('flex-grow-1', 'text-truncate'); + originalTitleElement.style.minWidth = '0'; + + titleWrapper.appendChild(originalTitleElement); + + const isGroupConversation = (convo.chat_type && convo.chat_type.startsWith('group')) || groupName; + if (isGroupConversation) { + const badge = document.createElement('span'); + badge.classList.add('badge', 'bg-info', 'sidebar-conversation-group-badge'); + badge.textContent = 'group'; + badge.title = groupName ? `Group conversation: ${groupName}` : 'Group conversation'; + titleWrapper.appendChild(badge); + } + + headerRow.insertBefore(titleWrapper, dropdownElement); + } // Add double-click editing to title const titleElement = convoItem.querySelector('.sidebar-conversation-title'); diff --git a/application/single_app/static/js/plugin_common.js b/application/single_app/static/js/plugin_common.js index 3e399d313..e40158b9a 100644 --- a/application/single_app/static/js/plugin_common.js +++ b/application/single_app/static/js/plugin_common.js @@ -291,30 +291,9 @@ export async function showPluginModal({ } } -// Validate plugin manifest with server-side validation +// Validate plugin manifest using server-side validation only export async function validatePluginManifest(pluginManifest) { - try { - // Try client-side validation first if available - if (!window.validatePlugin) { - try { - window.validatePlugin = (await import('/static/js/validatePlugin.mjs')).default; - } catch (importError) { - console.warn('Client-side validation module failed to load, falling back to server-side validation:', importError); - // Fallback to server-side validation - return await validatePluginManifestServerSide(pluginManifest); - } - } - - const result = window.validatePlugin(pluginManifest); - if (result === true) { - return { valid: true, errors: [] }; - } else { - return { valid: false, errors: result.errors || ['Validation failed'] }; - } - } catch (error) { - console.warn('Client-side validation failed, falling back to server-side validation:', error); - return await validatePluginManifestServerSide(pluginManifest); - } + return await validatePluginManifestServerSide(pluginManifest); } // Server-side validation fallback diff --git a/application/single_app/static/js/workspace/group_agents.js b/application/single_app/static/js/workspace/group_agents.js new file mode 100644 index 000000000..98a291f8a --- /dev/null +++ b/application/single_app/static/js/workspace/group_agents.js @@ -0,0 +1,389 @@ +// group_agents.js +// Handles group agent management within the group workspace UI + +import { showToast } from "../chat/chat-toast.js"; +import * as agentsCommon from "../agents_common.js"; +import { AgentModalStepper } from "../agent_modal_stepper.js"; + +const tableBody = document.getElementById("group-agents-table-body"); +const errorContainer = document.getElementById("group-agents-error"); +const searchInput = document.getElementById("group-agents-search"); +const createButton = document.getElementById("create-group-agent-btn"); +const permissionWarning = document.getElementById("group-agents-permission-warning"); + +let agents = []; +let filteredAgents = []; +let agentStepper = null; +let currentContext = window.groupWorkspaceContext || { + activeGroupId: null, + activeGroupName: "", + userRole: null +}; + +function escapeHtml(value) { + if (!value) return ""; + return value.replace(/[&<>"']/g, (char) => ({ + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'" + }[char] || char)); +} + +function canManageAgents() { + const role = currentContext?.userRole; + return role === "Owner" || role === "Admin"; +} + +function truncateName(name, maxLength = 18) { + if (!name || name.length <= maxLength) return name || ""; + return `${name.substring(0, maxLength)}…`; +} + +function updatePermissionUI() { + const canManage = canManageAgents(); + if (createButton) { + createButton.classList.toggle("d-none", !canManage); + createButton.disabled = !canManage; + } + if (permissionWarning) { + permissionWarning.classList.toggle("d-none", canManage); + } +} + +function renderLoading() { + if (!tableBody) return; + tableBody.innerHTML = ` + + +
+ Loading… +
+ Loading group agents… + + `; + if (errorContainer) { + errorContainer.innerHTML = ""; + } +} + +function renderNoGroupSelected() { + if (!tableBody) return; + tableBody.innerHTML = ` + + + Select a group to load agents. + + `; +} + +function renderError(message) { + if (errorContainer) { + errorContainer.innerHTML = `
${escapeHtml(message)}
`; + } + if (tableBody) { + tableBody.innerHTML = ""; + } +} + +function renderAgentsTable(list) { + if (!tableBody) return; + + if (!list.length) { + tableBody.innerHTML = ` + + + No group agents found. + + `; + return; + } + + const canManage = canManageAgents(); + tableBody.innerHTML = ""; + + list.forEach((agent) => { + const tr = document.createElement("tr"); + const displayName = truncateName(agent.display_name || agent.displayName || agent.name || ""); + const description = escapeHtml(agent.description || "No description available."); + + let actionsHtml = ""; + if (canManage) { + actionsHtml = ` + + `; + } + + tr.innerHTML = ` + ${escapeHtml(displayName)} + ${description} + ${actionsHtml}`; + + tableBody.appendChild(tr); + }); +} + +function filterAgents(term) { + if (!term) { + filteredAgents = agents.slice(); + } else { + const needle = term.toLowerCase(); + filteredAgents = agents.filter((agent) => { + const name = (agent.display_name || agent.displayName || agent.name || "").toLowerCase(); + const description = (agent.description || "").toLowerCase(); + return name.includes(needle) || description.includes(needle); + }); + } + renderAgentsTable(filteredAgents); +} + +function overrideAgentStepper(stepper) { + stepper.loadAvailableActions = async function loadGroupActions() { + const container = document.getElementById("agent-actions-container"); + const emptyMessage = document.getElementById("agent-no-actions-message"); + if (!container) return; + + try { + container.innerHTML = ` +
+
+ Loading… +
+

Loading available group actions…

+
`; + + const response = await fetch("/api/group/plugins"); + const payload = await response.json().catch(() => ({ actions: [] })); + if (!response.ok) { + throw new Error(payload?.error || response.statusText || "Failed to load actions"); + } + + const actions = Array.isArray(payload.actions) ? payload.actions : []; + const normalized = actions.map((action) => ({ + ...action, + display_name: action.display_name || action.displayName || action.name || "", + description: action.description || "", + is_global: Boolean(action.is_global) + })); + + normalized.sort((a, b) => { + const nameA = (a.display_name || a.name || "").toLowerCase(); + const nameB = (b.display_name || b.name || "").toLowerCase(); + return nameA.localeCompare(nameB); + }); + + container.innerHTML = ""; + + if (!normalized.length) { + container.style.display = "none"; + if (emptyMessage) emptyMessage.classList.remove("d-none"); + return; + } + + container.style.display = ""; + if (emptyMessage) emptyMessage.classList.add("d-none"); + + normalized.forEach((action) => { + const card = this.createActionCard(action); + container.appendChild(card); + }); + + this.initializeActionSearch(normalized); + + if (this.actionsToSelect && Array.isArray(this.actionsToSelect)) { + this.setSelectedActions(this.actionsToSelect); + this.actionsToSelect = null; + } + } catch (error) { + console.error("Error loading group actions:", error); + container.innerHTML = ` +
+
Unable to load group actions. ${escapeHtml(error.message || "")}
+
`; + } + }; + + stepper.savePersonalAgent = async function saveGroupAgent(agentData) { + const payload = { ...agentData }; + const isEdit = this.isEditMode && this.originalAgent && (this.originalAgent.id || this.originalAgent.name); + + if (!isEdit || !payload.id) { + payload.id = payload.id || crypto.randomUUID(); + } + + const agentId = encodeURIComponent(payload.id || ""); + const url = isEdit ? `/api/group/agents/${agentId}` : "/api/group/agents"; + const method = isEdit ? "PATCH" : "POST"; + + const response = await fetch(url, { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); + + let body = null; + try { + body = await response.json(); + } catch (parseError) { + body = null; + } + + if (!response.ok) { + const message = body?.error || `Failed to ${isEdit ? "update" : "create"} group agent`; + throw new Error(message); + } + + this.handleSaveSuccess(); + if (typeof window.fetchGroupAgents === "function") { + await window.fetchGroupAgents(); + } + }; + + return stepper; +} + +function getAgentStepper() { + if (!agentStepper) { + agentStepper = overrideAgentStepper(new AgentModalStepper(false)); + window.agentModalStepper = agentStepper; + } + return agentStepper; +} + +async function openAgentModal(agent = null) { + if (!canManageAgents()) { + showToast("You do not have permission to manage group agents.", "warning"); + return; + } + + try { + const stepper = getAgentStepper(); + await stepper.showModal(agent); + agentsCommon.setupApimToggle( + document.getElementById("agent-enable-apim"), + document.getElementById("agent-apim-fields"), + document.getElementById("agent-gpt-fields"), + () => agentsCommon.loadGlobalModelsForModal({ + endpoint: "/api/user/agent/settings", + agent, + globalModelSelect: document.getElementById("agent-global-model-select"), + isGlobal: false, + customConnectionCheck: agentsCommon.shouldEnableCustomConnection, + deploymentFieldIds: { gpt: "agent-gpt-deployment", apim: "agent-apim-deployment" } + }) + ); + } catch (error) { + console.error("Error opening group agent modal:", error); + showToast(error.message || "Unable to open agent modal.", "danger"); + } +} + +async function deleteGroupAgent(agentId) { + if (!canManageAgents()) { + showToast("You do not have permission to delete group agents.", "warning"); + return; + } + + if (!agentId) return; + if (!confirm("Delete this group agent?")) return; + + try { + const response = await fetch(`/api/group/agents/${encodeURIComponent(agentId)}`, { + method: "DELETE" + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(payload?.error || "Failed to delete group agent"); + } + showToast("Group agent deleted successfully.", "success"); + await fetchGroupAgents(); + } catch (error) { + console.error("Error deleting group agent:", error); + showToast(error.message || "Unable to delete group agent.", "danger"); + } +} + +async function fetchGroupAgents() { + if (!tableBody) return; + + if (!currentContext?.activeGroupId) { + renderNoGroupSelected(); + return; + } + + renderLoading(); + + try { + const response = await fetch("/api/group/agents"); + const payload = await response.json().catch(() => ({ agents: [] })); + if (!response.ok) { + throw new Error(payload?.error || response.statusText || "Failed to load group agents"); + } + + agents = Array.isArray(payload.agents) ? payload.agents : []; + const searchTerm = searchInput?.value?.trim() || ""; + filterAgents(searchTerm); + } catch (error) { + console.error("Error loading group agents:", error); + renderError(error.message || "Unable to load group agents."); + } +} + +function handleTableClick(event) { + const editBtn = event.target.closest(".edit-group-agent-btn"); + if (editBtn) { + const agentId = editBtn.dataset.agentId; + const agent = agents.find((item) => item.id === agentId || item.name === agentId); + openAgentModal(agent || null); + return; + } + + const deleteBtn = event.target.closest(".delete-group-agent-btn"); + if (deleteBtn) { + const agentId = deleteBtn.dataset.agentId; + deleteGroupAgent(agentId); + } +} + +function bindEventHandlers() { + if (searchInput) { + searchInput.addEventListener("input", (event) => { + filterAgents(event.target.value.trim()); + }); + } + + if (createButton) { + createButton.addEventListener("click", () => openAgentModal()); + } + + if (tableBody) { + tableBody.addEventListener("click", handleTableClick); + } + + window.addEventListener("groupWorkspace:context-changed", (event) => { + currentContext = event.detail || currentContext; + updatePermissionUI(); + }); +} + +function initialize() { + updatePermissionUI(); + bindEventHandlers(); + + if (document.getElementById("group-agents-tab-btn")?.classList.contains("active")) { + fetchGroupAgents(); + } +} + +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initialize); +} else { + initialize(); +} + +window.fetchGroupAgents = fetchGroupAgents; diff --git a/application/single_app/static/js/workspace/group_plugins.js b/application/single_app/static/js/workspace/group_plugins.js new file mode 100644 index 000000000..58833c39c --- /dev/null +++ b/application/single_app/static/js/workspace/group_plugins.js @@ -0,0 +1,403 @@ +// group_plugins.js +// Handles group action management within the group workspace UI + +import { ensurePluginsTableInRoot, validatePluginManifest } from "../plugin_common.js"; +import { showToast } from "../chat/chat-toast.js"; + +const root = document.getElementById("group-plugins-root"); +const permissionWarning = document.getElementById("group-plugins-permission-warning"); + +let plugins = []; +let filteredPlugins = []; +let templateReady = false; +let listenersBound = false; +let currentContext = window.groupWorkspaceContext || { + activeGroupId: null, + activeGroupName: "", + userRole: null +}; + +function escapeHtml(value) { + if (!value) return ""; + return value.replace(/[&<>"']/g, (char) => ({ + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'" + }[char] || char)); +} + +function canManagePlugins() { + const role = currentContext?.userRole; + return role === "Owner" || role === "Admin"; +} + +function ensureTemplate() { + if (!root) return null; + if (!templateReady) { + ensurePluginsTableInRoot({ + rootSelector: "#group-plugins-root", + templateId: "group-plugins-table-template" + }); + templateReady = true; + updatePermissionUI(); + bindRootEvents(); + } + return document.getElementById("group-plugins-table-body"); +} + +function bindRootEvents() { + if (!root || listenersBound) return; + + root.addEventListener("input", (event) => { + if (event.target && event.target.id === "group-plugins-search") { + filterPlugins(event.target.value.trim()); + } + }); + + root.addEventListener("click", async (event) => { + const createBtn = event.target.closest("#create-group-plugin-btn"); + if (createBtn) { + event.preventDefault(); + openPluginModal(); + return; + } + + const editBtn = event.target.closest(".edit-group-plugin-btn"); + if (editBtn) { + const pluginId = editBtn.dataset.pluginId; + openPluginModal(pluginId); + return; + } + + const deleteBtn = event.target.closest(".delete-group-plugin-btn"); + if (deleteBtn) { + const pluginId = deleteBtn.dataset.pluginId; + deleteGroupPlugin(pluginId); + } + }); + + listenersBound = true; +} + +function renderLoading() { + if (!root) return; + root.innerHTML = ` +
+
+ Loading… +
+
`; + templateReady = false; +} + +function renderNoGroupSelected() { + if (!root) return; + root.innerHTML = ` +
+ Select a group to load actions. +
`; + templateReady = false; +} + +function renderError(message) { + if (!root) return; + root.innerHTML = ` +
+ ${escapeHtml(message)} +
`; + templateReady = false; +} + +function updatePermissionUI() { + if (!root) return; + const canManage = canManagePlugins(); + const createBtn = document.getElementById("create-group-plugin-btn"); + if (createBtn) { + createBtn.classList.toggle("d-none", !canManage); + createBtn.disabled = !canManage; + } + if (permissionWarning) { + permissionWarning.classList.toggle("d-none", canManage); + } +} + +function renderPluginsTable(list) { + const tbody = ensureTemplate(); + if (!tbody) return; + + tbody.innerHTML = ""; + if (!list.length) { + tbody.innerHTML = ` + + No group actions found. + `; + return; + } + + const canManage = canManagePlugins(); + list.forEach((plugin) => { + const tr = document.createElement("tr"); + const displayName = plugin.displayName || plugin.display_name || plugin.name || ""; + const description = plugin.description || "No description available."; + const isGlobal = Boolean(plugin.is_global); + + let actionsHtml = ""; + if (canManage && !isGlobal) { + actionsHtml = ` +
+ + +
`; + } else if (canManage && isGlobal) { + actionsHtml = "Managed globally"; + } + + const titleHtml = isGlobal + ? `${escapeHtml(displayName)} global` + : escapeHtml(displayName); + + tr.innerHTML = ` + ${titleHtml} + ${escapeHtml(description)} + ${actionsHtml}`; + + tbody.appendChild(tr); + }); +} + +function filterPlugins(term) { + if (!term) { + filteredPlugins = plugins.slice(); + } else { + const needle = term.toLowerCase(); + filteredPlugins = plugins.filter((plugin) => { + const name = (plugin.displayName || plugin.display_name || plugin.name || "").toLowerCase(); + const description = (plugin.description || "").toLowerCase(); + return name.includes(needle) || description.includes(needle); + }); + } + renderPluginsTable(filteredPlugins); +} + +async function fetchGroupPlugins() { + if (!root) return; + + if (!currentContext?.activeGroupId) { + renderNoGroupSelected(); + return; + } + + renderLoading(); + + try { + const response = await fetch("/api/group/plugins"); + const payload = await response.json().catch(() => ({ actions: [] })); + if (!response.ok) { + throw new Error(payload?.error || response.statusText || "Failed to load group actions"); + } + + plugins = (payload.actions || []).map((action) => ({ + ...action, + displayName: action.displayName || action.display_name || action.name || "", + description: action.description || "", + is_global: Boolean(action.is_global) + })); + filteredPlugins = plugins.slice(); + + renderPluginsTable(filteredPlugins); + updatePermissionUI(); + } catch (error) { + console.error("Error loading group actions:", error); + renderError(error.message || "Unable to load group actions."); + } +} + +async function openPluginModal(pluginId = null) { + if (!canManagePlugins()) { + showToast("You do not have permission to manage group actions.", "warning"); + return; + } + + if (!window.pluginModalStepper) { + showToast("Action modal is not available. Please refresh and try again.", "danger"); + return; + } + + let plugin = null; + if (pluginId) { + const cached = plugins.find((item) => item.id === pluginId || item.name === pluginId); + if (cached?.is_global) { + showToast("Global actions are read-only and managed by administrators.", "info"); + return; + } + try { + const response = await fetch(`/api/group/plugins/${encodeURIComponent(pluginId)}`); + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(payload?.error || "Failed to load group action"); + } + plugin = payload; + } catch (error) { + console.error("Error loading group action:", error); + showToast(error.message || "Unable to load action details.", "danger"); + return; + } + } + + try { + const modal = await window.pluginModalStepper.showModal(plugin); + setupSaveHandler(plugin, modal); + } catch (error) { + console.error("Error opening action modal:", error); + showToast(error.message || "Unable to open action modal.", "danger"); + } +} + +function setupSaveHandler(existingPlugin, modalInstance) { + const saveBtn = document.getElementById("save-plugin-btn"); + if (!saveBtn) return; + + saveBtn.onclick = null; + saveBtn.onclick = async (event) => { + event.preventDefault(); + + const errorDiv = document.getElementById("plugin-modal-error"); + if (errorDiv) { + errorDiv.classList.add("d-none"); + errorDiv.textContent = ""; + } + + try { + const formData = window.pluginModalStepper.getFormData(); + if (existingPlugin?.id) { + formData.id = existingPlugin.id; + } + + const validation = await validatePluginManifest(formData); + const validationFailed = validation === false || (validation && validation.valid === false); + if (validationFailed) { + const message = validation?.errors?.join("\n") || "Validation error: Invalid action data."; + if (window.pluginModalStepper?.showError) { + window.pluginModalStepper.showError(message); + } + return; + } + + const originalText = saveBtn.innerHTML; + saveBtn.innerHTML = `Saving…`; + saveBtn.disabled = true; + try { + await saveGroupPlugin(formData, existingPlugin); + } finally { + saveBtn.innerHTML = originalText; + saveBtn.disabled = false; + } + + if (modalInstance && typeof modalInstance.hide === "function") { + modalInstance.hide(); + } else { + bootstrap.Modal.getInstance(document.getElementById("plugin-modal"))?.hide(); + } + + showToast(existingPlugin ? "Group action updated successfully." : "Group action created successfully.", "success"); + await fetchGroupPlugins(); + } catch (error) { + console.error("Error saving group action:", error); + const message = error.message || "Unable to save group action."; + if (window.pluginModalStepper?.showError) { + window.pluginModalStepper.showError(message); + } else { + showToast(message, "danger"); + } + } + }; +} + +async function saveGroupPlugin(pluginManifest, existingPlugin) { + const payload = { + ...pluginManifest, + displayName: pluginManifest.displayName || pluginManifest.display_name || pluginManifest.name || "" + }; + + delete payload.is_global; + delete payload.scope; + + const hasId = Boolean(existingPlugin?.id || payload.id); + if (!payload.id && existingPlugin?.id) { + payload.id = existingPlugin.id; + } + + const url = hasId ? `/api/group/plugins/${encodeURIComponent(payload.id)}` : "/api/group/plugins"; + const method = hasId ? "PATCH" : "POST"; + + const response = await fetch(url, { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); + + const body = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(body?.error || `Failed to ${hasId ? "update" : "create"} group action`); + } + return body; +} + +async function deleteGroupPlugin(pluginId) { + if (!canManagePlugins()) { + showToast("You do not have permission to delete group actions.", "warning"); + return; + } + if (!pluginId) return; + + const cached = plugins.find((item) => item.id === pluginId || item.name === pluginId); + if (cached?.is_global) { + showToast("Global actions cannot be deleted from a group workspace.", "info"); + return; + } + if (!confirm("Delete this group action?")) return; + + try { + const response = await fetch(`/api/group/plugins/${encodeURIComponent(pluginId)}`, { + method: "DELETE" + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(payload?.error || "Failed to delete group action"); + } + showToast("Group action deleted successfully.", "success"); + await fetchGroupPlugins(); + } catch (error) { + console.error("Error deleting group action:", error); + showToast(error.message || "Unable to delete group action.", "danger"); + } +} + +function initialize() { + if (!root) return; + ensureTemplate(); + updatePermissionUI(); + + window.addEventListener("groupWorkspace:context-changed", (event) => { + currentContext = event.detail || currentContext; + updatePermissionUI(); + }); + + if (document.getElementById("group-plugins-tab-btn")?.classList.contains("active")) { + fetchGroupPlugins(); + } +} + +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initialize); +} else { + initialize(); +} + +window.fetchGroupPlugins = fetchGroupPlugins; diff --git a/application/single_app/static/json/schemas/agent.schema.json b/application/single_app/static/json/schemas/agent.schema.json index 786a8c99c..69652a155 100644 --- a/application/single_app/static/json/schemas/agent.schema.json +++ b/application/single_app/static/json/schemas/agent.schema.json @@ -66,6 +66,16 @@ "description": "True if this agent is a global agent; required for agent selection and UI badging.", "default": false }, + "is_group": { + "type": "boolean", + "description": "True if this agent is a group agent; required for agent selection and UI badging.", + "default": false + }, + "agent_type": { + "type": "string", + "enum": ["local", "aifoundry", "copilot"], + "description": "Type of agent that needs to be instantiated." + }, "instructions": { "type": "string" }, @@ -89,10 +99,12 @@ "display_name", "description", "is_global", + "is_group", "instructions", "actions_to_load", "other_settings", - "max_completion_tokens" + "max_completion_tokens", + "agent_type" ], "title": "Agent" } diff --git a/application/single_app/static/json/schemas/azure_billing_plugin.definition.json b/application/single_app/static/json/schemas/azure_billing_plugin.definition.json new file mode 100644 index 000000000..e69de29bb diff --git a/application/single_app/static/json/schemas/plugin.definition.schema.json b/application/single_app/static/json/schemas/plugin.definition.schema.json new file mode 100644 index 000000000..e69de29bb diff --git a/application/single_app/swagger_wrapper.py b/application/single_app/swagger_wrapper.py index 3b421f26e..101d1aceb 100644 --- a/application/single_app/swagger_wrapper.py +++ b/application/single_app/swagger_wrapper.py @@ -775,7 +775,7 @@ def register_swagger_routes(app: Flask): from functions_settings import get_settings # Check if swagger is enabled in settings - settings = get_settings() + settings = get_settings(use_cosmos=True) if not settings.get('enable_swagger', True): # Default to True if setting not found print("Swagger documentation is disabled in admin settings.") return diff --git a/application/single_app/templates/_sidebar_nav.html b/application/single_app/templates/_sidebar_nav.html index df81b4b06..cef4eb85e 100644 --- a/application/single_app/templates/_sidebar_nav.html +++ b/application/single_app/templates/_sidebar_nav.html @@ -137,6 +137,18 @@ Group Prompts + {% if settings.allow_group_agents and settings.enable_semantic_kernel %} + + + {% endif %} {% else %} diff --git a/application/single_app/templates/chats.html b/application/single_app/templates/chats.html index 828c9734b..5d6e618a6 100644 --- a/application/single_app/templates/chats.html +++ b/application/single_app/templates/chats.html @@ -465,6 +465,7 @@

Group Workspace

Group Prompts + {% if settings.enable_semantic_kernel and settings.allow_group_agents %} + + {% endif %} + {% if settings.enable_semantic_kernel and settings.allow_group_plugins %} + + {% endif %} @@ -560,6 +592,106 @@
Group Prompts
+ + {% if settings.enable_semantic_kernel and settings.allow_group_agents %} + +
+
+
+
Group Agents
+ +
+
+ You do not have permission to manage group agents. +
+
+ +
+ + + + + + + + + + + + + +
Display NameDescriptionActions
+
+ Loading... +
+ Select a group to load agents. +
+
+
+
+ + {% endif %} + + {% if settings.enable_semantic_kernel and settings.allow_group_plugins %} + +
+
+ You do not have permission to manage group actions. +
+
+ +
+ + {% endif %} @@ -729,6 +861,13 @@