diff --git a/application/single_app/config.py b/application/single_app/config.py index 807e762ac..1a4ccda41 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -97,7 +97,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.261.041" +VERSION = "0.261.042" IS_DEVELOPMENT = is_development_env_enabled() SESSION_COOKIE_SAMESITE = os.getenv('SESSION_COOKIE_SAMESITE', 'Lax') diff --git a/application/single_app/functions_workspace_sections.py b/application/single_app/functions_workspace_sections.py new file mode 100644 index 000000000..cf995ece5 --- /dev/null +++ b/application/single_app/functions_workspace_sections.py @@ -0,0 +1,236 @@ +# functions_workspace_sections.py + +"""Decide which sections of the personal workspace a given user may see. + +The personal workspace is assembled from eight independent capabilities, and whether any +one of them is available depends on three different kinds of check: + +- plain application settings, such as ``allow_user_agents`` +- per-user role checks, such as file sync and workflows, which read app roles +- governance policy, which can deny an individual user a capability the tenant enabled + +Those checks were previously spread between ``route_frontend_workspace`` and the Jinja +conditions in ``workspace.html``. The V2 interface cannot read either, so rather than +restate the rules a third time they are collected here and both interfaces call this. A +capability added or retired in one place then reaches both surfaces at once, which is the +same reasoning that keeps the chat catalog builders shared in ``route_backend_v2``. + +Each section reports *why* it is unavailable, not merely that it is. The V2 overview shows +disabled sections with their reason, because a section that silently disappears is the main +reason people cannot tell whether a capability is missing, broken, or simply not switched on +for them. +""" + +import logging + +from functions_appinsights import log_event +from functions_file_sync import is_file_sync_enabled_for_user +from functions_governance import ( + is_action_scope_access_allowed, + is_governance_access_allowed, +) +from functions_settings import is_user_workflows_enabled_for_user + +# Order matters: it is the order the sections are presented in, within their groups. +WORKSPACE_SECTION_IDS = ( + "documents", + "sync", + "prompts", + "agents", + "actions", + "workflows", + "identities", + "endpoints", +) + +# Grouping is reported with the availability so the two interfaces agree on where a +# section belongs. "knowledge" is what the assistant can draw on, "automation" is what it +# can do, and "connections" is the shared plumbing the other two reuse. +WORKSPACE_SECTION_GROUPS = { + "documents": "knowledge", + "sync": "knowledge", + "prompts": "knowledge", + "agents": "automation", + "actions": "automation", + "workflows": "automation", + "identities": "connections", + "endpoints": "connections", +} + +_DISABLED_BY_ADMIN = "Your administrator has not enabled this for your account." +_DENIED_BY_GOVERNANCE = "Your administrator has restricted your access to this capability." + + +def _governance_allows(feature_key, user_id, *, scope=None): + """Report whether governance permits a capability, without letting an error hide it. + + Governance failures are treated as permissive here on purpose. This function only + decides whether to *show* a section; every underlying route re-checks governance and + answers 403 on its own. Failing closed would therefore hide a section the user is + entitled to without protecting anything, whereas failing open shows a section whose + endpoints still refuse the request. + """ + try: + if scope is not None: + return bool(is_action_scope_access_allowed(feature_key, user_id, scope)) + return bool(is_governance_access_allowed(feature_key, user_id)) + except Exception as exc: + log_event( + f"[WORKSPACE_SECTIONS] Governance check failed for {feature_key}: {exc}", + extra={"user_id": user_id, "feature_key": feature_key}, + level=logging.WARNING, + exceptionTraceback=True, + ) + return True + + +def _section(enabled, reason=None): + return {"enabled": bool(enabled), "reason": None if enabled else reason} + + +def build_workspace_section_availability( + settings, + user_id, + *, + user_info=None, + user_roles=None, + file_sync_enabled=None, +): + """Describe every personal workspace section for one user. + + ``file_sync_enabled`` may be supplied by a caller that has already computed it, since + the check reads app roles and there is no reason to repeat it. + + Returns a dict carrying the section map plus the two intermediate values callers need + in their own right: ``file_sync_enabled`` and the resolved ``governance`` flags. + """ + source_settings = settings or {} + resolved_user_id = str(user_id or "") + + if file_sync_enabled is None: + file_sync_enabled = False + if resolved_user_id: + try: + file_sync_enabled = bool( + is_file_sync_enabled_for_user( + source_settings, + resolved_user_id, + (user_info or {}).get("email"), + user_info=user_info, + ) + ) + except Exception as exc: + log_event( + f"[WORKSPACE_SECTIONS] File sync availability check failed: {exc}", + extra={"user_id": resolved_user_id}, + level=logging.WARNING, + exceptionTraceback=True, + ) + file_sync_enabled = bool(file_sync_enabled) + + governance = { + "user_agents": _governance_allows("governance_user_agents", resolved_user_id), + "user_actions": _governance_allows( + "governance_user_actions", resolved_user_id, scope="personal" + ), + "user_endpoints": _governance_allows("governance_user_endpoints", resolved_user_id), + "global_endpoints": _governance_allows("governance_global_endpoints", resolved_user_id), + } + + semantic_kernel_enabled = bool(source_settings.get("enable_semantic_kernel", False)) + # Personal agents and actions additionally require the per-user Semantic Kernel switch; + # without it the tenant runs a shared kernel and personal definitions have nowhere to load. + personal_kernel = bool(source_settings.get("per_user_semantic_kernel", False)) and ( + semantic_kernel_enabled + ) + allow_agents = bool(source_settings.get("allow_user_agents", False)) + allow_plugins = bool(source_settings.get("allow_user_plugins", False)) + allow_endpoints = bool(source_settings.get("allow_user_custom_endpoints", False)) and bool( + source_settings.get("enable_multi_model_endpoints", False) + ) + + try: + workflows_enabled = bool( + is_user_workflows_enabled_for_user(source_settings, user_roles=user_roles) + ) + except Exception as exc: + log_event( + f"[WORKSPACE_SECTIONS] Workflow availability check failed: {exc}", + extra={"user_id": resolved_user_id}, + level=logging.WARNING, + exceptionTraceback=True, + ) + workflows_enabled = False + + agents_enabled = personal_kernel and allow_agents + actions_enabled = agents_enabled and allow_plugins + + sections = { + # Documents and prompts have no capability of their own: reaching the workspace at + # all already required enable_user_workspace. + "documents": _section(True), + "prompts": _section(True), + "sync": _section( + file_sync_enabled, + "File sync is not enabled for your account, so external file sources cannot " + "be configured here.", + ), + # Identities serve file sync and actions, so either one being available is enough + # to justify managing stored credentials. + "identities": _section( + file_sync_enabled or semantic_kernel_enabled, + "Identities appear once file sync or agents are enabled, since they exist to " + "supply credentials to those.", + ), + "agents": _section( + agents_enabled and governance["user_agents"], + _DENIED_BY_GOVERNANCE + if agents_enabled and not governance["user_agents"] + else "Personal agents are not enabled for your account.", + ), + "actions": _section( + actions_enabled and governance["user_actions"], + _DENIED_BY_GOVERNANCE + if actions_enabled and not governance["user_actions"] + else "Personal actions are not enabled for your account.", + ), + "workflows": _section( + workflows_enabled, + "Personal workflows are not enabled for your account.", + ), + "endpoints": _section( + allow_endpoints and governance["user_endpoints"], + _DENIED_BY_GOVERNANCE + if allow_endpoints and not governance["user_endpoints"] + else "Personal model endpoints are not enabled for your account.", + ), + } + + for section_id, section in sections.items(): + section["group"] = WORKSPACE_SECTION_GROUPS[section_id] + + return { + "enabled": bool(source_settings.get("enable_user_workspace", False)), + "file_sync_enabled": file_sync_enabled, + "governance": governance, + "sections": sections, + } + + +def build_workspace_governance(settings, user_id, *, user_info=None, user_roles=None): + """Return only the governance flags, in the shape the classic workspace template uses.""" + availability = build_workspace_section_availability( + settings, + user_id, + user_info=user_info, + user_roles=user_roles, + ) + return availability["governance"] + + +__all__ = [ + "WORKSPACE_SECTION_GROUPS", + "WORKSPACE_SECTION_IDS", + "build_workspace_governance", + "build_workspace_section_availability", +] diff --git a/application/single_app/route_backend_agents.py b/application/single_app/route_backend_agents.py index 30a967da0..dc05394f5 100644 --- a/application/single_app/route_backend_agents.py +++ b/application/single_app/route_backend_agents.py @@ -1049,6 +1049,93 @@ def get_assigned_knowledge_catalog_route(): ) return jsonify(catalog), 200 +def _find_personal_agent(user_id, agent_ref): + """Resolve a personal agent by id first, then by name. + + Personal agents were addressed by name historically, but ``GET /api/user/agents`` can + merge personal and global agents and a name is editable, so an id is the only stable + handle. Both are accepted: the id keeps new clients correct, the name keeps the classic + interface working. + """ + reference = str(agent_ref or '') + if not reference: + return None + agents = get_personal_agents(user_id) + for agent in agents: + if str(agent.get('id') or '') == reference: + return agent + for agent in agents: + if agent.get('name') == reference: + return agent + return None + + +def _prepare_personal_agent_payload(user_id, agent, settings): + """Clean, enrich and validate a single personal agent. + + This is the per-agent half of the bulk save, factored out so the per-item create and + update routes cannot drift from it. Returns ``(cleaned_agent, error_response)`` where + exactly one of the two is None. + """ + if not settings.get('allow_user_custom_endpoints', False): + _strip_disallowed_local_custom_connection_fields(agent) + + try: + cleaned_agent = sanitize_agent_payload(agent) + except AgentPayloadError as exc: + return None, (jsonify({'error': str(exc)}), 400) + + cleaned_agent['is_global'] = False + cleaned_agent['is_group'] = False + + try: + cleaned_agent = apply_assigned_knowledge_to_agent_payload( + cleaned_agent, + user_id=user_id, + agent_scope='personal', + is_admin=False, + ) + except AssignedKnowledgeError as exc: + return None, (jsonify({'error': str(exc)}), 400) + + validation_error = validate_agent(cleaned_agent) + if validation_error: + return None, (jsonify({'error': f'Agent validation failed: {validation_error}'}), 400) + + return cleaned_agent, None + + +def _create_personal_agent(user_id, payload): + """Create one personal agent from an object body.""" + try: + ensure_governance_access('governance_user_agents', user_id) + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + + if payload.get('is_global'): + return jsonify({'error': 'Global agents cannot be created here.'}), 400 + + settings = get_settings() + cleaned_agent, error = _prepare_personal_agent_payload(user_id, dict(payload), settings) + if error: + return error + + existing = _find_personal_agent(user_id, cleaned_agent.get('name')) + if existing: + return jsonify({'error': f"An agent named '{cleaned_agent.get('name')}' already exists."}), 409 + + saved = save_personal_agent(user_id, cleaned_agent) or cleaned_agent + log_agent_creation( + user_id=user_id, + agent_id=saved.get('id', ''), + agent_name=saved.get('name', ''), + agent_display_name=saved.get('display_name', saved.get('name', '')), + scope='personal', + ) + log_event("User agent created", extra={"user_id": user_id, "agent_name": saved.get('name', '')}) + return jsonify(saved), 201 + + @bpa.route('/api/user/agents', methods=['POST']) @swagger_route( security=get_auth_security() @@ -1057,37 +1144,31 @@ def get_assigned_knowledge_catalog_route(): @user_required @enabled_required("allow_user_agents") def set_user_agents(): + """Create one agent, or replace the whole collection. + + An object body creates a single agent and is the supported form. An array body replaces + every personal agent at once; it is retained because the classic interface still saves + that way, but it is deprecated -- concurrent editors overwrite each other's work, and a + dropped element is an unintended delete. New callers should use POST with an object, + PATCH for edits and DELETE for removals. + """ user_id = get_current_user_id() - agents = request.json if isinstance(request.json, list) else [] + payload = request.get_json(silent=True) + + if isinstance(payload, dict): + return _create_personal_agent(user_id, payload) + + agents = payload if isinstance(payload, list) else [] settings = get_settings() - # If custom endpoints are not allowed, strip deployment settings for endpoint, key, and api-revision - if not settings.get('allow_user_custom_endpoints', False): - for agent in agents: - _strip_disallowed_local_custom_connection_fields(agent) # Remove any global agents before saving filtered_agents = [] for agent in agents: if agent.get('is_global', False): continue # Skip global agents - try: - cleaned_agent = sanitize_agent_payload(agent) - except AgentPayloadError as exc: - return jsonify({'error': str(exc)}), 400 - cleaned_agent['is_global'] = False - cleaned_agent['is_group'] = False - try: - cleaned_agent = apply_assigned_knowledge_to_agent_payload( - cleaned_agent, - user_id=user_id, - agent_scope='personal', - is_admin=False, - ) - except AssignedKnowledgeError as exc: - return jsonify({'error': str(exc)}), 400 - validation_error = validate_agent(cleaned_agent) - if validation_error: - return jsonify({'error': f'Agent validation failed: {validation_error}'}), 400 + cleaned_agent, error = _prepare_personal_agent_payload(user_id, agent, settings) + if error: + return error filtered_agents.append(cleaned_agent) # Enforce global agent only if per_user_semantic_kernel is False @@ -1134,42 +1215,126 @@ def set_user_agents(): log_event("User agents updated", extra={"user_id": user_id, "agents_count": len(filtered_agents)}) return jsonify({'success': True}) -# Add a DELETE endpoint for user agents (if not present) -@bpa.route('/api/user/agents/', methods=['DELETE']) +@bpa.route('/api/user/agents/', methods=['GET']) @swagger_route( security=get_auth_security() ) @login_required @user_required @enabled_required("allow_user_agents") -def delete_user_agent(agent_name): +def get_user_agent(agent_id): + """Return one personal agent, addressed by id or by name.""" user_id = get_current_user_id() - # Get current agents from personal_agents container - agents = get_personal_agents(user_id) - agent_to_delete = next((a for a in agents if a['name'] == agent_name), None) + try: + ensure_governance_access('governance_user_agents', user_id) + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + + agent = _find_personal_agent(user_id, agent_id) + if not agent or not _is_agent_allowed_for_user_selection(user_id, agent): + return jsonify({'error': 'Agent not found.'}), 404 + + agent['is_global'] = False + agent['is_group'] = False + agent.setdefault('agent_type', 'local') + return jsonify(agent), 200 + + +@bpa.route('/api/user/agents/', methods=['PATCH']) +@swagger_route( + security=get_auth_security() +) +@login_required +@user_required +@enabled_required("allow_user_agents") +def update_user_agent(agent_id): + """Apply a partial update to one personal agent. + + Only the supplied keys change, so an editor that knows about a subset of the agent's + fields cannot blank the rest -- which is what the whole-collection POST does whenever a + client round-trips a stale copy. + """ + user_id = get_current_user_id() + try: + ensure_governance_access('governance_user_agents', user_id) + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + + updates = request.get_json(silent=True) + if not isinstance(updates, dict): + return jsonify({'error': 'Agent payload must be an object.'}), 400 + + existing = _find_personal_agent(user_id, agent_id) + if not existing: + return jsonify({'error': 'Agent not found.'}), 404 + + merged = dict(existing) + merged.update(updates) + # The stored document is keyed on id, so it is carried over rather than taken from the + # body. That is also what makes a rename safe: the name changes, the document does not + # move, and no orphan is left behind. + merged['id'] = existing.get('id') + + settings = get_settings() + cleaned_agent, error = _prepare_personal_agent_payload(user_id, merged, settings) + if error: + return error + + saved = save_personal_agent(user_id, cleaned_agent) or cleaned_agent + log_agent_update( + user_id=user_id, + agent_id=saved.get('id', ''), + agent_name=saved.get('name', ''), + agent_display_name=saved.get('display_name', saved.get('name', '')), + scope='personal', + ) + log_event("User agent updated", extra={"user_id": user_id, "agent_name": saved.get('name', '')}) + return jsonify(saved), 200 + + +@bpa.route('/api/user/agents/', methods=['DELETE']) +@swagger_route( + security=get_auth_security() +) +@login_required +@user_required +@enabled_required("allow_user_agents") +def delete_user_agent(agent_id): + """Delete one personal agent, addressed by id or by name.""" + user_id = get_current_user_id() + # The collection save enforces governance; deleting is just as destructive, so it is + # enforced here too rather than left to the container helper, which does not check. + try: + ensure_governance_access('governance_user_agents', user_id) + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + + agent_to_delete = _find_personal_agent(user_id, agent_id) if not agent_to_delete: return jsonify({'error': 'Agent not found.'}), 404 - - # Prevent deleting the agent that matches global_selected_agent + + agent_name = agent_to_delete.get('name', agent_id) + + # Checked before the delete, not after. The previous implementation deleted the agent + # and then returned 400 if the remaining agents did not match global_selected_agent, + # which reported failure for work that had already succeeded -- and did so on every + # delete whenever no global agent was configured, because the comparison was against + # None. The classic interface avoided this route entirely as a result. settings = get_settings() - global_selected_agent = settings.get('global_selected_agent', {}) + global_selected_agent = settings.get('global_selected_agent', {}) or {} global_selected_name = global_selected_agent.get('name') - if agent_to_delete.get('name') == global_selected_name: + if global_selected_name and agent_name == global_selected_name: return jsonify({'error': 'Cannot delete the agent set as global_selected_agent. Please set another agent as global first.'}), 400 - - # Delete from personal_agents container - delete_personal_agent(user_id, agent_name) - - # Log agent deletion activity - log_agent_deletion(user_id=user_id, agent_id=agent_to_delete.get('id', agent_name), agent_name=agent_name, scope='personal') - # Check if there are any agents left and if they match global_selected_agent - remaining_agents = get_personal_agents(user_id) - if len(remaining_agents) > 0: - found = any(a.get('name') == global_selected_name for a in remaining_agents) - if not found: - return jsonify({'error': 'There must be at least one agent matching the global_selected_agent.'}), 400 - + if not delete_personal_agent(user_id, agent_to_delete.get('id') or agent_name): + return jsonify({'error': 'Agent not found.'}), 404 + + log_agent_deletion( + user_id=user_id, + agent_id=agent_to_delete.get('id', agent_name), + agent_name=agent_name, + scope='personal', + ) log_event("User agent deleted", extra={"user_id": user_id, "agent_name": agent_name}) return jsonify({'success': True}) diff --git a/application/single_app/route_backend_models.py b/application/single_app/route_backend_models.py index 652b1558f..4192ea2db 100644 --- a/application/single_app/route_backend_models.py +++ b/application/single_app/route_backend_models.py @@ -22,6 +22,7 @@ from azure.identity import DefaultAzureCredential, ClientSecretCredential, get_bearer_token_provider import re import requests +import uuid def _get_configured_models(settings, setting_key): @@ -798,28 +799,27 @@ def get_user_model_endpoints(): }) - @bp.route('/api/user/model-endpoints', methods=['POST']) - @swagger_route(security=get_auth_security()) - @login_required - @user_required - @enabled_required('allow_user_custom_endpoints') - def save_user_model_endpoints(): - user_id = get_current_user_id() - try: - ensure_governance_access("governance_user_endpoints", user_id) - except PermissionError as exc: - return jsonify({"error": str(exc)}), 403 - data = request.get_json() or {} - incoming = data.get("endpoints", []) - if not isinstance(incoming, list): - return jsonify({"error": "endpoints must be a list."}), 400 - + def _load_personal_endpoints(user_id): + """Read the caller's stored personal endpoints as a list.""" user_settings = get_user_settings(user_id) - existing = user_settings.get("settings", {}).get("personal_model_endpoints", []) - - merged = merge_model_endpoints_with_existing(incoming, existing) - - normalized, _ = normalize_model_endpoints(merged) + endpoints = user_settings.get("settings", {}).get("personal_model_endpoints", []) + return endpoints if isinstance(endpoints, list) else [] + + def _find_personal_endpoint(endpoints, endpoint_id): + reference = str(endpoint_id or "") + for endpoint in endpoints: + if isinstance(endpoint, dict) and str(endpoint.get("id") or "") == reference: + return endpoint + return None + + def _persist_personal_endpoints(user_id, normalized, existing): + """Save a full endpoint list, moving Key Vault secrets to match. + + Secrets are handled in three passes because each endpoint can carry them: saved + endpoints write theirs, changed endpoints have the superseded version cleaned up, + and endpoints that are gone have theirs deleted. Skipping the last one would leave + orphaned secrets behind after a delete. + """ existing_by_id = { endpoint.get("id"): endpoint for endpoint in existing @@ -861,6 +861,163 @@ def save_user_model_endpoints(): keyvault_model_endpoint_delete_helper(endpoint, endpoint_id, scope="user") update_user_settings(user_id, {"personal_model_endpoints": saved_endpoints}) + return saved_endpoints + + def _single_endpoint_response(saved_endpoints, endpoint_id, status): + saved = _find_personal_endpoint(saved_endpoints, endpoint_id) + sanitized = sanitize_model_endpoints_for_frontend([saved]) if saved else [] + return jsonify({"endpoint": sanitized[0] if sanitized else {}}), status + + def _create_personal_model_endpoint(user_id, payload): + """Add one endpoint to the caller's stored list.""" + if not isinstance(payload, dict) or not payload: + return jsonify({"error": "Model endpoint payload must be an object."}), 400 + + existing = _load_personal_endpoints(user_id) + candidate = dict(payload) + endpoint_id = str(candidate.get("id") or "").strip() + if not endpoint_id: + endpoint_id = str(uuid.uuid4()) + elif _find_personal_endpoint(existing, endpoint_id): + return jsonify({"error": "A model endpoint with that id already exists."}), 409 + candidate["id"] = endpoint_id + + normalized, _ = normalize_model_endpoints(list(existing) + [candidate]) + saved_endpoints = _persist_personal_endpoints(user_id, normalized, existing) + return _single_endpoint_response(saved_endpoints, endpoint_id, 201) + + @bp.route('/api/user/model-endpoints', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + @enabled_required('allow_user_custom_endpoints') + def save_user_model_endpoints(): + """Create one endpoint, or replace the whole collection. + + A body carrying an ``endpoints`` list replaces every personal endpoint at once. That + is how the classic interface saves, so it is retained, but it is deprecated: the + client has to send back endpoints it never edited, and a stale copy silently + overwrites another tab's work. Any other object body creates a single endpoint. + """ + user_id = get_current_user_id() + try: + ensure_governance_access("governance_user_endpoints", user_id) + except PermissionError as exc: + return jsonify({"error": str(exc)}), 403 + data = request.get_json() or {} + + if "endpoints" not in data: + return _create_personal_model_endpoint(user_id, data) + + incoming = data.get("endpoints", []) + if not isinstance(incoming, list): + return jsonify({"error": "endpoints must be a list."}), 400 + + existing = _load_personal_endpoints(user_id) + + merged = merge_model_endpoints_with_existing(incoming, existing) + + normalized, _ = normalize_model_endpoints(merged) + _persist_personal_endpoints(user_id, normalized, existing) + return jsonify({"success": True}) + + + @bp.route('/api/user/model-endpoints/', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + @enabled_required('allow_user_custom_endpoints') + def get_user_model_endpoint(endpoint_id): + """Return one personal model endpoint, with its secrets stripped.""" + user_id = get_current_user_id() + try: + ensure_governance_access("governance_user_endpoints", user_id) + except PermissionError as exc: + return jsonify({"error": str(exc)}), 403 + + endpoint = _find_personal_endpoint(_load_personal_endpoints(user_id), endpoint_id) + if not endpoint: + return jsonify({"error": "Model endpoint not found."}), 404 + + sanitized = sanitize_model_endpoints_for_frontend([endpoint]) + return jsonify({"endpoint": sanitized[0] if sanitized else {}}) + + + @bp.route('/api/user/model-endpoints/', methods=['PATCH']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + @enabled_required('allow_user_custom_endpoints') + def update_user_model_endpoint(endpoint_id): + """Apply a partial update to one personal model endpoint. + + The stored endpoint is merged with the supplied keys server-side, so a client that + never received the secret values -- they are stripped on the way out -- cannot blank + them by sending the object back. + """ + user_id = get_current_user_id() + try: + ensure_governance_access("governance_user_endpoints", user_id) + except PermissionError as exc: + return jsonify({"error": str(exc)}), 403 + + updates = request.get_json(silent=True) + if not isinstance(updates, dict): + return jsonify({"error": "Model endpoint payload must be an object."}), 400 + + existing = _load_personal_endpoints(user_id) + current = _find_personal_endpoint(existing, endpoint_id) + if not current: + return jsonify({"error": "Model endpoint not found."}), 404 + + merged_endpoint = merge_model_endpoint_payload(current, {**updates, "id": current.get("id")}) + replaced = [ + merged_endpoint + if isinstance(endpoint, dict) and str(endpoint.get("id") or "") == str(endpoint_id) + else endpoint + for endpoint in existing + ] + + normalized, _ = normalize_model_endpoints(replaced) + saved_endpoints = _persist_personal_endpoints(user_id, normalized, existing) + return _single_endpoint_response(saved_endpoints, current.get("id"), 200) + + + @bp.route('/api/user/model-endpoints/', methods=['DELETE']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + @enabled_required('allow_user_custom_endpoints') + def delete_user_model_endpoint(endpoint_id): + """Remove one personal model endpoint and its stored secrets. + + Unlike the collection save, this reads the stored list server-side, so it cannot + drop an endpoint the caller could not see. That is the case + ``merge_model_endpoints_with_existing`` has to defend against when a whole list + arrives from a browser. + """ + user_id = get_current_user_id() + try: + ensure_governance_access("governance_user_endpoints", user_id) + except PermissionError as exc: + return jsonify({"error": str(exc)}), 403 + + existing = _load_personal_endpoints(user_id) + if not _find_personal_endpoint(existing, endpoint_id): + return jsonify({"error": "Model endpoint not found."}), 404 + + remaining = [ + endpoint + for endpoint in existing + if not (isinstance(endpoint, dict) and str(endpoint.get("id") or "") == str(endpoint_id)) + ] + + normalized, _ = normalize_model_endpoints(remaining) + _persist_personal_endpoints(user_id, normalized, existing) + log_event( + "User model endpoint deleted", + extra={"user_id": user_id, "endpoint_id": endpoint_id}, + ) return jsonify({"success": True}) diff --git a/application/single_app/route_backend_plugins.py b/application/single_app/route_backend_plugins.py index ebfd0350b..d6add5ac9 100644 --- a/application/single_app/route_backend_plugins.py +++ b/application/single_app/route_backend_plugins.py @@ -958,14 +958,227 @@ def get_user_plugins(): else: return jsonify(plugins) +def _prepare_personal_action_payload(user_id, plugin): + """Clean, default and validate a single personal action. + + This is the per-action half of the bulk save, factored out so the per-item create and + update routes apply exactly the same identity checks, manifest validation and MCP + destination policy. Returns ``(plugin_to_save, error_response)`` with exactly one set. + """ + plugin_to_save = dict(plugin) + # Remove is_global if present + if 'is_global' in plugin_to_save: + del plugin_to_save['is_global'] + + # Ensure required fields have default values + plugin_to_save.setdefault('name', '') + plugin_to_save.setdefault('displayName', plugin_to_save.get('name', '')) + plugin_to_save.setdefault('description', '') + plugin_to_save.setdefault('metadata', {}) + plugin_to_save.setdefault('additionalFields', {}) + + # Remove storage-managed fields that are not part of the plugin manifest schema, + # but preserve the action ID so existing records can be updated in place. + for field in PLUGIN_STORAGE_MANAGED_FIELDS: + if field == 'id': + continue + plugin_to_save.pop(field, None) + + # Handle endpoint based on plugin type. Read before the defaults below fill it in, so + # the manifest check still sees the type the caller actually declared. + plugin_type = plugin_to_save.get('type', '') + plugin_to_save.setdefault('endpoint', '') + _apply_plugin_runtime_defaults(plugin_to_save) + mcp_stdio_error = _reject_non_admin_mcp_stdio(plugin_to_save, scope_label='personal') + if mcp_stdio_error: + return None, (jsonify({'error': mcp_stdio_error}), 400) + try: + _validate_action_identity_for_scope( + plugin_to_save, + WORKSPACE_IDENTITY_SCOPE_PERSONAL, + user_id, + ) + except (ValueError, LookupError, PermissionError): + return None, (jsonify({'error': 'Action identity configuration is invalid.'}), 400) + + # Ensure auth has default structure + if 'auth' not in plugin_to_save: + plugin_to_save['auth'] = {'type': 'identity'} + elif not isinstance(plugin_to_save['auth'], dict): + plugin_to_save['auth'] = {'type': 'identity'} + elif 'type' not in plugin_to_save['auth']: + plugin_to_save['auth']['type'] = 'identity' + + # Auto-fill type from metadata if missing or empty + if not plugin_to_save.get('type'): + if plugin_to_save.get('metadata', {}).get('type'): + plugin_to_save['type'] = plugin_to_save['metadata']['type'] + else: + plugin_to_save['type'] = 'unknown' # Default type + + debug_print(f"Plugin build: {_redact_plugin_for_logging(plugin_to_save)}") + validation_error = validate_plugin(plugin_to_save) + if validation_error: + return None, (jsonify({'error': f'Plugin validation failed: {validation_error}'}), 400) + is_valid, validation_errors = PluginHealthChecker.validate_plugin_manifest(plugin_to_save, plugin_type) + if not is_valid: + return None, (jsonify({'error': f'Plugin validation failed: {"; ".join(validation_errors)}'}), 400) + + try: + _enforce_mcp_destination_policy( + plugin_to_save, + WORKSPACE_IDENTITY_SCOPE_PERSONAL, + user_id, + operation='personal_action_save', + user_id=user_id, + ) + except McpDestinationPolicyError: + return None, (jsonify({'error': 'MCP destination is not allowed by governance policy.'}), 403) + except ValueError: + return None, (jsonify({'error': 'MCP destination configuration is invalid.'}), 400) + + return plugin_to_save, None + + +def _save_personal_action_or_error(user_id, plugin_to_save): + """Persist one personal action, mapping the storage failures onto HTTP responses.""" + try: + return save_personal_action(user_id, plugin_to_save), None + except ValueError as exc: + debug_print(f"Validation error saving personal action for user {user_id}: {exc}") + return None, (jsonify({'error': ACTION_VALIDATION_ERROR_MESSAGE}), 400) + except PermissionError as exc: + debug_print(f"Governance denied saving personal action for user {user_id}: {exc}") + return None, (jsonify({'error': ACTION_PERMISSION_ERROR_MESSAGE}), 403) + except RuntimeError as exc: + debug_print(f"Key Vault error saving personal action for user {user_id}: {exc}") + return None, (jsonify({'error': ACTION_KEY_VAULT_ERROR_MESSAGE}), 500) + except Exception as exc: + debug_print(f"Error saving personal action for user {user_id}: {exc}") + return None, (jsonify({'error': 'Failed to save plugin'}), 500) + + +def _create_personal_action(user_id, payload): + """Create one personal action from an object body.""" + name = str(payload.get('name') or '').strip() + if not name: + return jsonify({'error': 'Action name is required.'}), 400 + + global_plugin_names = set( + p['name'].lower() for p in get_global_actions() if 'name' in p + ) + if name.lower() in global_plugin_names: + return jsonify({'error': f"'{name}' is the name of a global action."}), 409 + + if get_personal_action(user_id, name, return_type=SecretReturnType.NAME): + return jsonify({'error': f"An action named '{name}' already exists."}), 409 + + plugin_to_save, error = _prepare_personal_action_payload(user_id, payload) + if error: + return error + + saved, error = _save_personal_action_or_error(user_id, plugin_to_save) + if error: + return error + + saved = saved or plugin_to_save + log_action_creation( + user_id=user_id, + action_id=saved.get('id', ''), + action_name=saved.get('name', ''), + action_type=saved.get('type', ''), + scope='personal', + ) + log_event("User plugin created", extra={"user_id": user_id, "plugin_name": saved.get('name', '')}) + return jsonify(saved), 201 + + +@bpap.route('/api/user/plugins/', methods=['GET']) +@swagger_route(security=get_auth_security()) +@login_required +@user_required +def get_user_plugin(action_id): + """Return one personal action, addressed by id or by name.""" + user_id = get_current_user_id() + try: + action = get_personal_action(user_id, action_id, return_type=SecretReturnType.NAME) + except PermissionError: + return jsonify({'error': ACTION_PERMISSION_ERROR_MESSAGE}), 403 + if not action: + return jsonify({'error': 'Plugin not found.'}), 404 + action['is_global'] = False + return jsonify(action), 200 + + +@bpap.route('/api/user/plugins/', methods=['PATCH']) +@swagger_route(security=get_auth_security()) +@login_required +@user_required +@enabled_required("allow_user_plugins") +def update_user_plugin(action_id): + """Apply a partial update to one personal action. + + Only the supplied keys change. The whole-collection POST cannot express this: a client + that does not know about a field has to send it back verbatim or lose it. + """ + user_id = get_current_user_id() + updates = request.get_json(silent=True) + if not isinstance(updates, dict): + return jsonify({'error': 'Action payload must be an object.'}), 400 + + try: + existing = get_personal_action(user_id, action_id, return_type=SecretReturnType.NAME) + except PermissionError: + return jsonify({'error': ACTION_PERMISSION_ERROR_MESSAGE}), 403 + if not existing: + return jsonify({'error': 'Plugin not found.'}), 404 + + merged = dict(existing) + merged.update(updates) + # The stored document is keyed on id, so it is carried over rather than taken from the + # body; that keeps a rename from orphaning the existing record. + merged['id'] = existing.get('id') + + plugin_to_save, error = _prepare_personal_action_payload(user_id, merged) + if error: + return error + + saved, error = _save_personal_action_or_error(user_id, plugin_to_save) + if error: + return error + + saved = saved or plugin_to_save + log_action_update( + user_id=user_id, + action_id=saved.get('id', ''), + action_name=saved.get('name', ''), + action_type=saved.get('type', ''), + scope='personal', + ) + log_event("User plugin updated", extra={"user_id": user_id, "plugin_name": saved.get('name', '')}) + return jsonify(saved), 200 + + @bpap.route('/api/user/plugins', methods=['POST']) @swagger_route(security=get_auth_security()) @login_required @user_required @enabled_required("allow_user_plugins") def set_user_plugins(): + """Create one action, or replace the whole collection. + + An object body creates a single action and is the supported form. An array body + replaces every personal action at once; it is retained because the classic interface + still saves that way, but it is deprecated -- concurrent editors overwrite each other, + and an omitted element is an unintended delete. + """ user_id = get_current_user_id() - plugins = request.json if isinstance(request.json, list) else [] + payload = request.get_json(silent=True) + + if isinstance(payload, dict): + return _create_personal_action(user_id, payload) + + plugins = payload if isinstance(payload, list) else [] # Get global plugin names (case-insensitive) global_plugins = get_global_actions() @@ -984,77 +1197,9 @@ def set_user_plugins(): for plugin in plugins: if plugin.get('name', '').lower() in global_plugin_names: continue # Skip global plugins - plugin_to_save = dict(plugin) - # Remove is_global if present - if 'is_global' in plugin_to_save: - del plugin_to_save['is_global'] - - # Ensure required fields have default values - plugin_to_save.setdefault('name', '') - plugin_to_save.setdefault('displayName', plugin_to_save.get('name', '')) - plugin_to_save.setdefault('description', '') - plugin_to_save.setdefault('metadata', {}) - plugin_to_save.setdefault('additionalFields', {}) - - # Remove storage-managed fields that are not part of the plugin manifest schema, - # but preserve the action ID so existing records can be updated in place. - for field in PLUGIN_STORAGE_MANAGED_FIELDS: - if field == 'id': - continue - plugin_to_save.pop(field, None) - - # Handle endpoint based on plugin type - plugin_type = plugin_to_save.get('type', '') - plugin_to_save.setdefault('endpoint', '') - _apply_plugin_runtime_defaults(plugin_to_save) - mcp_stdio_error = _reject_non_admin_mcp_stdio(plugin_to_save, scope_label='personal') - if mcp_stdio_error: - return jsonify({'error': mcp_stdio_error}), 400 - try: - _validate_action_identity_for_scope( - plugin_to_save, - WORKSPACE_IDENTITY_SCOPE_PERSONAL, - user_id, - ) - except (ValueError, LookupError, PermissionError): - return jsonify({'error': 'Action identity configuration is invalid.'}), 400 - - # Ensure auth has default structure - if 'auth' not in plugin_to_save: - plugin_to_save['auth'] = {'type': 'identity'} - elif not isinstance(plugin_to_save['auth'], dict): - plugin_to_save['auth'] = {'type': 'identity'} - elif 'type' not in plugin_to_save['auth']: - plugin_to_save['auth']['type'] = 'identity' - - # Auto-fill type from metadata if missing or empty - if not plugin_to_save.get('type'): - if plugin_to_save.get('metadata', {}).get('type'): - plugin_to_save['type'] = plugin_to_save['metadata']['type'] - else: - plugin_to_save['type'] = 'unknown' # Default type - - debug_print(f"Plugin build: {_redact_plugin_for_logging(plugin_to_save)}") - validation_error = validate_plugin(plugin_to_save) - if validation_error: - return jsonify({'error': f'Plugin validation failed: {validation_error}'}), 400 - is_valid, validation_errors = PluginHealthChecker.validate_plugin_manifest(plugin_to_save, plugin_type) - if not is_valid: - return jsonify({'error': f'Plugin validation failed: {"; ".join(validation_errors)}'}), 400 - - try: - _enforce_mcp_destination_policy( - plugin_to_save, - WORKSPACE_IDENTITY_SCOPE_PERSONAL, - user_id, - operation='personal_action_save', - user_id=user_id, - ) - except McpDestinationPolicyError: - return jsonify({'error': 'MCP destination is not allowed by governance policy.'}), 403 - except ValueError: - return jsonify({'error': 'MCP destination configuration is invalid.'}), 400 - + plugin_to_save, error = _prepare_personal_action_payload(user_id, plugin) + if error: + return error filtered_plugins.append(plugin_to_save) new_plugin_names.add(plugin_to_save['name']) if plugin_to_save.get('id'): @@ -1109,24 +1254,31 @@ def set_user_plugins(): log_event("User plugins updated", extra={"user_id": user_id, "plugins_count": len(filtered_plugins)}) return jsonify({'success': True}) -@bpap.route('/api/user/plugins/', methods=['DELETE']) +@bpap.route('/api/user/plugins/', methods=['DELETE']) @swagger_route(security=get_auth_security()) @login_required @user_required -def delete_user_plugin(plugin_name): +def delete_user_plugin(action_id): + """Delete one personal action, addressed by id or by name. + + Deliberately not gated on ``allow_user_plugins``. Creating and editing are, but removing + an action only reduces what a user has configured, and gating it would strand existing + actions with no way to clean them up after an administrator turns the capability off. + Governance is still enforced, inside ``delete_personal_action``. + """ user_id = get_current_user_id() # Try to delete from personal_actions container try: - deleted = delete_personal_action(user_id, plugin_name) + deleted = delete_personal_action(user_id, action_id) except PermissionError: return jsonify({'error': 'You are not authorized to delete this action.'}), 403 if not deleted: return jsonify({'error': 'Plugin not found.'}), 404 - log_action_deletion(user_id=user_id, action_id=plugin_name, action_name=plugin_name, scope='personal') - log_event("User plugin deleted", extra={"user_id": user_id, "plugin_name": plugin_name}) + log_action_deletion(user_id=user_id, action_id=action_id, action_name=action_id, scope='personal') + log_event("User plugin deleted", extra={"user_id": user_id, "plugin_name": action_id}) return jsonify({'success': True}) diff --git a/application/single_app/route_backend_v2.py b/application/single_app/route_backend_v2.py index 042111987..04b505185 100644 --- a/application/single_app/route_backend_v2.py +++ b/application/single_app/route_backend_v2.py @@ -66,6 +66,7 @@ is_source_review_enabled_for_user, is_url_access_enabled_for_user, ) +from functions_workspace_sections import build_workspace_section_availability from route_frontend_chats import ( _build_chat_model_catalog, _build_chat_prompt_catalog, @@ -395,6 +396,22 @@ def v2_bootstrap(): stored_workspace_id if stored_workspace_id in visible_workspace_ids else None ) + # Which workspace sections this user may see. Computed server-side because the + # answer combines settings, app-role checks and governance policy, and only + # `enable_*` keys reach `features` above -- `allow_user_agents`, + # `allow_user_plugins`, `per_user_semantic_kernel` and the file sync and + # governance checks would all be invisible to the SPA otherwise. + workspace = {"enabled": False, "sections": {}} + try: + workspace = build_workspace_section_availability( + settings, + user_id, + user_info=current_user_info, + user_roles=current_user_roles, + ) + except Exception as exc: + logger.warning(f"[V2_BOOTSTRAP] Failed to resolve workspace sections: {exc}") + payload = { "version": VERSION, "user": { @@ -425,6 +442,7 @@ def v2_bootstrap(): }, "admin_nav": ADMIN_NAV if "Admin" in current_user_roles else [], "notices": _build_notices(public_settings, user_settings_dict), + "workspace": workspace, "settings": public_settings, } diff --git a/application/single_app/route_frontend_workspace.py b/application/single_app/route_frontend_workspace.py index 77fd1107e..07d1a5c97 100644 --- a/application/single_app/route_frontend_workspace.py +++ b/application/single_app/route_frontend_workspace.py @@ -5,11 +5,11 @@ from config import * from functions_authentication import * from functions_group import get_user_groups -from functions_governance import filter_governed_model_endpoints, is_action_scope_access_allowed, is_governance_access_allowed +from functions_governance import filter_governed_model_endpoints from functions_public_workspaces import get_user_visible_public_workspace_docs from functions_settings import * -from functions_file_sync import is_file_sync_enabled_for_user from functions_source_review import is_url_access_enabled_for_user +from functions_workspace_sections import build_workspace_section_availability from swagger_wrapper import swagger_route, get_auth_security def register_route_frontend_workspace(bp): @@ -38,10 +38,20 @@ def workspace(): settings, user_roles=current_user_roles, ) - file_sync_enabled = is_file_sync_enabled_for_user(settings, user_id, user_info.get('email'), user_info=user_info) if user_id else False if not user_id: print("User not authenticated.") return redirect(url_for('frontend_authentication.login')) + + # Shared with the V2 interface so the two cannot disagree about which sections of + # the personal workspace a user may see. + workspace_availability = build_workspace_section_availability( + settings, + user_id, + user_info=user_info, + user_roles=current_user_roles, + ) + file_sync_enabled = workspace_availability['file_sync_enabled'] + query = """ SELECT VALUE COUNT(1) @@ -69,12 +79,7 @@ def workspace(): enable_audio=enable_audio_uploads ) - workspace_governance = { - "user_agents": is_governance_access_allowed("governance_user_agents", user_id), - "user_actions": is_action_scope_access_allowed("governance_user_actions", user_id, "personal"), - "user_endpoints": is_governance_access_allowed("governance_user_endpoints", user_id), - "global_endpoints": is_governance_access_allowed("governance_global_endpoints", user_id), - } + workspace_governance = workspace_availability['governance'] personal_endpoints = user_settings.get("settings", {}).get("personal_model_endpoints", []) personal_model_endpoints = sanitize_model_endpoints_for_frontend( diff --git a/application/v2_ui/src/App.tsx b/application/v2_ui/src/App.tsx index 0f067b7eb..079b34857 100644 --- a/application/v2_ui/src/App.tsx +++ b/application/v2_ui/src/App.tsx @@ -13,7 +13,7 @@ import { initializeTheme, hydrateUiPreferences } from './stores/uiStore'; import { ChatPage } from './pages/ChatPage'; import { AdminSettingsPage } from './pages/AdminSettingsPage'; import { SettingsPage } from './pages/SettingsPage'; -import { WorkspacePage } from './pages/WorkspacePage'; +import { WorkspacePage } from './pages/workspace/WorkspacePage'; import { PlaceholderPage } from './pages/PlaceholderPage'; function BootScreen() { @@ -110,6 +110,9 @@ export function App() { } /> } /> } /> + {/* Sections are real paths rather than a query parameter, so a link to one + reads as what it is and survives being shared. */} + } /> } /> } /> Agents, which is where you build your own. This is + // the catalogue of every agent you are allowed to use, wherever it came from. + hint: 'Browse every agent you can use', + }, + { + to: '/workspace', + label: 'My Workspace', + icon: FolderOpen, + hint: 'Your documents, prompts, agents and automation', + }, { to: '/groups', label: 'Group Workspaces', icon: Users }, { to: '/public', label: 'Public Workspaces', icon: Globe2 }, { to: '/admin', label: 'Admin Settings', icon: Settings, adminOnly: true }, @@ -226,7 +240,7 @@ export function Sidebar() {
  • clsx( 'flex items-center gap-2.5 rounded-xl px-3 py-2 text-sm transition-colors', diff --git a/application/v2_ui/src/components/workspace/primitives.tsx b/application/v2_ui/src/components/workspace/primitives.tsx new file mode 100644 index 000000000..8ab18bdff --- /dev/null +++ b/application/v2_ui/src/components/workspace/primitives.tsx @@ -0,0 +1,283 @@ +// primitives.tsx +// Shared building blocks for the personal workspace sections. +// +// The eight sections list very different things, but they all list *something*, and each +// one having its own idea of a row, a spinner and an empty state is how a page stops +// feeling like one page. These carry that shape so a section only supplies what is +// genuinely specific to it. + +import { useEffect, useRef, useState } from 'react'; +import { clsx } from 'clsx'; +import { Loader2, Search } from 'lucide-react'; +import type { ReactNode } from 'react'; +import { EmptyState, GlassPanel, Skeleton } from '../ui/primitives'; + +export function SectionIntro({ + title, + description, + actions, +}: { + title: string; + description: string; + actions?: ReactNode; +}) { + return ( +
    +
    +

    {title}

    +

    {description}

    +
    + {actions ?
    {actions}
    : null} +
    + ); +} + +export function SectionSearch({ + value, + onChange, + placeholder, +}: { + value: string; + onChange: (next: string) => void; + placeholder: string; +}) { + return ( +
    + + onChange(event.target.value)} + placeholder={placeholder} + aria-label={placeholder} + className="w-full rounded-xl border border-edge bg-surface-1 py-2 pr-3 pl-9 text-sm text-text-1 placeholder:text-text-3 focus:border-accent focus:outline-none" + /> +
    + ); +} + +export function SectionError({ message }: { message: string }) { + return ( + + {message} + + ); +} + +export function SectionSkeleton({ rows = 4 }: { rows?: number }) { + return ( +
    + {Array.from({ length: rows }).map((_, index) => ( + + ))} +
    + ); +} + +/** + * The list body of a section: skeleton, error, empty state or rows. + * + * The error is rendered above the rows rather than instead of them, which matters for the + * delete flows: they put the list back as it was and then report why, and replacing the + * list would hide the very rows the message refers to. + */ +export function SectionList({ + items, + loading, + error, + emptyIcon, + emptyTitle, + emptyDescription, + emptyAction, + getKey, + renderItem, +}: { + items: T[]; + loading: boolean; + error?: string | null; + emptyIcon?: ReactNode; + emptyTitle: string; + emptyDescription?: string; + emptyAction?: ReactNode; + getKey: (item: T, index: number) => string; + renderItem: (item: T) => ReactNode; +}) { + return ( +
    + {error ? : null} + + {loading ? : null} + + {!loading && items.length === 0 ? ( + + ) : null} + + {items.length > 0 ? ( +
      + {items.map((item, index) => ( +
    • {renderItem(item)}
    • + ))} +
    + ) : null} +
    + ); +} + +export function ResourceRow({ + icon, + title, + subtitle, + meta, + actions, +}: { + icon?: ReactNode; + title: ReactNode; + subtitle?: ReactNode; + meta?: ReactNode; + actions?: ReactNode; +}) { + return ( + + {icon ? {icon} : null} +
    +
    {title}
    + {subtitle ? ( +
    {subtitle}
    + ) : null} +
    + {meta ?
    {meta}
    : null} + {actions ?
    {actions}
    : null} +
    + ); +} + +export function Pill({ + children, + tone = 'neutral', +}: { + children: ReactNode; + tone?: 'neutral' | 'ok' | 'warn' | 'danger' | 'accent'; +}) { + const toneClass = { + neutral: 'bg-surface-2 text-text-2', + ok: 'bg-ok-soft text-ok', + warn: 'bg-warn-soft text-warn', + danger: 'bg-danger-soft text-danger', + accent: 'bg-accent-soft text-accent', + }[tone]; + + return ( + + {children} + + ); +} + +export function RowAction({ + icon, + label, + onClick, + disabled = false, + busy = false, + danger = false, +}: { + icon: ReactNode; + label: string; + onClick: () => void; + disabled?: boolean; + busy?: boolean; + danger?: boolean; +}) { + return ( + + ); +} + +/** + * A destructive action that asks first, in place. + * + * A native confirm() dialog would do, but it stops the whole tab and reads as a browser + * warning rather than as part of the page. Arming the button keeps the question next to the + * row it refers to, and it disarms itself so a button left armed cannot be triggered later + * by a stray click. + */ +export function ConfirmAction({ + icon, + label, + confirmLabel, + onConfirm, + busy = false, + disabled = false, +}: { + icon: ReactNode; + label: string; + confirmLabel: string; + onConfirm: () => void; + busy?: boolean; + disabled?: boolean; +}) { + const [armed, setArmed] = useState(false); + const timer = useRef(undefined); + + useEffect(() => { + if (!armed) { + return; + } + timer.current = window.setTimeout(() => setArmed(false), 4000); + return () => window.clearTimeout(timer.current); + }, [armed]); + + if (!armed) { + return ( + setArmed(true)} + busy={busy} + disabled={disabled} + danger + /> + ); + } + + return ( + + ); +} diff --git a/application/v2_ui/src/components/workspace/useSectionResource.ts b/application/v2_ui/src/components/workspace/useSectionResource.ts new file mode 100644 index 000000000..9a029e768 --- /dev/null +++ b/application/v2_ui/src/components/workspace/useSectionResource.ts @@ -0,0 +1,79 @@ +// useSectionResource.ts +// Loading, refreshing and error state for a workspace section's collection. +// +// Each section otherwise repeats the same twenty lines, and they tend to repeat them +// slightly differently: the interesting parts are the two that are easy to get wrong. +// A request in flight when the section changes is aborted, and a response that arrives +// after a newer request started is discarded, so switching sections quickly cannot leave +// one section showing another's rows. + +import { useCallback, useEffect, useRef, useState } from 'react'; + +/** Turn anything thrown into something worth showing a user. */ +export function errorMessage(error: unknown, fallback: string): string { + if (error instanceof Error && error.message) { + return error.message; + } + return fallback; +} + +export interface SectionResource { + items: T[]; + loading: boolean; + error: string | null; + /** Refetch from the server. */ + refresh: () => Promise; + /** Apply a local change, for optimistic updates that a failure can roll back. */ + setItems: (next: T[]) => void; + setError: (message: string | null) => void; +} + +export function useSectionResource( + load: (signal: AbortSignal) => Promise, + failureMessage: string, +): SectionResource { + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Identifies the newest request. A response whose token no longer matches belongs to a + // superseded request and is dropped rather than rendered. + const requestToken = useRef(0); + const abortRef = useRef(null); + const loadRef = useRef(load); + loadRef.current = load; + + const refresh = useCallback(async () => { + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + + requestToken.current += 1; + const token = requestToken.current; + + setLoading(true); + setError(null); + try { + const next = await loadRef.current(controller.signal); + if (token === requestToken.current) { + setItems(next); + } + } catch (loadError) { + if (controller.signal.aborted || token !== requestToken.current) { + return; + } + setError(errorMessage(loadError, failureMessage)); + } finally { + if (token === requestToken.current) { + setLoading(false); + } + } + }, [failureMessage]); + + useEffect(() => { + void refresh(); + return () => abortRef.current?.abort(); + }, [refresh]); + + return { items, loading, error, refresh, setItems, setError }; +} diff --git a/application/v2_ui/src/lib/types.ts b/application/v2_ui/src/lib/types.ts index 59e2733fc..d9c992007 100644 --- a/application/v2_ui/src/lib/types.ts +++ b/application/v2_ui/src/lib/types.ts @@ -134,6 +134,126 @@ export interface WorkspaceTag { color?: string; } +/** + * Entities behind the personal workspace sections. + * + * Every one of these routes returns more fields than the list views read, and several add + * fields over time, so each type is deliberately open. Only the fields the UI actually + * renders or sends are named; anything else stays available through the index signature + * rather than being dropped on the way through. + */ +export interface WorkspacePrompt { + id: string; + name?: string; + content?: string; + created_at?: string; + updated_at?: string; + [key: string]: unknown; +} + +export interface WorkspaceIdentity { + id: string; + name?: string; + description?: string; + auth_type?: string; + username?: string; + scope_type?: string; + scope_id?: string; + created_at?: string; + updated_at?: string; + [key: string]: unknown; +} + +export interface WorkspaceSyncSource { + id: string; + name?: string; + source_type?: string; + identity_id?: string; + remote_path?: string; + enabled?: boolean; + sync_interval_minutes?: number; + created_at?: string; + updated_at?: string; + [key: string]: unknown; +} + +export interface WorkspaceSyncRun { + id: string; + source_id?: string; + status?: string; + started_at?: string; + completed_at?: string; + triggered_by?: string; + [key: string]: unknown; +} + +export interface WorkspaceAgent { + id: string; + name?: string; + display_name?: string; + description?: string; + instructions?: string; + /** True for agents supplied by an administrator, which a user may not edit or delete. */ + is_global?: boolean; + is_group?: boolean; + agent_type?: string; + tags?: string[]; + actions_to_load?: string[]; + [key: string]: unknown; +} + +export interface WorkspaceAction { + id: string; + name?: string; + displayName?: string; + description?: string; + /** Which connector this action is, for example `mcp`, `openapi` or `sql_query`. */ + type?: string; + endpoint?: string; + is_global?: boolean; + [key: string]: unknown; +} + +export interface WorkspaceWorkflow { + id: string; + name?: string; + description?: string; + status?: string; + active_run_id?: string | null; + created_at?: string; + updated_at?: string; + [key: string]: unknown; +} + +export interface WorkspaceWorkflowRun { + id: string; + workflow_id?: string; + status?: string; + started_at?: string; + completed_at?: string; + [key: string]: unknown; +} + +export interface WorkspaceModelEndpointModel { + id?: string; + deploymentName?: string; + modelName?: string; + displayName?: string; + enabled?: boolean; + [key: string]: unknown; +} + +export interface WorkspaceModelEndpoint { + id: string; + name?: string; + provider?: string; + enabled?: boolean; + connection?: Record; + auth?: Record; + models?: WorkspaceModelEndpointModel[]; + [key: string]: unknown; +} + /** Where a cited document lives, from functions_citation_tracking._scope_from_citation. */ export interface UsedDocumentScope { type?: 'personal' | 'group' | 'public' | string; @@ -484,10 +604,37 @@ export interface BootstrapPayload { ai: AiNoticeConfig; web_search: WebSearchNoticeConfig; }; + /** + * Which personal workspace sections this user may see. + * + * Resolved server-side by functions_workspace_sections, because the answer combines + * plain settings, app-role checks and governance policy. `features` cannot carry it: + * it only forwards `enable_*` keys, so `allow_user_agents`, `allow_user_plugins`, + * `per_user_semantic_kernel` and `allow_user_custom_endpoints` would all be missing, + * and the file sync and governance checks are not settings keys at all. + */ + workspace: WorkspaceAvailability; /** Sanitized settings. Never contains keys, secrets or connection strings. */ settings: Json; } +/** The group a workspace section belongs to, as reported by the server. */ +export type WorkspaceSectionGroup = 'knowledge' | 'automation' | 'connections'; + +export interface WorkspaceSectionAvailability { + enabled: boolean; + /** Why the section is unavailable. Null when it is enabled. */ + reason: string | null; + group: WorkspaceSectionGroup; +} + +export interface WorkspaceAvailability { + enabled: boolean; + file_sync_enabled?: boolean; + governance?: Record; + sections: Record; +} + /** * A single decoded SSE frame from POST /api/chat/stream. * diff --git a/application/v2_ui/src/lib/workspaceApi.ts b/application/v2_ui/src/lib/workspaceApi.ts new file mode 100644 index 000000000..d2e4d2ba0 --- /dev/null +++ b/application/v2_ui/src/lib/workspaceApi.ts @@ -0,0 +1,224 @@ +// workspaceApi.ts +// Every call the personal workspace sections make. +// +// The routes behind these sections grew independently and do not share a response shape: +// prompts, identities, sync sources and workflows wrap their collection in a named key, +// while agents and actions return a bare array. Rather than make each section deal with +// that, the differences are absorbed here and every function returns a plain array or a +// plain entity. +// +// Per-item writes are used throughout. Agents, actions and endpoints also accept a +// whole-collection POST, but that form requires the client to send back rows it never +// edited, so a stale tab silently reverts another one's work. Nothing here uses it. + +import { api } from './apiClient'; +import type { + WorkspaceAction, + WorkspaceAgent, + WorkspaceIdentity, + WorkspaceModelEndpoint, + WorkspacePrompt, + WorkspaceSyncRun, + WorkspaceSyncSource, + WorkspaceWorkflow, + WorkspaceWorkflowRun, +} from './types'; + +/** Coerce a response that should be a list into one, whatever shape it arrived in. */ +function asArray(value: unknown, key?: string): T[] { + if (Array.isArray(value)) { + return value as T[]; + } + if (key && value && typeof value === 'object') { + const nested = (value as Record)[key]; + if (Array.isArray(nested)) { + return nested as T[]; + } + } + return []; +} + +/* -------------------------------------------------------------------------- Prompts */ + +/** + * List personal prompts. + * + * Paging and search are server-side. The default page size is large because the section + * renders a single scrolling list rather than paged controls. + */ +export async function fetchPrompts( + { search = '', pageSize = 500 }: { search?: string; pageSize?: number } = {}, + signal?: AbortSignal, +): Promise { + const params = new URLSearchParams({ page: '1', page_size: String(pageSize) }); + if (search.trim()) { + params.set('search_term', search.trim()); + } + const response = await api.get(`/api/prompts?${params.toString()}`, signal); + return asArray(response, 'prompts'); +} + +export const createPrompt = (name: string, content: string) => + api.post('/api/prompts', { name, content }); + +export const updatePrompt = (promptId: string, updates: { name?: string; content?: string }) => + api.patch(`/api/prompts/${encodeURIComponent(promptId)}`, updates); + +export const deletePrompt = (promptId: string) => + api.delete<{ message?: string }>(`/api/prompts/${encodeURIComponent(promptId)}`); + +/* ----------------------------------------------------------------------- Identities */ + +export async function fetchIdentities(signal?: AbortSignal): Promise { + const response = await api.get( + '/api/workspace-identities/personal/identities', + signal, + ); + return asArray(response, 'identities'); +} + +export const deleteIdentity = (identityId: string) => + api.delete<{ message?: string }>( + `/api/workspace-identities/personal/identities/${encodeURIComponent(identityId)}`, + ); + +/* --------------------------------------------------------------------- File sources */ + +export async function fetchSyncSources(signal?: AbortSignal): Promise { + const response = await api.get('/api/file-sync/personal/sources', signal); + return asArray(response, 'sources'); +} + +export async function fetchSyncRuns( + sourceId: string, + signal?: AbortSignal, +): Promise { + const response = await api.get( + `/api/file-sync/personal/sources/${encodeURIComponent(sourceId)}/runs`, + signal, + ); + return asArray(response, 'runs'); +} + +/** Queue a sync. The route answers 202 with the run it created, not a finished result. */ +export const startSyncRun = (sourceId: string) => + api.post<{ run?: WorkspaceSyncRun }>( + `/api/file-sync/personal/sources/${encodeURIComponent(sourceId)}/sync`, + ); + +/** + * Delete a sync source. + * + * `delete_associated_files` is read from the body, so it is sent explicitly rather than + * left to the server's default: whether the documents a source produced also disappear is + * too consequential to leave implicit. + */ +export const deleteSyncSource = (sourceId: string, deleteAssociatedFiles = false) => + api.delete<{ message?: string }>( + `/api/file-sync/personal/sources/${encodeURIComponent(sourceId)}`, + { delete_associated_files: deleteAssociatedFiles }, + ); + +/* --------------------------------------------------------------------------- Agents */ + +/** + * Reserve an agent id. + * + * The agent schema requires a UUID on the way in -- validation runs before storage would + * assign one -- so a new agent needs an id before it can be saved. It is taken from the + * server rather than generated in the browser because `crypto.randomUUID` is only present + * in a secure context, and this has to work wherever the app is served from. + */ +export async function generateAgentId(): Promise { + const response = await api.get<{ id?: string }>('/api/agents/generate_id'); + return String(response?.id ?? ''); +} + +export async function fetchAgents(signal?: AbortSignal): Promise { + const response = await api.get('/api/user/agents', signal); + return asArray(response, 'agents'); +} + +export const fetchAgent = (agentId: string, signal?: AbortSignal) => + api.get(`/api/user/agents/${encodeURIComponent(agentId)}`, signal); + +export const createAgent = (agent: Partial) => + api.post('/api/user/agents', agent); + +export const updateAgent = (agentId: string, updates: Partial) => + api.patch(`/api/user/agents/${encodeURIComponent(agentId)}`, updates); + +export const deleteAgent = (agentId: string) => + api.delete<{ success?: boolean }>(`/api/user/agents/${encodeURIComponent(agentId)}`); + +/* -------------------------------------------------------------------------- Actions */ + +export async function fetchActions(signal?: AbortSignal): Promise { + const response = await api.get('/api/user/plugins', signal); + return asArray(response, 'plugins'); +} + +export const fetchAction = (actionId: string, signal?: AbortSignal) => + api.get(`/api/user/plugins/${encodeURIComponent(actionId)}`, signal); + +export const deleteAction = (actionId: string) => + api.delete<{ success?: boolean }>(`/api/user/plugins/${encodeURIComponent(actionId)}`); + +/* ------------------------------------------------------------------------ Workflows */ + +export async function fetchWorkflows(signal?: AbortSignal): Promise { + const response = await api.get('/api/user/workflows', signal); + return asArray(response, 'workflows'); +} + +export async function fetchWorkflowRuns( + workflowId: string, + signal?: AbortSignal, +): Promise { + const response = await api.get( + `/api/user/workflows/${encodeURIComponent(workflowId)}/runs`, + signal, + ); + return asArray(response, 'runs'); +} + +export const startWorkflowRun = (workflowId: string) => + api.post(`/api/user/workflows/${encodeURIComponent(workflowId)}/run`); + +export const cancelWorkflow = (workflowId: string) => + api.post(`/api/user/workflows/${encodeURIComponent(workflowId)}/cancel`); + +export const deleteWorkflow = (workflowId: string) => + api.delete<{ success?: boolean }>(`/api/user/workflows/${encodeURIComponent(workflowId)}`); + +/* ------------------------------------------------------------------------ Endpoints */ + +export async function fetchModelEndpoints( + signal?: AbortSignal, +): Promise { + const response = await api.get('/api/user/model-endpoints', signal); + return asArray(response, 'endpoints'); +} + +/** + * Update one endpoint. + * + * Sent as a partial update rather than the whole endpoint, because the values that come + * back from the server have their secrets stripped. Posting that object back would blank + * the stored key; a PATCH carrying only what changed cannot. + */ +export async function updateModelEndpoint( + endpointId: string, + updates: Partial, +): Promise { + const response = await api.patch<{ endpoint?: WorkspaceModelEndpoint }>( + `/api/user/model-endpoints/${encodeURIComponent(endpointId)}`, + updates, + ); + return response?.endpoint ?? null; +} + +export const deleteModelEndpoint = (endpointId: string) => + api.delete<{ success?: boolean }>( + `/api/user/model-endpoints/${encodeURIComponent(endpointId)}`, + ); diff --git a/application/v2_ui/src/lib/workspaceSections.ts b/application/v2_ui/src/lib/workspaceSections.ts new file mode 100644 index 000000000..7e45d3a01 --- /dev/null +++ b/application/v2_ui/src/lib/workspaceSections.ts @@ -0,0 +1,133 @@ +// workspaceSections.ts +// Grouping and availability rules for the personal workspace. +// +// Deliberately free of React and of the section registry, which carries icons and +// components: everything here is a plain function over plain data so the rules can be +// exercised directly in a test without a renderer. +// +// The workspace is assembled from eight capabilities that an administrator can enable +// independently, which is why a flat list of tabs reads as arbitrary. Grouping them by what +// they are *for* gives the page a shape that survives any particular tenant's +// configuration: knowledge is what the assistant can draw on, automation is what it can do, +// and connections is the shared setup the other two reuse. + +import type { WorkspaceAvailability, WorkspaceSectionGroup } from './types'; + +export interface WorkspaceGroupMeta { + id: WorkspaceSectionGroup; + label: string; + /** One line saying what the group is for. Shown on the overview and above the rail. */ + blurb: string; +} + +export const WORKSPACE_GROUPS: WorkspaceGroupMeta[] = [ + { + id: 'knowledge', + label: 'Knowledge', + blurb: 'What your assistant can draw on.', + }, + { + id: 'automation', + label: 'Automation', + blurb: 'What your assistant can do.', + }, + { + id: 'connections', + label: 'Connections', + blurb: 'Shared setup the other sections reuse.', + }, +]; + +export interface WorkspaceSectionDescriptor { + id: string; + group: WorkspaceSectionGroup; +} + +export interface ResolvedWorkspaceSection { + section: T; + enabled: boolean; + /** Why the section is unavailable, straight from the server. Null when enabled. */ + reason: string | null; +} + +export interface WorkspaceSectionGroupView { + group: WorkspaceGroupMeta; + sections: ResolvedWorkspaceSection[]; +} + +const MISSING_SECTION_REASON = 'This section is not available in this deployment.'; + +/** + * Pair each known section with the server's verdict on it. + * + * A section the server does not mention is treated as unavailable rather than as available. + * The alternative fails open: a capability the server has stopped reporting would render a + * section whose endpoints refuse every request. + */ +export function resolveWorkspaceSections( + descriptors: readonly T[], + availability: WorkspaceAvailability | null | undefined, +): ResolvedWorkspaceSection[] { + const sections = availability?.sections ?? {}; + return descriptors.map((section) => { + const state = sections[section.id]; + if (!state) { + return { section, enabled: false, reason: MISSING_SECTION_REASON }; + } + return { + section, + enabled: Boolean(state.enabled), + reason: state.enabled ? null : (state.reason ?? MISSING_SECTION_REASON), + }; + }); +} + +/** + * The sections that belong in the navigation rail. + * + * Only enabled ones. A disabled section is still described on the overview, where there is + * room to say *why* it is unavailable -- which is the part that stops people wondering + * whether a capability is missing, broken, or simply not switched on for them. A dead entry + * in the rail would carry no such explanation. + */ +export function navigableSections( + resolved: readonly ResolvedWorkspaceSection[], +): ResolvedWorkspaceSection[] { + return resolved.filter((entry) => entry.enabled); +} + +/** Group sections for display, dropping groups that ended up with nothing in them. */ +export function groupWorkspaceSections( + resolved: readonly ResolvedWorkspaceSection[], +): WorkspaceSectionGroupView[] { + return WORKSPACE_GROUPS.map((group) => ({ + group, + sections: resolved.filter((entry) => entry.section.group === group.id), + })).filter((view) => view.sections.length > 0); +} + +/** + * Which section to show when none was asked for, or when the one asked for is unavailable. + * + * Returns null when nothing is enabled, which the page renders as an empty state rather + * than redirecting somewhere the user did not ask to go. + */ +export function defaultSectionId( + resolved: readonly ResolvedWorkspaceSection[], + requested?: string | null, +): string | null { + if (requested) { + const match = resolved.find((entry) => entry.section.id === requested); + if (match?.enabled) { + return match.section.id; + } + } + return navigableSections(resolved)[0]?.section.id ?? null; +} + +/** True when the user has at least one section available. */ +export function hasAnyWorkspaceSection( + resolved: readonly ResolvedWorkspaceSection[], +): boolean { + return resolved.some((entry) => entry.enabled); +} diff --git a/application/v2_ui/src/pages/WorkspacePage.tsx b/application/v2_ui/src/pages/WorkspacePage.tsx deleted file mode 100644 index 441543231..000000000 --- a/application/v2_ui/src/pages/WorkspacePage.tsx +++ /dev/null @@ -1,299 +0,0 @@ -// WorkspacePage.tsx -// Personal workspace documents: list, search, tag filter, upload and delete. - -import { useEffect, useMemo, useRef, useState } from 'react'; -import { clsx } from 'clsx'; -import { FileText, Loader2, Search, Trash2, Upload } from 'lucide-react'; -import { - deletePersonalDocument, - fetchPersonalDocumentTags, - fetchPersonalDocuments, - uploadDocument, -} from '../lib/endpoints'; -import { PageHeader } from '../components/layout/PageHeader'; -import { EmptyState, GlassButton, GlassPanel, Skeleton } from '../components/ui/primitives'; -import type { WorkspaceDocument, WorkspaceTag } from '../lib/types'; - -/** - * Reduce a tag of any shape to its name. - * - * Tags arrive in more than one form: /api/documents/tags returns - * `{name, count, color}` objects, while a document's own `tags` field may be an array of - * strings or a comma-separated string. Rendering an object directly is what caused React - * error #31 on this page, so every tag is funnelled through here. - */ -function tagName(tag: unknown): string { - if (typeof tag === 'string') { - return tag.trim(); - } - if (tag && typeof tag === 'object' && 'name' in tag) { - return String((tag as { name: unknown }).name ?? '').trim(); - } - return ''; -} - -function normalizeTags(tags: unknown): string[] { - if (Array.isArray(tags)) { - return tags.map(tagName).filter(Boolean); - } - if (typeof tags === 'string' && tags.trim()) { - return tags.split(',').map((tag) => tag.trim()).filter(Boolean); - } - return []; -} - -function ProcessingBadge({ document }: { document: WorkspaceDocument }) { - const percent = Number(document.percentage_complete ?? 100); - const complete = Number.isFinite(percent) ? percent >= 100 : true; - - if (complete) { - return ( - - Ready - - ); - } - - return ( - - - {Math.round(percent)}% - - ); -} - -export function WorkspacePage() { - const [documents, setDocuments] = useState([]); - const [tags, setTags] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [query, setQuery] = useState(''); - const [activeTag, setActiveTag] = useState(null); - const [uploading, setUploading] = useState(false); - - const fileInputRef = useRef(null); - - const load = async () => { - setLoading(true); - setError(null); - try { - const [documentsResponse, tagsResponse] = await Promise.all([ - fetchPersonalDocuments(), - fetchPersonalDocumentTags().catch(() => ({ tags: [] })), - ]); - // The endpoint has used both `documents` and `items` as its collection key. - setDocuments(documentsResponse.documents ?? documentsResponse.items ?? []); - setTags(tagsResponse.tags ?? []); - } catch (loadError) { - setError( - loadError instanceof Error ? loadError.message : 'Failed to load documents.', - ); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - void load(); - }, []); - - const visible = useMemo(() => { - const needle = query.trim().toLowerCase(); - return documents.filter((document) => { - const name = String(document.file_name ?? document.title ?? '').toLowerCase(); - if (needle && !name.includes(needle)) { - return false; - } - if (activeTag && !normalizeTags(document.tags).includes(activeTag)) { - return false; - } - return true; - }); - }, [documents, query, activeTag]); - - const onSelectFile = async (event: React.ChangeEvent) => { - const file = event.target.files?.[0]; - if (!file) { - return; - } - setUploading(true); - try { - await uploadDocument(file, null); - await load(); - } catch (uploadError) { - setError( - uploadError instanceof Error - ? uploadError.message - : `Could not upload ${file.name}.`, - ); - } finally { - setUploading(false); - event.target.value = ''; - } - }; - - const onDelete = async (document: WorkspaceDocument) => { - const id = String(document.id ?? document.document_id ?? ''); - if (!id) { - return; - } - const previous = documents; - setDocuments(documents.filter((item) => (item.id ?? item.document_id) !== id)); - try { - await deletePersonalDocument(id); - } catch (deleteError) { - setDocuments(previous); - setError( - deleteError instanceof Error ? deleteError.message : 'Could not delete document.', - ); - } - }; - - return ( - <> - - - fileInputRef.current?.click()} - > - {uploading ? ( - - ) : ( - - )} - Upload - - - } - /> - -
    -
    -
    -
    - - setQuery(event.target.value)} - placeholder="Search documents" - aria-label="Search documents" - className="w-full rounded-xl border border-edge bg-surface-1 py-2 pr-3 pl-9 text-sm text-text-1 placeholder:text-text-3 focus:border-accent focus:outline-none" - /> -
    - - {tags.slice(0, 8).map((tag) => { - const name = tagName(tag); - if (!name) { - return null; - } - return ( - - ); - })} -
    - - {error && ( - - {error} - - )} - - {loading && ( -
    - {Array.from({ length: 5 }).map((_, index) => ( - - ))} -
    - )} - - {!loading && visible.length === 0 && !error && ( - } - title={ - documents.length === 0 - ? 'No documents yet' - : 'No documents match your filters' - } - description={ - documents.length === 0 - ? 'Upload a file to make it available for grounded chat.' - : undefined - } - /> - )} - -
      - {visible.map((document) => { - const id = String(document.id ?? document.document_id ?? ''); - const documentTags = normalizeTags(document.tags); - return ( -
    • - - -
      -

      - {String( - document.file_name ?? - document.title ?? - 'Untitled', - )} -

      - {documentTags.length > 0 && ( -

      - {documentTags.join(' · ')} -

      - )} -
      - - -
      -
    • - ); - })} -
    -
    -
    - - ); -} diff --git a/application/v2_ui/src/pages/workspace/ActionsSection.tsx b/application/v2_ui/src/pages/workspace/ActionsSection.tsx new file mode 100644 index 000000000..6c845ce5b --- /dev/null +++ b/application/v2_ui/src/pages/workspace/ActionsSection.tsx @@ -0,0 +1,153 @@ +// ActionsSection.tsx +// Personal actions: the tools an agent is allowed to call. +// +// Listing and removal only. Each connector type has its own configuration -- endpoints, +// credentials, query templates -- and there are more than twenty of them, so authoring +// stays in the classic interface for now rather than being half-represented here. + +import { useMemo, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { Plug, Shield, Trash2 } from 'lucide-react'; +import { + ConfirmAction, + Pill, + ResourceRow, + SectionIntro, + SectionList, + SectionSearch, +} from '../../components/workspace/primitives'; +import { + errorMessage, + useSectionResource, +} from '../../components/workspace/useSectionResource'; +import { deleteAction, fetchActions } from '../../lib/workspaceApi'; +import type { WorkspaceAction } from '../../lib/types'; + +/** Render a connector type as something readable: `document_search` -> `Document search`. */ +export function actionTypeLabel(type: unknown): string { + const raw = String(type ?? '').trim(); + if (!raw) { + return 'Unknown'; + } + const spaced = raw.replace(/[_-]+/g, ' '); + return spaced.charAt(0).toUpperCase() + spaced.slice(1); +} + +export function ActionsSection({ agentsEnabled }: { agentsEnabled: boolean }) { + const { items, loading, error, setItems, setError } = useSectionResource( + fetchActions, + 'Failed to load actions.', + ); + + const [query, setQuery] = useState(''); + const [busyId, setBusyId] = useState(null); + + const visible = useMemo(() => { + const needle = query.trim().toLowerCase(); + if (!needle) { + return items; + } + return items.filter((action) => + `${action.displayName ?? ''} ${action.name ?? ''} ${action.type ?? ''}` + .toLowerCase() + .includes(needle), + ); + }, [items, query]); + + const onDelete = async (action: WorkspaceAction) => { + const previous = items; + const identifier = action.id || String(action.name ?? ''); + setBusyId(identifier); + setItems(items.filter((item) => (item.id || item.name) !== (action.id || action.name))); + try { + await deleteAction(identifier); + } catch (deleteError) { + setItems(previous); + setError(errorMessage(deleteError, 'Could not delete the action.')); + } finally { + setBusyId(null); + } + }; + + return ( +
    + + +

    + Adding and configuring actions is still done in the{' '} + + classic workspace + + .{' '} + {agentsEnabled ? ( + <> + Attach them to an{' '} + + agent + {' '} + to put them to use. + + ) : null} +

    + + + + } + emptyTitle={ + items.length === 0 ? 'No actions yet' : 'No actions match your search' + } + emptyDescription={ + items.length === 0 + ? 'Actions let an agent reach a system outside this chat.' + : undefined + } + getKey={(action, index) => String(action.id ?? action.name ?? index)} + renderItem={(action) => { + const managed = Boolean(action.is_global); + const identifier = action.id || String(action.name ?? ''); + return ( + } + title={String(action.displayName || action.name || 'Untitled action')} + subtitle={ + String(action.description || '') || + String(action.endpoint || '') + } + meta={ + <> + {actionTypeLabel(action.type)} + {managed ? ( + + + + Provided + + + ) : null} + + } + actions={ + managed ? undefined : ( + } + label={`Delete ${action.displayName ?? action.name ?? 'action'}`} + confirmLabel="Delete" + busy={busyId === identifier} + onConfirm={() => void onDelete(action)} + /> + ) + } + /> + ); + }} + /> +
    + ); +} diff --git a/application/v2_ui/src/pages/workspace/AgentsSection.tsx b/application/v2_ui/src/pages/workspace/AgentsSection.tsx new file mode 100644 index 000000000..0911cd0fd --- /dev/null +++ b/application/v2_ui/src/pages/workspace/AgentsSection.tsx @@ -0,0 +1,347 @@ +// AgentsSection.tsx +// Personal agents: list, create, edit and delete. +// +// The editor covers identity and instructions. Binding a model, attaching actions and +// assigning knowledge are not here yet and are still done in the classic interface; the +// section says so rather than offering controls that do nothing. + +import { useMemo, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { Pencil, Plus, Shield, Sparkles, Trash2 } from 'lucide-react'; +import { GlassButton, GlassPanel } from '../../components/ui/primitives'; +import { + ConfirmAction, + Pill, + ResourceRow, + RowAction, + SectionIntro, + SectionList, + SectionSearch, +} from '../../components/workspace/primitives'; +import { + errorMessage, + useSectionResource, +} from '../../components/workspace/useSectionResource'; +import { + createAgent, + deleteAgent, + fetchAgents, + generateAgentId, + updateAgent, +} from '../../lib/workspaceApi'; +import type { WorkspaceAgent } from '../../lib/types'; + +interface DraftAgent { + id: string | null; + displayName: string; + description: string; + instructions: string; +} + +const EMPTY_DRAFT: DraftAgent = { + id: null, + displayName: '', + description: '', + instructions: '', +}; + +/** + * Derive the stored `name` from what the user typed. + * + * The agent schema constrains `name` to letters, digits, underscore and dash, while the + * display name is free text. Rather than ask for both, the machine-readable one is derived + * and only the display name is edited. + */ +export function agentNameFromDisplayName(displayName: string): string { + const slug = displayName + .trim() + .replace(/[^A-Za-z0-9_-]+/g, '-') + .replace(/^-+|-+$/g, ''); + return slug || 'agent'; +} + +function AgentEditor({ + draft, + saving, + onChange, + onSave, + onCancel, +}: { + draft: DraftAgent; + saving: boolean; + onChange: (next: DraftAgent) => void; + onSave: () => void; + onCancel: () => void; +}) { + const canSave = draft.displayName.trim().length > 0; + + return ( + +
    + + + onChange({ ...draft, displayName: event.target.value }) + } + placeholder="Contract reviewer" + className="w-full rounded-xl border border-edge bg-surface-1 px-3 py-2 text-sm text-text-1 placeholder:text-text-3 focus:border-accent focus:outline-none" + /> + {draft.id === null && draft.displayName.trim() ? ( +

    + Stored as{' '} + + {agentNameFromDisplayName(draft.displayName)} + +

    + ) : null} +
    + +
    + + + onChange({ ...draft, description: event.target.value }) + } + placeholder="Reviews contracts against our standard terms." + className="w-full rounded-xl border border-edge bg-surface-1 px-3 py-2 text-sm text-text-1 placeholder:text-text-3 focus:border-accent focus:outline-none" + /> +
    + +
    + +