Files
Sybil-2/tui/src/api.ts

279 lines
7.0 KiB
TypeScript
Raw Normal View History

2026-03-02 20:33:41 -08:00
import type {
ChatDetail,
ChatSummary,
CompletionRequestMessage,
CompletionStreamHandlers,
ModelCatalogResponse,
Provider,
SearchDetail,
SearchRunRequest,
SearchStreamHandlers,
SearchSummary,
SessionStatus,
WorkspaceItem,
2026-03-02 20:33:41 -08:00
} from "./types.js";
type RequestOptions = {
method?: "GET" | "POST" | "PATCH" | "DELETE";
body?: unknown;
signal?: AbortSignal;
headers?: Record<string, string>;
};
export class SybilApiClient {
private readonly baseUrl: string;
private readonly token: string | null;
constructor(baseUrl: string, token: string | null) {
this.baseUrl = baseUrl;
this.token = token;
}
async verifySession() {
return this.request<SessionStatus>("/v1/auth/session");
}
async listModels() {
return this.request<ModelCatalogResponse>("/v1/models");
}
async listChats() {
const data = await this.request<{ chats: ChatSummary[] }>("/v1/chats");
return data.chats;
}
async listWorkspaceItems() {
const data = await this.request<{ items: WorkspaceItem[] }>("/v1/workspace-items");
return data.items;
}
2026-03-02 20:33:41 -08:00
async createChat(title?: string) {
const data = await this.request<{ chat: ChatSummary }>("/v1/chats", {
method: "POST",
body: { title },
});
return data.chat;
}
async getChat(chatId: string) {
const data = await this.request<{ chat: ChatDetail }>(`/v1/chats/${chatId}`);
return data.chat;
}
async suggestChatTitle(body: { chatId: string; content: string }) {
const data = await this.request<{ chat: ChatSummary }>("/v1/chats/title/suggest", {
method: "POST",
body,
});
return data.chat;
}
async deleteChat(chatId: string) {
await this.request<{ deleted: true }>(`/v1/chats/${chatId}`, { method: "DELETE" });
}
async listSearches() {
const data = await this.request<{ searches: SearchSummary[] }>("/v1/searches");
return data.searches;
}
async createSearch(body?: { title?: string; query?: string }) {
const data = await this.request<{ search: SearchSummary }>("/v1/searches", {
method: "POST",
body: body ?? {},
});
return data.search;
}
async getSearch(searchId: string) {
const data = await this.request<{ search: SearchDetail }>(`/v1/searches/${searchId}`);
return data.search;
}
async deleteSearch(searchId: string) {
await this.request<{ deleted: true }>(`/v1/searches/${searchId}`, { method: "DELETE" });
}
async runCompletionStream(
body: {
chatId: string;
provider: Provider;
model: string;
messages: CompletionRequestMessage[];
},
handlers: CompletionStreamHandlers,
options?: { signal?: AbortSignal }
) {
await this.runSse(
"/v1/chat-completions/stream",
body,
{
meta: handlers.onMeta,
tool_call: handlers.onToolCall,
delta: handlers.onDelta,
done: handlers.onDone,
error: handlers.onError,
},
options
);
}
async runSearchStream(
searchId: string,
body: SearchRunRequest,
handlers: SearchStreamHandlers,
options?: { signal?: AbortSignal }
) {
await this.runSse(
`/v1/searches/${searchId}/run/stream`,
body,
{
search_results: handlers.onSearchResults,
search_error: handlers.onSearchError,
answer: handlers.onAnswer,
answer_error: handlers.onAnswerError,
done: handlers.onDone,
error: handlers.onError,
},
options
);
}
private async request<T>(path: string, options?: RequestOptions): Promise<T> {
const headers = new Headers(options?.headers ?? {});
const hasBody = options?.body !== undefined;
if (hasBody && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}
if (this.token) {
headers.set("Authorization", `Bearer ${this.token}`);
}
const init: RequestInit = {
method: options?.method ?? "GET",
headers,
};
if (hasBody) {
init.body = JSON.stringify(options?.body);
}
if (options?.signal) {
init.signal = options.signal;
}
const response = await fetch(`${this.baseUrl}${path}`, init);
if (!response.ok) {
throw new Error(await this.readErrorMessage(response));
}
return (await response.json()) as T;
}
private async runSse(
path: string,
body: unknown,
handlers: Record<string, ((payload: any) => void) | undefined>,
options?: { signal?: AbortSignal }
) {
const headers = new Headers({
Accept: "text/event-stream",
"Content-Type": "application/json",
});
if (this.token) {
headers.set("Authorization", `Bearer ${this.token}`);
}
const init: RequestInit = {
method: "POST",
headers,
body: JSON.stringify(body),
};
if (options?.signal) {
init.signal = options.signal;
}
const response = await fetch(`${this.baseUrl}${path}`, init);
if (!response.ok) {
throw new Error(await this.readErrorMessage(response));
}
if (!response.body) {
throw new Error("No response stream");
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let eventName = "message";
let dataLines: string[] = [];
const flushEvent = () => {
if (!dataLines.length) {
eventName = "message";
return;
}
const dataText = dataLines.join("\n");
let payload: any = null;
try {
payload = JSON.parse(dataText);
} catch {
payload = { message: dataText };
}
handlers[eventName]?.(payload);
dataLines = [];
eventName = "message";
};
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let newlineIndex = buffer.indexOf("\n");
while (newlineIndex >= 0) {
const rawLine = buffer.slice(0, newlineIndex);
buffer = buffer.slice(newlineIndex + 1);
const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
if (!line) {
flushEvent();
} else if (line.startsWith("event:")) {
eventName = line.slice("event:".length).trim();
} else if (line.startsWith("data:")) {
dataLines.push(line.slice("data:".length).trimStart());
}
newlineIndex = buffer.indexOf("\n");
}
}
buffer += decoder.decode();
if (buffer.length) {
const line = buffer.endsWith("\r") ? buffer.slice(0, -1) : buffer;
if (line.startsWith("event:")) {
eventName = line.slice("event:".length).trim();
} else if (line.startsWith("data:")) {
dataLines.push(line.slice("data:".length).trimStart());
}
}
flushEvent();
}
private async readErrorMessage(response: Response) {
const fallback = `${response.status} ${response.statusText}`;
try {
const body = (await response.json()) as { message?: string };
if (typeof body.message === "string" && body.message.trim()) {
return body.message;
}
return fallback;
} catch {
return fallback;
}
}
}