Connect Sybil to the Pi root conversation with authenticated REST and WebSocket APIs, persistent read state, and a dedicated agent tab. Keep optimistic messages stable through delivery and retries, and animate arrivals without losing the reader's scroll position. Share bubble styling with regular chats and remove the obsolete admission sender. Reject malformed upstream receipts and WebSocket upgrade requests, with regression coverage and updated API documentation. Validated with 51 web tests, 44 server tests, both production builds, desktop/mobile bubble comparisons, and 11 browser animation and scrolling checks.
698 lines
19 KiB
TypeScript
698 lines
19 KiB
TypeScript
export type ChatSummary = {
|
|
id: string;
|
|
title: string | null;
|
|
parentChatId: string | null;
|
|
titleGenerationPending: boolean;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
starred: boolean;
|
|
starredAt: string | null;
|
|
initiatedProvider: Provider | null;
|
|
initiatedModel: string | null;
|
|
lastUsedProvider: Provider | null;
|
|
lastUsedModel: string | null;
|
|
additionalSystemPrompt: string | null;
|
|
enabledTools: string[] | null;
|
|
};
|
|
|
|
export type SearchSummary = {
|
|
id: string;
|
|
title: string | null;
|
|
query: string | null;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
starred: boolean;
|
|
starredAt: string | null;
|
|
};
|
|
|
|
export type ChatWorkspaceItem = ChatSummary & {
|
|
type: "chat";
|
|
};
|
|
|
|
export type SearchWorkspaceItem = SearchSummary & {
|
|
type: "search";
|
|
};
|
|
|
|
export type WorkspaceItem = (ChatWorkspaceItem | SearchWorkspaceItem) & { searchSnippet?: string | null };
|
|
|
|
export type WorkspaceSearchResponse = { items: WorkspaceItem[]; hasMore: boolean };
|
|
|
|
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: "initiated" | "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;
|
|
parentChatId: string | null;
|
|
titleGenerationPending: boolean;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
starred: boolean;
|
|
starredAt: string | null;
|
|
initiatedProvider: Provider | null;
|
|
initiatedModel: string | null;
|
|
lastUsedProvider: Provider | null;
|
|
lastUsedModel: string | null;
|
|
additionalSystemPrompt: string | null;
|
|
enabledTools: 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;
|
|
starred: boolean;
|
|
starredAt: string | null;
|
|
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 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;
|
|
|
|
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;
|
|
attachments?: ChatAttachment[];
|
|
};
|
|
|
|
export type Provider = "openai" | "anthropic" | "xai" | "gemini" | "hermes-agent";
|
|
|
|
export type ProviderModelInfo = {
|
|
models: string[];
|
|
loadedAt: string | null;
|
|
error: string | null;
|
|
};
|
|
|
|
export type ModelCatalogResponse = {
|
|
providers: Partial<Record<Provider, ProviderModelInfo>>;
|
|
};
|
|
|
|
export type ChatToolInfo = {
|
|
name: string;
|
|
description: string;
|
|
};
|
|
|
|
export type ActiveRunsResponse = {
|
|
chats: string[];
|
|
searches: string[];
|
|
};
|
|
|
|
type CompletionResponse = {
|
|
chatId: string | null;
|
|
message: {
|
|
role: "assistant";
|
|
content: string;
|
|
};
|
|
};
|
|
|
|
type CompletionStreamHandlers = {
|
|
onMeta?: (payload: { chatId: string | null; callId: string | null; 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;
|
|
};
|
|
|
|
function dispatchCompletionStreamEvent(handlers: CompletionStreamHandlers, eventName: string, payload: any) {
|
|
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);
|
|
}
|
|
|
|
type CreateChatRequest = {
|
|
title?: string;
|
|
provider?: Provider;
|
|
model?: string;
|
|
additionalSystemPrompt?: string;
|
|
enabledTools?: string[];
|
|
messages?: CompletionRequestMessage[];
|
|
};
|
|
|
|
type CreateSearchRequest = {
|
|
title?: string;
|
|
query?: string;
|
|
reuseByQuery?: boolean;
|
|
};
|
|
|
|
type CreateSearchResponse = {
|
|
search: SearchSummary;
|
|
reused: boolean;
|
|
cacheHit: boolean;
|
|
};
|
|
|
|
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "/api";
|
|
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 getAuthToken() {
|
|
return authToken;
|
|
}
|
|
|
|
export function getApiBaseUrl() {
|
|
return API_BASE_URL;
|
|
}
|
|
|
|
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 ?? {});
|
|
const hasBody = init?.body !== undefined && init.body !== null;
|
|
if (hasBody && !headers.has("Content-Type")) {
|
|
headers.set("Content-Type", "application/json");
|
|
}
|
|
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 listWorkspaceItems() {
|
|
const data = await api<{ items: WorkspaceItem[] }>("/v1/workspace-items");
|
|
return data.items;
|
|
}
|
|
|
|
export function searchWorkspaceItems(query: string, signal?: AbortSignal) {
|
|
return api<WorkspaceSearchResponse>(`/v1/workspace-items?${new URLSearchParams({ q: query })}`, { signal });
|
|
}
|
|
|
|
export async function verifySession() {
|
|
return api<{ authenticated: true; mode: "open" | "token" }>("/v1/auth/session");
|
|
}
|
|
|
|
export async function listModels() {
|
|
return api<ModelCatalogResponse>("/v1/models");
|
|
}
|
|
|
|
export async function listChatTools() {
|
|
const data = await api<{ tools: ChatToolInfo[] }>("/v1/chat-tools");
|
|
return data.tools;
|
|
}
|
|
|
|
export async function getActiveRuns() {
|
|
return api<ActiveRunsResponse>("/v1/active-runs");
|
|
}
|
|
|
|
export async function createChat(input?: string | CreateChatRequest) {
|
|
const body = typeof input === "string" ? { title: input } : input ?? {};
|
|
const data = await api<{ chat: ChatSummary }>("/v1/chats", {
|
|
method: "POST",
|
|
body: JSON.stringify(body),
|
|
});
|
|
return data.chat;
|
|
}
|
|
|
|
export async function getChat(chatId: string) {
|
|
const data = await api<{ chat: ChatDetail }>(`/v1/chats/${chatId}`);
|
|
return data.chat;
|
|
}
|
|
|
|
export async function forkChat(chatId: string, messageId?: string) {
|
|
const data = await api<{ chat: ChatSummary }>(`/v1/chats/${chatId}/fork`, {
|
|
method: "POST",
|
|
body: JSON.stringify(messageId ? { messageId } : {}),
|
|
});
|
|
return data.chat;
|
|
}
|
|
|
|
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 updateChatStar(chatId: string, starred: boolean) {
|
|
const data = await api<{ chat: ChatSummary }>(`/v1/chats/${chatId}/star`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify({ starred }),
|
|
});
|
|
return data.chat;
|
|
}
|
|
|
|
export async function updateChatSettings(
|
|
chatId: string,
|
|
body: { title?: string; additionalSystemPrompt?: string | null; enabledTools?: string[] }
|
|
) {
|
|
const data = await api<{ chat: ChatSummary }>(`/v1/chats/${chatId}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify(body),
|
|
});
|
|
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;
|
|
}
|
|
|
|
export async function deleteChat(chatId: string) {
|
|
await api<{ deleted: true }>(`/v1/chats/${chatId}`, { method: "DELETE" });
|
|
}
|
|
|
|
export async function listSearches() {
|
|
const data = await api<{ searches: SearchSummary[] }>("/v1/searches");
|
|
return data.searches;
|
|
}
|
|
|
|
async function postSearch(body?: CreateSearchRequest) {
|
|
return api<CreateSearchResponse>("/v1/searches", {
|
|
method: "POST",
|
|
body: JSON.stringify(body ?? {}),
|
|
});
|
|
}
|
|
|
|
export async function createSearch(body?: CreateSearchRequest) {
|
|
const data = await postSearch(body);
|
|
return data.search;
|
|
}
|
|
|
|
export async function createReusableSearch(body: Omit<CreateSearchRequest, "reuseByQuery">) {
|
|
return postSearch({ ...body, reuseByQuery: true });
|
|
}
|
|
|
|
export async function getSearch(searchId: string) {
|
|
const data = await api<{ search: SearchDetail }>(`/v1/searches/${searchId}`);
|
|
return data.search;
|
|
}
|
|
|
|
export async function updateSearchStar(searchId: string, starred: boolean) {
|
|
const data = await api<{ search: SearchSummary }>(`/v1/searches/${searchId}/star`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify({ starred }),
|
|
});
|
|
return data.search;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
export async function deleteSearch(searchId: string) {
|
|
await api<{ deleted: true }>(`/v1/searches/${searchId}`, { method: "DELETE" });
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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[] = [];
|
|
let sawTerminalEvent = false;
|
|
|
|
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);
|
|
if (eventName === "done" || eventName === "error") {
|
|
sawTerminalEvent = true;
|
|
}
|
|
|
|
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();
|
|
|
|
if (!sawTerminalEvent) {
|
|
throw new Error("Stream disconnected before completion");
|
|
}
|
|
}
|
|
|
|
export async function runSearchStream(
|
|
searchId: string,
|
|
body: SearchRunRequest,
|
|
handlers: RunSearchStreamHandlers,
|
|
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/searches/${searchId}/run/stream`, {
|
|
method: "POST",
|
|
headers,
|
|
body: JSON.stringify(body),
|
|
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);
|
|
});
|
|
}
|
|
|
|
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);
|
|
});
|
|
}
|
|
|
|
export async function runCompletion(body: {
|
|
chatId: string;
|
|
provider: Provider;
|
|
model: string;
|
|
messages: CompletionRequestMessage[];
|
|
additionalSystemPrompt?: string;
|
|
enabledTools?: string[];
|
|
userLocation?: string;
|
|
}) {
|
|
return api<CompletionResponse>("/v1/chat-completions", {
|
|
method: "POST",
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
export async function runCompletionStream(
|
|
body: {
|
|
chatId?: string | null;
|
|
persist?: boolean;
|
|
clientRequestId?: string;
|
|
provider: Provider;
|
|
model: string;
|
|
messages: CompletionRequestMessage[];
|
|
additionalSystemPrompt?: string;
|
|
enabledTools?: string[];
|
|
userLocation?: string;
|
|
},
|
|
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,
|
|
});
|
|
|
|
await readSseStream(response, (eventName, payload) => dispatchCompletionStreamEvent(handlers, eventName, payload));
|
|
}
|
|
|
|
export async function runQuickQuestionStream(
|
|
body: {
|
|
provider: Provider;
|
|
model: string;
|
|
question: string;
|
|
},
|
|
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/quick-questions/stream`, {
|
|
method: "POST",
|
|
headers,
|
|
body: JSON.stringify(body),
|
|
signal: options?.signal,
|
|
});
|
|
|
|
await readSseStream(response, (eventName, payload) => dispatchCompletionStreamEvent(handlers, eventName, payload));
|
|
}
|
|
|
|
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) => dispatchCompletionStreamEvent(handlers, eventName, payload));
|
|
}
|