Skip to content
316 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 look: d.look ?? null,
146 };
147}
148
149export 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}