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.

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