Skip to content
173 linesCodeBlameRaw
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}