| 1 | import { Bot, Monitor, Plug, Server } from "lucide-react"; |
| 2 | import { data, useFetcher, useOutletContext } from "react-router"; |
| 3 | |
| 4 | import type { AbilityLevel, AbilitySection, WorkspaceAgent } from "@g1t/contracts"; |
| 5 | import { MAX_MCP_SERVERS, abilitiesSummary, autonomyOfLevel, findAbility, resolveAbilities, withSetting } from "@g1t/contracts/abilities"; |
| 6 | import { CONNECTORS, connectorPath, connectorView } from "@g1t/contracts/connectors"; |
| 7 | |
| 8 | import type { Route } from "./+types/abilities"; |
| 9 | import { AddMcpServerDialog, McpServerCount, SourceBlock } from "../../../components/agents/abilities"; |
| 10 | import { agentsAction, answer } from "../../../components/agents/actions.server"; |
| 11 | import type { ActionResult } from "../../../components/agents/dialogs"; |
| 12 | import { Badge } from "../../../components/ui/badge"; |
| 13 | import { connectedStates } from "../../../lib/connected.server"; |
| 14 | import type { ConnectedState } from "../../../lib/connectors"; |
| 15 | import { workspaceAgents } from "../../../lib/services.server"; |
| 16 | import { requireUser, roleIn } from "../../../lib/session.server"; |
| 17 | |
| 18 | /** |
| 19 | * What the Abilities tab needs beside the agent: who the viewer is, what |
| 20 | * the workspace has connected (by connector id), and where each is set up |
| 21 | * or managed. |
| 22 | */ |
| 23 | export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ isOwner: boolean; viewerId: string; connected: string[]; hrefs: Record<string, string | null> }> { |
| 24 | const viewer = requireUser(context, request); |
| 25 | const slug = params.owner.toLowerCase(); |
| 26 | const role = roleIn(viewer, slug); |
| 27 | if (!role) throw data(null, { status: 404 }); |
| 28 | const states: Record<string, ConnectedState> = await connectedStates(slug, viewer).catch(() => ({})); |
| 29 | const hrefs: Record<string, string | null> = {}; |
| 30 | for (const connector of CONNECTORS) { |
| 31 | const view = connectorView(connector, "workspace"); |
| 32 | hrefs[connector.id] = states[connector.id]?.manage ?? (view?.href ? connectorPath(view.href, slug) : null); |
| 33 | } |
| 34 | return { isOwner: role === "owner", viewerId: viewer.id, connected: Object.keys(states), hrefs }; |
| 35 | } |
| 36 | |
| 37 | /** |
| 38 | * Changes a level or whose connection (a new version of the agent, like any |
| 39 | * change), or adds, refreshes or removes an MCP server. The agents service |
| 40 | * decides who may: owners a workspace agent, its member a personal one, |
| 41 | * and only owners the servers and the workspace's connection. |
| 42 | */ |
| 43 | export async function action({ params, context, request }: Route.ActionArgs): Promise<ActionResult> { |
| 44 | const { viewer, slug, isOwner, form } = await agentsAction(request, context, params.owner); |
| 45 | const intent = String(form.get("intent") ?? ""); |
| 46 | const handle = params.handle.toLowerCase(); |
| 47 | if (intent === "mcp_add") return answer(intent, workspaceAgents.addMcpServer(slug, handle, viewer, { name: String(form.get("name") ?? ""), url: String(form.get("url") ?? "") })); |
| 48 | if (intent === "mcp_remove") return answer(intent, workspaceAgents.removeMcpServer(slug, handle, viewer, String(form.get("server") ?? ""))); |
| 49 | if (intent === "mcp_refresh") return answer(intent, workspaceAgents.refreshMcpServer(slug, handle, viewer, String(form.get("server") ?? ""))); |
| 50 | if (intent !== "level" && intent !== "credentials") return { ok: false, intent, error: "Unknown request." }; |
| 51 | const current = await workspaceAgents.get(slug, handle, viewer).catch(() => null); |
| 52 | if (!current) return { ok: false, intent, error: "The agents service didn't answer. Try again in a moment." }; |
| 53 | if (!current.ok) return { ok: false, intent, error: current.error.message }; |
| 54 | const agent = current.value; |
| 55 | const id = String(form.get("ability") ?? ""); |
| 56 | const sections = resolveAbilities({ connectors: CONNECTORS, abilities: agent.abilities, autonomy: agent.autonomy, connected: CONNECTORS.map((c) => c.id), personal: agent.scope === "personal" }); |
| 57 | const found = findAbility(sections, id); |
| 58 | if (!found) return { ok: false, intent, error: "There is no such ability." }; |
| 59 | if (intent === "level") { |
| 60 | const level = String(form.get("level") ?? "") as AbilityLevel; |
| 61 | if (!found.ability.choices.includes(level)) return { ok: false, intent, error: `${found.ability.label} can't be set to that.` }; |
| 62 | if (found.ability.autonomy) return answer(intent, workspaceAgents.update(slug, handle, viewer, { autonomy: { [found.ability.autonomy.key]: autonomyOfLevel(found.ability.autonomy.key, level) } })); |
| 63 | return answer(intent, workspaceAgents.update(slug, handle, viewer, { abilities: { settings: withSetting(agent.abilities, id, { level }).settings } })); |
| 64 | } |
| 65 | const credentials = String(form.get("credentials") ?? ""); |
| 66 | if (credentials !== "workspace" && credentials !== "asker") return { ok: false, intent, error: "Whose connection is the workspace's or the asker's." }; |
| 67 | // The workspace's connection is the owners' to give. |
| 68 | if (credentials === "workspace" && !isOwner) return { ok: false, intent, error: "Only the workspace's owners let an agent act as the workspace's connection." }; |
| 69 | return answer(intent, workspaceAgents.update(slug, handle, viewer, { abilities: { settings: withSetting(agent.abilities, id, { credentials }).settings } })); |
| 70 | } |
| 71 | |
| 72 | const ICONS = { g1t: Bot, computer: Monitor, integration: Plug, mcp: Server } as const; |
| 73 | |
| 74 | /** |
| 75 | * The agent's abilities, in groups: g1t's own (always on), its computer |
| 76 | * (coming), each connected integration's actions with a level and whose |
| 77 | * connection, and the MCP servers owners add. A summary in plain words |
| 78 | * sits at the top. Owners edit a workspace agent; a member their own |
| 79 | * personal one, within what owners allow. |
| 80 | */ |
| 81 | export default function AbilitiesTab({ loaderData, params }: Route.ComponentProps) { |
| 82 | const agent = useOutletContext<WorkspaceAgent>(); |
| 83 | const { isOwner, viewerId, connected, hrefs } = loaderData; |
| 84 | const fetcher = useFetcher<ActionResult>({ key: `abilities-${agent.id}` }); |
| 85 | const personal = agent.scope === "personal"; |
| 86 | const canEdit = personal ? agent.personal_owner_id === viewerId : isOwner; |
| 87 | const sections: AbilitySection[] = resolveAbilities({ connectors: CONNECTORS, abilities: agent.abilities, autonomy: agent.autonomy, connected, hrefs, personal }); |
| 88 | const error = fetcher.state === "idle" && fetcher.data && !fetcher.data.ok ? fetcher.data.error : null; |
| 89 | const slug = params.owner; |
| 90 | const servers = agent.abilities?.mcp_servers ?? []; |
| 91 | return ( |
| 92 | <div className="space-y-10"> |
| 93 | <section aria-labelledby="summary" className="max-w-3xl"> |
| 94 | <h2 id="summary" className="sr-only"> |
| 95 | In short |
| 96 | </h2> |
| 97 | <p className="text-base leading-relaxed text-fg">{abilitiesSummary(sections)}</p> |
| 98 | <p className="mt-2 text-sm text-muted"> |
| 99 | Each ability runs <strong className="font-medium text-fg">alone</strong>, <strong className="font-medium text-fg">alone when the person asked for it</strong>,{" "} |
| 100 | <strong className="font-medium text-fg">after asking first</strong> (a card in chat, for the person it acts for or an owner), or <strong className="font-medium text-fg">never</strong>. Every choice is enforced |
| 101 | in code, and a refusal names its rule in the transcript and the audit log. |
| 102 | {canEdit ? " Each change is saved as a new version." : personal ? " Its member changes these." : " Owners change these."} |
| 103 | </p> |
| 104 | </section> |
| 105 | {error && ( |
| 106 | <p role="alert" className="rounded-lg border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger"> |
| 107 | {error} |
| 108 | </p> |
| 109 | )} |
| 110 | {sections.map((section) => { |
| 111 | const Icon = ICONS[section.group]; |
| 112 | return ( |
| 113 | <section key={section.group} aria-labelledby={`group-${section.group}`} className="space-y-3"> |
| 114 | <div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2"> |
| 115 | <div className="flex min-w-0 items-start gap-2.5"> |
| 116 | <span className="mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-md bg-accent/10 text-accent"> |
| 117 | <Icon size={15} aria-hidden /> |
| 118 | </span> |
| 119 | <div className="min-w-0"> |
| 120 | <h2 id={`group-${section.group}`} className="flex flex-wrap items-center gap-2 text-sm font-semibold"> |
| 121 | {section.title} |
| 122 | {section.group === "computer" && section.sources.every((source) => source.abilities.every((ability) => ability.status === "coming")) && <Badge tone="neutral">Coming</Badge>} |
| 123 | {section.group === "mcp" && <McpServerCount servers={servers} max={MAX_MCP_SERVERS} />} |
| 124 | </h2> |
| 125 | <p className="text-sm text-muted">{section.about}</p> |
| 126 | </div> |
| 127 | </div> |
| 128 | {section.group === "mcp" && isOwner && <AddMcpServerDialog agentName={agent.display_name} count={servers.length} max={MAX_MCP_SERVERS} />} |
| 129 | </div> |
| 130 | {section.sources.length === 0 ? ( |
| 131 | <p className="rounded-xl border border-dashed border-line px-4 py-6 text-center text-sm text-muted"> |
| 132 | {section.group === "mcp" ? (isOwner ? `None yet. Add a server, and its tools become ${agent.display_name}'s abilities.` : "None yet. Owners add them.") : "Nothing here yet."} |
| 133 | </p> |
| 134 | ) : ( |
| 135 | <div className="space-y-3"> |
| 136 | {section.sources.map((source) => ( |
| 137 | <SourceBlock key={source.id} section={section} source={source} agent={agent} canEdit={canEdit} isOwner={isOwner} slug={slug} fetcher={fetcher} /> |
| 138 | ))} |
| 139 | </div> |
| 140 | )} |
| 141 | </section> |
| 142 | ); |
| 143 | })} |
| 144 | </div> |
| 145 | ); |
| 146 | } |