Skip to content
315 linesCodeBlameRaw
1import { ArrowLeft, Lock } from "lucide-react";
2import { useEffect, useRef, useState } from "react";
3import { Link, data, redirect, useFetcher, useSearchParams } from "react-router";
4
5import type { AgentProposal, AgentTemplate, DraftTurn } from "@g1t/contracts";
6
7import type { Route } from "./+types/new";
8import { AgentForm, TemplateGallery } from "../../../components/agents-mode";
9import { type BuilderAnswer, CreateDraft, DescribeBox, ProposalCard, TryChat } from "../../../components/agents/builder";
10import { type BuilderDefinition, readDefinition } from "../../../lib/agent-builder";
11import { type AgentDraft, BLANK_DRAFT, cleanHandle, readAgentForm, readTeams } from "../../../lib/agent-form";
12import { channelPath } from "../../../lib/chat";
13import { page } from "../../../lib/meta";
14import { chat, docs, identity, workspaceAgents } from "../../../lib/services.server";
15import { assertSameOrigin, requireUser, roleIn } from "../../../lib/session.server";
16import { Alert } from "../../../components/ui/alert";
17import { Button } from "../../../components/ui/button";
18import { Card } from "../../../components/ui/card";
19
20export function meta({ params, ...args }: Route.MetaArgs) {
21 return page(args, { title: `New agent · ${params.owner} · g1t` });
22}
23
24export 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. */
49async 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
54const 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 */
61export 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. */
109function 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
114function 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. */
133function 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 };
146}
147
148export default function NewAgent({ loaderData, actionData, params }: Route.ComponentProps) {
149 const [search, setSearch] = useSearchParams();
150 const chosen = search.get("template");
151 const { owner, mayCreate } = loaderData;
152 const templates = loaderData.templates ?? [];
153 const template = templates.find((t) => t.id === chosen);
154 const errors = actionData && "errors" in actionData ? actionData.errors : undefined;
155 const drafter = useFetcher<BuilderAnswer>({ key: "agent-draft" });
156 const [proposal, setProposal] = useState<AgentProposal | null>(null);
157 const [definition, setDefinition] = useState<BuilderDefinition | null>(null);
158 const [full, setFull] = useState(false);
159 // "Add to teams" on the drafted card: memberships, posted with Create.
160 const [joining, setJoining] = useState<string[]>([]);
161 const handled = useRef<unknown>(null);
162 useEffect(() => {
163 if (drafter.state !== "idle" || !drafter.data || handled.current === drafter.data) return;
164 handled.current = drafter.data;
165 if (drafter.data.ok && drafter.data.intent === "draft") {
166 setProposal(drafter.data.proposal);
167 setDefinition({ ...drafter.data.proposal.definition, avatar_seed: drafter.data.proposal.definition.handle });
168 setFull(false);
169 }
170 }, [drafter.state, drafter.data]);
171
172 // A role, or every field from nothing: the form, as before.
173 const formFrom = template ? draftFrom(template) : chosen === "blank" ? BLANK_DRAFT : full && definition ? formDraft(definition) : null;
174 const personal = full && definition ? definition.scope === "personal" : !owner;
175 const back = (
176 <Link to={`/${params.owner}/-/agents`} className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg">
177 <ArrowLeft size={14} />
178 Agents
179 </Link>
180 );
181
182 if (formFrom) {
183 return (
184 <div className="pb-4">
185 {back}
186 <header className="mt-4 mb-8">
187 <h1 className="text-2xl font-semibold tracking-tight">New agent</h1>
188 <p className="mt-1.5 max-w-2xl text-sm text-muted">
189 {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.{" "}
190 <Button
191 type="button"
192 onClick={() => {
193 setFull(false);
194 setSearch({}, { preventScrollReset: true, replace: true });
195 }}
196 variant="link"
197 size="inline"
198 className="text-fg underline-offset-2 font-normal"
199 >
200 {definition ? "Back to the draft" : "Describe it instead"}
201 </Button>
202 .
203 </p>
204 </header>
205 {!full && <TemplateGallery templates={templates} chosen={chosen} onChoose={(id) => setSearch(id ? { template: id } : {}, { preventScrollReset: true, replace: true })} />}
206 <div className={full ? "" : "mt-10 border-t border-line pt-10"}>
207 {errors?.form && (
208 <Alert asChild className="mb-6 px-4 py-3">
209 <p>{errors.form}</p>
210 </Alert>
211 )}
212 <AgentForm
213 draft={formFrom}
214 errors={errors}
215 submit="Create agent"
216 intent="create"
217 formKey={full ? `draft:${definition?.handle}` : (chosen ?? "blank")}
218 nameIdeas={full && proposal ? [proposal.definition.display_name, ...proposal.name_ideas] : ideasOf(template)}
219 joinable={loaderData.joinable}
220 personal={personal}
221 spaces={loaderData.spaces}
222 seed={full ? definition?.avatar_seed : undefined}
223 hidden={{
224 scope: full && definition ? definition.scope : owner ? "workspace" : "personal",
225 ...(full && definition ? { skills_off: (definition.skills_off ?? []).join(","), avatar_seed: definition.avatar_seed ?? "" } : {}),
226 }}
227 />
228 </div>
229 </div>
230 );
231 }
232
233 if (proposal && definition) {
234 return (
235 <div className="pb-4">
236 {back}
237 <header className="mt-4 mb-6 flex flex-wrap items-end justify-between gap-3">
238 <div className="min-w-0">
239 <h1 className="text-2xl font-semibold tracking-tight">Meet {definition.display_name || "your agent"}</h1>
240 <p className="mt-1.5 max-w-2xl text-sm text-muted">
241 Drafted from what you described. Change anything, try it in the test chat, and create it when it's right.{" "}
242 {definition.scope === "personal" && (
243 <span className="inline-flex items-center gap-1">
244 <Lock size={12} />
245 Only you will be able to talk to it.
246 </span>
247 )}
248 </p>
249 </div>
250 <Button
251 type="button"
252 onClick={() => {
253 setProposal(null);
254 setDefinition(null);
255 }}
256 variant="link"
257 size="inline"
258 className="text-sm text-muted hover:text-fg font-normal"
259 >
260 Describe it again
261 </Button>
262 </header>
263 {errors?.form && (
264 <Alert asChild className="mb-6 px-4 py-3">
265 <p>{errors.form}</p>
266 </Alert>
267 )}
268 {errors?.handle && (
269 <Alert asChild className="mb-6 px-4 py-3">
270 <p>{errors.handle}</p>
271 </Alert>
272 )}
273 <div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,26rem)]">
274 <div className="min-w-0 space-y-4">
275 <ProposalCard
276 slug={params.owner.toLowerCase()}
277 owner={owner}
278 proposal={proposal}
279 value={definition}
280 onChange={setDefinition}
281 joinable={loaderData.joinable}
282 teams={joining}
283 onTeams={setJoining}
284 />
285 <CreateDraft definition={definition} teams={definition.scope === "personal" ? [] : joining} onEditAll={() => setFull(true)} />
286 </div>
287 <div className="lg:sticky lg:top-4 lg:h-[calc(100dvh-13rem)] lg:self-start">
288 <TryChat definition={definition} />
289 </div>
290 </div>
291 </div>
292 );
293 }
294
295 return (
296 <div className="pb-4">
297 {back}
298 <div className="mx-auto mt-8 max-w-2xl sm:mt-14">
299 <h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">What should this agent do?</h1>
300 <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>
301 {!mayCreate && (
302 <Card asChild radius="lg" className="mt-5 px-4 py-3 text-sm text-muted">
303 <p>
304 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.
305 </p>
306 </Card>
307 )}
308 {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>}
309 <div className="mt-6">
310 <DescribeBox slug={params.owner.toLowerCase()} owner={owner} mayCreate={mayCreate} templates={templates} fetcher={drafter} />
311 </div>
312 </div>
313 </div>
314 );
315}