feat(ui,api,admin): sveltekit admin spa, usage by day/agent/conversation/model, rate limits, memory tree, turn snapshot
This commit is contained in:
@@ -0,0 +1,323 @@
|
||||
import { readSse } from "./sse";
|
||||
import type {
|
||||
AgentsResponse,
|
||||
AuditPage,
|
||||
BusEvent,
|
||||
ConversationInfo,
|
||||
ConversationSummary,
|
||||
EntriesPage,
|
||||
HistoryMessage,
|
||||
LimitsResponse,
|
||||
MemoryFile,
|
||||
MemoryTree,
|
||||
Schedule,
|
||||
SessionsResponse,
|
||||
TokenRow,
|
||||
UsageGroup,
|
||||
UsageResponse,
|
||||
} 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`);
|
||||
}
|
||||
|
||||
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" | "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`);
|
||||
}
|
||||
|
||||
bind(
|
||||
id: string,
|
||||
frontend: string,
|
||||
externalId: string,
|
||||
visible: boolean
|
||||
): Promise<ConversationInfo> {
|
||||
return this.post(`/api/conversations/${id}/bind`, {
|
||||
external_id: externalId,
|
||||
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);
|
||||
}
|
||||
|
||||
sessions(): Promise<SessionsResponse> {
|
||||
return this.get("/api/sessions");
|
||||
}
|
||||
|
||||
schedules(): Promise<{ schedules: Schedule[] }> {
|
||||
return this.get("/api/schedules");
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user