diff --git a/application/single_app/functions_v2_agents_catalog.py b/application/single_app/functions_v2_agents_catalog.py new file mode 100644 index 000000000..5bed04617 --- /dev/null +++ b/application/single_app/functions_v2_agents_catalog.py @@ -0,0 +1,117 @@ +# functions_v2_agents_catalog.py +"""The Agents catalogue, shaped for the V2 interface. + +The classic ``/agents`` page reads ``GET /api/agents/catalog``, which lists every +agent the user can reach in their personal, group and global scopes. The V2 page +lists only the agents the user may start a chat with. Governance can block a whole +scope or a single agent, and the chat picker already leaves those out, so the +catalogue does too rather than offering a Chat button that cannot work. + +The classic endpoint keeps its behaviour because the admin Promoted Agents editor +reads it, and governance has no administrator bypass: filtering it there would +hide agents from the very control that promotes them. + +Only the fields the page draws are sent. Assigned knowledge and the model +endpoint, deployment and provider stay on the server; the page shows the model +label. Instructions are sent only when the administrator shows them in the +details dialog, the same rule the classic endpoint applies. + +It imports no Flask and no Azure client, so it can be exercised directly: the +route passes in the catalogue loader, the governance check, the usage and +promotion annotators, and the page configuration. +""" + + +# Every field the V2 page reads, and nothing else. ``instructions`` is handled +# separately because it depends on an administrator setting. +V2_CATALOG_AGENT_FIELDS = ( + "id", + "name", + "display_name", + "description", + "agent_type", + "is_global", + "is_group", + "scope_type", + "scope_id", + "scope_name", + "group_id", + "group_name", + "tags", + "icon", + "model_label", + "actions_to_load", + "action_labels", + "catalog_key", + "usage_count", + "usage_count_30_days", + "usage_count_all_time", + "is_promoted_popular", + "promoted_popular_window", + "promoted_popular_rank", + "promoted_popular_order", + "promoted_popular_tag_enabled", + "promoted_popular_tag_label", +) + +V2_CATALOG_INSTRUCTIONS_FIELD = "instructions" + + +def catalog_agent_governance_scope(agent): + """Return the governance scope of a catalogue record, read as the bootstrap reads it.""" + return str(agent.get("scope_type") or "").strip().lower() or "personal" + + +def project_v2_catalog_agent(agent, *, include_instructions): + """Copy only the fields the V2 page draws from one catalogue record.""" + projected = {field: agent[field] for field in V2_CATALOG_AGENT_FIELDS if field in agent} + if include_instructions and V2_CATALOG_INSTRUCTIONS_FIELD in agent: + projected[V2_CATALOG_INSTRUCTIONS_FIELD] = agent[V2_CATALOG_INSTRUCTIONS_FIELD] + return projected + + +def build_v2_agents_catalog_payload( + *, + load_catalog, + is_allowed, + apply_usage_counts, + apply_promotions, + page_config, +): + """Return ``{"page": ..., "agents": [...]}`` for the V2 Agents page. + + ``load_catalog()`` + Returns the accessible catalogue records. + ``is_allowed(agent, scope)`` + Whether governance lets the user chat with the agent. An error it raises + propagates, so the page fails closed rather than listing agents that were + never checked. + ``apply_usage_counts(records)`` and ``apply_promotions(records)`` + Annotate the records and return them. + ``page_config`` + The normalised display configuration from ``build_agents_page_config``. Its + ``show_instructions_in_details`` decides whether instructions are sent, so the + flag the page reads and the redaction cannot disagree. + + Governance runs first, so usage is counted and promotions are applied only to + agents the user can see: an administrator promoting an agent this user is + blocked from cannot put it back on this user's Popular tab. + """ + page = dict(page_config or {}) + include_instructions = bool(page.get("show_instructions_in_details")) + + allowed = [ + agent + for agent in load_catalog() or [] + if isinstance(agent, dict) and is_allowed(agent, catalog_agent_governance_scope(agent)) + ] + allowed = apply_usage_counts(allowed) + allowed = apply_promotions(allowed) + + return { + "page": page, + "agents": [ + project_v2_catalog_agent(agent, include_instructions=include_instructions) + for agent in allowed + ], + } diff --git a/application/single_app/route_backend_v2.py b/application/single_app/route_backend_v2.py index 8d9d4dea8..c98d21a37 100644 --- a/application/single_app/route_backend_v2.py +++ b/application/single_app/route_backend_v2.py @@ -193,6 +193,7 @@ build_latest_features_payload, build_user_latest_features_payload, ) +from functions_v2_agents_catalog import build_v2_agents_catalog_payload from functions_workspace_context import ( WorkspaceContextError, build_group_workspace_context, @@ -204,10 +205,17 @@ _build_initial_chat_model_selection, _is_chat_agent_allowed_by_governance, ) +# Shared with the server-rendered Agents page so both interfaces read the same +# title, hero colours, disclaimer and instructions setting. +from route_frontend_agents import build_agents_page_config # Shared with the server-rendered admin page so both interfaces run the same # connection tests rather than maintaining two lists of what can be tested. from route_backend_settings import run_admin_settings_connection_test -from functions_agent_catalog import build_accessible_agent_catalog +from functions_agent_catalog import ( + apply_agent_popular_promotions, + apply_agent_usage_counts, + build_accessible_agent_catalog, +) from functions_ai_notice import get_ai_notice_config, is_ai_notice_dismissed from functions_model_capabilities import resolve_model_vision_support from functions_model_endpoint_providers import get_model_endpoint_provider_ui_options @@ -942,6 +950,48 @@ def v2_support_latest_features(): ) return jsonify({"error": "Failed to load Latest Features."}), 500 + @bp.route("/api/v2/agents/catalog", methods=["GET"]) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + @enabled_required("enable_semantic_kernel") + def v2_agents_catalog(): + """Return the agents this user may chat with, for the V2 Agents page. + + The V2 counterpart of the classic ``/agents`` page, behind the same gates: an + application role and Semantic Kernel. Unlike the classic catalogue it leaves out + agents governance blocks for this user, as the chat picker does, so every card it + draws can start a chat. Usage counts and the administrator's Popular promotions + are applied after that filter, and the page configuration is sent alongside so + the SPA does not need a second request to draw the hero. + """ + user_id = get_current_user_id() + if not user_id: + return jsonify({"error": "User not authenticated"}), 401 + + try: + settings = get_settings() + payload = build_v2_agents_catalog_payload( + load_catalog=lambda: build_accessible_agent_catalog(user_id, settings=settings), + is_allowed=lambda agent, scope: _is_chat_agent_allowed_by_governance( + user_id, agent, scope + ), + apply_usage_counts=apply_agent_usage_counts, + apply_promotions=lambda catalog: apply_agent_popular_promotions( + catalog, settings=settings + ), + page_config=build_agents_page_config(sanitize_settings_for_user(settings)), + ) + return jsonify(payload), 200 + except Exception as exc: + log_event( + "[AGENTS_CATALOG] Failed to load the V2 agent catalogue.", + extra={"user_id": user_id, "error_type": type(exc).__name__}, + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({"error": "Failed to load agents."}), 500 + @bp.route("/api/v2/bootstrap", methods=["GET"]) @swagger_route(security=get_auth_security()) @login_required diff --git a/application/v2_ui/src/App.tsx b/application/v2_ui/src/App.tsx index 72ca68a56..f6e4d3e53 100644 --- a/application/v2_ui/src/App.tsx +++ b/application/v2_ui/src/App.tsx @@ -24,7 +24,7 @@ import { AdminActionEditorPage, AdminAgentEditorPage } from './pages/AdminGlobal import { SettingsPage } from './pages/SettingsPage'; import { WorkspacePage } from './pages/workspace/WorkspacePage'; import { safeSameOriginUrl } from './lib/adminOperations'; -import { PlaceholderPage } from './pages/PlaceholderPage'; +import { AgentsCatalogPage } from './pages/AgentsCatalogPage'; import { GroupWorkspacePage } from './pages/GroupWorkspacePage'; import { GroupDirectoryPage } from './pages/GroupDirectoryPage'; import { PublicWorkspacePage } from './pages/PublicWorkspacePage'; @@ -290,17 +290,7 @@ export function App() { } /> } /> } /> - - } - /> + } /> } /> } /> } /> diff --git a/application/v2_ui/src/components/agentsCatalog/AgentCatalogParts.tsx b/application/v2_ui/src/components/agentsCatalog/AgentCatalogParts.tsx new file mode 100644 index 000000000..82d363e47 --- /dev/null +++ b/application/v2_ui/src/components/agentsCatalog/AgentCatalogParts.tsx @@ -0,0 +1,44 @@ +// AgentCatalogParts.tsx + +import { MessageSquare } from 'lucide-react'; +import { Link } from 'react-router-dom'; +import { agentScopeType } from '../../lib/adminAgents'; +import { + agentLinkScope, catalogDisplayName, catalogScopeLabel, promotedBadgeLabel, type CatalogAgent, +} from '../../lib/agentCatalog'; +import { chatHrefForAgent } from '../../lib/conversationUrl'; +import { GlassButton } from '../ui/primitives'; +import { Pill } from '../workspace/primitives'; + +export function AgentCatalogBadges({ agent }: { agent: CatalogAgent }) { + const promoted = promotedBadgeLabel(agent); + return ( +
+ + {catalogScopeLabel(agent)} + + {promoted ? {promoted} : null} +
+ ); +} + +export function AgentChatLink({ agent }: { agent: CatalogAgent }) { + const scope = agentLinkScope(agent); + const label = `Chat with ${catalogDisplayName(agent)}`; + if (!scope || !agent.id) { + return ( + + + ); + } + return ( + +