Skip to content
125 linesCodeBlameRaw
1import { ArrowLeft } from "lucide-react";
2import { Link, data, redirect, useSearchParams } from "react-router";
3
4import type { AgentTemplate } from "@g1t/contracts";
5
6import type { Route } from "./+types/new";
7import { AgentForm, TemplateGallery } from "../../../components/agents-mode";
8import { type AgentDraft, BLANK_DRAFT, cleanHandle, readAgentForm } from "../../../lib/agent-form";
9import { channelPath } from "../../../lib/chat";
10import { page } from "../../../lib/meta";
11import { chat, docs, identity, workspaceAgents } from "../../../lib/services.server";
12import { assertSameOrigin, requireUser, roleIn } from "../../../lib/session.server";
13
14export function meta({ params, ...args }: Route.MetaArgs) {
15 return page(args, { title: `New agent · ${params.owner} · g1t` });
16}
17
18export async function loader({ params, context, request }: Route.LoaderArgs) {
19 const viewer = requireUser(context, request);
20 if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
21 const [templates, teams, spaces] = await Promise.all([
22 workspaceAgents.templates().catch(() => null),
23 identity.listTeams(viewer, params.owner).catch(() => null),
24 readingSpaces(params.owner.toLowerCase(), viewer),
25 ]);
26 return { templates, teams: teams?.ok ? teams.value.map((team) => ({ slug: team.slug, name: team.name })) : [], spaces };
27}
28
29/** The Docs spaces the viewer can read, for an agent's required reading; none when Docs can't say. */
30async function readingSpaces(slug: string, viewer: Parameters<typeof docs.sidebar>[1]): Promise<{ id: string; name: string; kind: string }[]> {
31 const sidebar = await docs.sidebar(slug, viewer).catch(() => null);
32 return sidebar?.ok ? sidebar.value.spaces.filter((space) => !space.archived_at).map((space) => ({ id: space.id, name: space.name, kind: space.kind })) : [];
33}
34
35
36/**
37 * Makes the agent, then opens a direct message with it: talking to it is
38 * how work starts. If chat does not answer, its page instead.
39 */
40export async function action({ params, context, request }: Route.ActionArgs) {
41 assertSameOrigin(request);
42 const viewer = requireUser(context, request);
43 const slug = params.owner.toLowerCase();
44 if (!roleIn(viewer, slug)) throw data(null, { status: 404 });
45 const read = readAgentForm(await request.formData());
46 if (!read.ok) return { errors: read.errors };
47 const made = await workspaceAgents.create(slug, viewer, read.input).catch(() => null);
48 if (!made) return { errors: { form: "The agents service didn't answer. Try again in a moment." } };
49 if (!made.ok) {
50 const field = made.error.code === "conflict" ? "handle" : "form";
51 return { errors: { [field]: made.error.message } };
52 }
53 const dm = await chat.openDm(slug, viewer, [{ kind: "agent", id: made.value.id }]).catch(() => null);
54 throw redirect(dm?.ok ? channelPath(slug, dm.value) : `/${slug}/-/agents/${made.value.handle}`);
55}
56
57/** A template's suggested names (`name_ideas`), when the agents service sends them. */
58function ideasOf(template: AgentTemplate | undefined): string[] {
59 if (!template) return [];
60 return [template.display_name, ...(template.name_ideas ?? []).filter((name) => name !== template.display_name)];
61}
62
63function draftFrom(template: AgentTemplate): AgentDraft {
64 // A name to enjoy first, its handle after it; the template's own name when it has none.
65 const name = ideasOf(template)[0] ?? template.display_name;
66 return {
67 ...BLANK_DRAFT,
68 handle: cleanHandle(name),
69 display_name: name,
70 title: template.title,
71 department: template.department,
72 responsibilities: template.responsibilities,
73 subagents: template.subagents,
74 role: template.role,
75 instructions: template.instructions,
76 personality_preset: template.personality_preset,
77 routing: template.routing,
78 template: template.id,
79 };
80}
81
82export default function NewAgent({ loaderData, actionData, params }: Route.ComponentProps) {
83 const [search, setSearch] = useSearchParams();
84 const chosen = search.get("template");
85 const templates = loaderData.templates ?? [];
86 const template = templates.find((t) => t.id === chosen);
87 const draft = template ? draftFrom(template) : chosen === "blank" ? BLANK_DRAFT : null;
88 const errors = actionData?.errors;
89 return (
90 <div className="pb-4">
91 <Link to={`/${params.owner}/-/agents`} className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg">
92 <ArrowLeft size={14} />
93 Agents
94 </Link>
95 <header className="mt-4 mb-8">
96 <h1 className="text-2xl font-semibold tracking-tight">New agent</h1>
97 <p className="mt-1.5 max-w-2xl text-sm text-muted">
98 Hire a colleague into a role: a name, a title, a team and what it is responsible for. Start from a template and configure it (its instructions, voice, models and budget), or from nothing. Once it is hired, you will be in a direct message with it.{" "}
99 <Link to={`/${params.owner}/-/agents/templates`} className="text-fg underline-offset-2 hover:underline">
100 Read each template in full
101 </Link>
102 .
103 </p>
104 </header>
105 {loaderData.templates == null && (
106 <p className="mb-6 rounded-lg border border-line bg-surface px-4 py-3 text-sm text-muted">
107 Templates aren't available right now; the agents service didn't answer. You can still start from a blank agent.
108 </p>
109 )}
110 <TemplateGallery
111 templates={templates}
112 chosen={chosen}
113 onChoose={(id) => setSearch(id ? { template: id } : {}, { preventScrollReset: true, replace: true })}
114 />
115 {draft ? (
116 <div className="mt-10 border-t border-line pt-10">
117 {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>}
118 <AgentForm draft={draft} errors={errors} submit="Create agent" intent="create" formKey={chosen ?? "blank"} nameIdeas={ideasOf(template)} teams={loaderData.teams} spaces={loaderData.spaces} />
119 </div>
120 ) : (
121 <p className="mt-6 text-sm text-faint">Choose a starting point to see its settings.</p>
122 )}
123 </div>
124 );
125}