Skip to content

Commit

The top bar's Ask is live: it opens a conversation with g1t in a panel beside the page, answered by the AI provider and model the workspace's owner chose, streamed as it is written, with Markdown drawn as chat draws it, the provider and model under each answer, Stop to end an answer (what came stays), and New conversation to start over. Each question carries the page you are on, so g1t can say which repository, issue or pull request you are looking at. The conversation is kept in the browser tab for now, across pages and reloads; closing the panel keeps an answer coming. Until a provider is chosen the panel says so: owners get a link to choose one, members are told to ask an owner. On Integrations, Your AI providers has an Ask g1t card where owners choose the provider and one of the models it offers (a deployment for Azure, any name for an endpoint that lists none), or turn Ask off, and members see what it answers with. Questions go through -/ask, which streams the integrations service's answer straight through and counts 20 questions a minute per person (WEB_ASK_LIMIT).

syntaqxcommitted Parent49e3406Browse files
10 files+883−800/10 viewed
+364−72
1−import { ArrowUp, AtSign, Plus, Sparkles } from "lucide-react";
2−import type { ReactNode } from "react";
3−import { Link } from "react-router";
1+import { ArrowUp, CircleAlert, RotateCcw, Settings2, Square } from "lucide-react";
2+import { type FormEvent, type KeyboardEvent, useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
3+import { Link, useLocation } from "react-router";
4+
5+import { ASK_MAX_MESSAGE_CHARS, type AskEvent, type AskSetup } from "@g1t/contracts";
46
7+import { EMPTY_ASK, type Turn, askPage, askReducer, cutOff, history, readLines } from "../lib/ask";
8+import { MessageText, type TextContext } from "./chat/text";
59 import { MarkGlyph } from "./logo";
10+import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
611 import { Badge } from "./ui/badge";
712 import { Button } from "./ui/button";
8−import { Card } from "./ui/card";
913 import { Hint } from "./ui/hint";
10−import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
14+import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from "./ui/sheet";
15+import { Skeleton } from "./ui/skeleton";
1116
1217 /**
13− * The place to ask AI for something and hand the work to one of the
14− * workspace's AI providers (Workspace settings → Integrations → Your AI
15− * providers). Shown so it is clear where this will be, and not usable yet:
16− * nothing here sends, and every control says so.
18+ * Ask g1t: a conversation with g1t about your work, from the top bar. The
19+ * answers come from the AI provider and model an owner chose for the
20+ * workspace (Integrations → Your AI providers), on the workspace's own key:
21+ * g1t hosts no model for it. Each question goes to `-/ask`
22+ * (routes/workspace/ask.ts) with the page you are on, and the answer
23+ * streams back.
24+ *
25+ * The conversation lives in this browser tab (session storage), so it
26+ * survives moving between pages and reloading, and goes with the tab.
27+ * Keeping conversations on the server comes later.
1728 */
18−export function AskComposer({ slug, children }: { slug: string; children?: ReactNode }) {
29+
30+type Setup = AskSetup & { owner: boolean };
31+type SetupAnswer = { ok: boolean; value?: AskSetup; error?: { message: string }; owner: boolean };
32+
33+const storageKey = (slug: string) => `g1t:ask:${slug}`;
34+
35+function load(slug: string) {
36+ try {
37+ const kept = sessionStorage.getItem(storageKey(slug));
38+ return kept ? (JSON.parse(kept) as unknown) : null;
39+ } catch {
40+ return null;
41+ }
42+}
43+
44+function save(slug: string, value: unknown) {
45+ try {
46+ sessionStorage.setItem(storageKey(slug), JSON.stringify(value));
47+ } catch {
48+ // Storage full or blocked: the conversation stays for this page.
49+ }
50+}
51+
52+/**
53+ * The conversation and what it can do. It belongs to the top bar's button,
54+ * not the panel, so closing the panel keeps an answer coming.
55+ */
56+function useAskConversation(slug: string) {
57+ const { pathname } = useLocation();
58+ const [state, dispatch] = useReducer(askReducer, EMPTY_ASK);
59+ const [draft, setDraft] = useState("");
60+ const controller = useRef<AbortController | null>(null);
61+ const restored = useRef(false);
62+
63+ // The conversation kept in this tab, once; then kept as it changes.
64+ useEffect(() => {
65+ if (!restored.current) {
66+ restored.current = true;
67+ const kept = load(slug);
68+ if (kept) dispatch({ type: "restore", state: kept as never });
69+ return;
70+ }
71+ save(slug, state);
72+ }, [slug, state]);
73+
74+ // Leaving the workspace stops an answer still coming.
75+ useEffect(() => () => controller.current?.abort(), []);
76+
77+ const ask = useCallback(
78+ async (question: string) => {
79+ const text = question.trim();
80+ if (!text || state.busy) return;
81+ const messages = history(state.turns, text);
82+ const id = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
83+ dispatch({ type: "ask", question: text, id });
84+ setDraft("");
85+ const abort = new AbortController();
86+ controller.current = abort;
87+ let ended = false;
88+ const take = (events: AskEvent[]) => {
89+ for (const event of events) {
90+ dispatch({ type: "event", event });
91+ if (event.type === "done" || event.type === "error") ended = true;
92+ }
93+ };
94+ try {
95+ const response = await fetch(`/${slug}/-/ask`, {
96+ method: "POST",
97+ headers: { "content-type": "application/json", accept: "application/x-ndjson" },
98+ body: JSON.stringify({ messages, page: askPage(pathname) }),
99+ signal: abort.signal,
100+ });
101+ if (!response.body) throw new Error("no body");
102+ const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
103+ let rest = "";
104+ for (;;) {
105+ const { value, done } = await reader.read();
106+ if (done) break;
107+ const read = readLines(rest, value);
108+ rest = read.rest;
109+ take(read.events);
110+ }
111+ take(readLines(rest, "\n").events);
112+ if (!ended) dispatch({ type: "failed", message: "The answer broke off. Ask again." });
113+ } catch {
114+ if (abort.signal.aborted) dispatch({ type: "stop" });
115+ else dispatch({ type: "failed", message: "Ask could not be reached. Check your connection and ask again." });
116+ } finally {
117+ if (controller.current === abort) controller.current = null;
118+ }
119+ },
120+ [pathname, slug, state.busy, state.turns],
121+ );
122+
123+ const stop = useCallback(() => {
124+ controller.current?.abort();
125+ dispatch({ type: "stop" });
126+ }, []);
127+
128+ const fresh = useCallback(() => {
129+ controller.current?.abort();
130+ dispatch({ type: "reset" });
131+ setDraft("");
132+ }, []);
133+
134+ return { state, draft, setDraft, ask, stop, fresh };
135+}
136+
137+type Conversation = ReturnType<typeof useAskConversation>;
138+
139+/** The top bar's Ask: the lavender pixel 1 beside its words. Opens the conversation beside the page. */
140+export function AskButton({ slug }: { slug: string }) {
141+ const [open, setOpen] = useState(false);
142+ const conversation = useAskConversation(slug);
143+ return (
144+ <Sheet open={open} onOpenChange={setOpen}>
145+ <Hint label="Ask g1t about your work">
146+ <SheetTrigger asChild>
147+ <Button variant="soft" size="bar" aria-label="Ask g1t" className="gap-2 max-md:ring-0 lg:px-3">
148+ <MarkGlyph className="h-3.5" />
149+ <span className="max-lg:hidden">Ask</span>
150+ </Button>
151+ </SheetTrigger>
152+ </Hint>
153+ <SheetContent side="right" className="sm:max-w-lg">
154+ <AskPanel slug={slug} open={open} conversation={conversation} />
155+ </SheetContent>
156+ </Sheet>
157+ );
158+}
159+
160+function AskPanel({ slug, open, conversation }: { slug: string; open: boolean; conversation: Conversation }) {
161+ const { pathname } = useLocation();
162+ const { state, draft, setDraft, ask, stop } = conversation;
163+ const [setup, setSetup] = useState<Setup | null>(null);
164+ const [setupError, setSetupError] = useState<string | null>(null);
165+ const end = useRef<HTMLDivElement>(null);
166+ const input = useRef<HTMLTextAreaElement>(null);
167+
168+ // What Ask answers with, each time it opens: an owner may have changed it.
169+ useEffect(() => {
170+ if (!open) return;
171+ let live = true;
172+ fetch(`/${slug}/-/ask`, { headers: { accept: "application/json" } })
173+ .then((response) => (response.ok ? (response.json() as Promise<SetupAnswer>) : Promise.reject(new Error(String(response.status)))))
174+ .then((answer) => {
175+ if (!live) return;
176+ if (answer.ok && answer.value) {
177+ setSetup({ ...answer.value, owner: answer.owner });
178+ setSetupError(null);
179+ } else setSetupError(answer.error?.message ?? "Ask could not be loaded.");
180+ })
181+ .catch(() => live && setSetupError("Ask could not be loaded. Try again in a moment."));
182+ return () => {
183+ live = false;
184+ };
185+ }, [open, slug]);
186+
187+ useEffect(() => {
188+ if (open) requestAnimationFrame(() => input.current?.focus());
189+ }, [open]);
190+
191+ // Keep the newest words in view as they come.
192+ const last = state.turns.at(-1);
193+ useEffect(() => {
194+ end.current?.scrollIntoView({ block: "end" });
195+ }, [state.turns.length, last?.content.length]);
196+
197+ const fresh = () => {
198+ conversation.fresh();
199+ input.current?.focus();
200+ };
201+
202+ const submit = (event: FormEvent) => {
203+ event.preventDefault();
204+ void ask(draft);
205+ };
206+
207+ const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
208+ if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
209+ event.preventDefault();
210+ void ask(draft);
211+ }
212+ };
213+
214+ const ready = setup != null && setup.problem == null;
215+ const context: TextContext = useMemo(() => ({ slug, me: null, channels: new Set(), project: null }), [slug]);
216+ const where = askPage(pathname);
217+
19218 return (
20− <section aria-label="Ask AI" className="space-y-3">
21− <Card aria-disabled="true" className="opacity-80">
22− <p className="flex items-center gap-2 px-4 pt-3 text-sm font-medium text-fg">
23− <Sparkles size={14} className="text-accent" aria-hidden="true" />
24− Ask AI
25− <Badge className="px-1.5 py-0 font-normal">Coming soon</Badge>
219+ <>
220+ <SheetHeader className="gap-1">
221+ <SheetTitle className="flex items-center gap-2">
222+ <MarkGlyph className="h-3.5" />
223+ Ask g1t
224+ </SheetTitle>
225+ <SheetDescription className="sr-only">A conversation with g1t about your work in {slug}.</SheetDescription>
226+ <div className="flex min-h-6 flex-wrap items-center gap-2 text-xs text-muted">
227+ {setup == null && !setupError ? (
228+ <Skeleton className="h-4 w-40" />
229+ ) : ready ? (
230+ <span className="truncate">
231+ Answers with {setup.connection_name} · <span className="font-mono">{setup.model}</span>
232+ </span>
233+ ) : (
234+ <span>Not set up yet</span>
235+ )}
236+ <span className="ml-auto flex items-center gap-1">
237+ {setup?.owner && (
238+ <Hint label="Choose the provider and model">
239+ <Button asChild variant="ghost" size="icon-xs" aria-label="Choose the provider and model">
240+ <Link to={`/${slug}/-/integrations/models#ask`}>
241+ <Settings2 size={14} />
242+ </Link>
243+ </Button>
244+ </Hint>
245+ )}
246+ <Button variant="ghost" size="xs" onClick={fresh} disabled={state.turns.length === 0}>
247+ <RotateCcw size={13} />
248+ New conversation
249+ </Button>
250+ </span>
251+ </div>
252+ </SheetHeader>
253+
254+ <div className="min-h-0 grow overflow-y-auto px-5 py-4" aria-live="polite" aria-busy={state.busy}>
255+ {setupError && (
256+ <Alert tone="danger">
257+ <AlertDescription className="text-danger">{setupError}</AlertDescription>
258+ </Alert>
259+ )}
260+ {setup && setup.problem && <NotSetUp slug={slug} setup={setup} />}
261+ {state.turns.length === 0 && ready && <Welcome where={where.repo ? `${where.repo.namespace}/${where.repo.name}` : null} />}
262+ <ol className="space-y-5">
263+ {state.turns.map((turn) => (
264+ <li key={turn.id}>{turn.role === "user" ? <Question turn={turn} /> : <Answer turn={turn} context={context} />}</li>
265+ ))}
266+ </ol>
267+ <div ref={end} />
268+ </div>
269+
270+ <form onSubmit={submit} className="border-t border-line px-4 py-3">
271+ <div className="rounded-lg border border-line bg-bg focus-within:border-accent-dim focus-within:ring-2 focus-within:ring-accent/25">
272+ <label htmlFor="ask-g1t" className="sr-only">
273+ Ask g1t
274+ </label>
275+ <textarea
276+ id="ask-g1t"
277+ ref={input}
278+ value={draft}
279+ onChange={(event) => setDraft(event.target.value)}
280+ onKeyDown={onKeyDown}
281+ rows={2}
282+ maxLength={ASK_MAX_MESSAGE_CHARS}
283+ disabled={!ready}
284+ placeholder={ready ? "Ask about this page, a repository, or your work" : "Ask is not set up yet"}
285+ className="block max-h-48 min-h-14 w-full resize-none bg-transparent px-3 pt-2.5 pb-1 text-sm text-fg placeholder:text-faint focus:outline-none disabled:cursor-not-allowed"
286+ />
287+ <div className="flex items-center gap-2 px-2 pb-2">
288+ <span className="truncate pl-1 text-xs text-faint">
289+ {where.issue ? `On issue #${where.issue}` : where.pull ? `On pull request #${where.pull}` : where.repo ? `In ${where.repo.name}` : null}
290+ </span>
291+ <span className="ml-auto" />
292+ {state.busy ? (
293+ <Hint label="Stop the answer">
294+ <Button type="button" variant="outline" size="icon-sm" onClick={stop} aria-label="Stop">
295+ <Square size={12} className="fill-current" />
296+ </Button>
297+ </Hint>
298+ ) : (
299+ <Hint label="Send (Enter)">
300+ <Button type="submit" variant="accent" size="icon-sm" disabled={!ready || !draft.trim()} aria-label="Send">
301+ <ArrowUp size={15} />
302+ </Button>
303+ </Hint>
304+ )}
305+ </div>
306+ </div>
307+ <p className="mt-2 px-1 text-[0.6875rem] text-faint">
308+ Answers come from your workspace's own AI provider and can be wrong. Kept in this tab only.
309+ </p>
310+ </form>
311+ </>
312+ );
313+}
314+
315+function NotSetUp({ slug, setup }: { slug: string; setup: Setup }) {
316+ return (
317+ <Alert tone="info" className="mb-4">
318+ <AlertTitle>Ask needs one of your workspace's AI providers</AlertTitle>
319+ <AlertDescription>
320+ <p>{setup.problem}</p>
321+ <p className="mt-2">
322+ Ask answers with an AI provider your workspace connects, on its own key: g1t hosts no model for it.{" "}
323+ {setup.owner ? (
324+ <Link to={`/${slug}/-/integrations/models#ask`} className="text-accent hover:underline">
325+ Connect a provider and choose it for Ask
326+ </Link>
327+ ) : (
328+ <>Ask an owner of {slug} to connect one and choose it for Ask.</>
329+ )}
26330 </p>
27− <label htmlFor="ask-ai" className="sr-only">
28− Ask AI (coming soon)
29− </label>
30− <textarea
31− id="ask-ai"
32− disabled
33− rows={2}
34− placeholder="Ask anything, or describe work to hand to your AI provider."
35− className="block w-full cursor-not-allowed resize-none bg-transparent px-4 pt-3.5 pb-2 text-sm text-fg placeholder:text-muted focus:outline-none"
36− />
37− <div className="flex flex-wrap items-center gap-2 px-3 pb-3">
38− <Soon label="Add context">
39− <Plus size={15} />
40− </Soon>
41− <Soon label="Mention">
42− <AtSign size={14} />
43− </Soon>
44− <span className="ml-auto" />
45− <Soon label="Send">
46− <ArrowUp size={15} />
47− </Soon>
48− </div>
49− </Card>
50− <p className="text-xs text-muted">
51− It will hand off to{" "}
52− <Link to={`/${slug}/-/integrations/models`} className="text-accent hover:underline">
53− your AI providers
54− </Link>
55− .
331+ </AlertDescription>
332+ </Alert>
333+ );
334+}
335+
336+function Welcome({ where }: { where: string | null }) {
337+ return (
338+ <div className="mb-4 rounded-lg border border-dashed border-line px-4 py-5 text-sm text-muted">
339+ <p className="font-medium text-fg">Ask about your work</p>
340+ <p className="mt-1">
341+ g1t tells the model which page you are on{where ? `, here ${where}` : ""}, and the issue or pull request you are looking at, so you can ask
342+ about it directly.
56343 </p>
57− {children}
58− </section>
344+ </div>
59345 );
60346 }
61347
62−/** One of the composer's controls: there, disabled, with a hint saying why. */
63−function Soon({ label, children }: { label: string; children: ReactNode }) {
348+function Question({ turn }: { turn: Turn }) {
64349 return (
65− <Hint label="Coming soon" disabled>
66− <Button type="button" variant="outline" size="icon-sm" disabled aria-label={`${label} (coming soon)`}>
67− {children}
68− </Button>
69− </Hint>
350+ <div className="ml-auto w-fit max-w-[85%] rounded-lg bg-raised px-3 py-2 text-[0.9375rem] leading-[1.55] wrap-break-word whitespace-pre-wrap text-fg">
351+ {turn.content}
352+ </div>
70353 );
71354 }
72355
73−/**
74− * The top bar's Ask AI: the lavender pixel 1 beside its words, on a wash of
75− * the accent. Not on yet: it opens the composer, which says so.
76− */
77−export function AskButton({ slug }: { slug: string }) {
356+function Answer({ turn, context }: { turn: Turn; context: TextContext }) {
357+ const waiting = turn.status === "streaming" && !turn.content;
78358 return (
79− <Popover>
80− <Hint label="Ask AI · coming soon">
81− <PopoverTrigger asChild>
82− <Button variant="soft" size="bar" aria-label="Ask AI (coming soon)" className="gap-2 max-md:ring-0 lg:px-3">
83− <MarkGlyph className="h-3.5" />
84− <span className="max-lg:hidden">Ask AI</span>
85− </Button>
86− </PopoverTrigger>
87− </Hint>
88− <PopoverContent align="end" className="w-[26rem] p-3">
89− <AskComposer slug={slug} />
90− </PopoverContent>
91− </Popover>
359+ <div className="space-y-2">
360+ {waiting ? (
361+ <div className="flex items-center gap-2 text-sm text-muted" role="status">
362+ <MarkGlyph className="h-3 animate-pulse motion-reduce:animate-none" />
363+ Thinking…
364+ </div>
365+ ) : (
366+ turn.content && <MessageText body={turn.content} context={context} />
367+ )}
368+ {turn.status === "error" && (
369+ <Alert tone="danger" className="flex items-start gap-2">
370+ <CircleAlert size={14} className="mt-0.5 shrink-0" />
371+ <span>{turn.error}</span>
372+ </Alert>
373+ )}
374+ <div className="flex flex-wrap items-center gap-1.5 text-xs text-faint">
375+ {turn.provider && (
376+ <Badge className="px-1.5 py-0 font-normal">
377+ {turn.provider} · <span className="font-mono">{turn.model}</span>
378+ </Badge>
379+ )}
380+ {turn.status === "stopped" && <span>Stopped</span>}
381+ {turn.status === "done" && cutOff(turn) && <span>Cut off at the length limit</span>}
382+ </div>
383+ </div>
92384 );
93385 }
+3−3
18241824 * against it with a hairline between, which folds away with Ctrl B and is
18251825 * a drawer below 1024px; and the page, flat against the sidebar and
18261826 * filling the rest of the window, with its top bar across the top and a
1827− * line under it: the sidebar's toggle, where you are, search, Ask AI, the bell and
1827+ * line under it: the sidebar's toggle, where you are, search, Ask, the bell and
18281828 * Create new. On a phone the rail is a bar along the bottom and the page
18291829 * fills the window above it, flat as well. Only for someone signed in; without a workspace yet, there
18301830 * is no rail. The rail and the sidebar are drawn from the sidebar kit
19971997 </span>
19981998 )}
19991999 <Breadcrumbs pathname={pathname} missing={missing} repo={shell.repo} workspace={ws?.slug ?? null} />
2000− {/* Search, Ask AI, then the bell and Create new: one height, one rhythm. */}
2000+ {/* Search, Ask, then the bell and Create new: one height, one rhythm. */}
20012001 <div className="ml-auto flex shrink-0 items-center gap-1 md:gap-1.5">
20022002 {/* With the sidebar open, search is at its top. */}
20032003 {!titleSearch && (
20052005 <SearchButton place="bar" onClick={() => setPalette(true)} />
20062006 </span>
20072007 )}
2008− {ws && <AskButton slug={ws.slug} />}
2008+ {ws && <AskButton key={ws.slug} slug={ws.slug} />}
20092009 <Separator orientation="vertical" className="mx-1 data-[orientation=vertical]:h-5 max-md:hidden" />
20102010 {/* On a phone, Notifications is in the bottom bar. */}
20112011 <span className="flex max-md:hidden">
+96−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { EMPTY_ASK, type AskState, askPage, askReducer, cutOff, history, readLines, restorable } from "./ask.ts";
5+
6+test("the page says which repository, issue or pull request the person is on", () => {
7+ assert.deepEqual(askPage("/Acme/web/issues/12"), { path: "/Acme/web/issues/12", repo: { namespace: "acme", name: "web" }, issue: 12 });
8+ assert.deepEqual(askPage("/acme/web/pull/3/files"), { path: "/acme/web/pull/3/files", repo: { namespace: "acme", name: "web" }, pull: 3 });
9+ assert.deepEqual(askPage("/acme/web/tree/main"), { path: "/acme/web/tree/main", repo: { namespace: "acme", name: "web" } });
10+ assert.deepEqual(askPage("/acme/web/issues"), { path: "/acme/web/issues", repo: { namespace: "acme", name: "web" } });
11+ // A workspace's own pages, and g1t's, name no repository.
12+ assert.deepEqual(askPage("/acme/-/chat/general"), { path: "/acme/-/chat/general" });
13+ assert.deepEqual(askPage("/settings/profile"), { path: "/settings/profile" });
14+ assert.deepEqual(askPage("/acme"), { path: "/acme" });
15+ assert.deepEqual(askPage(""), { path: "/" });
16+});
17+
18+function run(...actions: Parameters<typeof askReducer>[1][]): AskState {
19+ return actions.reduce(askReducer, EMPTY_ASK);
20+}
21+
22+test("an answer streams into the turn after the question", () => {
23+ const state = run(
24+ { type: "ask", question: " What broke? ", id: "1" },
25+ { type: "event", event: { type: "meta", provider: "anthropic", provider_label: "Anthropic", model: "claude-x" } },
26+ { type: "event", event: { type: "text", text: "The " } },
27+ { type: "event", event: { type: "text", text: "login." } },
28+ );
29+ assert.equal(state.busy, true);
30+ assert.equal(state.turns[0]!.content, "What broke?");
31+ assert.deepEqual(
32+ { content: state.turns[1]!.content, provider: state.turns[1]!.provider, model: state.turns[1]!.model, status: state.turns[1]!.status },
33+ { content: "The login.", provider: "Anthropic", model: "claude-x", status: "streaming" },
34+ );
35+ const done = askReducer(state, { type: "event", event: { type: "done", stop_reason: "max_tokens" } });
36+ assert.equal(done.busy, false);
37+ assert.equal(done.turns[1]!.status, "done");
38+ assert.ok(cutOff(done.turns[1]!));
39+});
40+
41+test("one question at a time; empty ones are not asked", () => {
42+ const asking = run({ type: "ask", question: "a", id: "1" });
43+ assert.equal(askReducer(asking, { type: "ask", question: "b", id: "2" }), asking);
44+ assert.equal(askReducer(EMPTY_ASK, { type: "ask", question: " ", id: "1" }), EMPTY_ASK);
45+});
46+
47+test("stopping keeps what came; an error says what went wrong; new conversation starts over", () => {
48+ const stopped = run({ type: "ask", question: "a", id: "1" }, { type: "event", event: { type: "text", text: "par" } }, { type: "stop" });
49+ assert.equal(stopped.busy, false);
50+ assert.deepEqual([stopped.turns[1]!.status, stopped.turns[1]!.content], ["stopped", "par"]);
51+ // Nothing after the stop lands on the answer.
52+ assert.equal(askReducer(stopped, { type: "event", event: { type: "text", text: "tial" } }).turns[1]!.content, "par");
53+
54+ const failed = run({ type: "ask", question: "a", id: "1" }, { type: "event", event: { type: "error", message: "Anthropic did not accept the credentials." } });
55+ assert.equal(failed.turns[1]!.error, "Anthropic did not accept the credentials.");
56+ assert.equal(failed.busy, false);
57+ assert.equal(run({ type: "ask", question: "a", id: "1" }, { type: "failed", message: "Too many questions." }).turns[1]!.status, "error");
58+ assert.equal(askReducer(failed, { type: "reset" }), EMPTY_ASK);
59+});
60+
61+test("what is sent alternates, leaving out questions whose answer failed", () => {
62+ const state = run(
63+ { type: "ask", question: "one", id: "1" },
64+ { type: "event", event: { type: "text", text: "first" } },
65+ { type: "event", event: { type: "done", stop_reason: "end_turn" } },
66+ { type: "ask", question: "two", id: "2" },
67+ { type: "event", event: { type: "error", message: "no" } },
68+ { type: "ask", question: "three", id: "3" },
69+ { type: "event", event: { type: "text", text: "cut" } },
70+ { type: "stop" },
71+ );
72+ assert.deepEqual(history(state.turns, " four "), [
73+ { role: "user", content: "one" },
74+ { role: "assistant", content: "first" },
75+ { role: "user", content: "three" },
76+ { role: "assistant", content: "cut" },
77+ { role: "user", content: "four" },
78+ ]);
79+});
80+
81+test("lines are read as they arrive, across chunks", () => {
82+ const first = readLines("", '{"type":"meta","provider":"openai","provider_label":"OpenAI","model":"gpt-5"}\n{"type":"te');
83+ assert.equal(first.events.length, 1);
84+ assert.equal(first.rest, '{"type":"te');
85+ const second = readLines(first.rest, 'xt","text":"Hi"}\nnot json\n{"type":"other"}\n');
86+ assert.deepEqual(second.events, [{ type: "text", text: "Hi" }]);
87+ assert.equal(second.rest, "");
88+});
89+
90+test("a conversation kept in the tab comes back stopped where it was", () => {
91+ const kept = restorable({ turns: [{ id: "1-q", role: "user", content: "a", status: "done" }, { id: "1-a", role: "assistant", content: "b", status: "streaming" }], busy: true });
92+ assert.equal(kept.busy, false);
93+ assert.equal(kept.turns[1]!.status, "stopped");
94+ assert.deepEqual(restorable("junk"), EMPTY_ASK);
95+ assert.deepEqual(restorable({ turns: [{ role: "system", content: "x" }] }).turns, []);
96+});
+173−0
1+/**
2+ * Ask g1t's conversation, as the composer keeps it (components/ask-composer):
3+ * where the person is, the turns so far, and reading the answer as it
4+ * streams. Pure, so it is tested on its own.
5+ *
6+ * A conversation lives in the browser tab for now (the composer keeps it in
7+ * session storage); keeping conversations on the server comes later.
8+ */
9+import type { AskEvent, AskMessage, AskPage } from "@g1t/contracts";
10+
11+/** First path segments that are g1t's own pages, never a workspace. */
12+const RESERVED = new Set([
13+ "settings", "u", "auth", "oauth", "integrations", "new", "invite", "workspaces", "device", "verify",
14+ "confirm-email", "login", "register", "logout", "forgot", "reset", "search", "status", "avatars", "docs", "-",
15+]);
16+
17+/** Where the person is: the page, and the repository, issue or pull request on it. */
18+export function askPage(pathname: string): AskPage {
19+ const path = pathname.split(/[?#]/)[0] ?? "";
20+ const parts = path.split("/").filter(Boolean).map((part) => decodeURIComponent(part));
21+ const page: AskPage = { path: path || "/" };
22+ const [owner, name, section, number] = parts;
23+ if (!owner || !name || RESERVED.has(owner.toLowerCase()) || name === "-") return page;
24+ page.repo = { namespace: owner.toLowerCase(), name };
25+ const numbered = number && /^\d+$/.test(number) ? Number(number) : null;
26+ if (numbered != null && section === "issues") page.issue = numbered;
27+ if (numbered != null && section === "pull") page.pull = numbered;
28+ return page;
29+}
30+
31+export type TurnStatus = "streaming" | "done" | "stopped" | "error";
32+
33+export type Turn = {
34+ id: string;
35+ role: "user" | "assistant";
36+ content: string;
37+ status: TurnStatus;
38+ /** Who answered: the provider's name and the model. */
39+ provider?: string;
40+ model?: string;
41+ /** What went wrong, for an answer that did not come. */
42+ error?: string;
43+ /** `max_tokens`, `length`: the answer was cut off at its limit. */
44+ stopReason?: string | null;
45+};
46+
47+export type AskState = { turns: Turn[]; busy: boolean };
48+
49+export const EMPTY_ASK: AskState = { turns: [], busy: false };
50+
51+export type AskAction =
52+ | { type: "ask"; question: string; id: string }
53+ | { type: "event"; event: AskEvent }
54+ | { type: "stop" }
55+ | { type: "failed"; message: string }
56+ | { type: "reset" }
57+ | { type: "restore"; state: AskState };
58+
59+/** The answer being written, if one is. */
60+function answering(state: AskState): Turn | null {
61+ const last = state.turns.at(-1);
62+ return last?.role === "assistant" && last.status === "streaming" ? last : null;
63+}
64+
65+function withLast(state: AskState, change: (turn: Turn) => Turn, busy: boolean): AskState {
66+ const last = answering(state);
67+ if (!last) return state;
68+ return { turns: [...state.turns.slice(0, -1), change(last)], busy };
69+}
70+
71+export function askReducer(state: AskState, action: AskAction): AskState {
72+ switch (action.type) {
73+ case "ask": {
74+ const question = action.question.trim();
75+ if (state.busy || !question) return state;
76+ return {
77+ busy: true,
78+ turns: [
79+ ...state.turns,
80+ { id: `${action.id}-q`, role: "user", content: question, status: "done" },
81+ { id: `${action.id}-a`, role: "assistant", content: "", status: "streaming" },
82+ ],
83+ };
84+ }
85+ case "event": {
86+ const { event } = action;
87+ switch (event.type) {
88+ case "meta":
89+ return withLast(state, (turn) => ({ ...turn, provider: event.provider_label, model: event.model }), true);
90+ case "text":
91+ return withLast(state, (turn) => ({ ...turn, content: turn.content + event.text }), true);
92+ case "done":
93+ return withLast(state, (turn) => ({ ...turn, status: "done", stopReason: event.stop_reason }), false);
94+ case "error":
95+ return withLast(state, (turn) => ({ ...turn, status: "error", error: event.message }), false);
96+ }
97+ return state;
98+ }
99+ case "stop":
100+ return withLast(state, (turn) => ({ ...turn, status: "stopped" }), false);
101+ case "failed":
102+ return withLast(state, (turn) => ({ ...turn, status: "error", error: action.message }), false);
103+ case "reset":
104+ return EMPTY_ASK;
105+ case "restore":
106+ return restorable(action.state);
107+ }
108+}
109+
110+/**
111+ * A conversation kept in the tab, made safe to show again: an answer that
112+ * was still coming when the page went away is marked stopped.
113+ */
114+export function restorable(state: unknown): AskState {
115+ if (!state || typeof state !== "object" || !Array.isArray((state as AskState).turns)) return EMPTY_ASK;
116+ const turns = (state as AskState).turns
117+ .filter((turn) => turn && (turn.role === "user" || turn.role === "assistant") && typeof turn.content === "string")
118+ .map((turn) => (turn.status === "streaming" ? { ...turn, status: "stopped" as const } : turn));
119+ return { turns, busy: false };
120+}
121+
122+/**
123+ * What to send with a new question: every question that got an answer, and
124+ * the answer (as far as it got, if it was stopped), then the new question.
125+ * A question whose answer failed or never started is left out, so the turns
126+ * alternate.
127+ */
128+export function history(turns: Turn[], question: string): AskMessage[] {
129+ const messages: AskMessage[] = [];
130+ for (let at = 0; at < turns.length; at++) {
131+ const turn = turns[at]!;
132+ const next = turns[at + 1];
133+ if (turn.role !== "user") continue;
134+ if (next?.role === "assistant" && next.status !== "error" && next.content.trim()) {
135+ messages.push({ role: "user", content: turn.content }, { role: "assistant", content: next.content });
136+ at++;
137+ }
138+ }
139+ messages.push({ role: "user", content: question.trim() });
140+ return messages;
141+}
142+
143+/**
144+ * Reads newline-delimited JSON as it arrives: each whole line's event, and
145+ * what is left of a line still coming. Lines that are not events are
146+ * skipped.
147+ */
148+export function readLines(pending: string, chunk: string): { events: AskEvent[]; rest: string } {
149+ const text = pending + chunk;
150+ const lines = text.split("\n");
151+ const rest = lines.pop() ?? "";
152+ const events: AskEvent[] = [];
153+ for (const line of lines) {
154+ const event = parseEvent(line);
155+ if (event) events.push(event);
156+ }
157+ return { events, rest };
158+}
159+
160+export function parseEvent(line: string): AskEvent | null {
161+ if (!line.trim()) return null;
162+ try {
163+ const event = JSON.parse(line) as AskEvent;
164+ return event && typeof event === "object" && ["meta", "text", "done", "error"].includes(event.type) ? event : null;
165+ } catch {
166+ return null;
167+ }
168+}
169+
170+/** Whether an answer was cut off at its length limit. */
171+export function cutOff(turn: Turn): boolean {
172+ return turn.stopReason === "max_tokens" || turn.stopReason === "length";
173+}
+3−0
139139 // provider connection: models, alerts, trackers (lib/integration-sections).
140140 route("-/integrations", "routes/workspace/integrations-directory.tsx"),
141141 route("-/integrations/:section", "routes/workspace/integrations.tsx"),
142+ // Ask g1t, from the top bar: what it answers with, and each answer,
143+ // streamed (components/ask-composer).
144+ route("-/ask", "routes/workspace/ask.ts"),
142145 route("-/webhooks", "routes/workspace/webhooks.tsx"),
143146 // The workspace's own emoji, for chat (components/emoji).
144147 route("-/emoji", "routes/workspace/emoji.tsx"),
+100−0
1+/**
2+ * Ask g1t (components/ask-composer): `GET -/ask` says what Ask answers with
3+ * in the workspace, or why it cannot yet; `POST -/ask` with
4+ * `{ messages, page }` streams one answer back as newline-delimited JSON
5+ * (`AskEvent`s), from the integrations service, which calls the AI provider
6+ * an owner chose with the workspace's own key. The key never reaches the
7+ * browser.
8+ */
9+import { env } from "cloudflare:workers";
10+import { data } from "react-router";
11+
12+import {
13+ ASK_MAX_MESSAGES,
14+ ASK_MAX_TOTAL_CHARS,
15+ type AskEvent,
16+ type AskMessage,
17+ type AskPage,
18+ checkLimit,
19+ integrationsClient,
20+} from "@g1t/contracts";
21+
22+import type { Route } from "./+types/ask";
23+import { integrations } from "../../lib/services.server";
24+import { assertSameOrigin, getViewer, requireUser, roleIn } from "../../lib/session.server";
25+
26+/** The most a request may carry: the conversation's cap, with room for JSON. */
27+const MAX_BODY_BYTES = ASK_MAX_TOTAL_CHARS * 4 + 16_384;
28+
29+const NDJSON = { "content-type": "application/x-ndjson; charset=utf-8", "cache-control": "no-store" };
30+
31+export async function loader({ params, context }: Route.LoaderArgs) {
32+ const viewer = getViewer(context);
33+ const role = roleIn(viewer, params.owner);
34+ if (!role) throw data(null, { status: 404 });
35+ const setup = await integrations.askSettings(params.owner.toLowerCase(), viewer);
36+ return Response.json({ ...setup, owner: role === "owner" }, { headers: { "cache-control": "no-store" } });
37+}
38+
39+/** An answer that never started: one `error` line, with the status that says why. */
40+function refuse(message: string, status: number, headers: Record<string, string> = {}): Response {
41+ const event: AskEvent = { type: "error", message };
42+ return new Response(`${JSON.stringify(event)}\n`, { status, headers: { ...NDJSON, ...headers } });
43+}
44+
45+function isMessage(value: unknown): value is AskMessage {
46+ if (!value || typeof value !== "object") return false;
47+ const { role, content } = value as AskMessage;
48+ return (role === "user" || role === "assistant") && typeof content === "string";
49+}
50+
51+/** The page as sent, with only what Ask reads, of the shapes it reads. */
52+function pageFrom(value: unknown): AskPage {
53+ if (!value || typeof value !== "object") return {};
54+ const sent = value as Record<string, unknown>;
55+ const number = (n: unknown) => (typeof n === "number" && Number.isInteger(n) && n > 0 && n < 2 ** 31 ? n : null);
56+ const repo = sent.repo as { namespace?: unknown; name?: unknown } | null | undefined;
57+ return {
58+ path: typeof sent.path === "string" ? sent.path.slice(0, 500) : null,
59+ repo:
60+ repo && typeof repo.namespace === "string" && typeof repo.name === "string" && repo.namespace && repo.name
61+ ? { namespace: repo.namespace.slice(0, 100), name: repo.name.slice(0, 100) }
62+ : null,
63+ issue: number(sent.issue),
64+ pull: number(sent.pull),
65+ };
66+}
67+
68+export async function action({ params, context, request }: Route.ActionArgs) {
69+ if (request.method !== "POST") throw data(null, { status: 405 });
70+ assertSameOrigin(request);
71+ const viewer = requireUser(context, request);
72+ if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
73+ const slug = params.owner.toLowerCase();
74+ // Each question spends the workspace's own provider and key: a few a
75+ // minute per person is plenty for a conversation.
76+ if ((await checkLimit(env.WEB_ASK_LIMIT, `ask:${viewer.id}`)) === "limited") {
77+ return refuse("You have asked a lot in the last minute. Wait a moment, then ask again.", 429, { "retry-after": "60" });
78+ }
79+ const length = Number(request.headers.get("content-length") ?? 0);
80+ if (length > MAX_BODY_BYTES) return refuse("The conversation is too long. Start a new one.", 413);
81+ const text = await request.text();
82+ if (text.length > MAX_BODY_BYTES) return refuse("The conversation is too long. Start a new one.", 413);
83+ let sent: { messages?: unknown; page?: unknown };
84+ try {
85+ sent = JSON.parse(text) as typeof sent;
86+ } catch {
87+ return refuse("The question did not arrive whole. Ask again.", 400);
88+ }
89+ const messages = Array.isArray(sent.messages) ? sent.messages.filter(isMessage).slice(-ASK_MAX_MESSAGES) : [];
90+ if (messages.length === 0) return refuse("Write a question to ask.", 400);
91+ // The binding itself: the timed one (lib/perf.server.ts) reads the whole
92+ // answer before handing it on, and this one streams. The person closing
93+ // the tab or pressing Stop ends the provider's answer too.
94+ const answer = await integrationsClient(env.INTEGRATIONS)
95+ .ask(viewer, slug, messages, pageFrom(sent.page), request.signal)
96+ .catch(() => null);
97+ if (!answer) return refuse("Ask could not reach g1t's integrations. Try again in a moment.", 502);
98+ if (!answer.ok || !answer.body) return refuse("Ask could not reach g1t's integrations. Try again in a moment.", 502);
99+ return new Response(answer.body, { headers: NDJSON });
100+}
+137−4
88 Ticket,
99 X,
1010 } from "lucide-react";
11−import type { ReactNode } from "react";
11+import { type ReactNode, useState } from "react";
1212 import { Form, Link } from "react-router";
1313
1414 import {
15+ type AskSetup,
1516 type Connection,
1617 type ConnectionConfig,
1718 type Delivery,
3031 import { Avatar } from "../../components/ui/avatar";
3132 import { Card } from "../../components/ui/card";
3233 import { Combobox } from "../../components/ui/combobox";
34+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select";
3335 import { integrations, repos } from "../../lib/services.server";
3436 import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
3537
4749 const kind = sectionKind(params.section);
4850 if (!role || !kind) throw new Response(null, { status: 404 });
4951 const slug = params.owner.toLowerCase();
50− const [connections, listed] = await Promise.all([integrations.list(slug, viewer), repos.list(viewer, { namespace: slug })]);
52+ const [connections, listed, ask] = await Promise.all([
53+ integrations.list(slug, viewer),
54+ repos.list(viewer, { namespace: slug }),
55+ // What Ask g1t answers with, on the AI providers' page.
56+ kind === "models" ? integrations.askSettings(slug, viewer) : null,
57+ ]);
5158 const all = unwrap(connections);
5259 // What each alert source has sent lately, to see it is wired up.
5360 const deliveries: Record<string, Delivery[]> = {};
6875 deliveries,
6976 repos: listed.map((repo) => `${repo.namespace}/${repo.name}`),
7077 adding: isProvider(adding) ? adding : null,
78+ ask: ask?.ok ? ask.value : null,
7179 };
7280 }
7381
115123 });
116124 return updated.ok ? { updated: id } : { error: updated.error.message };
117125 }
126+ if (intent === "ask") {
127+ // Which provider and model Ask g1t answers with; none turns it off.
128+ const connection = text(form, "connection");
129+ const chosen = await integrations.setAskSettings(user, slug, connection ?? null, connection ? (text(form, "model") ?? null) : null);
130+ return chosen.ok ? { asked: true } : { error: chosen.error.message, ask: true };
131+ }
118132 if (intent === "provider") {
119133 // An AI provider's model and address, and a new key if one was given:
120134 // its other settings as they are.
170184 };
171185
172186 export default function WorkspaceIntegrations({ loaderData, actionData }: Route.ComponentProps) {
173− const { slug, role, kind, connections, deliveries, repos: repoNames, adding } = loaderData;
187+ const { slug, role, kind, connections, deliveries, repos: repoNames, adding, ask } = loaderData;
174188 const owner = role === "owner";
175189 const justConnected = actionData && "connected" in actionData ? actionData.connected : null;
176190 const tested = (actionData && "tested" in actionData ? actionData.tested : null) ?? null;
177191 const error = (actionData && "error" in actionData ? actionData.error : null) ?? null;
178192 const updated = (actionData && "updated" in actionData ? actionData.updated : null) ?? null;
193+ const asked = actionData != null && "asked" in actionData;
194+ const askError = actionData && "ask" in actionData ? error : null;
179195
180196 return (
181197 <div>
184200 </Link>
185201 {justConnected && <Connected connected={justConnected} />}
186202 <div className="mt-4">
187− <ErrorText>{error}</ErrorText>
203+ <ErrorText>{actionData && "ask" in actionData ? null : error}</ErrorText>
188204 </div>
189205
190206 {/* AI providers -------------------------------------------------------- */}
200216 <p className="mb-4 text-xs text-faint">
201217 Keys are sealed when saved and never shown again. Only an owner of {slug} can add, test, change or remove them.
202218 </p>
219+ <AskSetting
220+ connections={connections.filter((c) => c.kind === "models")}
221+ setup={ask}
222+ owner={owner}
223+ saved={asked}
224+ error={askError}
225+ />
203226 {owner && <ModelCatalog slug={slug} adding={adding} />}
204227 {adding && PROVIDERS[adding].kind === "models" && owner && (
205228 <AddForm
436459 );
437460 }
438461
462+/**
463+ * Ask g1t's provider and model: one of the workspace's AI providers, and
464+ * one of the models it offered when last checked (or any name, for one
465+ * that lists none). Owners choose; members see what it is.
466+ */
467+function AskSetting({
468+ connections,
469+ setup,
470+ owner,
471+ saved,
472+ error,
473+}: {
474+ connections: Connection[];
475+ setup: AskSetup | null;
476+ owner: boolean;
477+ saved: boolean;
478+ error: string | null;
479+}) {
480+ const current = connections.find((c) => c.id === setup?.connection_id) ?? null;
481+ const [chosen, setChosen] = useState(current?.id ?? connections[0]?.id ?? "");
482+ const connection = connections.find((c) => c.id === chosen) ?? null;
483+ const azure = connection?.provider === "azure_openai";
484+ const defaultModel =
485+ connection && connection.id === current?.id ? (setup?.model ?? "") : (connection?.config.model ?? connection?.models[0] ?? "");
486+ return (
487+ <Card asChild className="mb-6 scroll-mt-20 p-4">
488+ <section id="ask" aria-labelledby="ask-heading">
489+ <h2 id="ask-heading" className="text-sm font-medium">
490+ Ask g1t
491+ </h2>
492+ <p className="mt-1 text-xs text-muted">
493+ The top bar's Ask answers with this provider and model, on your workspace's own key. g1t hosts no model for it and
494+ bills nothing for it: what it costs is on your provider's bill.
495+ </p>
496+ {setup?.connection_id && !setup.problem && (
497+ <p className="mt-3 text-sm">
498+ Answers with <span className="font-medium">{setup.connection_name}</span> ·{" "}
499+ <span className="font-mono text-[0.8125rem]">{setup.model}</span>
500+ </p>
501+ )}
502+ {setup?.problem && <p className="mt-3 text-sm text-warn">{setup.problem}</p>}
503+ {connections.length === 0 ? (
504+ <p className="mt-3 text-sm text-muted">
505+ {owner ? "Connect an AI provider below, then choose it here." : `An owner of the workspace can connect one and choose it here.`}
506+ </p>
507+ ) : (
508+ owner && (
509+ <Form method="post" className="mt-4 grid max-w-2xl gap-3 sm:grid-cols-[1fr_1fr_auto] sm:items-end">
510+ <input type="hidden" name="intent" value="ask" />
511+ <Field label="Provider">
512+ <Select name="connection" value={chosen} onValueChange={setChosen}>
513+ <SelectTrigger aria-label="Provider">
514+ <SelectValue />
515+ </SelectTrigger>
516+ <SelectContent>
517+ {connections.map((c) => (
518+ <SelectItem key={c.id} value={c.id} description={PROVIDERS[c.provider].label}>
519+ {c.name}
520+ </SelectItem>
521+ ))}
522+ </SelectContent>
523+ </Select>
524+ </Field>
525+ <Field label={azure ? "Deployment" : "Model"}>
526+ {connection && connection.models.length > 0 && !azure ? (
527+ <Combobox
528+ key={connection.id}
529+ name="model"
530+ defaultValue={connection.models.includes(defaultModel) ? defaultModel : (connection.models[0] ?? "")}
531+ aria-label="Model"
532+ searchPlaceholder="Find a model"
533+ emptyText="It offers no model by that name."
534+ options={connection.models.map((model) => ({ value: model, label: model }))}
535+ className="font-mono text-[0.8125rem]"
536+ />
537+ ) : (
538+ <Input
539+ key={connection?.id}
540+ name="model"
541+ required
542+ defaultValue={defaultModel}
543+ placeholder={azure ? "The deployment's name" : "The model's name"}
544+ className="font-mono text-[0.8125rem]"
545+ autoComplete="off"
546+ />
547+ )}
548+ </Field>
549+ <div className="flex items-center gap-2">
550+ <SubmitButton variant="outline" match={{ intent: "ask" }} pending="Saving…">
551+ Use for Ask
552+ </SubmitButton>
553+ </div>
554+ </Form>
555+ )
556+ )}
557+ {owner && setup?.connection_id && (
558+ <Form method="post" className="mt-2">
559+ <input type="hidden" name="intent" value="ask" />
560+ <SubmitButton variant="link" size="inline" match={{ intent: "ask" }} className="text-xs text-muted">
561+ Turn Ask off
562+ </SubmitButton>
563+ </Form>
564+ )}
565+ {saved && !error && <p className="mt-2 text-sm text-success">Saved.</p>}
566+ <ErrorText>{error}</ErrorText>
567+ </section>
568+ </Card>
569+ );
570+}
571+
439572 /** What a provider is for, in a line. */
440573 function blurb(provider: Provider): string {
441574 return PROVIDER_BLURB[provider] ?? AI_PROVIDERS[provider]?.blurb ?? "";
+2−0
7373 WEB_SESSION_LIMIT?: RateLimitBinding;
7474 WEB_ADDRESS_LIMIT?: RateLimitBinding;
7575 WEB_TOKEN_LIMIT?: RateLimitBinding;
76+ /** Ask g1t's questions, per person (app/routes/workspace/ask.ts). */
77+ WEB_ASK_LIMIT?: RateLimitBinding;
7678 GIT_ANONYMOUS_LIMIT?: RateLimitBinding;
7779 GIT_SIGNED_LIMIT?: RateLimitBinding;
7880 /**
+2−1
6464 { "name": "WEB_ADDRESS_LIMIT", "namespace_id": "4204", "simple": { "limit": 3000, "period": 60 } },
6565 { "name": "GIT_ANONYMOUS_LIMIT", "namespace_id": "4205", "simple": { "limit": 120, "period": 60 } },
6666 { "name": "GIT_SIGNED_LIMIT", "namespace_id": "4206", "simple": { "limit": 1200, "period": 60 } },
67− { "name": "WEB_TOKEN_LIMIT", "namespace_id": "4207", "simple": { "limit": 1000, "period": 60 } }
67+ { "name": "WEB_TOKEN_LIMIT", "namespace_id": "4207", "simple": { "limit": 1000, "period": 60 } },
68+ { "name": "WEB_ASK_LIMIT", "namespace_id": "4208", "simple": { "limit": 20, "period": 60 } }
6869 ],
6970 // Logs of a tenth of requests: every page view is one, too many to keep all.
7071 // Cloudflare Turnstile on email sign-up (app/lib/turnstile.ts): the
+3−0
5959 GIT_SIGNED_LIMIT: { worker: "apps/web", namespaceId: 4206, limit: 1200, per: "credential, git requests with credentials" },
6060 // The same as API_TOKEN_LIMIT: a token counts alike on the website and the API.
6161 WEB_TOKEN_LIMIT: { worker: "apps/web", namespaceId: 4207, limit: 1000, per: "token, pages and data requests with an access token" },
62+ // Ask g1t (routes/workspace/ask.ts): answers on the workspace's own AI
63+ // provider and key, so a runaway tab cannot spend them.
64+ WEB_ASK_LIMIT: { worker: "apps/web", namespaceId: 4208, limit: 20, per: "person, Ask g1t questions" },
6265 // services/repos (src/lib.rs): what an anonymous clone can cost a repository's owner.
6366 PACK_FILL_LIMIT: { worker: "services/repos", namespaceId: 4301, limit: 30, per: "repository, packs written to the pack cache" },
6467 ANONYMOUS_FETCH_LIMIT: { worker: "services/repos", namespaceId: 4302, limit: 120, per: "repository, anonymous fetches the store answers" },