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