| 1 | import { ArrowUpCircle, BookOpen, ChartColumn, Check, Code, FileText, FolderOpen, Globe, Library, type LucideIcon, Plus, Send, Store } from "lucide-react"; |
| 2 | import { Link, data, useFetcher, useOutletContext } from "react-router"; |
| 3 | |
| 4 | import { |
| 5 | type AgentSkill, |
| 6 | type AgentSkillLine, |
| 7 | type AgentSkills, |
| 8 | FOUNDATIONAL_SKILLS, |
| 9 | FOUNDATIONAL_SKILLS_VERSION, |
| 10 | FOUNDATIONAL_SKILL_IDS, |
| 11 | type LibrarySkill, |
| 12 | type SkillAbility, |
| 13 | type SkillCategory, |
| 14 | type WorkspaceAgent, |
| 15 | } from "@g1t/contracts"; |
| 16 | |
| 17 | import type { Route } from "./+types/skills"; |
| 18 | import { agentsAction, answer, readOrNull } from "../../../components/agents/actions.server"; |
| 19 | import { type ActionResult, BUTTONS } from "../../../components/agents/dialogs"; |
| 20 | import { AttachToAgentDialog, AttachmentChip, NeedsComputer, skillsPath } from "../../../components/agents/skills"; |
| 21 | import { Badge } from "../../../components/ui/badge"; |
| 22 | import { Hint } from "../../../components/ui/hint"; |
| 23 | import { Switch } from "../../../components/ui/switch"; |
| 24 | import { cn } from "../../../lib/cn"; |
| 25 | import { skillLibrary, workspaceAgents } from "../../../lib/services.server"; |
| 26 | import { requireUser, roleIn } from "../../../lib/session.server"; |
| 27 | |
| 28 | /** A library skill's id, as an agent's `skills_off` holds it. */ |
| 29 | const LIBRARY_ID = /^skl_[0-9a-z]{26}$/; |
| 30 | |
| 31 | /** |
| 32 | * The agent's skills: whether the viewer owns the workspace (owners turn |
| 33 | * skills on and off), the library's skills that reach it, and for owners |
| 34 | * the library's others, to attach. |
| 35 | */ |
| 36 | export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ isOwner: boolean; skills: AgentSkills | null; attachable: Pick<LibrarySkill, "name" | "description" | "version">[] }> { |
| 37 | const viewer = requireUser(context, request); |
| 38 | const slug = params.owner.toLowerCase(); |
| 39 | const role = roleIn(viewer, slug); |
| 40 | if (!role) throw data(null, { status: 404 }); |
| 41 | const isOwner = role === "owner"; |
| 42 | const [skills, library] = await Promise.all([ |
| 43 | readOrNull(skillLibrary.agentSkills(slug, viewer, params.handle.toLowerCase())), |
| 44 | isOwner ? readOrNull(skillLibrary.library(slug, viewer)) : Promise.resolve(null), |
| 45 | ]); |
| 46 | const has = new Set(skills?.skills.map((s) => s.id) ?? []); |
| 47 | const attachable = (library?.skills ?? []).filter((s) => s.status === "published" && !has.has(s.id)).map((s) => ({ name: s.name, description: s.description, version: s.version })); |
| 48 | return { isOwner, skills, attachable }; |
| 49 | } |
| 50 | |
| 51 | /** Turns a skill on or off (a new version of the agent), attaches one, or moves one to its newest version. */ |
| 52 | export async function action({ params, context, request }: Route.ActionArgs): Promise<ActionResult> { |
| 53 | const { viewer, slug, isOwner, form } = await agentsAction(request, context, params.owner); |
| 54 | const intent = String(form.get("intent") ?? ""); |
| 55 | const handle = params.handle.toLowerCase(); |
| 56 | if (intent === "attach") return answer(intent, skillLibrary.attachSkill(slug, viewer, String(form.get("name") ?? ""), "agent", handle)); |
| 57 | if (intent === "pin") return answer(intent, skillLibrary.pinSkill(slug, viewer, String(form.get("name") ?? ""), String(form.get("attachment") ?? ""), null)); |
| 58 | if (intent !== "skill") return { ok: false, intent, error: "Unknown request." }; |
| 59 | if (!isOwner) return { ok: false, intent, error: "Only the workspace's owners turn an agent's skills on or off." }; |
| 60 | const skill = String(form.get("skill") ?? ""); |
| 61 | if (!FOUNDATIONAL_SKILL_IDS.includes(skill) && !LIBRARY_ID.test(skill)) return { ok: false, intent, error: "There is no such skill." }; |
| 62 | const current = await workspaceAgents.get(slug, handle, viewer).catch(() => null); |
| 63 | if (!current) return { ok: false, intent, error: "The agents service didn't answer. Try again in a moment." }; |
| 64 | if (!current.ok) return { ok: false, intent, error: current.error.message }; |
| 65 | const off = new Set(current.value.skills_off ?? []); |
| 66 | if (form.get("on") === "true") off.delete(skill); |
| 67 | else off.add(skill); |
| 68 | return answer(intent, workspaceAgents.update(slug, handle, viewer, { skills_off: [...off] })); |
| 69 | } |
| 70 | |
| 71 | const ICONS: Record<SkillCategory, LucideIcon> = { documents: FileText, research: Globe, data: ChartColumn, code: Code, communication: Send, files: FolderOpen }; |
| 72 | |
| 73 | /** |
| 74 | * An agent's skills: g1t's foundational ones, what each does today and |
| 75 | * with which of the agent's tools, what's coming; then the library's that |
| 76 | * reach it, through it, its teams or every agent. Owners switch each on or |
| 77 | * off, attach more, and move one to its newest version. |
| 78 | */ |
| 79 | export default function SkillsTab({ loaderData, params }: Route.ComponentProps) { |
| 80 | const agent = useOutletContext<WorkspaceAgent>(); |
| 81 | const { isOwner, skills, attachable } = loaderData; |
| 82 | const fetcher = useFetcher<ActionResult>({ key: `skills-${agent.id}` }); |
| 83 | // While a switch's change is on its way, it shows as changed. |
| 84 | const pending = fetcher.formData && fetcher.formData.get("intent") === "skill" ? { skill: String(fetcher.formData.get("skill")), on: fetcher.formData.get("on") === "true" } : null; |
| 85 | const isOn = (id: string) => (pending?.skill === id ? pending.on : !(agent.skills_off ?? []).includes(id)); |
| 86 | const onCount = FOUNDATIONAL_SKILLS.filter((skill) => isOn(skill.id)).length; |
| 87 | const error = fetcher.state === "idle" && fetcher.data && !fetcher.data.ok ? fetcher.data.error : null; |
| 88 | const library = skills?.skills.filter((s) => !s.foundational) ?? []; |
| 89 | const slug = params.owner; |
| 90 | return ( |
| 91 | <div className="space-y-10"> |
| 92 | <p className="max-w-2xl text-sm text-muted"> |
| 93 | Skills tell {agent.display_name} how to do a kind of work with the tools it already has, so asking it for a PDF gets you a PDF. It sees each skill's name and when to |
| 94 | use it, and reads the rest when a request matches. Skills never add a tool or a permission, and say plainly what isn't possible yet. |
| 95 | </p> |
| 96 | {error && ( |
| 97 | <p role="alert" className="rounded-lg border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger"> |
| 98 | {error} |
| 99 | </p> |
| 100 | )} |
| 101 | |
| 102 | <section aria-labelledby="foundational"> |
| 103 | <div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1"> |
| 104 | <h2 id="foundational" className="text-sm font-medium"> |
| 105 | Foundational, from g1t <span className="text-faint">{onCount === FOUNDATIONAL_SKILLS.length ? FOUNDATIONAL_SKILLS.length : `${onCount} of ${FOUNDATIONAL_SKILLS.length} on`}</span> |
| 106 | </h2> |
| 107 | <p className="text-xs text-faint">Version {FOUNDATIONAL_SKILLS_VERSION}, updated with every release</p> |
| 108 | </div> |
| 109 | {isOwner && <p className="mt-1 text-xs text-faint">Turning a skill off takes it out of {agent.display_name}'s instructions. Its tools stay as they are.</p>} |
| 110 | <div className="mt-4 grid gap-3 lg:grid-cols-2"> |
| 111 | {FOUNDATIONAL_SKILLS.map((skill) => ( |
| 112 | <SkillCard key={skill.id} skill={skill} on={isOn(skill.id)} agentName={agent.display_name} isOwner={isOwner} fetcher={fetcher} /> |
| 113 | ))} |
| 114 | </div> |
| 115 | </section> |
| 116 | |
| 117 | <section aria-labelledby="library"> |
| 118 | <div className="flex flex-wrap items-center justify-between gap-3"> |
| 119 | <div className="min-w-0"> |
| 120 | <h2 id="library" className="text-sm font-medium"> |
| 121 | From your library <span className="text-faint">{library.length}</span> |
| 122 | </h2> |
| 123 | <p className="mt-1 text-xs text-faint">Attached to {agent.display_name}, to a team it is on, or to every agent. Each uses the version pinned where it is attached.</p> |
| 124 | </div> |
| 125 | <div className="flex shrink-0 flex-wrap gap-2"> |
| 126 | <Link to={skillsPath(slug)} className={`${BUTTONS.QUIET} h-8 px-3 py-0 text-xs`}> |
| 127 | <Library size={13} /> |
| 128 | Open the library |
| 129 | </Link> |
| 130 | {isOwner && ( |
| 131 | <AttachToAgentDialog |
| 132 | agentName={agent.display_name} |
| 133 | skills={attachable} |
| 134 | trigger={ |
| 135 | <button type="button" className={`${BUTTONS.QUIET} h-8 px-3 py-0 text-xs`}> |
| 136 | <Plus size={13} /> |
| 137 | Attach a skill |
| 138 | </button> |
| 139 | } |
| 140 | /> |
| 141 | )} |
| 142 | </div> |
| 143 | </div> |
| 144 | {skills == null ? ( |
| 145 | <p className="mt-3 rounded-xl border border-dashed border-line px-4 py-6 text-center text-sm text-muted">The library didn't answer. Reload in a moment.</p> |
| 146 | ) : library.length === 0 ? ( |
| 147 | <p className="mt-3 rounded-xl border border-dashed border-line px-4 py-6 text-center text-sm text-muted"> |
| 148 | None yet. Skills your workspace writes, imports or saves from sessions reach {agent.display_name} once they are attached. |
| 149 | </p> |
| 150 | ) : ( |
| 151 | <ul className="mt-3 divide-y divide-line/60 overflow-hidden rounded-xl border border-line bg-surface"> |
| 152 | {library.map((line) => ( |
| 153 | <LibraryLine key={line.id} slug={slug} line={line} on={isOn(line.id)} agentName={agent.display_name} isOwner={isOwner} fetcher={fetcher} /> |
| 154 | ))} |
| 155 | </ul> |
| 156 | )} |
| 157 | {skills && skills.over_limit > 0 && ( |
| 158 | <p className="mt-2 text-sm text-warn"> |
| 159 | {agent.display_name} has {skills.over_limit} more than the 100 library skills an agent can have; it doesn't get the last {skills.over_limit}. Turn some off or |
| 160 | detach them. |
| 161 | </p> |
| 162 | )} |
| 163 | </section> |
| 164 | |
| 165 | <section aria-label="Coming" className="divide-y divide-line/60 overflow-hidden rounded-xl border border-line bg-surface"> |
| 166 | <ComingRow icon={Globe} title="Web access" body="Searching and reading the open web, set per team: open, approved sites only, or off." /> |
| 167 | <ComingRow icon={Store} title="Skills from the Marketplace" body="Skills that extensions bring, added in one step." /> |
| 168 | </section> |
| 169 | </div> |
| 170 | ); |
| 171 | } |
| 172 | |
| 173 | function ComingRow({ icon: Icon, title, body }: { icon: LucideIcon; title: string; body: string }) { |
| 174 | return ( |
| 175 | <div className="flex flex-wrap items-center justify-between gap-3 px-4 py-3"> |
| 176 | <div className="flex min-w-0 items-start gap-3"> |
| 177 | <Icon size={16} className="mt-0.5 shrink-0 text-muted" aria-hidden /> |
| 178 | <div className="min-w-0"> |
| 179 | <h3 className="text-sm font-medium">{title}</h3> |
| 180 | <p className="mt-0.5 text-sm text-muted">{body}</p> |
| 181 | </div> |
| 182 | </div> |
| 183 | <ComingBadge /> |
| 184 | </div> |
| 185 | ); |
| 186 | } |
| 187 | |
| 188 | function ComingBadge() { |
| 189 | return <Badge tone="neutral">Coming</Badge>; |
| 190 | } |
| 191 | |
| 192 | function LibraryLine({ |
| 193 | slug, |
| 194 | line, |
| 195 | on, |
| 196 | agentName, |
| 197 | isOwner, |
| 198 | fetcher, |
| 199 | }: { |
| 200 | slug: string; |
| 201 | line: AgentSkillLine; |
| 202 | on: boolean; |
| 203 | agentName: string; |
| 204 | isOwner: boolean; |
| 205 | fetcher: ReturnType<typeof useFetcher<ActionResult>>; |
| 206 | }) { |
| 207 | const toggle = (next: boolean) => fetcher.submit({ intent: "skill", skill: line.id, on: String(next) }, { method: "post" }); |
| 208 | return ( |
| 209 | <li className="flex items-start gap-3 px-4 py-3.5"> |
| 210 | <div className={cn("min-w-0 grow", !on && "opacity-60")}> |
| 211 | <div className="flex flex-wrap items-center gap-x-2 gap-y-1"> |
| 212 | <Link to={skillsPath(slug, line.name)} className="font-mono text-sm font-medium text-fg hover:underline"> |
| 213 | {line.name} |
| 214 | </Link> |
| 215 | <span className="text-xs text-faint">v{line.version}</span> |
| 216 | {line.requires_computer && <NeedsComputer />} |
| 217 | </div> |
| 218 | <p className="mt-0.5 text-sm text-muted">{line.description}</p> |
| 219 | <div className="mt-2 flex flex-wrap items-center gap-2"> |
| 220 | {line.via && <AttachmentChip attachment={{ scope: line.via, label: line.via_label ?? "", version: Number(line.version) }} />} |
| 221 | {line.update != null && |
| 222 | (line.can_change ? ( |
| 223 | <fetcher.Form method="post"> |
| 224 | <input type="hidden" name="intent" value="pin" /> |
| 225 | <input type="hidden" name="name" value={line.name} /> |
| 226 | <input type="hidden" name="attachment" value={line.attachment_id ?? ""} /> |
| 227 | <Hint label={line.via === "agent" ? `Move ${agentName} to version ${line.update}` : `Moves it to version ${line.update} for every agent it reaches through ${line.via_label}`}> |
| 228 | <button type="submit" className="inline-flex h-6 items-center gap-1 rounded-md px-1.5 text-xs font-medium text-accent hover:bg-accent/10" disabled={fetcher.state !== "idle"}> |
| 229 | <ArrowUpCircle size={13} /> |
| 230 | Update to v{line.update} |
| 231 | </button> |
| 232 | </Hint> |
| 233 | </fetcher.Form> |
| 234 | ) : ( |
| 235 | <span className="text-xs text-warn">Version {line.update} is out</span> |
| 236 | ))} |
| 237 | </div> |
| 238 | </div> |
| 239 | {isOwner ? ( |
| 240 | <Hint label={on ? `Turn off ${line.name}` : `Turn on ${line.name}`}> |
| 241 | <span className="mt-0.5 inline-flex"> |
| 242 | <Switch checked={on} onCheckedChange={toggle} aria-label={`${line.name} for ${agentName}`} disabled={fetcher.state !== "idle"} /> |
| 243 | </span> |
| 244 | </Hint> |
| 245 | ) : ( |
| 246 | <Badge tone={on ? "success" : "neutral"} className="mt-0.5 shrink-0"> |
| 247 | {on ? "On" : "Off"} |
| 248 | </Badge> |
| 249 | )} |
| 250 | </li> |
| 251 | ); |
| 252 | } |
| 253 | |
| 254 | function SkillCard({ |
| 255 | skill, |
| 256 | on, |
| 257 | agentName, |
| 258 | isOwner, |
| 259 | fetcher, |
| 260 | }: { |
| 261 | skill: AgentSkill; |
| 262 | on: boolean; |
| 263 | agentName: string; |
| 264 | isOwner: boolean; |
| 265 | fetcher: ReturnType<typeof useFetcher<ActionResult>>; |
| 266 | }) { |
| 267 | const Icon = ICONS[skill.category]; |
| 268 | const ready = skill.abilities.filter((ability) => ability.status === "ready"); |
| 269 | const coming = skill.abilities.filter((ability) => ability.status === "coming"); |
| 270 | const toggle = (next: boolean) => fetcher.submit({ intent: "skill", skill: skill.id, on: String(next) }, { method: "post" }); |
| 271 | const label = `${skill.name} for ${agentName}`; |
| 272 | return ( |
| 273 | <article className={cn("flex flex-col rounded-xl border border-line bg-surface", !on && "bg-transparent")} aria-labelledby={`skill-${skill.id}`}> |
| 274 | <header className="flex items-start gap-3 p-4 pb-3"> |
| 275 | <span className={cn("flex size-9 shrink-0 items-center justify-center rounded-lg", on ? "bg-accent/10 text-accent" : "bg-raised text-faint")}> |
| 276 | <Icon size={17} aria-hidden /> |
| 277 | </span> |
| 278 | <div className="min-w-0 grow"> |
| 279 | <h3 id={`skill-${skill.id}`} className={cn("text-sm font-semibold", on ? "text-fg" : "text-muted")}> |
| 280 | {skill.name} |
| 281 | </h3> |
| 282 | <p className="mt-0.5 text-sm text-muted">{skill.description}</p> |
| 283 | </div> |
| 284 | {isOwner ? ( |
| 285 | <Hint label={on ? `Turn off ${skill.name}` : `Turn on ${skill.name}`}> |
| 286 | <span className="mt-0.5 inline-flex"> |
| 287 | <Switch checked={on} onCheckedChange={toggle} aria-label={label} disabled={fetcher.state !== "idle"} /> |
| 288 | </span> |
| 289 | </Hint> |
| 290 | ) : ( |
| 291 | <Badge tone={on ? "success" : "neutral"} className="mt-0.5 shrink-0"> |
| 292 | {on ? "On" : "Off"} |
| 293 | </Badge> |
| 294 | )} |
| 295 | </header> |
| 296 | <ul className={cn("space-y-2.5 border-t border-line/60 px-4 py-3", !on && "opacity-60")}> |
| 297 | {ready.map((ability) => ( |
| 298 | <Ability key={ability.id} ability={ability} /> |
| 299 | ))} |
| 300 | {coming.map((ability) => ( |
| 301 | <Ability key={ability.id} ability={ability} /> |
| 302 | ))} |
| 303 | </ul> |
| 304 | <details className="group mt-auto border-t border-line/60"> |
| 305 | <summary className="flex cursor-pointer list-none items-center gap-1.5 px-4 py-2.5 text-xs font-medium text-muted select-none hover:text-fg [&::-webkit-details-marker]:hidden"> |
| 306 | <BookOpen size={13} aria-hidden /> |
| 307 | <span className="group-open:hidden">Read the playbook</span> |
| 308 | <span className="hidden group-open:inline">Hide the playbook</span> |
| 309 | </summary> |
| 310 | <div className="px-4 pb-4"> |
| 311 | <p className="text-xs text-faint"> |
| 312 | What {agentName} reads when it uses {skill.name}. {skill.when} |
| 313 | </p> |
| 314 | <p className="mt-2 rounded-lg bg-raised/60 px-3 py-2.5 text-[0.8125rem] leading-relaxed whitespace-pre-wrap text-fg-soft">{skill.instructions}</p> |
| 315 | </div> |
| 316 | </details> |
| 317 | </article> |
| 318 | ); |
| 319 | } |
| 320 | |
| 321 | function Ability({ ability }: { ability: SkillAbility }) { |
| 322 | const ready = ability.status === "ready"; |
| 323 | return ( |
| 324 | <li className="flex items-start gap-2.5"> |
| 325 | {ready ? ( |
| 326 | <Check size={14} className="mt-0.75 shrink-0 text-success" aria-label="Works today" /> |
| 327 | ) : ( |
| 328 | <span className="mt-0.75 flex size-3.5 shrink-0 items-center justify-center" aria-hidden> |
| 329 | <span className="size-2 rounded-full border border-line-strong" /> |
| 330 | </span> |
| 331 | )} |
| 332 | <div className="min-w-0 grow"> |
| 333 | <div className="flex flex-wrap items-center gap-x-2 gap-y-1"> |
| 334 | <span className={cn("text-sm", ready ? "text-fg" : "text-muted")}>{ability.label}</span> |
| 335 | {!ready && <ComingBadge />} |
| 336 | </div> |
| 337 | <p className="mt-0.5 text-xs text-faint">{ability.note}</p> |
| 338 | {ready && ability.tools.length > 0 && ( |
| 339 | <ul className="mt-1.5 flex flex-wrap gap-1" aria-label="Tools it uses"> |
| 340 | {ability.tools.map((tool) => ( |
| 341 | <li key={tool} className="rounded-[5px] bg-raised px-1.5 py-px font-mono text-[0.6875rem] text-muted ring-1 ring-line ring-inset"> |
| 342 | {tool} |
| 343 | </li> |
| 344 | ))} |
| 345 | </ul> |
| 346 | )} |
| 347 | </div> |
| 348 | </li> |
| 349 | ); |
| 350 | } |