Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

471 linesCodeBlameRaw
1import { AlertTriangle, CheckCircle2, ChevronRight, Sparkles, X } from "lucide-react";
2import { useState } from "react";
3import { Form, Link } from "react-router";
4
5import { type AiConnection, type AiProvider, type AskSetup, AI_PROVIDERS, aiOwnAddress, isAiProvider } from "@g1t/contracts";
6
7import type { Route } from "./+types/ai";
8import { page } from "../../lib/meta";
9import { AiProviderMark } from "../../components/connectors";
10import { GettingStartedButton } from "../../components/onboarding";
11import { ConsoleWorkspaceField, ModelCatalog, ModelProviderFields } from "../../components/model-providers";
12import { AI_PROVIDER_LOOKS, isEndpoint } from "../../lib/ai-providers";
13import { ErrorText, Field, Input, SubmitButton, TimeAgo } from "../../components/ui";
14import { Card } from "../../components/ui/card";
15import { Combobox } from "../../components/ui/combobox";
16import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select";
17import { autoOffered, defaultModel as startingModel, modelLabel, modelOptions } from "../../lib/ask-model";
18import { ai } from "../../lib/services.server";
19import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
20import { Unavailable } from "../../components/boundary";
21import { isFailed, part } from "../../lib/partial";
22
23export function meta({ params, ...args }: Route.MetaArgs) {
24 return page(args, { title: `AI · ${params.owner} · g1t` });
25}
26
27/**
28 * The workspace's AI: the providers it keeps keys for (a frontier lab, a
29 * platform, or a model it hosts itself) and which one Ask g1t answers
30 * with. It is the workspace's own setting, not an integration: the
31 * Integrations directory is for connecting other products.
32 */
33export async function loader({ params, context, request }: Route.LoaderArgs) {
34 const viewer = getViewer(context);
35 const role = roleIn(viewer, params.owner);
36 if (!role) throw new Response(null, { status: 404 });
37 const slug = params.owner.toLowerCase();
38 // The providers are what the page is about. Ask's setting is a section
39 // beside them: when it does not answer, that section says so instead of
40 // offering a form that would save over a choice it could not read.
41 const [connections, ask] = await Promise.all([ai.list(slug, viewer), part(ai.askSettings(slug, viewer), "Ask g1t's provider")]);
42 const adding = new URL(request.url).searchParams.get("add");
43 return {
44 slug,
45 owner: role === "owner",
46 connections: unwrap(connections),
47 adding: adding && isAiProvider(adding) ? adding : null,
48 ask: isFailed(ask) ? ask : ask.ok ? ask.value : null,
49 };
50}
51
52function text(form: FormData, name: string): string | undefined {
53 const value = String(form.get(name) ?? "").trim();
54 return value || undefined;
55}
56
57export async function action({ request, params, context }: Route.ActionArgs) {
58 assertSameOrigin(request);
59 const user = requireUser(context, request);
60 const slug = params.owner.toLowerCase();
61 const form = await request.formData();
62 const intent = form.get("intent");
63 const id = String(form.get("id") ?? "");
64 if (intent === "remove") {
65 const removed = await ai.remove(user, slug, id);
66 return { error: removed.ok ? null : removed.error.message };
67 }
68 if (intent === "test") {
69 const tested = await ai.test(user, slug, id);
70 return tested.ok ? { tested: { id, ...tested.value } } : { error: tested.error.message };
71 }
72 if (intent === "ask") {
73 // Which provider and model Ask g1t answers with; none turns it off.
74 const providerId = text(form, "providerId");
75 const chosen = await ai.setAskSettings(user, slug, providerId ?? null, providerId ? (text(form, "model") ?? null) : null);
76 return chosen.ok ? { asked: true } : { error: chosen.error.message, ask: true };
77 }
78 if (intent === "provider") {
79 // A provider's model and address, and a new key if one was given: its
80 // other settings as they are.
81 const listed = await ai.list(slug, user);
82 const connection = listed.ok ? listed.value.find((c) => c.id === id) : undefined;
83 if (!connection) return { error: listed.ok ? "No such AI provider." : listed.error.message };
84 const updated = await ai.update(user, slug, id, {
85 config: {
86 ...connection.config,
87 model: text(form, "model"),
88 ...(form.has("baseUrl") ? { baseUrl: text(form, "baseUrl") } : {}),
89 ...(form.has("consoleWorkspace") ? { consoleWorkspace: text(form, "consoleWorkspace") } : {}),
90 },
91 secret: text(form, "secret"),
92 });
93 return updated.ok ? { updated: id } : { error: updated.error.message };
94 }
95 const provider = String(form.get("provider") ?? "");
96 if (!isAiProvider(provider)) return { error: "Choose an AI provider." };
97 const added = await ai.add(user, slug, {
98 provider,
99 name: text(form, "name"),
100 config: {
101 baseUrl: text(form, "baseUrl"),
102 authHeader: text(form, "authHeader"),
103 model: text(form, "model"),
104 consoleWorkspace: text(form, "consoleWorkspace"),
105 },
106 secret: text(form, "secret"),
107 });
108 if (!added.ok) return { error: added.error.message, provider };
109 return { added: added.value };
110}
111
112export default function WorkspaceAi({ loaderData, actionData }: Route.ComponentProps) {
113 const { slug, owner, connections, adding, ask } = loaderData;
114 const justAdded = actionData && "added" in actionData ? actionData.added : null;
115 const tested = (actionData && "tested" in actionData ? actionData.tested : null) ?? null;
116 const error = (actionData && "error" in actionData ? actionData.error : null) ?? null;
117 const updated = (actionData && "updated" in actionData ? actionData.updated : null) ?? null;
118 const asked = actionData != null && "asked" in actionData;
119 const askError = actionData && "ask" in actionData ? error : null;
120 const addError = actionData && "provider" in actionData ? error : null;
121
122 return (
123 <div>
124 <div className="flex flex-wrap items-center justify-between gap-3">
125 <h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight [&_svg]:size-5">
126 <span className="text-muted">
127 <Sparkles size={16} />
128 </span>
129 AI
130 </h1>
131 {owner && <GettingStartedButton slug={slug} />}
132 </div>
133 <p className="mt-1.5 mb-6 border-b border-line pb-6 text-sm text-muted">
134 The models g1t works with in {slug}, on your own keys: Ask and @g1t in Chat answer with one, and an Anthropic key starts sessions on work items. Use a
135 frontier lab or a platform, or a model you host yourself. g1t hosts no model for this and bills nothing for it: what it costs is on your provider's
136 bill.
137 </p>
138 {justAdded && <Added connection={justAdded} />}
139 <ErrorText>{askError || addError ? null : error}</ErrorText>
140
141 {isFailed(ask) ? (
142 <section aria-labelledby="ask-heading" className="mb-8">
143 <h2 id="ask-heading" className="mb-3 text-sm font-medium">
144 Ask g1t
145 </h2>
146 <Unavailable what={ask.what} />
147 </section>
148 ) : (
149 <AskSetting connections={connections} setup={ask} owner={owner} saved={asked} error={askError} />
150 )}
151
152 <section aria-labelledby="providers-heading">
153 <h2 id="providers-heading" className="mb-3 text-sm font-medium">
154 Providers
155 </h2>
156 {connections.length === 0 ? (
157 <p className="mb-4 text-sm text-muted">{owner ? "None yet. Add one below." : `None yet. An owner of ${slug} can add one.`}</p>
158 ) : (
159 <Card asChild divided className="mb-4 overflow-hidden">
160 <ul>
161 {connections.map((connection) => (
162 <li key={connection.id} className="p-4">
163 <ProviderRow connection={connection} owner={owner} tested={tested} updated={updated === connection.id} />
164 </li>
165 ))}
166 </ul>
167 </Card>
168 )}
169 <p className="mb-6 text-xs text-faint">
170 Keys are sealed when saved and never shown again. Only an owner of {slug} can add, test, change or remove them.
171 </p>
172 </section>
173
174 {owner && (
175 <section aria-labelledby="add-heading">
176 <h2 id="add-heading" className="text-sm font-medium">
177 Add a provider
178 </h2>
179 <ModelCatalog slug={slug} adding={adding} />
180 {adding && <AddForm provider={adding} slug={slug} error={addError} />}
181 </section>
182 )}
183 </div>
184 );
185}
186
187/** Just the host of an address, which is what tells providers apart. */
188function host(url: string | undefined): string | undefined {
189 if (!url) return undefined;
190 try {
191 return new URL(url).host;
192 } catch {
193 return url;
194 }
195}
196
197function ProviderRow({
198 connection,
199 owner,
200 tested,
201 updated,
202}: {
203 connection: AiConnection;
204 owner: boolean;
205 tested: { id: string; ok: boolean; message: string } | null;
206 updated: boolean;
207}) {
208 const { config } = connection;
209 const facts = [
210 AI_PROVIDERS[connection.provider].label,
211 host(config.baseUrl),
212 config.model && `model ${config.model}`,
213 connection.models.length > 0 && `${connection.models.length} models`,
214 connection.secretHint && `key ${connection.secretHint}`,
215 ].filter(Boolean);
216 return (
217 <div>
218 <div className="flex items-center gap-3">
219 <AiProviderMark provider={connection.provider} />
220 <div className="min-w-0 grow">
221 <p className="truncate text-sm font-medium">{connection.name}</p>
222 <p className="truncate text-xs text-muted">{facts.join(" · ")}</p>
223 </div>
224 {connection.lastUsedAt && (
225 <span className="hidden shrink-0 text-xs text-faint sm:inline">
226 Used <TimeAgo at={connection.lastUsedAt} />
227 </span>
228 )}
229 {owner && (
230 <Form method="post" className="flex shrink-0 gap-2">
231 <input type="hidden" name="id" value={connection.id} />
232 <SubmitButton variant="outline" name="intent" value="test" match={{ id: connection.id }} pending="Testing…">
233 Test
234 </SubmitButton>
235 <SubmitButton variant="outline" name="intent" value="remove" match={{ id: connection.id }} pending="Removing…" aria-label="Remove">
236 <X size={14} />
237 </SubmitButton>
238 </Form>
239 )}
240 </div>
241 {tested?.id === connection.id && <p className={`mt-3 text-sm ${tested.ok ? "text-success" : "text-danger"}`}>{tested.message}</p>}
242 {connection.lastError && (
243 <p className="mt-3 flex items-start gap-2 text-xs text-warn">
244 <AlertTriangle size={13} className="mt-0.5 shrink-0" />
245 {connection.lastError}
246 </p>
247 )}
248 {owner && <ProviderSettings connection={connection} updated={updated} />}
249 </div>
250 );
251}
252
253/**
254 * Ask g1t's provider and model: one of the workspace's AI providers, and
255 * Auto (the default: a model for each question) or one of the models it
256 * offered when last checked (or any name, for one that lists none). Owners
257 * choose; members see what it is.
258 */
259function AskSetting({
260 connections,
261 setup,
262 owner,
263 saved,
264 error,
265}: {
266 connections: AiConnection[];
267 setup: AskSetup | null;
268 owner: boolean;
269 saved: boolean;
270 error: string | null;
271}) {
272 const current = connections.find((c) => c.id === setup?.provider_id) ?? null;
273 const [chosen, setChosen] = useState(current?.id ?? connections[0]?.id ?? "");
274 const connection = connections.find((c) => c.id === chosen) ?? null;
275 const azure = connection?.provider === "azure_openai";
276 const defaultModel = startingModel(connection, setup);
277 return (
278 <Card asChild className="mb-8 scroll-mt-20 p-4">
279 <section id="ask" aria-labelledby="ask-heading">
280 <h2 id="ask-heading" className="text-sm font-medium">
281 Ask g1t
282 </h2>
283 <p className="mt-1 text-xs text-muted">The top bar's Ask, and @g1t in Chat, answer with this provider and model.</p>
284 {setup?.provider_id && !setup.problem && (
285 <p className="mt-3 text-sm">
286 Answers with <span className="font-medium">{setup.provider_name}</span> · <span className="font-mono text-[0.8125rem]">{modelLabel(setup)}</span>
287 </p>
288 )}
289 {setup?.problem && <p className="mt-3 text-sm text-warn">{setup.problem}</p>}
290 {connections.length === 0 ? (
291 <p className="mt-3 text-sm text-muted">
292 {owner ? "Add an AI provider below, then choose it here." : "An owner of the workspace can add one and choose it here."}
293 </p>
294 ) : (
295 owner && (
296 <Form method="post" className="mt-4 grid max-w-2xl gap-3 sm:grid-cols-[1fr_1fr_auto] sm:items-end">
297 <input type="hidden" name="intent" value="ask" />
298 <Field label="Provider">
299 <Select name="providerId" value={chosen} onValueChange={setChosen}>
300 <SelectTrigger aria-label="Provider">
301 <SelectValue />
302 </SelectTrigger>
303 <SelectContent>
304 {connections.map((c) => (
305 <SelectItem key={c.id} value={c.id} description={AI_PROVIDERS[c.provider].label}>
306 {c.name}
307 </SelectItem>
308 ))}
309 </SelectContent>
310 </Select>
311 </Field>
312 <Field label={azure ? "Deployment" : "Model"}>
313 {autoOffered(connection) && connection ? (
314 <Combobox
315 key={connection.id}
316 name="model"
317 defaultValue={defaultModel}
318 aria-label="Model"
319 searchPlaceholder="Find a model"
320 emptyText="It offers no model by that name."
321 options={modelOptions(connection)}
322 className="font-mono text-[0.8125rem]"
323 />
324 ) : (
325 <Input
326 key={connection?.id}
327 name="model"
328 required
329 defaultValue={defaultModel}
330 placeholder={azure ? "The deployment's name" : "The model's name"}
331 className="font-mono text-[0.8125rem]"
332 autoComplete="off"
333 />
334 )}
335 </Field>
336 <div className="flex items-center gap-2">
337 <SubmitButton variant="outline" match={{ intent: "ask" }} pending="Saving…">
338 Use for Ask
339 </SubmitButton>
340 </div>
341 </Form>
342 )
343 )}
344 {owner && setup?.provider_id && (
345 <Form method="post" className="mt-2">
346 <input type="hidden" name="intent" value="ask" />
347 <SubmitButton variant="link" size="inline" match={{ intent: "ask" }} className="text-xs text-muted">
348 Turn Ask off
349 </SubmitButton>
350 </Form>
351 )}
352 {saved && !error && <p className="mt-2 text-sm text-success">Saved.</p>}
353 <ErrorText>{error}</ErrorText>
354 </section>
355 </Card>
356 );
357}
358
359/** Changing an AI provider after it is added: its model, its address, a new key. */
360function ProviderSettings({ connection, updated }: { connection: AiConnection; updated: boolean }) {
361 const azure = connection.provider === "azure_openai";
362 const ownAddress = aiOwnAddress(connection.provider);
363 return (
364 <details className="group mt-3" open={updated || undefined}>
365 <summary className="inline-flex cursor-pointer list-none items-center gap-1 text-xs text-muted hover:text-fg">
366 <ChevronRight size={12} className="transition-transform group-open:rotate-90" />
367 {connection.provider === "anthropic" ? "Change its model, Console workspace or key" : "Change its model, address or key"}
368 </summary>
369 <Form method="post" className="mt-3 max-w-2xl space-y-3">
370 <input type="hidden" name="intent" value="provider" />
371 <input type="hidden" name="id" value={connection.id} />
372 {ownAddress && (
373 <Field label="Base URL">
374 <Input name="baseUrl" type="url" required defaultValue={connection.config.baseUrl ?? ""} className="max-w-sm" />
375 </Field>
376 )}
377 <Field
378 label={azure ? "Deployment" : "Model"}
379 hint={
380 azure
381 ? undefined
382 : connection.provider === "anthropic"
383 ? "Optional, and separate from Ask's model above. Sessions on work items run on Ask's model when it names one, else this one, else claude-opus-5-5."
384 : "Optional, and separate from Ask's model above: the model your team uses on it."
385 }
386 >
387 <Input
388 name="model"
389 required={azure}
390 placeholder={azure ? undefined : "None"}
391 defaultValue={connection.config.model ?? ""}
392 list={connection.models.length > 0 ? `models-${connection.id}` : undefined}
393 className="max-w-sm font-mono text-[0.8125rem]"
394 autoComplete="off"
395 />
396 </Field>
397 {connection.models.length > 0 && (
398 <datalist id={`models-${connection.id}`}>
399 {connection.models.map((model) => (
400 <option key={model} value={model} />
401 ))}
402 </datalist>
403 )}
404 {connection.provider === "anthropic" && <ConsoleWorkspaceField defaultValue={connection.config.consoleWorkspace} />}
405 <Field label="Replace the key" hint="Write-only: g1t never shows it again. Leave it empty to keep the key you have.">
406 <Input
407 name="secret"
408 type="password"
409 placeholder={connection.secretHint ? `Now ${connection.secretHint}` : "Paste a key"}
410 autoComplete="new-password"
411 className="max-w-sm"
412 />
413 </Field>
414 <div className="flex items-center gap-3">
415 <SubmitButton variant="outline" match={{ intent: "provider", id: connection.id }} pending="Saving…">
416 Save
417 </SubmitButton>
418 {updated && <span className="text-sm text-success">Saved.</span>}
419 </div>
420 </Form>
421 </details>
422 );
423}
424
425function AddForm({ provider, slug, error }: { provider: AiProvider; slug: string; error: string | null }) {
426 return (
427 <Card asChild className="mt-4 scroll-mt-20 p-5">
428 <section id="add">
429 <div className="flex items-center gap-3">
430 <AiProviderMark provider={provider} />
431 <div className="grow">
432 <h3 className="font-medium">Add {isEndpoint(provider) ? `an ${AI_PROVIDERS[provider].label}` : AI_PROVIDERS[provider].label}</h3>
433 <p className="text-xs text-muted">{AI_PROVIDER_LOOKS[provider].blurb}</p>
434 </div>
435 <Link to={`/${slug}/-/ai`} className="rounded-md p-1.5 text-faint hover:bg-raised hover:text-fg" aria-label="Close">
436 <X size={16} />
437 </Link>
438 </div>
439 <Form method="post" className="mt-5 grid max-w-xl gap-4">
440 <input type="hidden" name="provider" value={provider} />
441 <ModelProviderFields provider={provider} />
442 <ErrorText>{error}</ErrorText>
443 <div>
444 <SubmitButton match={{ provider }} pending="Adding…">
445 Add
446 </SubmitButton>
447 </div>
448 </Form>
449 </section>
450 </Card>
451 );
452}
453
454/** What happened, right after adding a provider: shown once. */
455function Added({ connection }: { connection: AiConnection }) {
456 return (
457 <div className="mb-6 rounded-xl border border-success-dim/60 bg-success/5 p-5">
458 <p className="flex items-center gap-2 font-medium">
459 <CheckCircle2 size={16} className="text-success" />
460 {connection.name} is added.
461 </p>
462 <p className="mt-2 text-sm text-muted">
463 {connection.lastError
464 ? `It was saved, but the check failed: ${connection.lastError}`
465 : connection.models.length > 0
466 ? `The key works, and it offers ${connection.models.length} models.`
467 : "The key is saved. Use Test to check it again."}
468 </p>
469 </div>
470 );
471}