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.

479 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
135 starts sessions on issues. Use a frontier lab or a platform, or a model you host yourself. g1t hosts no model for
136 this and bills nothing for it: what it costs is on your provider's 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">
158 {owner ? "None yet. Add one below." : `None yet. An owner of ${slug} can add one.`}
159 </p>
160 ) : (
161 <Card asChild divided className="mb-4 overflow-hidden">
162 <ul>
163 {connections.map((connection) => (
164 <li key={connection.id} className="p-4">
165 <ProviderRow connection={connection} owner={owner} tested={tested} updated={updated === connection.id} />
166 </li>
167 ))}
168 </ul>
169 </Card>
170 )}
171 <p className="mb-6 text-xs text-faint">
172 Keys are sealed when saved and never shown again. Only an owner of {slug} can add, test, change or remove them.
173 </p>
174 </section>
175
176 {owner && (
177 <section aria-labelledby="add-heading">
178 <h2 id="add-heading" className="text-sm font-medium">
179 Add a provider
180 </h2>
181 <ModelCatalog slug={slug} adding={adding} />
182 {adding && <AddForm provider={adding} slug={slug} error={addError} />}
183 </section>
184 )}
185 </div>
186 );
187}
188
189/** Just the host of an address, which is what tells providers apart. */
190function host(url: string | undefined): string | undefined {
191 if (!url) return undefined;
192 try {
193 return new URL(url).host;
194 } catch {
195 return url;
196 }
197}
198
199function ProviderRow({
200 connection,
201 owner,
202 tested,
203 updated,
204}: {
205 connection: AiConnection;
206 owner: boolean;
207 tested: { id: string; ok: boolean; message: string } | null;
208 updated: boolean;
209}) {
210 const { config } = connection;
211 const facts = [
212 AI_PROVIDERS[connection.provider].label,
213 host(config.baseUrl),
214 config.model && `model ${config.model}`,
215 connection.models.length > 0 && `${connection.models.length} models`,
216 connection.secretHint && `key ${connection.secretHint}`,
217 ].filter(Boolean);
218 return (
219 <div>
220 <div className="flex items-center gap-3">
221 <AiProviderMark provider={connection.provider} />
222 <div className="min-w-0 grow">
223 <p className="truncate text-sm font-medium">{connection.name}</p>
224 <p className="truncate text-xs text-muted">{facts.join(" · ")}</p>
225 </div>
226 {connection.lastUsedAt && (
227 <span className="hidden shrink-0 text-xs text-faint sm:inline">
228 Used <TimeAgo at={connection.lastUsedAt} />
229 </span>
230 )}
231 {owner && (
232 <Form method="post" className="flex shrink-0 gap-2">
233 <input type="hidden" name="id" value={connection.id} />
234 <SubmitButton variant="outline" name="intent" value="test" match={{ id: connection.id }} pending="Testing…">
235 Test
236 </SubmitButton>
237 <SubmitButton variant="outline" name="intent" value="remove" match={{ id: connection.id }} pending="Removing…" aria-label="Remove">
238 <X size={14} />
239 </SubmitButton>
240 </Form>
241 )}
242 </div>
243 {tested?.id === connection.id && (
244 <p className={`mt-3 text-sm ${tested.ok ? "text-success" : "text-danger"}`}>{tested.message}</p>
245 )}
246 {connection.lastError && (
247 <p className="mt-3 flex items-start gap-2 text-xs text-warn">
248 <AlertTriangle size={13} className="mt-0.5 shrink-0" />
249 {connection.lastError}
250 </p>
251 )}
252 {owner && <ProviderSettings connection={connection} updated={updated} />}
253 </div>
254 );
255}
256
257/**
258 * Ask g1t's provider and model: one of the workspace's AI providers, and
259 * Auto (the default: a model for each question) or one of the models it
260 * offered when last checked (or any name, for one that lists none). Owners
261 * choose; members see what it is.
262 */
263function AskSetting({
264 connections,
265 setup,
266 owner,
267 saved,
268 error,
269}: {
270 connections: AiConnection[];
271 setup: AskSetup | null;
272 owner: boolean;
273 saved: boolean;
274 error: string | null;
275}) {
276 const current = connections.find((c) => c.id === setup?.provider_id) ?? null;
277 const [chosen, setChosen] = useState(current?.id ?? connections[0]?.id ?? "");
278 const connection = connections.find((c) => c.id === chosen) ?? null;
279 const azure = connection?.provider === "azure_openai";
280 const defaultModel = startingModel(connection, setup);
281 return (
282 <Card asChild className="mb-8 scroll-mt-20 p-4">
283 <section id="ask" aria-labelledby="ask-heading">
284 <h2 id="ask-heading" className="text-sm font-medium">
285 Ask g1t
286 </h2>
287 <p className="mt-1 text-xs text-muted">The top bar's Ask, and @g1t in Chat, answer with this provider and model.</p>
288 {setup?.provider_id && !setup.problem && (
289 <p className="mt-3 text-sm">
290 Answers with <span className="font-medium">{setup.provider_name}</span> ·{" "}
291 <span className="font-mono text-[0.8125rem]">{modelLabel(setup)}</span>
292 </p>
293 )}
294 {setup?.problem && <p className="mt-3 text-sm text-warn">{setup.problem}</p>}
295 {connections.length === 0 ? (
296 <p className="mt-3 text-sm text-muted">
297 {owner ? "Add an AI provider below, then choose it here." : "An owner of the workspace can add one and choose it here."}
298 </p>
299 ) : (
300 owner && (
301 <Form method="post" className="mt-4 grid max-w-2xl gap-3 sm:grid-cols-[1fr_1fr_auto] sm:items-end">
302 <input type="hidden" name="intent" value="ask" />
303 <Field label="Provider">
304 <Select name="providerId" value={chosen} onValueChange={setChosen}>
305 <SelectTrigger aria-label="Provider">
306 <SelectValue />
307 </SelectTrigger>
308 <SelectContent>
309 {connections.map((c) => (
310 <SelectItem key={c.id} value={c.id} description={AI_PROVIDERS[c.provider].label}>
311 {c.name}
312 </SelectItem>
313 ))}
314 </SelectContent>
315 </Select>
316 </Field>
317 <Field label={azure ? "Deployment" : "Model"}>
318 {autoOffered(connection) && connection ? (
319 <Combobox
320 key={connection.id}
321 name="model"
322 defaultValue={defaultModel}
323 aria-label="Model"
324 searchPlaceholder="Find a model"
325 emptyText="It offers no model by that name."
326 options={modelOptions(connection)}
327 className="font-mono text-[0.8125rem]"
328 />
329 ) : (
330 <Input
331 key={connection?.id}
332 name="model"
333 required
334 defaultValue={defaultModel}
335 placeholder={azure ? "The deployment's name" : "The model's name"}
336 className="font-mono text-[0.8125rem]"
337 autoComplete="off"
338 />
339 )}
340 </Field>
341 <div className="flex items-center gap-2">
342 <SubmitButton variant="outline" match={{ intent: "ask" }} pending="Saving…">
343 Use for Ask
344 </SubmitButton>
345 </div>
346 </Form>
347 )
348 )}
349 {owner && setup?.provider_id && (
350 <Form method="post" className="mt-2">
351 <input type="hidden" name="intent" value="ask" />
352 <SubmitButton variant="link" size="inline" match={{ intent: "ask" }} className="text-xs text-muted">
353 Turn Ask off
354 </SubmitButton>
355 </Form>
356 )}
357 {saved && !error && <p className="mt-2 text-sm text-success">Saved.</p>}
358 <ErrorText>{error}</ErrorText>
359 </section>
360 </Card>
361 );
362}
363
364/** Changing an AI provider after it is added: its model, its address, a new key. */
365function ProviderSettings({ connection, updated }: { connection: AiConnection; updated: boolean }) {
366 const azure = connection.provider === "azure_openai";
367 const ownAddress = aiOwnAddress(connection.provider);
368 return (
369 <details className="group mt-3" open={updated || undefined}>
370 <summary className="inline-flex cursor-pointer list-none items-center gap-1 text-xs text-muted hover:text-fg">
371 <ChevronRight size={12} className="transition-transform group-open:rotate-90" />
372 {connection.provider === "anthropic" ? "Change its model, Console workspace or key" : "Change its model, address or key"}
373 </summary>
374 <Form method="post" className="mt-3 max-w-2xl space-y-3">
375 <input type="hidden" name="intent" value="provider" />
376 <input type="hidden" name="id" value={connection.id} />
377 {ownAddress && (
378 <Field label="Base URL">
379 <Input name="baseUrl" type="url" required defaultValue={connection.config.baseUrl ?? ""} className="max-w-sm" />
380 </Field>
381 )}
382 <Field
383 label={azure ? "Deployment" : "Model"}
384 hint={
385 azure
386 ? undefined
387 : connection.provider === "anthropic"
388 ? "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."
389 : "Optional, and separate from Ask's model above: the model your team uses on it."
390 }
391 >
392 <Input
393 name="model"
394 required={azure}
395 placeholder={azure ? undefined : "None"}
396 defaultValue={connection.config.model ?? ""}
397 list={connection.models.length > 0 ? `models-${connection.id}` : undefined}
398 className="max-w-sm font-mono text-[0.8125rem]"
399 autoComplete="off"
400 />
401 </Field>
402 {connection.models.length > 0 && (
403 <datalist id={`models-${connection.id}`}>
404 {connection.models.map((model) => (
405 <option key={model} value={model} />
406 ))}
407 </datalist>
408 )}
409 {connection.provider === "anthropic" && <ConsoleWorkspaceField defaultValue={connection.config.consoleWorkspace} />}
410 <Field label="Replace the key" hint="Write-only: g1t never shows it again. Leave it empty to keep the key you have.">
411 <Input
412 name="secret"
413 type="password"
414 placeholder={connection.secretHint ? `Now ${connection.secretHint}` : "Paste a key"}
415 autoComplete="new-password"
416 className="max-w-sm"
417 />
418 </Field>
419 <div className="flex items-center gap-3">
420 <SubmitButton variant="outline" match={{ intent: "provider", id: connection.id }} pending="Saving…">
421 Save
422 </SubmitButton>
423 {updated && <span className="text-sm text-success">Saved.</span>}
424 </div>
425 </Form>
426 </details>
427 );
428}
429
430function AddForm({ provider, slug, error }: { provider: AiProvider; slug: string; error: string | null }) {
431 return (
432 <Card asChild className="mt-4 scroll-mt-20 p-5">
433 <section id="add">
434 <div className="flex items-center gap-3">
435 <AiProviderMark provider={provider} />
436 <div className="grow">
437 <h3 className="font-medium">
438 Add {isEndpoint(provider) ? `an ${AI_PROVIDERS[provider].label}` : AI_PROVIDERS[provider].label}
439 </h3>
440 <p className="text-xs text-muted">{AI_PROVIDER_LOOKS[provider].blurb}</p>
441 </div>
442 <Link to={`/${slug}/-/ai`} className="rounded-md p-1.5 text-faint hover:bg-raised hover:text-fg" aria-label="Close">
443 <X size={16} />
444 </Link>
445 </div>
446 <Form method="post" className="mt-5 grid max-w-xl gap-4">
447 <input type="hidden" name="provider" value={provider} />
448 <ModelProviderFields provider={provider} />
449 <ErrorText>{error}</ErrorText>
450 <div>
451 <SubmitButton match={{ provider }} pending="Adding…">
452 Add
453 </SubmitButton>
454 </div>
455 </Form>
456 </section>
457 </Card>
458 );
459}
460
461/** What happened, right after adding a provider: shown once. */
462function Added({ connection }: { connection: AiConnection }) {
463 return (
464 <div className="mb-6 rounded-xl border border-success-dim/60 bg-success/5 p-5">
465 <p className="flex items-center gap-2 font-medium">
466 <CheckCircle2 size={16} className="text-success" />
467 {connection.name} is added.
468 </p>
469 <p className="mt-2 text-sm text-muted">
470 {connection.lastError
471 ? `It was saved, but the check failed: ${connection.lastError}`
472 : connection.models.length > 0
473 ? `The key works, and it offers ${connection.models.length} models.`
474 : "The key is saved. Use Test to check it again."}
475 </p>
476 </div>
477 );
478}
479