| 1 | import { AlertTriangle, CheckCircle2, ChevronRight, Sparkles, X } from "lucide-react"; |
| 2 | import { useState } from "react"; |
| 3 | import { Form, Link } from "react-router"; |
| 4 | |
| 5 | import { type AiConnection, type AiProvider, type AskSetup, AI_PROVIDERS, aiOwnAddress, isAiProvider } from "@g1t/contracts"; |
| 6 | |
| 7 | import type { Route } from "./+types/ai"; |
| 8 | import { page } from "../../lib/meta"; |
| 9 | import { AiProviderMark } from "../../components/connectors"; |
| 10 | import { GettingStartedButton } from "../../components/onboarding"; |
| 11 | import { ConsoleWorkspaceField, ModelCatalog, ModelProviderFields } from "../../components/model-providers"; |
| 12 | import { AI_PROVIDER_LOOKS, isEndpoint } from "../../lib/ai-providers"; |
| 13 | import { ErrorText, Field, Input, SubmitButton, TimeAgo } from "../../components/ui"; |
| 14 | import { Card } from "../../components/ui/card"; |
| 15 | import { Combobox } from "../../components/ui/combobox"; |
| 16 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select"; |
| 17 | import { autoOffered, defaultModel as startingModel, modelLabel, modelOptions } from "../../lib/ask-model"; |
| 18 | import { ai } from "../../lib/services.server"; |
| 19 | import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server"; |
| 20 | |
| 21 | export 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 | */ |
| 31 | export 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 | |
| 47 | function text(form: FormData, name: string): string | undefined { |
| 48 | const value = String(form.get(name) ?? "").trim(); |
| 49 | return value || undefined; |
| 50 | } |
| 51 | |
| 52 | export 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 | |
| 107 | export 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. */ |
| 176 | function 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 | |
| 185 | function 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 | */ |
| 249 | function 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. */ |
| 351 | function 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 | |
| 416 | function 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. */ |
| 448 | function 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 | |