| 1 | import { ChevronRight, History, Sparkles, UsersRound } from "lucide-react"; |
| 2 | import { useState } from "react"; |
| 3 | import { Form, data, redirect, useNavigation, useOutletContext } from "react-router"; |
| 4 | |
| 5 | import type { AgentVersion, NewWorkspaceAgent, WorkspaceAgent } from "@g1t/contracts"; |
| 6 | |
| 7 | import type { Route } from "./+types/profile"; |
| 8 | import { AgentForm } from "../../../components/agents-mode"; |
| 9 | import { type BuilderAnswer, PersonalNotice, RedraftBox } from "../../../components/agents/builder"; |
| 10 | import { readOrNull } from "../../../components/agents/actions.server"; |
| 11 | import { versionChanges } from "../../../components/agents/format"; |
| 12 | import { TimeAgo } from "../../../components/ui"; |
| 13 | import { Alert } from "../../../components/ui/alert"; |
| 14 | import { Button } from "../../../components/ui/button"; |
| 15 | import { Card } from "../../../components/ui/card"; |
| 16 | import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../../../components/ui/collapsible"; |
| 17 | import { ToldText } from "../../../components/people"; |
| 18 | import { isOrchestrator } from "../../../components/orchestrator"; |
| 19 | import { readAgentForm } from "../../../lib/agent-form"; |
| 20 | import { docs, workspaceAgents } from "../../../lib/services.server"; |
| 21 | import { wholeDollars } from "../../../lib/money"; |
| 22 | import { agentTeamsFor, changeAgentTeam } from "../../../lib/agent-teams.server"; |
| 23 | import { AgentTeamsEditor } from "../../../components/teams"; |
| 24 | import { 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. */ |
| 27 | export 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. */ |
| 55 | async 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. */ |
| 62 | export 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 | throw redirect(`/${slug}/-/agents`); |
| 111 | } |
| 112 | const read = readAgentForm(form, { orchestrator: params.handle.toLowerCase() === "g1t" }); |
| 113 | if (!read.ok) return { errors: read.errors, saved: false }; |
| 114 | const saved = await workspaceAgents.update(slug, params.handle.toLowerCase(), viewer, read.input).catch(() => null); |
| 115 | if (!saved) return { errors: { form: "The agents service didn't answer. Try again in a moment." }, saved: false }; |
| 116 | if (!saved.ok) return { errors: { [saved.error.code === "conflict" ? "handle" : "form"]: saved.error.message }, saved: false }; |
| 117 | // A new handle is a new address. |
| 118 | if (saved.value.handle !== params.handle.toLowerCase()) throw redirect(`/${slug}/-/agents/${saved.value.handle}/profile`); |
| 119 | return { errors: undefined, saved: true }; |
| 120 | } |
| 121 | |
| 122 | export default function Profile({ loaderData, actionData }: Route.ComponentProps) { |
| 123 | const agent = useOutletContext<WorkspaceAgent>(); |
| 124 | const errors = actionData && "errors" in actionData ? (actionData.errors as Record<string, string> | undefined) : undefined; |
| 125 | const personal = agent.scope === "personal"; |
| 126 | const mine = personal && agent.personal_owner_id === loaderData.viewerId; |
| 127 | // Who may change it: owners a workspace agent, its member a personal one. |
| 128 | const mayChange = personal ? mine : loaderData.owner; |
| 129 | return ( |
| 130 | <> |
| 131 | {personal && <PersonalNotice agent={agent} owner={loaderData.owner} mine={mine} />} |
| 132 | {mayChange && <RedraftBox agent={agent} />} |
| 133 | {!isOrchestrator(agent) && ( |
| 134 | <Card asChild className="mb-8 p-4"> |
| 135 | <section aria-labelledby="agent-teams"> |
| 136 | <h2 id="agent-teams" className="flex items-center gap-2 text-sm font-medium"> |
| 137 | <UsersRound size={14} className="text-faint" /> |
| 138 | Teams |
| 139 | </h2> |
| 140 | <p className="mt-0.5 mb-3 text-xs text-faint">On teams like anyone: owners and a team's maintainers add it and take it off.</p> |
| 141 | <AgentTeamsEditor |
| 142 | slug={loaderData.slug} |
| 143 | name={agent.display_name} |
| 144 | teams={loaderData.teams} |
| 145 | personal={personal} |
| 146 | change={actionData && "teamChange" in actionData ? actionData.teamChange : null} |
| 147 | /> |
| 148 | {loaderData.told && ( |
| 149 | <Collapsible className="mt-4 border-t border-line pt-3"> |
| 150 | <CollapsibleTrigger> |
| 151 | <ChevronRight size={14} className="shrink-0 text-faint" /> |
| 152 | <Sparkles size={14} className="text-accent" /> |
| 153 | What {agent.display_name} knows about its teams |
| 154 | </CollapsibleTrigger> |
| 155 | <CollapsibleContent className="mt-3"> |
| 156 | <p className="mb-3 text-xs text-faint">Told every turn, from the team pages and who's around now.</p> |
| 157 | <ToldText text={loaderData.told} /> |
| 158 | </CollapsibleContent> |
| 159 | </Collapsible> |
| 160 | )} |
| 161 | </section> |
| 162 | </Card> |
| 163 | )} |
| 164 | {actionData && "saved" in actionData && actionData.saved && ( |
| 165 | <p role="status" className="mb-6 rounded-lg border border-success/30 bg-success/10 px-4 py-2.5 text-sm text-success"> |
| 166 | Saved as version {agent.version}. |
| 167 | </p> |
| 168 | )} |
| 169 | {errors?.form && ( |
| 170 | <Alert asChild className="mb-6 px-4 py-3"> |
| 171 | <p>{errors.form}</p> |
| 172 | </Alert> |
| 173 | )} |
| 174 | {/* A member's personal agent is theirs to change; owners promote or archive it. */} |
| 175 | {(!personal || mine) && ( |
| 176 | <AgentForm |
| 177 | draft={agent} |
| 178 | errors={errors} |
| 179 | submit="Save changes" |
| 180 | intent="update" |
| 181 | formKey={`${agent.id}:${agent.version}`} |
| 182 | locked={isOrchestrator(agent)} |
| 183 | personal={personal} |
| 184 | spaces={loaderData.spaces} |
| 185 | effortCosts={loaderData.effortCosts} |
| 186 | seed={agent.avatar_seed || agent.id} |
| 187 | /> |
| 188 | )} |
| 189 | {loaderData.versions && loaderData.versions.length > 0 && <Versions versions={loaderData.versions} current={agent.version} />} |
| 190 | {!isOrchestrator(agent) && <Archive name={agent.display_name} />} |
| 191 | </> |
| 192 | ); |
| 193 | } |
| 194 | |
| 195 | /** Retiring an agent: asked twice, since it leaves every channel at once. */ |
| 196 | function Archive({ name }: { name: string }) { |
| 197 | const [asking, setAsking] = useState(false); |
| 198 | const busy = useNavigation().formData?.get("intent") === "archive"; |
| 199 | return ( |
| 200 | <section className="mt-12 border-t border-line pt-8 md:grid md:grid-cols-[14rem_1fr] md:gap-10"> |
| 201 | <div className="mb-4 md:mb-0"> |
| 202 | <h2 className="text-sm font-semibold text-fg">Archive</h2> |
| 203 | <p className="mt-1 text-sm text-muted">Retire this agent. Owners can, and a member their own personal agent.</p> |
| 204 | </div> |
| 205 | <div> |
| 206 | <p className="text-sm text-muted"> |
| 207 | {name} stops answering, leaves every channel and DM, and its handle is free to use again. Its runs, replies and spend stay in |
| 208 | the audit log and on Usage. |
| 209 | </p> |
| 210 | {asking ? ( |
| 211 | <Form method="post" className="mt-4 flex flex-wrap items-center gap-3"> |
| 212 | <input type="hidden" name="intent" value="archive" /> |
| 213 | <Button |
| 214 | type="submit" |
| 215 | disabled={busy} |
| 216 | variant="destructive" |
| 217 | className="rounded-lg border-danger/50 bg-danger/15 px-4 hover:bg-danger/25 disabled:opacity-60" |
| 218 | > |
| 219 | {busy ? "Archiving…" : `Archive ${name}`} |
| 220 | </Button> |
| 221 | <Button type="button" onClick={() => setAsking(false)} variant="ghost" className="px-2 font-normal"> |
| 222 | Keep {name} |
| 223 | </Button> |
| 224 | </Form> |
| 225 | ) : ( |
| 226 | <Button |
| 227 | type="button" |
| 228 | onClick={() => setAsking(true)} |
| 229 | variant="outline" |
| 230 | className="mt-4 rounded-lg px-4 text-muted hover:border-danger/50 hover:text-danger font-normal" |
| 231 | > |
| 232 | Archive agent… |
| 233 | </Button> |
| 234 | )} |
| 235 | </div> |
| 236 | </section> |
| 237 | ); |
| 238 | } |
| 239 | |
| 240 | /** |
| 241 | * Every saved version of the agent, newest first: who saved it, when, and |
| 242 | * what changed from the one before. Each run and reply records the version |
| 243 | * it ran with. |
| 244 | */ |
| 245 | function Versions({ versions, current }: { versions: AgentVersion[]; current: number }) { |
| 246 | const [open, setOpen] = useState(false); |
| 247 | const sorted = [...versions].sort((a, b) => b.version - a.version); |
| 248 | const shown = open ? sorted : sorted.slice(0, 3); |
| 249 | return ( |
| 250 | <section className="mt-12 border-t border-line pt-8 md:grid md:grid-cols-[14rem_1fr] md:gap-10"> |
| 251 | <div className="mb-4 md:mb-0"> |
| 252 | <h2 className="flex items-center gap-2 text-sm font-semibold text-fg"> |
| 253 | <History size={14} className="text-faint" /> |
| 254 | Version history |
| 255 | </h2> |
| 256 | <p className="mt-1 text-sm text-muted">Every save is a new version. Sessions and replies record the one they ran with.</p> |
| 257 | </div> |
| 258 | <div> |
| 259 | <Card asChild className="divide-y divide-line/60 overflow-hidden"> |
| 260 | <ol> |
| 261 | {shown.map((version) => { |
| 262 | const before = sorted.find((v) => v.version < version.version) ?? null; |
| 263 | const definition = version.definition as Record<string, unknown>; |
| 264 | return ( |
| 265 | <li key={version.version} className="px-4 py-3"> |
| 266 | <details className="group"> |
| 267 | <summary className="flex cursor-pointer list-none items-center gap-3 text-sm [&::-webkit-details-marker]:hidden"> |
| 268 | <ChevronRight size={14} className="shrink-0 text-faint transition-transform group-open:rotate-90" /> |
| 269 | <span className="font-medium tabular-nums">Version {version.version}</span> |
| 270 | {version.version === current && <span className="rounded-full bg-accent/15 px-1.5 py-px text-[0.6875rem] font-medium text-accent">Current</span>} |
| 271 | <span className="min-w-0 grow truncate text-muted"> |
| 272 | {before ? versionChanges(before.definition as Record<string, unknown>, definition) : version.version === 1 ? "Created" : "Saved"} |
| 273 | </span> |
| 274 | <span className="shrink-0 text-xs text-faint"> |
| 275 | @{version.changed_by} · <TimeAgo at={version.created_at} /> |
| 276 | </span> |
| 277 | </summary> |
| 278 | <dl className="mt-3 grid gap-x-6 gap-y-2 pl-7 text-xs sm:grid-cols-2"> |
| 279 | {summaryOf(definition).map(([label, value]) => ( |
| 280 | <div key={label} className="min-w-0"> |
| 281 | <dt className="text-faint">{label}</dt> |
| 282 | <dd className="mt-0.5 line-clamp-3 text-fg-soft">{value}</dd> |
| 283 | </div> |
| 284 | ))} |
| 285 | </dl> |
| 286 | </details> |
| 287 | </li> |
| 288 | ); |
| 289 | })} |
| 290 | </ol> |
| 291 | </Card> |
| 292 | {sorted.length > 3 && ( |
| 293 | <Button type="button" onClick={() => setOpen(!open)} variant="link" size="inline" className="mt-3 text-sm text-muted hover:text-fg font-normal"> |
| 294 | {open ? "Show fewer" : `Show all ${sorted.length} versions`} |
| 295 | </Button> |
| 296 | )} |
| 297 | </div> |
| 298 | </section> |
| 299 | ); |
| 300 | } |
| 301 | |
| 302 | /** A saved definition, in a few lines. */ |
| 303 | function summaryOf(definition: Record<string, unknown>): [string, string][] { |
| 304 | const out: [string, string][] = []; |
| 305 | const text = (value: unknown) => (typeof value === "string" && value.trim() ? value.trim() : null); |
| 306 | const name = text(definition.display_name); |
| 307 | if (name) out.push(["Name", `${name}${text(definition.handle) ? ` (@${definition.handle})` : ""}`]); |
| 308 | const title = text(definition.title) ?? text(definition.role); |
| 309 | if (title) out.push(["Role", title]); |
| 310 | const job = text(definition.instructions); |
| 311 | if (job) out.push(["Job", job]); |
| 312 | const voice = text(definition.personality_preset); |
| 313 | if (voice) out.push(["Personality", [voice, text(definition.personality)].filter(Boolean).join(" · ")]); |
| 314 | const budget = definition.budget as { monthly_micros?: number | null; task_micros?: number | null } | undefined; |
| 315 | if (budget && (budget.monthly_micros != null || budget.task_micros != null)) { |
| 316 | const dollars = wholeDollars; |
| 317 | 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(", ")]); |
| 318 | } |
| 319 | const duties = Array.isArray(definition.responsibilities) ? (definition.responsibilities as string[]) : []; |
| 320 | if (duties.length > 0) out.push(["Responsibilities", duties.join("; ")]); |
| 321 | return out; |
| 322 | } |