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.

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