| 1 | import { ArrowLeft, Lock } from "lucide-react"; |
| 2 | import { useEffect, useRef, useState } from "react"; |
| 3 | import { Link, data, redirect, useFetcher, useSearchParams } from "react-router"; |
| 4 | |
| 5 | import type { AgentProposal, AgentTemplate, DraftTurn } from "@g1t/contracts"; |
| 6 | |
| 7 | import type { Route } from "./+types/new"; |
| 8 | import { AgentForm, TemplateGallery } from "../../../components/agents-mode"; |
| 9 | import { type BuilderAnswer, CreateDraft, DescribeBox, ProposalCard, TryChat } from "../../../components/agents/builder"; |
| 10 | import { type BuilderDefinition, readDefinition } from "../../../lib/agent-builder"; |
| 11 | import { type AgentDraft, BLANK_DRAFT, cleanHandle, readAgentForm, readTeams } from "../../../lib/agent-form"; |
| 12 | import { channelPath } from "../../../lib/chat"; |
| 13 | import { page } from "../../../lib/meta"; |
| 14 | import { chat, docs, identity, workspaceAgents } from "../../../lib/services.server"; |
| 15 | import { assertSameOrigin, requireUser, roleIn } from "../../../lib/session.server"; |
| 16 | import { Alert } from "../../../components/ui/alert"; |
| 17 | import { Button } from "../../../components/ui/button"; |
| 18 | import { Card } from "../../../components/ui/card"; |
| 19 | |
| 20 | export function meta({ params, ...args }: Route.MetaArgs) { |
| 21 | return page(args, { title: `New agent · ${params.owner} · g1t` }); |
| 22 | } |
| 23 | |
| 24 | export async function loader({ params, context, request }: Route.LoaderArgs) { |
| 25 | const viewer = requireUser(context, request); |
| 26 | const role = roleIn(viewer, params.owner); |
| 27 | if (!role) throw data(null, { status: 404 }); |
| 28 | const slug = params.owner.toLowerCase(); |
| 29 | const [templates, teams, spaces, policy] = await Promise.all([ |
| 30 | workspaceAgents.templates().catch(() => null), |
| 31 | identity.listTeams(viewer, params.owner).catch(() => null), |
| 32 | readingSpaces(slug, viewer), |
| 33 | workspaceAgents.policy(slug, viewer).catch(() => null), |
| 34 | ]); |
| 35 | const owner = role === "owner"; |
| 36 | // Members make personal agents unless an owner turned that off; owners always may. |
| 37 | const membersMay = policy?.ok ? policy.value.members_create_agents : true; |
| 38 | return { |
| 39 | templates, |
| 40 | owner, |
| 41 | mayCreate: owner || membersMay, |
| 42 | // The teams the viewer may add a new agent to: owners any, a maintainer theirs. |
| 43 | joinable: teams?.ok ? teams.value.filter((team) => team.can_manage).map((team) => ({ slug: team.slug, name: team.name })) : [], |
| 44 | spaces, |
| 45 | }; |
| 46 | } |
| 47 | |
| 48 | /** The Docs spaces the viewer can read, for an agent's required reading; none when Docs can't say. */ |
| 49 | async function readingSpaces(slug: string, viewer: Parameters<typeof docs.sidebar>[1]): Promise<{ id: string; name: string; kind: string }[]> { |
| 50 | const sidebar = await docs.sidebar(slug, viewer).catch(() => null); |
| 51 | return sidebar?.ok ? sidebar.value.spaces.filter((space) => !space.archived_at).map((space) => ({ id: space.id, name: space.name, kind: space.kind })) : []; |
| 52 | } |
| 53 | |
| 54 | const NO_ANSWER = "The agents service didn't answer. Try again in a moment."; |
| 55 | |
| 56 | /** |
| 57 | * Drafting from a description and trying the draft answer here; creating |
| 58 | * makes the agent, then opens a direct message with it: talking to it is |
| 59 | * how work starts. If chat does not answer, its page instead. |
| 60 | */ |
| 61 | export async function action({ params, context, request }: Route.ActionArgs) { |
| 62 | assertSameOrigin(request); |
| 63 | const viewer = requireUser(context, request); |
| 64 | const slug = params.owner.toLowerCase(); |
| 65 | if (!roleIn(viewer, slug)) throw data(null, { status: 404 }); |
| 66 | const form = await request.formData(); |
| 67 | const intent = String(form.get("intent") ?? ""); |
| 68 | if (intent === "draft") { |
| 69 | const scope = form.get("scope") === "personal" ? "personal" : form.get("scope") === "workspace" ? "workspace" : null; |
| 70 | const drafted = await workspaceAgents.draft(slug, viewer, { description: String(form.get("description") ?? ""), scope }).catch(() => null); |
| 71 | if (!drafted) return { intent, ok: false, error: NO_ANSWER } satisfies BuilderAnswer; |
| 72 | return (drafted.ok ? { intent, ok: true, proposal: drafted.value } : { intent, ok: false, error: drafted.error.message }) satisfies BuilderAnswer; |
| 73 | } |
| 74 | if (intent === "try") { |
| 75 | const definition = readDefinition(form.get("definition")); |
| 76 | let messages: DraftTurn[] = []; |
| 77 | try { |
| 78 | messages = JSON.parse(String(form.get("messages") ?? "[]")) as DraftTurn[]; |
| 79 | } catch { |
| 80 | messages = []; |
| 81 | } |
| 82 | if (!definition) return { intent, ok: false, error: "The draft couldn't be read. Reload and try again." } satisfies BuilderAnswer; |
| 83 | const answered = await workspaceAgents.tryDraft(slug, viewer, { definition, messages }).catch(() => null); |
| 84 | if (!answered) return { intent, ok: false, error: NO_ANSWER } satisfies BuilderAnswer; |
| 85 | return (answered.ok ? { intent, ok: true, ...answered.value } : { intent, ok: false, error: answered.error.message }) satisfies BuilderAnswer; |
| 86 | } |
| 87 | let input; |
| 88 | if (intent === "create_draft") { |
| 89 | const definition = readDefinition(form.get("definition")); |
| 90 | if (!definition) return { errors: { form: "The draft couldn't be read. Reload and try again." } }; |
| 91 | input = { ...definition, handle: cleanHandle(definition.handle || definition.display_name) }; |
| 92 | } else { |
| 93 | const read = readAgentForm(form); |
| 94 | if (!read.ok) return { errors: read.errors }; |
| 95 | input = read.input; |
| 96 | } |
| 97 | // Its teams: memberships, added as it is made; a personal agent joins none. |
| 98 | const made = await workspaceAgents.create(slug, viewer, input, { teams: input.scope === "personal" ? [] : readTeams(form) }).catch(() => null); |
| 99 | if (!made) return { errors: { form: NO_ANSWER } }; |
| 100 | if (!made.ok) { |
| 101 | const field = made.error.code === "conflict" ? "handle" : "form"; |
| 102 | return { errors: { [field]: made.error.message } }; |
| 103 | } |
| 104 | const dm = await chat.openDm(slug, viewer, [{ kind: "agent", id: made.value.id }]).catch(() => null); |
| 105 | throw redirect(dm?.ok ? channelPath(slug, dm.value) : `/${slug}/-/agents/${made.value.handle}`); |
| 106 | } |
| 107 | |
| 108 | /** A template's suggested names (`name_ideas`), when the agents service sends them. */ |
| 109 | function ideasOf(template: AgentTemplate | undefined): string[] { |
| 110 | if (!template) return []; |
| 111 | return [template.display_name, ...(template.name_ideas ?? []).filter((name) => name !== template.display_name)]; |
| 112 | } |
| 113 | |
| 114 | function draftFrom(template: AgentTemplate): AgentDraft { |
| 115 | // A name to enjoy first, its handle after it; the template's own name when it has none. |
| 116 | const name = ideasOf(template)[0] ?? template.display_name; |
| 117 | return { |
| 118 | ...BLANK_DRAFT, |
| 119 | handle: cleanHandle(name), |
| 120 | display_name: name, |
| 121 | title: template.title, |
| 122 | responsibilities: template.responsibilities, |
| 123 | subagents: template.subagents, |
| 124 | role: template.role, |
| 125 | instructions: template.instructions, |
| 126 | personality_preset: template.personality_preset, |
| 127 | routing: template.routing, |
| 128 | template: template.id, |
| 129 | }; |
| 130 | } |
| 131 | |
| 132 | /** The drafted agent as the full form takes it. */ |
| 133 | function formDraft(d: BuilderDefinition): AgentDraft { |
| 134 | return { |
| 135 | ...BLANK_DRAFT, |
| 136 | handle: d.handle, |
| 137 | display_name: d.display_name, |
| 138 | title: d.title ?? "", |
| 139 | responsibilities: d.responsibilities ?? [], |
| 140 | instructions: d.instructions, |
| 141 | personality_preset: d.personality_preset ?? "crisp", |
| 142 | personality: d.personality ?? "", |
| 143 | routing: { floor: d.routing?.floor ?? null, ceiling: d.routing?.ceiling ?? null, providers: [], pinned: null }, |
| 144 | budget: { monthly_micros: d.budget?.monthly_micros ?? null, daily_micros: d.budget?.daily_micros ?? null, task_micros: d.budget?.task_micros ?? null }, |
| 145 | look: d.look ?? null, |
| 146 | }; |
| 147 | } |
| 148 | |
| 149 | export default function NewAgent({ loaderData, actionData, params }: Route.ComponentProps) { |
| 150 | const [search, setSearch] = useSearchParams(); |
| 151 | const chosen = search.get("template"); |
| 152 | const { owner, mayCreate } = loaderData; |
| 153 | const templates = loaderData.templates ?? []; |
| 154 | const template = templates.find((t) => t.id === chosen); |
| 155 | const errors = actionData && "errors" in actionData ? actionData.errors : undefined; |
| 156 | const drafter = useFetcher<BuilderAnswer>({ key: "agent-draft" }); |
| 157 | const [proposal, setProposal] = useState<AgentProposal | null>(null); |
| 158 | const [definition, setDefinition] = useState<BuilderDefinition | null>(null); |
| 159 | const [full, setFull] = useState(false); |
| 160 | // "Add to teams" on the drafted card: memberships, posted with Create. |
| 161 | const [joining, setJoining] = useState<string[]>([]); |
| 162 | const handled = useRef<unknown>(null); |
| 163 | useEffect(() => { |
| 164 | if (drafter.state !== "idle" || !drafter.data || handled.current === drafter.data) return; |
| 165 | handled.current = drafter.data; |
| 166 | if (drafter.data.ok && drafter.data.intent === "draft") { |
| 167 | setProposal(drafter.data.proposal); |
| 168 | setDefinition({ ...drafter.data.proposal.definition, avatar_seed: drafter.data.proposal.definition.handle }); |
| 169 | setFull(false); |
| 170 | } |
| 171 | }, [drafter.state, drafter.data]); |
| 172 | |
| 173 | // A role, or every field from nothing: the form, as before. |
| 174 | const formFrom = template ? draftFrom(template) : chosen === "blank" ? BLANK_DRAFT : full && definition ? formDraft(definition) : null; |
| 175 | const personal = full && definition ? definition.scope === "personal" : !owner; |
| 176 | const back = ( |
| 177 | <Link to={`/${params.owner}/-/agents`} className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg"> |
| 178 | <ArrowLeft size={14} /> |
| 179 | Agents |
| 180 | </Link> |
| 181 | ); |
| 182 | |
| 183 | if (formFrom) { |
| 184 | return ( |
| 185 | <div className="pb-4"> |
| 186 | {back} |
| 187 | <header className="mt-4 mb-8"> |
| 188 | <h1 className="text-2xl font-semibold tracking-tight">New agent</h1> |
| 189 | <p className="mt-1.5 max-w-2xl text-sm text-muted"> |
| 190 | {personal ? "Your personal agent: only you can talk to it, and it spends from your budget. " : ""}Every field, as you'll find it on its profile later.{" "} |
| 191 | <Button |
| 192 | type="button" |
| 193 | onClick={() => { |
| 194 | setFull(false); |
| 195 | setSearch({}, { preventScrollReset: true, replace: true }); |
| 196 | }} |
| 197 | variant="link" |
| 198 | size="inline" |
| 199 | className="text-fg underline-offset-2 font-normal" |
| 200 | > |
| 201 | {definition ? "Back to the draft" : "Describe it instead"} |
| 202 | </Button> |
| 203 | . |
| 204 | </p> |
| 205 | </header> |
| 206 | {!full && <TemplateGallery templates={templates} chosen={chosen} onChoose={(id) => setSearch(id ? { template: id } : {}, { preventScrollReset: true, replace: true })} />} |
| 207 | <div className={full ? "" : "mt-10 border-t border-line pt-10"}> |
| 208 | {errors?.form && ( |
| 209 | <Alert asChild className="mb-6 px-4 py-3"> |
| 210 | <p>{errors.form}</p> |
| 211 | </Alert> |
| 212 | )} |
| 213 | <AgentForm |
| 214 | draft={formFrom} |
| 215 | errors={errors} |
| 216 | submit="Create agent" |
| 217 | intent="create" |
| 218 | formKey={full ? `draft:${definition?.handle}` : (chosen ?? "blank")} |
| 219 | nameIdeas={full && proposal ? [proposal.definition.display_name, ...proposal.name_ideas] : ideasOf(template)} |
| 220 | joinable={loaderData.joinable} |
| 221 | personal={personal} |
| 222 | spaces={loaderData.spaces} |
| 223 | seed={full ? definition?.avatar_seed : undefined} |
| 224 | hidden={{ |
| 225 | scope: full && definition ? definition.scope : owner ? "workspace" : "personal", |
| 226 | ...(full && definition ? { skills_off: (definition.skills_off ?? []).join(","), avatar_seed: definition.avatar_seed ?? "" } : {}), |
| 227 | }} |
| 228 | /> |
| 229 | </div> |
| 230 | </div> |
| 231 | ); |
| 232 | } |
| 233 | |
| 234 | if (proposal && definition) { |
| 235 | return ( |
| 236 | <div className="pb-4"> |
| 237 | {back} |
| 238 | <header className="mt-4 mb-6 flex flex-wrap items-end justify-between gap-3"> |
| 239 | <div className="min-w-0"> |
| 240 | <h1 className="text-2xl font-semibold tracking-tight">Meet {definition.display_name || "your agent"}</h1> |
| 241 | <p className="mt-1.5 max-w-2xl text-sm text-muted"> |
| 242 | Drafted from what you described. Change anything, try it in the test chat, and create it when it's right.{" "} |
| 243 | {definition.scope === "personal" && ( |
| 244 | <span className="inline-flex items-center gap-1"> |
| 245 | <Lock size={12} /> |
| 246 | Only you will be able to talk to it. |
| 247 | </span> |
| 248 | )} |
| 249 | </p> |
| 250 | </div> |
| 251 | <Button |
| 252 | type="button" |
| 253 | onClick={() => { |
| 254 | setProposal(null); |
| 255 | setDefinition(null); |
| 256 | }} |
| 257 | variant="link" |
| 258 | size="inline" |
| 259 | className="text-sm text-muted hover:text-fg font-normal" |
| 260 | > |
| 261 | Describe it again |
| 262 | </Button> |
| 263 | </header> |
| 264 | {errors?.form && ( |
| 265 | <Alert asChild className="mb-6 px-4 py-3"> |
| 266 | <p>{errors.form}</p> |
| 267 | </Alert> |
| 268 | )} |
| 269 | {errors?.handle && ( |
| 270 | <Alert asChild className="mb-6 px-4 py-3"> |
| 271 | <p>{errors.handle}</p> |
| 272 | </Alert> |
| 273 | )} |
| 274 | <div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,26rem)]"> |
| 275 | <div className="min-w-0 space-y-4"> |
| 276 | <ProposalCard |
| 277 | slug={params.owner.toLowerCase()} |
| 278 | owner={owner} |
| 279 | proposal={proposal} |
| 280 | value={definition} |
| 281 | onChange={setDefinition} |
| 282 | joinable={loaderData.joinable} |
| 283 | teams={joining} |
| 284 | onTeams={setJoining} |
| 285 | /> |
| 286 | <CreateDraft definition={definition} teams={definition.scope === "personal" ? [] : joining} onEditAll={() => setFull(true)} /> |
| 287 | </div> |
| 288 | <div className="lg:sticky lg:top-4 lg:h-[calc(100dvh-13rem)] lg:self-start"> |
| 289 | <TryChat definition={definition} /> |
| 290 | </div> |
| 291 | </div> |
| 292 | </div> |
| 293 | ); |
| 294 | } |
| 295 | |
| 296 | return ( |
| 297 | <div className="pb-4"> |
| 298 | {back} |
| 299 | <div className="mx-auto mt-8 max-w-2xl sm:mt-14"> |
| 300 | <h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">What should this agent do?</h1> |
| 301 | <p className="mt-2 text-sm text-muted">Describe the job in a sentence or a paragraph. g1t drafts the whole agent for you to change, try and create.</p> |
| 302 | {!mayCreate && ( |
| 303 | <Card asChild radius="lg" className="mt-5 px-4 py-3 text-sm text-muted"> |
| 304 | <p> |
| 305 | This workspace's owners have turned off personal agents, so only owners create agents here. Ask an owner in chat if you'd like one. |
| 306 | </p> |
| 307 | </Card> |
| 308 | )} |
| 309 | {loaderData.templates == null && <p className="mt-5 rounded-lg border border-line bg-surface px-4 py-3 text-sm text-muted">Roles to start from aren't available right now; the agents service didn't answer.</p>} |
| 310 | <div className="mt-6"> |
| 311 | <DescribeBox slug={params.owner.toLowerCase()} owner={owner} mayCreate={mayCreate} templates={templates} fetcher={drafter} /> |
| 312 | </div> |
| 313 | </div> |
| 314 | </div> |
| 315 | ); |
| 316 | } |