Skip to content
323 linesCodeBlameRaw
1import { ChevronRight, History, Sparkles, UsersRound } from "lucide-react";
2import { useState } from "react";
3import { Form, data, redirect, useNavigation, useOutletContext } from "react-router";
4
5import type { AgentVersion, NewWorkspaceAgent, WorkspaceAgent } from "@g1t/contracts";
6
7import type { Route } from "./+types/profile";
8import { AgentForm } from "../../../components/agents-mode";
9import { type BuilderAnswer, PersonalNotice, RedraftBox } from "../../../components/agents/builder";
10import { readOrNull } from "../../../components/agents/actions.server";
11import { versionChanges } from "../../../components/agents/format";
12import { TimeAgo } from "../../../components/ui";
13import { Alert } from "../../../components/ui/alert";
14import { Button } from "../../../components/ui/button";
15import { Card } from "../../../components/ui/card";
16import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../../../components/ui/collapsible";
17import { ToldText } from "../../../components/people";
18import { isOrchestrator } from "../../../components/orchestrator";
19import { readAgentForm } from "../../../lib/agent-form";
20import { docs, workspaceAgents } from "../../../lib/services.server";
21import { wholeDollars } from "../../../lib/money";
22import { agentTeamsFor, changeAgentTeam } from "../../../lib/agent-teams.server";
23import { AgentTeamsEditor } from "../../../components/teams";
24import { assertSameOrigin, requireUser, roleIn } from "../../../lib/session.server";
25
26/** The teams it is on (and may join) and what it is told about them, and its saved versions. */
27export async function loader({ params, context, request }: Route.LoaderArgs) {
28 const viewer = requireUser(context, request);
29 const role = roleIn(viewer, params.owner);
30 const [teams, told, versions, spaces, effortCosts] = await Promise.all([
31 workspaceAgents
32 .get(params.owner.toLowerCase(), params.handle.toLowerCase(), viewer)
33 .then((found) => (found.ok && found.value.scope !== "personal" ? agentTeamsFor(viewer, params.owner.toLowerCase(), found.value.id) : null))
34 .catch(() => null),
35 // What it is told about its teams every turn, word for word.
36 readOrNull(workspaceAgents.teamContext(params.owner.toLowerCase(), params.handle.toLowerCase(), viewer)).then((context) => context?.text || null),
37 readOrNull(workspaceAgents.versions(params.owner.toLowerCase(), params.handle.toLowerCase(), viewer)),
38 readingSpaces(params.owner.toLowerCase(), viewer),
39 // What each effort level has cost it, beside the control.
40 readOrNull(workspaceAgents.effortCosts(params.owner.toLowerCase(), params.handle.toLowerCase(), viewer)),
41 ]);
42 return {
43 teams,
44 told,
45 slug: params.owner.toLowerCase(),
46 versions,
47 spaces,
48 effortCosts,
49 owner: role === "owner",
50 viewerId: viewer.id,
51 };
52}
53
54/** The Docs spaces the viewer can read, for an agent's required reading; none when Docs can't say. */
55async function readingSpaces(slug: string, viewer: Parameters<typeof docs.sidebar>[1]): Promise<{ id: string; name: string; kind: string }[]> {
56 const sidebar = await docs.sidebar(slug, viewer).catch(() => null);
57 return sidebar?.ok ? sidebar.value.spaces.filter((space) => !space.archived_at).map((space) => ({ id: space.id, name: space.name, kind: space.kind })) : [];
58}
59
60
61/** Saving makes a new version; every run records which one it ran. */
62export async function action({ params, context, request }: Route.ActionArgs) {
63 assertSameOrigin(request);
64 const viewer = requireUser(context, request);
65 const slug = params.owner.toLowerCase();
66 if (!roleIn(viewer, slug)) throw data(null, { status: 404 });
67 const form = await request.formData();
68 const handle = params.handle.toLowerCase();
69 const intent = String(form.get("intent") ?? "");
70 // Its teams: the team's own add and remove, from here.
71 const changed = await changeAgentTeam(form, viewer, slug, handle);
72 if (changed) return { teamChange: changed };
73 // "Tell <name> what to change": drafted, shown, and saved only when they say so.
74 if (intent === "redraft") {
75 const drafted = await workspaceAgents.redraft(slug, handle, viewer, String(form.get("request") ?? "")).catch(() => null);
76 if (!drafted) return { intent, ok: false, error: "The agents service didn't answer. Try again in a moment." } satisfies BuilderAnswer;
77 return (drafted.ok ? { intent, ok: true, redraft: drafted.value } : { intent, ok: false, error: drafted.error.message }) satisfies BuilderAnswer;
78 }
79 if (intent === "apply") {
80 let changes: Partial<NewWorkspaceAgent> = {};
81 try {
82 changes = JSON.parse(String(form.get("changes") ?? "{}")) as Partial<NewWorkspaceAgent>;
83 } catch {
84 return { errors: { form: "The change couldn't be read. Draft it again." }, saved: false };
85 }
86 // Drafted from one version: if someone saved another since, it is drafted again rather than laid over theirs.
87 const current = await workspaceAgents.get(slug, handle, viewer).catch(() => null);
88 if (current?.ok && String(current.value.version) !== String(form.get("from_version"))) {
89 return { errors: { form: `@${handle} was changed since this was drafted. Draft the change again.` }, saved: false };
90 }
91 const saved = await workspaceAgents.update(slug, handle, viewer, changes).catch(() => null);
92 if (!saved) return { errors: { form: "The agents service didn't answer. Try again in a moment." }, saved: false };
93 if (!saved.ok) return { errors: { [saved.error.code === "conflict" ? "handle" : "form"]: saved.error.message }, saved: false };
94 if (saved.value.handle !== handle) throw redirect(`/${slug}/-/agents/${saved.value.handle}/profile`);
95 return { errors: undefined, saved: true };
96 }
97 // Owners make a personal agent the workspace's: the same handle, a new agent.
98 if (intent === "promote") {
99 const promoted = await workspaceAgents.promote(slug, handle, viewer).catch(() => null);
100 if (!promoted) return { errors: { form: "The agents service didn't answer. Try again in a moment." }, saved: false };
101 if (!promoted.ok) return { errors: { form: promoted.error.message }, saved: false };
102 throw redirect(`/${slug}/-/agents/${promoted.value.handle}/profile`);
103 }
104 // Archiving keeps the agent's history (runs, replies, spend) but takes it
105 // out of chat and frees its handle. g1t itself can't be archived.
106 if (form.get("intent") === "archive") {
107 const archived = await workspaceAgents.archive(slug, params.handle.toLowerCase(), viewer).catch(() => null);
108 if (!archived) return { errors: { form: "The agents service didn't answer. Try again in a moment." }, saved: false };
109 if (!archived.ok) return { errors: { form: archived.error.message }, saved: false };
110 // Back on the Agents page, which says where the agent went: Restore or delete it under Archived.
111 throw redirect(`/${slug}/-/agents?archived=${encodeURIComponent(params.handle.toLowerCase())}`);
112 }
113 const read = readAgentForm(form, { orchestrator: params.handle.toLowerCase() === "g1t" });
114 if (!read.ok) return { errors: read.errors, saved: false };
115 const saved = await workspaceAgents.update(slug, params.handle.toLowerCase(), viewer, read.input).catch(() => null);
116 if (!saved) return { errors: { form: "The agents service didn't answer. Try again in a moment." }, saved: false };
117 if (!saved.ok) return { errors: { [saved.error.code === "conflict" ? "handle" : "form"]: saved.error.message }, saved: false };
118 // A new handle is a new address.
119 if (saved.value.handle !== params.handle.toLowerCase()) throw redirect(`/${slug}/-/agents/${saved.value.handle}/profile`);
120 return { errors: undefined, saved: true };
121}
122
123export default function Profile({ loaderData, actionData }: Route.ComponentProps) {
124 const agent = useOutletContext<WorkspaceAgent>();
125 const errors = actionData && "errors" in actionData ? (actionData.errors as Record<string, string> | undefined) : undefined;
126 const personal = agent.scope === "personal";
127 const mine = personal && agent.personal_owner_id === loaderData.viewerId;
128 // Who may change it: owners a workspace agent, its member a personal one.
129 const mayChange = personal ? mine : loaderData.owner;
130 return (
131 <>
132 {personal && <PersonalNotice agent={agent} owner={loaderData.owner} mine={mine} />}
133 {mayChange && <RedraftBox agent={agent} />}
134 {!isOrchestrator(agent) && (
135 <Card asChild className="mb-8 p-4">
136 <section aria-labelledby="agent-teams">
137 <h2 id="agent-teams" className="flex items-center gap-2 text-sm font-medium">
138 <UsersRound size={14} className="text-faint" />
139 Teams
140 </h2>
141 <p className="mt-0.5 mb-3 text-xs text-faint">On teams like anyone: owners and a team&apos;s maintainers add it and take it off.</p>
142 <AgentTeamsEditor
143 slug={loaderData.slug}
144 name={agent.display_name}
145 teams={loaderData.teams}
146 personal={personal}
147 change={actionData && "teamChange" in actionData ? actionData.teamChange : null}
148 />
149 {loaderData.told && (
150 <Collapsible className="mt-4 border-t border-line pt-3">
151 <CollapsibleTrigger>
152 <ChevronRight size={14} className="shrink-0 text-faint" />
153 <Sparkles size={14} className="text-accent" />
154 What {agent.display_name} knows about its teams
155 </CollapsibleTrigger>
156 <CollapsibleContent className="mt-3">
157 <p className="mb-3 text-xs text-faint">Told every turn, from the team pages and who&apos;s around now.</p>
158 <ToldText text={loaderData.told} />
159 </CollapsibleContent>
160 </Collapsible>
161 )}
162 </section>
163 </Card>
164 )}
165 {actionData && "saved" in actionData && actionData.saved && (
166 <p role="status" className="mb-6 rounded-lg border border-success/30 bg-success/10 px-4 py-2.5 text-sm text-success">
167 Saved as version {agent.version}.
168 </p>
169 )}
170 {errors?.form && (
171 <Alert asChild className="mb-6 px-4 py-3">
172 <p>{errors.form}</p>
173 </Alert>
174 )}
175 {/* A member's personal agent is theirs to change; owners promote or archive it. */}
176 {(!personal || mine) && (
177 <AgentForm
178 draft={agent}
179 errors={errors}
180 submit="Save changes"
181 intent="update"
182 formKey={`${agent.id}:${agent.version}`}
183 locked={isOrchestrator(agent)}
184 personal={personal}
185 spaces={loaderData.spaces}
186 effortCosts={loaderData.effortCosts}
187 seed={agent.avatar_seed || agent.id}
188 />
189 )}
190 {loaderData.versions && loaderData.versions.length > 0 && <Versions versions={loaderData.versions} current={agent.version} />}
191 {!isOrchestrator(agent) && <Archive name={agent.display_name} />}
192 </>
193 );
194}
195
196/** Retiring an agent: asked twice, since it leaves every channel at once. */
197function Archive({ name }: { name: string }) {
198 const [asking, setAsking] = useState(false);
199 const busy = useNavigation().formData?.get("intent") === "archive";
200 return (
201 <section className="mt-12 border-t border-line pt-8 md:grid md:grid-cols-[14rem_1fr] md:gap-10">
202 <div className="mb-4 md:mb-0">
203 <h2 className="text-sm font-semibold text-fg">Archive</h2>
204 <p className="mt-1 text-sm text-muted">Retire this agent. Owners can, and a member their own personal agent.</p>
205 </div>
206 <div>
207 <p className="text-sm text-muted">
208 {name} stops answering, leaves every channel and DM, and its handle is free to use again. Its runs, replies and spend stay in
209 the audit log and on Usage.
210 </p>
211 {asking ? (
212 <Form method="post" className="mt-4 flex flex-wrap items-center gap-3">
213 <input type="hidden" name="intent" value="archive" />
214 <Button
215 type="submit"
216 disabled={busy}
217 variant="destructive"
218 className="rounded-lg border-danger/50 bg-danger/15 px-4 hover:bg-danger/25 disabled:opacity-60"
219 >
220 {busy ? "Archiving…" : `Archive ${name}`}
221 </Button>
222 <Button type="button" onClick={() => setAsking(false)} variant="ghost" className="px-2 font-normal">
223 Keep {name}
224 </Button>
225 </Form>
226 ) : (
227 <Button
228 type="button"
229 onClick={() => setAsking(true)}
230 variant="outline"
231 className="mt-4 rounded-lg px-4 text-muted hover:border-danger/50 hover:text-danger font-normal"
232 >
233 Archive agent…
234 </Button>
235 )}
236 </div>
237 </section>
238 );
239}
240
241/**
242 * Every saved version of the agent, newest first: who saved it, when, and
243 * what changed from the one before. Each run and reply records the version
244 * it ran with.
245 */
246function Versions({ versions, current }: { versions: AgentVersion[]; current: number }) {
247 const [open, setOpen] = useState(false);
248 const sorted = [...versions].sort((a, b) => b.version - a.version);
249 const shown = open ? sorted : sorted.slice(0, 3);
250 return (
251 <section className="mt-12 border-t border-line pt-8 md:grid md:grid-cols-[14rem_1fr] md:gap-10">
252 <div className="mb-4 md:mb-0">
253 <h2 className="flex items-center gap-2 text-sm font-semibold text-fg">
254 <History size={14} className="text-faint" />
255 Version history
256 </h2>
257 <p className="mt-1 text-sm text-muted">Every save is a new version. Sessions and replies record the one they ran with.</p>
258 </div>
259 <div>
260 <Card asChild className="divide-y divide-line/60 overflow-hidden">
261 <ol>
262 {shown.map((version) => {
263 const before = sorted.find((v) => v.version < version.version) ?? null;
264 const definition = version.definition as Record<string, unknown>;
265 return (
266 <li key={version.version} className="px-4 py-3">
267 <details className="group">
268 <summary className="flex cursor-pointer list-none items-center gap-3 text-sm [&::-webkit-details-marker]:hidden">
269 <ChevronRight size={14} className="shrink-0 text-faint transition-transform group-open:rotate-90" />
270 <span className="font-medium tabular-nums">Version {version.version}</span>
271 {version.version === current && <span className="rounded-full bg-accent/15 px-1.5 py-px text-[0.6875rem] font-medium text-accent">Current</span>}
272 <span className="min-w-0 grow truncate text-muted">
273 {before ? versionChanges(before.definition as Record<string, unknown>, definition) : version.version === 1 ? "Created" : "Saved"}
274 </span>
275 <span className="shrink-0 text-xs text-faint">
276 @{version.changed_by} · <TimeAgo at={version.created_at} />
277 </span>
278 </summary>
279 <dl className="mt-3 grid gap-x-6 gap-y-2 pl-7 text-xs sm:grid-cols-2">
280 {summaryOf(definition).map(([label, value]) => (
281 <div key={label} className="min-w-0">
282 <dt className="text-faint">{label}</dt>
283 <dd className="mt-0.5 line-clamp-3 text-fg-soft">{value}</dd>
284 </div>
285 ))}
286 </dl>
287 </details>
288 </li>
289 );
290 })}
291 </ol>
292 </Card>
293 {sorted.length > 3 && (
294 <Button type="button" onClick={() => setOpen(!open)} variant="link" size="inline" className="mt-3 text-sm text-muted hover:text-fg font-normal">
295 {open ? "Show fewer" : `Show all ${sorted.length} versions`}
296 </Button>
297 )}
298 </div>
299 </section>
300 );
301}
302
303/** A saved definition, in a few lines. */
304function summaryOf(definition: Record<string, unknown>): [string, string][] {
305 const out: [string, string][] = [];
306 const text = (value: unknown) => (typeof value === "string" && value.trim() ? value.trim() : null);
307 const name = text(definition.display_name);
308 if (name) out.push(["Name", `${name}${text(definition.handle) ? ` (@${definition.handle})` : ""}`]);
309 const title = text(definition.title) ?? text(definition.role);
310 if (title) out.push(["Role", title]);
311 const job = text(definition.instructions);
312 if (job) out.push(["Job", job]);
313 const voice = text(definition.personality_preset);
314 if (voice) out.push(["Personality", [voice, text(definition.personality)].filter(Boolean).join(" · ")]);
315 const budget = definition.budget as { monthly_micros?: number | null; task_micros?: number | null } | undefined;
316 if (budget && (budget.monthly_micros != null || budget.task_micros != null)) {
317 const dollars = wholeDollars;
318 out.push(["Budget", [budget.monthly_micros != null ? `${dollars(budget.monthly_micros)} a month` : null, budget.task_micros != null ? `${dollars(budget.task_micros)} a session` : null].filter(Boolean).join(", ")]);
319 }
320 const duties = Array.isArray(definition.responsibilities) ? (definition.responsibilities as string[]) : [];
321 if (duties.length > 0) out.push(["Responsibilities", duties.join("; ")]);
322 return out;
323}