| 1 | import { Bot, ChevronRight, Monitor, Plug, Server } from "lucide-react"; |
| 2 | import { data, useFetcher, useOutletContext } from "react-router"; |
| 3 | |
| 4 | import type { AbilityLevel, AbilitySection, AgentConnector, McpServer, User, WorkspaceAgent, WorkspaceConnector } 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 | import { customizePath } from "@g1t/contracts/customize"; |
| 8 | |
| 9 | import type { Route } from "./+types/abilities"; |
| 10 | import { AddMcpServerDialog, McpServerCount, SourceBlock } from "../../../components/agents/abilities"; |
| 11 | import { agentsAction, answer, readOrNull } from "../../../components/agents/actions.server"; |
| 12 | import type { ActionResult } from "../../../components/agents/dialogs"; |
| 13 | import { ButtonLink } from "../../../components/ui"; |
| 14 | import { Badge } from "../../../components/ui/badge"; |
| 15 | import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../../../components/ui/collapsible"; |
| 16 | import { connectedStates } from "../../../lib/connected.server"; |
| 17 | import type { ConnectedState } from "../../../lib/connectors"; |
| 18 | import { workspaceAgents } from "../../../lib/services.server"; |
| 19 | import { requireUser, roleIn } from "../../../lib/session.server"; |
| 20 | |
| 21 | /** |
| 22 | * The workspace's connectors that reach the agent (docs.g1t.sh/guides/customize/, |
| 23 | * "Connectors"), and each as an MCP server of the agent's, so its tools are |
| 24 | * abilities with levels like any server's: the service does the same for a |
| 25 | * turn (abilitiesWithConnectors), with the connector's id, so the levels |
| 26 | * set here are the ones it reads. |
| 27 | */ |
| 28 | async function reachingConnectors(slug: string, handle: string, viewer: User): Promise<{ connectors: AgentConnector[] | null; servers: McpServer[] }> { |
| 29 | const [reach, all] = await Promise.all([readOrNull(workspaceAgents.agentConnectors(slug, handle, viewer)), readOrNull(workspaceAgents.connectors(slug, viewer))]); |
| 30 | const byId = new Map((all ?? []).map((connector) => [connector.id, connector])); |
| 31 | const servers = (reach ?? []) |
| 32 | .filter((connector) => connector.via !== "agent-only") |
| 33 | .map((connector) => byId.get(connector.id)) |
| 34 | .filter((connector): connector is WorkspaceConnector => connector != null) |
| 35 | .map((c) => ({ id: c.id, name: c.name, url: c.url, tools: c.tools, added_by: c.added_by, added_at: c.added_at, checked_at: c.checked_at, problem: c.problem })); |
| 36 | return { connectors: reach, servers }; |
| 37 | } |
| 38 | |
| 39 | /** |
| 40 | * What the Abilities tab needs beside the agent: who the viewer is, what |
| 41 | * the workspace has connected (by connector id), where each is set up |
| 42 | * or managed, and the connectors that reach the agent. |
| 43 | */ |
| 44 | export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ isOwner: boolean; viewerId: string; connected: string[]; hrefs: Record<string, string | null>; connectors: AgentConnector[] | null; servers: McpServer[] }> { |
| 45 | const viewer = requireUser(context, request); |
| 46 | const slug = params.owner.toLowerCase(); |
| 47 | const role = roleIn(viewer, slug); |
| 48 | if (!role) throw data(null, { status: 404 }); |
| 49 | const [states, reaching] = await Promise.all([connectedStates(slug, viewer).catch(() => ({}) as Record<string, ConnectedState>), reachingConnectors(slug, params.handle.toLowerCase(), viewer)]); |
| 50 | const hrefs: Record<string, string | null> = {}; |
| 51 | for (const connector of CONNECTORS) { |
| 52 | const view = connectorView(connector, "workspace"); |
| 53 | hrefs[connector.id] = states[connector.id]?.manage ?? (view?.href ? connectorPath(view.href, slug) : null); |
| 54 | } |
| 55 | return { isOwner: role === "owner", viewerId: viewer.id, connected: Object.keys(states), hrefs, ...reaching }; |
| 56 | } |
| 57 | |
| 58 | /** |
| 59 | * Changes a level or whose connection (a new version of the agent, like any |
| 60 | * change), or adds, refreshes or removes a server only this agent has. The |
| 61 | * agents service decides who may: owners a workspace agent, its member a |
| 62 | * personal one, and only owners the servers and the workspace's connection. |
| 63 | */ |
| 64 | export async function action({ params, context, request }: Route.ActionArgs): Promise<ActionResult> { |
| 65 | const { viewer, slug, isOwner, form } = await agentsAction(request, context, params.owner); |
| 66 | const intent = String(form.get("intent") ?? ""); |
| 67 | const handle = params.handle.toLowerCase(); |
| 68 | if (intent === "mcp_add") return answer(intent, workspaceAgents.addMcpServer(slug, handle, viewer, { name: String(form.get("name") ?? ""), url: String(form.get("url") ?? "") })); |
| 69 | if (intent === "mcp_remove") return answer(intent, workspaceAgents.removeMcpServer(slug, handle, viewer, String(form.get("server") ?? ""))); |
| 70 | if (intent === "mcp_refresh") return answer(intent, workspaceAgents.refreshMcpServer(slug, handle, viewer, String(form.get("server") ?? ""))); |
| 71 | if (intent !== "level" && intent !== "credentials") return { ok: false, intent, error: "Unknown request." }; |
| 72 | const [current, reaching] = await Promise.all([workspaceAgents.get(slug, handle, viewer).catch(() => null), reachingConnectors(slug, handle, viewer)]); |
| 73 | if (!current) return { ok: false, intent, error: "The agents service didn't answer. Try again in a moment." }; |
| 74 | if (!current.ok) return { ok: false, intent, error: current.error.message }; |
| 75 | const agent = current.value; |
| 76 | const id = String(form.get("ability") ?? ""); |
| 77 | const abilities = { ...agent.abilities, mcp_servers: [...(agent.abilities?.mcp_servers ?? []), ...reaching.servers] }; |
| 78 | const sections = resolveAbilities({ connectors: CONNECTORS, abilities, autonomy: agent.autonomy, connected: CONNECTORS.map((c) => c.id), personal: agent.scope === "personal" }); |
| 79 | const found = findAbility(sections, id); |
| 80 | if (!found) return { ok: false, intent, error: "There is no such ability." }; |
| 81 | if (intent === "level") { |
| 82 | const level = String(form.get("level") ?? "") as AbilityLevel; |
| 83 | if (!found.ability.choices.includes(level)) return { ok: false, intent, error: `${found.ability.label} can't be set to that.` }; |
| 84 | if (found.ability.autonomy) return answer(intent, workspaceAgents.update(slug, handle, viewer, { autonomy: { [found.ability.autonomy.key]: autonomyOfLevel(found.ability.autonomy.key, level) } })); |
| 85 | return answer(intent, workspaceAgents.update(slug, handle, viewer, { abilities: { settings: withSetting(agent.abilities, id, { level }).settings } })); |
| 86 | } |
| 87 | const credentials = String(form.get("credentials") ?? ""); |
| 88 | if (credentials !== "workspace" && credentials !== "asker") return { ok: false, intent, error: "Whose connection is the workspace's or the asker's." }; |
| 89 | // The workspace's connection is the owners' to give. |
| 90 | if (credentials === "workspace" && !isOwner) return { ok: false, intent, error: "Only the workspace's owners let an agent act as the workspace's connection." }; |
| 91 | return answer(intent, workspaceAgents.update(slug, handle, viewer, { abilities: { settings: withSetting(agent.abilities, id, { credentials }).settings } })); |
| 92 | } |
| 93 | |
| 94 | const ICONS = { g1t: Bot, computer: Monitor, integration: Plug, mcp: Server } as const; |
| 95 | |
| 96 | /** |
| 97 | * The agent's abilities, in groups: g1t's own (always on), its computer |
| 98 | * (coming), each connected integration's actions with a level and whose |
| 99 | * connection, and its connectors: the workspace's that reach it (given in |
| 100 | * Customize to every agent, a team of its, or it) and the servers only it |
| 101 | * has, which owners add here. A summary in plain words sits at the top. |
| 102 | * Owners edit a workspace agent; a member their own personal one, within |
| 103 | * what owners allow. |
| 104 | */ |
| 105 | export default function AbilitiesTab({ loaderData, params }: Route.ComponentProps) { |
| 106 | const agent = useOutletContext<WorkspaceAgent>(); |
| 107 | const { isOwner, viewerId, connected, hrefs, connectors, servers } = loaderData; |
| 108 | const fetcher = useFetcher<ActionResult>({ key: `abilities-${agent.id}` }); |
| 109 | const personal = agent.scope === "personal"; |
| 110 | const canEdit = personal ? agent.personal_owner_id === viewerId : isOwner; |
| 111 | const own = agent.abilities?.mcp_servers ?? []; |
| 112 | const sections: AbilitySection[] = resolveAbilities({ connectors: CONNECTORS, abilities: { ...agent.abilities, mcp_servers: [...own, ...servers] }, autonomy: agent.autonomy, connected, hrefs, personal }); |
| 113 | const error = fetcher.state === "idle" && fetcher.data && !fetcher.data.ok ? fetcher.data.error : null; |
| 114 | const slug = params.owner; |
| 115 | const reach = new Map((connectors ?? []).map((connector) => [connector.id, connector])); |
| 116 | return ( |
| 117 | <div className="space-y-10"> |
| 118 | <section aria-labelledby="summary" className="max-w-3xl"> |
| 119 | <h2 id="summary" className="sr-only"> |
| 120 | In short |
| 121 | </h2> |
| 122 | <p className="text-base leading-relaxed text-fg">{abilitiesSummary(sections)}</p> |
| 123 | <p className="mt-2 text-sm text-muted"> |
| 124 | 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>,{" "} |
| 125 | <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 |
| 126 | in code, and a refusal names its rule in the transcript and the audit log. |
| 127 | {canEdit ? " Each change is saved as a new version." : personal ? " Its member changes these." : " Owners change these."} |
| 128 | </p> |
| 129 | </section> |
| 130 | {error && ( |
| 131 | <p role="alert" className="rounded-lg border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger"> |
| 132 | {error} |
| 133 | </p> |
| 134 | )} |
| 135 | {sections.map((section) => { |
| 136 | const Icon = ICONS[section.group]; |
| 137 | const mcp = section.group === "mcp"; |
| 138 | return ( |
| 139 | <section key={section.group} aria-labelledby={`group-${section.group}`} className="space-y-3"> |
| 140 | <div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2"> |
| 141 | <div className="flex min-w-0 items-start gap-2.5"> |
| 142 | <span className="mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-md bg-accent/10 text-accent"> |
| 143 | <Icon size={15} aria-hidden /> |
| 144 | </span> |
| 145 | <div className="min-w-0"> |
| 146 | <h2 id={`group-${section.group}`} className="flex flex-wrap items-center gap-2 text-sm font-semibold"> |
| 147 | {mcp ? "Connectors" : section.title} |
| 148 | {section.group === "computer" && section.sources.every((source) => source.abilities.every((ability) => ability.status === "coming")) && <Badge tone="neutral">Coming</Badge>} |
| 149 | </h2> |
| 150 | <p className="text-sm text-muted"> |
| 151 | {mcp |
| 152 | ? "MCP servers the workspace connected in Customize and gave to every agent, a team it is on, or it; and servers only it has. Each tool is a row; a tool that doesn't say it only reads counts as a write." |
| 153 | : section.about} |
| 154 | </p> |
| 155 | </div> |
| 156 | </div> |
| 157 | {mcp && ( |
| 158 | <ButtonLink to={customizePath("connectors", slug)} variant="outline" size="sm"> |
| 159 | Customize |
| 160 | <ChevronRight size={13} /> |
| 161 | </ButtonLink> |
| 162 | )} |
| 163 | </div> |
| 164 | {section.sources.length === 0 ? ( |
| 165 | <p className="rounded-xl border border-dashed border-line px-4 py-6 text-center text-sm text-muted"> |
| 166 | {!mcp |
| 167 | ? "Nothing here yet." |
| 168 | : connectors === null |
| 169 | ? "Its connectors can't be read right now." |
| 170 | : isOwner |
| 171 | ? `None yet. Connect one in Customize and make it available to ${agent.display_name}, a team it is on, or every agent.` |
| 172 | : "None reach it yet. Owners connect them in Customize."} |
| 173 | </p> |
| 174 | ) : ( |
| 175 | <div className="space-y-3"> |
| 176 | {section.sources.map((source) => ( |
| 177 | <SourceBlock key={source.id} section={section} source={source} agent={agent} canEdit={canEdit} isOwner={isOwner} slug={slug} fetcher={fetcher} connector={reach.get(source.id) ?? null} /> |
| 178 | ))} |
| 179 | </div> |
| 180 | )} |
| 181 | {mcp && ( |
| 182 | <Collapsible> |
| 183 | <CollapsibleTrigger> |
| 184 | <ChevronRight size={14} aria-hidden /> |
| 185 | Only this agent |
| 186 | <McpServerCount servers={own} max={MAX_MCP_SERVERS} /> |
| 187 | </CollapsibleTrigger> |
| 188 | <CollapsibleContent className="mt-3 flex flex-wrap items-center justify-between gap-3 rounded-xl border border-dashed border-line px-4 py-3 text-sm text-muted"> |
| 189 | <p className="max-w-xl"> |
| 190 | A server that only {agent.display_name} has, by address. Connectors for the whole workspace, a team or several agents are added in Customize → Connectors, where they sign in once and are given out. |
| 191 | </p> |
| 192 | {isOwner && <AddMcpServerDialog agentName={agent.display_name} count={own.length} max={MAX_MCP_SERVERS} />} |
| 193 | </CollapsibleContent> |
| 194 | </Collapsible> |
| 195 | )} |
| 196 | </section> |
| 197 | ); |
| 198 | })} |
| 199 | </div> |
| 200 | ); |
| 201 | } |