Files
beaver-gateway/ui/src/lib/api/client.ts
T

368 lines
9.0 KiB
TypeScript

import { readSse } from "./sse";
import type {
AgentsResponse,
AuditPage,
BusEvent,
ContextResponse,
ConversationInfo,
ConversationSummary,
EntriesPage,
HistoryMessage,
JobsResponse,
LimitsResponse,
MemoryFile,
MemoryTree,
SearchResponse,
SessionsResponse,
TokenRow,
UsageGroup,
UsageResponse,
VaultGraph,
} from "./types";
const TRAILING_SLASHES = /\/+$/;
export class ApiError extends Error {
status: number;
body: unknown;
constructor(status: number, message: string, body: unknown) {
super(message);
this.status = status;
this.body = body;
}
}
function messageOf(status: number, body: unknown): string {
if (body && typeof body === "object") {
const record = body as Record<string, unknown>;
for (const key of ["error", "detail"]) {
if (typeof record[key] === "string") {
return record[key] as string;
}
}
}
if (typeof body === "string" && body) {
return body;
}
return `HTTP ${status}`;
}
async function bodyOf(response: Response): Promise<unknown> {
const text = await response.text();
try {
return JSON.parse(text);
} catch {
return text;
}
}
export type Params = Record<string, string | number | boolean | undefined>;
function query(params?: Params): string {
if (!params) {
return "";
}
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== "") {
search.set(key, String(value));
}
}
const text = search.toString();
return text ? `?${text}` : "";
}
export interface ClientOptions {
onUnauthorized?: () => Promise<string | null>;
}
// One client per signed-in session: the gateway origin, a bearer, and a
// hook that refreshes the bearer once when the gateway rotated it.
export class ApiClient {
base: string;
token: string;
private readonly onUnauthorized?: () => Promise<string | null>;
constructor(base: string, token: string, options: ClientOptions = {}) {
this.base = base.replace(TRAILING_SLASHES, "");
this.token = token;
this.onUnauthorized = options.onUnauthorized;
}
url(path: string, params?: Params): string {
return `${this.base}${path}${query(params)}`;
}
private headers(extra: Record<string, string> = {}): Record<string, string> {
return { Authorization: `Bearer ${this.token}`, ...extra };
}
private async request<T>(
method: string,
path: string,
body?: unknown,
params?: Params,
retried = false
): Promise<T> {
const response = await fetch(this.url(path, params), {
body: body === undefined ? undefined : JSON.stringify(body),
headers: this.headers(
body === undefined ? {} : { "Content-Type": "application/json" }
),
method,
});
if (response.status === 401 && !retried && this.onUnauthorized) {
const token = await this.onUnauthorized();
if (token) {
this.token = token;
return this.request<T>(method, path, body, params, true);
}
}
if (!response.ok) {
const payload = await bodyOf(response);
throw new ApiError(
response.status,
messageOf(response.status, payload),
payload
);
}
if (response.status === 204) {
return undefined as T;
}
return (await response.json()) as T;
}
get<T>(path: string, params?: Params): Promise<T> {
return this.request<T>("GET", path, undefined, params);
}
post<T>(path: string, body?: unknown): Promise<T> {
return this.request<T>("POST", path, body ?? {});
}
patch<T>(path: string, body?: unknown): Promise<T> {
return this.request<T>("PATCH", path, body ?? {});
}
async stream(
path: string,
onEvent: (event: BusEvent) => void,
signal: AbortSignal
): Promise<void> {
const response = await fetch(this.url(path), {
headers: this.headers({ Accept: "text/event-stream" }),
signal,
});
if (!response.ok) {
const payload = await bodyOf(response);
throw new ApiError(
response.status,
messageOf(response.status, payload),
payload
);
}
await readSse(response, onEvent, signal);
}
agents(): Promise<AgentsResponse> {
return this.get("/api/agents");
}
conversations(params?: {
kind?: string;
status?: string;
limit?: number;
}): Promise<{ conversations: ConversationSummary[] }> {
return this.get("/api/conversations", params);
}
conversation(id: string): Promise<ConversationInfo> {
return this.get(`/api/conversations/${id}`);
}
history(id: string): Promise<{ id: string; messages: HistoryMessage[] }> {
return this.get(`/api/conversations/${id}/history`);
}
context(id: string, prompt = false): Promise<ContextResponse> {
return this.get(`/api/conversations/${id}/context`, {
prompt: prompt ? 1 : undefined,
});
}
vaultGraph(paths: string[], limit?: number): Promise<VaultGraph> {
const search = new URLSearchParams();
for (const path of paths) {
search.append("path", path);
}
if (limit) {
search.set("limit", String(limit));
}
return this.get(`/api/vault/graph?${search.toString()}`);
}
entries(
id: string,
params?: { subpath?: string; offset?: number; limit?: number }
): Promise<EntriesPage> {
return this.get(`/api/conversations/${id}/entries`, params);
}
postMessage(
id: string,
text: string,
origin = "panel"
): Promise<{ id: string; item: number; status: string }> {
return this.post(`/api/conversations/${id}/messages`, { origin, text });
}
inject(
id: string,
text: string,
urgency: "normal" | "wake" | "urgent",
origin = "panel"
): Promise<{ id: string; item: number; priority: string }> {
return this.post(`/api/conversations/${id}/inject`, {
origin,
text,
urgency,
});
}
answer(
id: string,
questionId: string,
answer: string
): Promise<{ id: string; question_id: string }> {
return this.post(`/api/conversations/${id}/answer`, {
answer,
question_id: questionId,
});
}
branch(
id: string,
body: { seed?: string; text?: string; title?: string; window?: number }
): Promise<ConversationInfo> {
return this.post(`/api/conversations/${id}/branch`, body);
}
merge(
id: string
): Promise<{ id: string; status: string; fork: string; text: string }> {
return this.post(`/api/conversations/${id}/merge`);
}
close(id: string): Promise<{
id: string;
status: string;
fork?: string;
text?: string;
digest?: string | null;
error?: string | null;
}> {
return this.post(`/api/conversations/${id}/close`);
}
// Without ``externalId`` the frontend opens a fresh window (a new topic).
bind(
id: string,
frontend: string,
externalId: string | null,
visible: boolean
): Promise<ConversationInfo> {
return this.post(`/api/conversations/${id}/bind`, {
external_id: externalId ?? undefined,
frontend,
visible,
});
}
setFlags(
id: string,
flags: Record<string, unknown>
): Promise<ConversationSummary> {
return this.patch(`/api/conversations/${id}/flags`, flags);
}
update(
id: string,
body: { status?: string; title?: string }
): Promise<ConversationSummary> {
return this.patch(`/api/conversations/${id}`, body);
}
spawn(body: {
kind: string;
agent?: string;
seed?: string;
text?: string;
title?: string;
}): Promise<ConversationInfo> {
return this.post("/api/conversations", body);
}
search(q: string, limit?: number): Promise<SearchResponse> {
return this.get("/api/search", { limit, q });
}
sessions(): Promise<SessionsResponse> {
return this.get("/api/sessions");
}
jobs(): Promise<JobsResponse> {
return this.get("/api/jobs");
}
runJob(name: string): Promise<{ job: number | null }> {
return this.post(`/api/jobs/${encodeURIComponent(name)}/run`);
}
cancelJob(id: number): Promise<{ cancelled: number }> {
return this.request("DELETE", `/api/jobs/queue/${id}`);
}
usage(params: {
since?: string;
until?: string;
hours?: number;
group_by: UsageGroup;
}): Promise<UsageResponse> {
return this.get("/api/usage", params);
}
limits(): Promise<LimitsResponse> {
return this.get("/api/limits");
}
memory(): Promise<MemoryTree> {
return this.get("/api/memory");
}
memoryFile(path: string): Promise<MemoryFile> {
return this.get("/api/memory/file", { path });
}
tokens(includeRevoked = false): Promise<{ tokens: TokenRow[] }> {
return this.get("/api/tokens", {
include_revoked: includeRevoked ? 1 : undefined,
});
}
createToken(
name: string,
scope: string
): Promise<{ token: TokenRow; plaintext: string }> {
return this.post("/api/tokens", { name, scope });
}
revokeToken(id: number): Promise<{ id: number; revoked: boolean }> {
return this.post(`/api/tokens/${id}/revoke`);
}
audit(params?: { before?: number; limit?: number }): Promise<AuditPage> {
return this.get("/api/audit", params);
}
}