Files
Sybil-2/web/src/lib/api.ts

694 lines
19 KiB
TypeScript
Raw Normal View History

2026-02-13 23:15:12 -08:00
export type ChatSummary = {
id: string;
title: string | null;
createdAt: string;
updatedAt: string;
2026-02-14 22:06:30 -08:00
initiatedProvider: Provider | null;
initiatedModel: string | null;
lastUsedProvider: Provider | null;
lastUsedModel: string | null;
2026-02-13 23:15:12 -08:00
};
2026-02-13 23:49:55 -08:00
export type SearchSummary = {
id: string;
title: string | null;
query: string | null;
createdAt: string;
updatedAt: string;
};
2026-02-13 23:15:12 -08:00
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;
2026-02-13 23:15:12 -08:00
};
export type ChatDetail = {
id: string;
title: string | null;
createdAt: string;
updatedAt: string;
2026-02-14 22:06:30 -08:00
initiatedProvider: Provider | null;
initiatedModel: string | null;
lastUsedProvider: Provider | null;
lastUsedModel: string | null;
2026-02-13 23:15:12 -08:00
messages: Message[];
};
2026-02-13 23:49:55 -08:00
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;
2026-02-14 00:14:10 -08:00
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;
2026-02-13 23:49:55 -08:00
results: SearchResultItem[];
};
2026-05-02 19:21:06 -07:00
export type ChatImageAttachment = {
kind: "image";
id: string;
filename: string;
mimeType: "image/png" | "image/jpeg";
sizeBytes: number;
dataUrl: string;
};
export type ChatTextAttachment = {
kind: "text";
id: string;
filename: string;
mimeType: string;
sizeBytes: number;
text: string;
truncated?: boolean;
};
export type ChatAttachment = ChatImageAttachment | ChatTextAttachment;
2026-02-14 01:53:34 -08:00
export type SearchRunRequest = {
query?: string;
title?: string;
type?: "auto" | "fast" | "deep" | "instant";
numResults?: number;
includeDomains?: string[];
excludeDomains?: string[];
};
2026-02-13 23:15:12 -08:00
export type CompletionRequestMessage = {
role: "system" | "user" | "assistant" | "tool";
content: string;
name?: string;
2026-05-02 19:21:06 -07:00
attachments?: ChatAttachment[];
2026-02-13 23:15:12 -08:00
};
2026-02-14 21:00:30 -08:00
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 ActiveRunsResponse = {
chats: string[];
searches: string[];
};
2026-02-13 23:15:12 -08:00
type CompletionResponse = {
chatId: string | null;
message: {
role: "assistant";
content: string;
};
};
2026-02-14 21:15:54 -08:00
type CompletionStreamHandlers = {
2026-05-02 23:48:01 -07:00
onMeta?: (payload: { chatId: string | null; callId: string | null; provider: Provider; model: string }) => void;
onToolCall?: (payload: ToolCallEvent) => void;
2026-02-14 21:15:54 -08:00
onDelta?: (payload: { text: string }) => void;
onDone?: (payload: { text: string; usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number } }) => void;
onError?: (payload: { message: string }) => void;
};
2026-05-02 23:48:01 -07:00
type CreateChatRequest = {
title?: string;
provider?: Provider;
model?: string;
messages?: CompletionRequestMessage[];
};
2026-02-14 20:16:34 -08:00
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "/api";
2026-02-13 23:15:12 -08:00
const ENV_ADMIN_TOKEN = (import.meta.env.VITE_ADMIN_TOKEN as string | undefined)?.trim() || null;
let authToken: string | null = ENV_ADMIN_TOKEN;
export function getConfiguredToken() {
return ENV_ADMIN_TOKEN;
}
export function setAuthToken(token: string | null) {
authToken = token?.trim() || null;
}
async function api<T>(path: string, init?: RequestInit): Promise<T> {
const headers = new Headers(init?.headers ?? {});
2026-02-14 01:10:27 -08:00
const hasBody = init?.body !== undefined && init.body !== null;
if (hasBody && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}
2026-02-13 23:15:12 -08:00
if (authToken) {
headers.set("Authorization", `Bearer ${authToken}`);
}
const response = await fetch(`${API_BASE_URL}${path}`, {
...init,
headers,
});
if (!response.ok) {
const fallback = `${response.status} ${response.statusText}`;
let message = fallback;
try {
const body = (await response.json()) as { message?: string };
if (body.message) message = body.message;
} catch {
// keep fallback message
}
throw new Error(message);
}
return (await response.json()) as T;
}
export async function listChats() {
const data = await api<{ chats: ChatSummary[] }>("/v1/chats");
return data.chats;
}
export async function verifySession() {
return api<{ authenticated: true; mode: "open" | "token" }>("/v1/auth/session");
}
2026-02-14 21:00:30 -08:00
export async function listModels() {
return api<ModelCatalogResponse>("/v1/models");
}
export async function getActiveRuns() {
return api<ActiveRunsResponse>("/v1/active-runs");
}
2026-05-02 23:48:01 -07:00
export async function createChat(input?: string | CreateChatRequest) {
const body = typeof input === "string" ? { title: input } : input ?? {};
2026-02-13 23:15:12 -08:00
const data = await api<{ chat: ChatSummary }>("/v1/chats", {
method: "POST",
2026-05-02 23:48:01 -07:00
body: JSON.stringify(body),
2026-02-13 23:15:12 -08:00
});
return data.chat;
}
export async function getChat(chatId: string) {
const data = await api<{ chat: ChatDetail }>(`/v1/chats/${chatId}`);
return data.chat;
}
2026-02-14 21:27:44 -08:00
export async function updateChatTitle(chatId: string, title: string) {
const data = await api<{ chat: ChatSummary }>(`/v1/chats/${chatId}`, {
method: "PATCH",
body: JSON.stringify({ title }),
});
return data.chat;
}
export async function suggestChatTitle(body: { chatId: string; content: string }) {
const data = await api<{ chat: ChatSummary }>("/v1/chats/title/suggest", {
method: "POST",
body: JSON.stringify(body),
});
return data.chat;
}
2026-02-14 01:10:27 -08:00
export async function deleteChat(chatId: string) {
await api<{ deleted: true }>(`/v1/chats/${chatId}`, { method: "DELETE" });
}
2026-02-13 23:49:55 -08:00
export async function listSearches() {
const data = await api<{ searches: SearchSummary[] }>("/v1/searches");
return data.searches;
}
export async function createSearch(body?: { title?: string; query?: string }) {
const data = await api<{ search: SearchSummary }>("/v1/searches", {
method: "POST",
body: JSON.stringify(body ?? {}),
});
return data.search;
}
export async function getSearch(searchId: string) {
const data = await api<{ search: SearchDetail }>(`/v1/searches/${searchId}`);
return data.search;
}
2026-05-02 16:48:01 -07:00
export async function createChatFromSearch(searchId: string, body?: { title?: string }) {
const data = await api<{ chat: ChatSummary }>(`/v1/searches/${searchId}/chat`, {
method: "POST",
body: JSON.stringify(body ?? {}),
});
return data.chat;
}
2026-02-14 01:10:27 -08:00
export async function deleteSearch(searchId: string) {
await api<{ deleted: true }>(`/v1/searches/${searchId}`, { method: "DELETE" });
}
2026-05-02 19:21:06 -07:00
export function getMessageAttachments(metadata: unknown): ChatAttachment[] {
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return [];
const attachments = (metadata as Record<string, unknown>).attachments;
if (!Array.isArray(attachments)) return [];
const parsed: ChatAttachment[] = [];
for (const entry of attachments) {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
const record = entry as Record<string, unknown>;
const kind = record.kind;
const id = typeof record.id === "string" ? record.id : "";
const filename = typeof record.filename === "string" ? record.filename : "";
const mimeType = typeof record.mimeType === "string" ? record.mimeType : "";
const sizeBytes = typeof record.sizeBytes === "number" ? record.sizeBytes : 0;
if (kind === "image" && typeof record.dataUrl === "string" && (mimeType === "image/png" || mimeType === "image/jpeg")) {
parsed.push({
kind,
id,
filename,
mimeType,
sizeBytes,
dataUrl: record.dataUrl,
} satisfies ChatImageAttachment);
continue;
}
if (kind === "text" && typeof record.text === "string") {
parsed.push({
kind,
id,
filename,
mimeType,
sizeBytes,
text: record.text,
truncated: record.truncated === true,
} satisfies ChatTextAttachment);
}
}
return parsed;
}
2026-02-14 01:53:34 -08:00
type RunSearchStreamHandlers = {
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;
};
async function readSseStream(response: Response, dispatch: (eventName: string, payload: any) => void) {
if (!response.ok) {
const fallback = `${response.status} ${response.statusText}`;
let message = fallback;
try {
const body = (await response.json()) as { message?: string };
if (body.message) message = body.message;
} catch {
// keep fallback message
}
throw new Error(message);
}
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 };
}
dispatch(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();
}
2026-02-14 01:53:34 -08:00
export async function runSearchStream(
2026-02-13 23:49:55 -08:00
searchId: string,
2026-02-14 01:53:34 -08:00
body: SearchRunRequest,
handlers: RunSearchStreamHandlers,
options?: { signal?: AbortSignal }
2026-02-13 23:49:55 -08:00
) {
2026-02-14 01:53:34 -08:00
const headers = new Headers({
Accept: "text/event-stream",
"Content-Type": "application/json",
});
if (authToken) {
headers.set("Authorization", `Bearer ${authToken}`);
}
const response = await fetch(`${API_BASE_URL}/v1/searches/${searchId}/run/stream`, {
2026-02-13 23:49:55 -08:00
method: "POST",
2026-02-14 01:53:34 -08:00
headers,
2026-02-13 23:49:55 -08:00
body: JSON.stringify(body),
2026-02-14 01:53:34 -08:00
signal: options?.signal,
2026-02-13 23:49:55 -08:00
});
2026-02-14 01:53:34 -08:00
if (!response.ok) {
const fallback = `${response.status} ${response.statusText}`;
let message = fallback;
try {
const body = (await response.json()) as { message?: string };
if (body.message) message = body.message;
} catch {
// keep fallback message
}
throw new Error(message);
}
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 };
}
if (eventName === "search_results") handlers.onSearchResults?.(payload);
else if (eventName === "search_error") handlers.onSearchError?.(payload);
else if (eventName === "answer") handlers.onAnswer?.(payload);
else if (eventName === "answer_error") handlers.onAnswerError?.(payload);
else if (eventName === "done") handlers.onDone?.(payload);
else if (eventName === "error") handlers.onError?.(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();
2026-02-13 23:49:55 -08:00
}
export async function attachSearchStream(searchId: string, handlers: RunSearchStreamHandlers, options?: { signal?: AbortSignal }) {
const headers = new Headers({
Accept: "text/event-stream",
});
if (authToken) {
headers.set("Authorization", `Bearer ${authToken}`);
}
const response = await fetch(`${API_BASE_URL}/v1/searches/${searchId}/run/stream/attach`, {
method: "POST",
headers,
signal: options?.signal,
});
await readSseStream(response, (eventName, payload) => {
if (eventName === "search_results") handlers.onSearchResults?.(payload);
else if (eventName === "search_error") handlers.onSearchError?.(payload);
else if (eventName === "answer") handlers.onAnswer?.(payload);
else if (eventName === "answer_error") handlers.onAnswerError?.(payload);
else if (eventName === "done") handlers.onDone?.(payload);
else if (eventName === "error") handlers.onError?.(payload);
});
}
2026-02-13 23:15:12 -08:00
export async function runCompletion(body: {
chatId: string;
2026-02-14 21:00:30 -08:00
provider: Provider;
2026-02-13 23:15:12 -08:00
model: string;
messages: CompletionRequestMessage[];
}) {
return api<CompletionResponse>("/v1/chat-completions", {
method: "POST",
body: JSON.stringify(body),
});
}
2026-02-14 21:15:54 -08:00
export async function runCompletionStream(
body: {
2026-05-02 23:48:01 -07:00
chatId?: string | null;
persist?: boolean;
2026-02-14 21:15:54 -08:00
provider: Provider;
model: string;
messages: CompletionRequestMessage[];
},
handlers: CompletionStreamHandlers,
options?: { signal?: AbortSignal }
) {
const headers = new Headers({
Accept: "text/event-stream",
"Content-Type": "application/json",
});
if (authToken) {
headers.set("Authorization", `Bearer ${authToken}`);
}
const response = await fetch(`${API_BASE_URL}/v1/chat-completions/stream`, {
method: "POST",
headers,
body: JSON.stringify(body),
signal: options?.signal,
});
if (!response.ok) {
const fallback = `${response.status} ${response.statusText}`;
let message = fallback;
try {
const body = (await response.json()) as { message?: string };
if (body.message) message = body.message;
} catch {
// keep fallback message
}
throw new Error(message);
}
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 };
}
if (eventName === "meta") handlers.onMeta?.(payload);
else if (eventName === "tool_call") handlers.onToolCall?.(payload);
2026-02-14 21:15:54 -08:00
else if (eventName === "delta") handlers.onDelta?.(payload);
else if (eventName === "done") handlers.onDone?.(payload);
else if (eventName === "error") handlers.onError?.(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();
}
export async function attachCompletionStream(chatId: string, handlers: CompletionStreamHandlers, options?: { signal?: AbortSignal }) {
const headers = new Headers({
Accept: "text/event-stream",
});
if (authToken) {
headers.set("Authorization", `Bearer ${authToken}`);
}
const response = await fetch(`${API_BASE_URL}/v1/chats/${chatId}/stream/attach`, {
method: "POST",
headers,
signal: options?.signal,
});
await readSseStream(response, (eventName, payload) => {
if (eventName === "meta") handlers.onMeta?.(payload);
else if (eventName === "tool_call") handlers.onToolCall?.(payload);
else if (eventName === "delta") handlers.onDelta?.(payload);
else if (eventName === "done") handlers.onDone?.(payload);
else if (eventName === "error") handlers.onError?.(payload);
});
}