368 lines
9.0 KiB
TypeScript
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);
|
|
}
|
|
}
|