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;
|
||||
}
|
||||
}
|
||||
}
|
||||
48
tui/src/config.ts
Normal file
48
tui/src/config.ts
Normal 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
1403
tui/src/index.ts
Normal file
File diff suppressed because it is too large
Load Diff
140
tui/src/types.ts
Normal file
140
tui/src/types.ts
Normal 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";
|
||||
};
|
||||
Reference in New Issue
Block a user