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.

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