Skip to content
283 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 } 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";
16
17export function meta({ params, ...args }: Route.MetaArgs) {
18 return page(args, { title: `New agent · ${params.owner} · g1t` });
19}
20
21export 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. */
45async 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
50const 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 */
57export 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. */
104function 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
109function 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. */
129function 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
145export 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}