This commit is contained in:
2026-03-02 20:33:41 -08:00
parent e3253d1741
commit 49e30296b9
9 changed files with 2566 additions and 0 deletions

272
tui/src/api.ts Normal file
View 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;
}
}
}

48
tui/src/config.ts Normal file
View File

@@ -0,0 +1,48 @@
import type { Provider } from "./types.js";
const PROVIDERS: Provider[] = ["openai", "anthropic", "xai"];
function normalizeBaseUrl(value: string) {
const trimmed = value.trim();
if (!trimmed) {
throw new Error("SYBIL_TUI_API_BASE_URL cannot be empty");
}
let parsed: URL;
try {
parsed = new URL(trimmed);
} catch {
throw new Error(`Invalid SYBIL_TUI_API_BASE_URL: ${trimmed}`);
}
const normalizedPath = parsed.pathname.replace(/\/+$/, "");
parsed.pathname = normalizedPath || "/";
return parsed.toString().replace(/\/$/, "");
}
function parseProvider(value: string | undefined): Provider {
const trimmed = value?.trim().toLowerCase();
if (!trimmed) return "openai";
if (PROVIDERS.includes(trimmed as Provider)) return trimmed as Provider;
throw new Error(`Invalid SYBIL_TUI_DEFAULT_PROVIDER: ${value}`);
}
function parsePositiveInt(value: string | undefined, fallback: number) {
if (!value?.trim()) return fallback;
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
throw new Error(`Invalid positive integer value: ${value}`);
}
return parsed;
}
const apiBaseUrlValue =
process.env.SYBIL_TUI_API_BASE_URL?.trim() || process.env.SYBIL_API_BASE_URL?.trim() || "http://127.0.0.1:8787";
export const config = {
apiBaseUrl: normalizeBaseUrl(apiBaseUrlValue),
adminToken: process.env.SYBIL_TUI_ADMIN_TOKEN?.trim() || process.env.SYBIL_ADMIN_TOKEN?.trim() || null,
defaultProvider: parseProvider(process.env.SYBIL_TUI_DEFAULT_PROVIDER),
defaultModel: process.env.SYBIL_TUI_DEFAULT_MODEL?.trim() || null,
searchNumResults: parsePositiveInt(process.env.SYBIL_TUI_SEARCH_NUM_RESULTS, 10),
};

1403
tui/src/index.ts Normal file

File diff suppressed because it is too large Load Diff

140
tui/src/types.ts Normal file
View File

@@ -0,0 +1,140 @@
export type Provider = "openai" | "anthropic" | "xai";
export type ProviderModelInfo = {
models: string[];
loadedAt: string | null;
error: string | null;
};
export type ModelCatalogResponse = {
providers: Record<Provider, ProviderModelInfo>;
};
export type ChatSummary = {
id: string;
title: string | null;
createdAt: string;
updatedAt: string;
initiatedProvider: Provider | null;
initiatedModel: string | null;
lastUsedProvider: Provider | null;
lastUsedModel: string | null;
};
export type SearchSummary = {
id: string;
title: string | null;
query: string | null;
createdAt: string;
updatedAt: string;
};
export type Message = {
id: string;
createdAt: string;
role: "system" | "user" | "assistant" | "tool";
content: string;
name: string | null;
metadata: unknown | null;
};
export type ToolCallEvent = {
toolCallId: string;
name: string;
status: "completed" | "failed";
summary: string;
args: Record<string, unknown>;
startedAt: string;
completedAt: string;
durationMs: number;
error?: string;
resultPreview?: string;
};
export type ChatDetail = {
id: string;
title: string | null;
createdAt: string;
updatedAt: string;
initiatedProvider: Provider | null;
initiatedModel: string | null;
lastUsedProvider: Provider | null;
lastUsedModel: string | null;
messages: Message[];
};
export type SearchResultItem = {
id: string;
createdAt: string;
rank: number;
title: string | null;
url: string;
publishedDate: string | null;
author: string | null;
text: string | null;
highlights: string[] | null;
highlightScores: number[] | null;
score: number | null;
favicon: string | null;
image: string | null;
};
export type SearchDetail = {
id: string;
title: string | null;
query: string | null;
createdAt: string;
updatedAt: string;
requestId: string | null;
latencyMs: number | null;
error: string | null;
answerText: string | null;
answerRequestId: string | null;
answerCitations: Array<{
id?: string;
url?: string;
title?: string | null;
publishedDate?: string | null;
author?: string | null;
text?: string | null;
}> | null;
answerError: string | null;
results: SearchResultItem[];
};
export type SearchRunRequest = {
query?: string;
title?: string;
type?: "auto" | "fast" | "deep" | "instant";
numResults?: number;
includeDomains?: string[];
excludeDomains?: string[];
};
export type CompletionRequestMessage = {
role: "system" | "user" | "assistant" | "tool";
content: string;
name?: string;
};
export type CompletionStreamHandlers = {
onMeta?: (payload: { chatId: string; callId: string; provider: Provider; model: string }) => void;
onToolCall?: (payload: ToolCallEvent) => void;
onDelta?: (payload: { text: string }) => void;
onDone?: (payload: { text: string; usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number } }) => void;
onError?: (payload: { message: string }) => void;
};
export type SearchStreamHandlers = {
onSearchResults?: (payload: { requestId: string | null; results: SearchResultItem[] }) => void;
onSearchError?: (payload: { error: string }) => void;
onAnswer?: (payload: { answerText: string | null; answerRequestId: string | null; answerCitations: SearchDetail["answerCitations"] }) => void;
onAnswerError?: (payload: { error: string }) => void;
onDone?: (payload: { search: SearchDetail }) => void;
onError?: (payload: { message: string }) => void;
};
export type SessionStatus = {
authenticated: true;
mode: "open" | "token";
};