add TUI
This commit is contained in:
272
tui/src/api.ts
Normal file
272
tui/src/api.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import type {
|
||||
ChatDetail,
|
||||
ChatSummary,
|
||||
CompletionRequestMessage,
|
||||
CompletionStreamHandlers,
|
||||
ModelCatalogResponse,
|
||||
Provider,
|
||||
SearchDetail,
|
||||
SearchRunRequest,
|
||||
SearchStreamHandlers,
|
||||
SearchSummary,
|
||||
SessionStatus,
|
||||
} 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 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user