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.

g1t.sh
173 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

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).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 */
9import type { AskEvent, AskMessage, AskPage } from "@g1t/contracts";
10
11/** First path segments that are g1t's own pages, never a workspace. */
12const 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. */
18export 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
31export type TurnStatus = "streaming" | "done" | "stopped" | "error";
32
33export 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
47export type AskState = { turns: Turn[]; busy: boolean };
48
49export const EMPTY_ASK: AskState = { turns: [], busy: false };
50
51export 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. */
60function answering(state: AskState): Turn | null {
61 const last = state.turns.at(-1);
62 return last?.role === "assistant" && last.status === "streaming" ? last : null;
63}
64
65function 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
71export 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 */
114export 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 */
128export 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 */
148export 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
160export 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. */
171export function cutOff(turn: Turn): boolean {
172 return turn.stopReason === "max_tokens" || turn.stopReason === "length";
173}