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 | */ | |
| 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 | } |