mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-10-19 01:50:51 -05:00
241 lines
6.6 KiB
TypeScript
241 lines
6.6 KiB
TypeScript
/**
|
|
* API Service - Handles all communication with Onyx backend
|
|
*/
|
|
|
|
import {
|
|
Packet,
|
|
CreateSessionRequest,
|
|
CreateSessionResponse,
|
|
SendMessageRequest,
|
|
} from "@/types/api-types";
|
|
import { TokenProvider } from "@/types/widget-types";
|
|
|
|
export class ApiService {
|
|
private maxRetries = 3;
|
|
private retryDelay = 1000;
|
|
|
|
constructor(
|
|
private backendUrl: string,
|
|
private resolveToken: TokenProvider
|
|
) {}
|
|
|
|
/**
|
|
* Create a new chat session
|
|
*/
|
|
async createChatSession(agentId?: number): Promise<string> {
|
|
const request: CreateSessionRequest = {};
|
|
if (agentId !== undefined) {
|
|
request.persona_id = agentId;
|
|
}
|
|
|
|
const response = await this.fetchWithRetry(
|
|
`${this.backendUrl}/chat/create-chat-session`,
|
|
async () => ({
|
|
method: "POST",
|
|
headers: await this.getHeaders(),
|
|
body: JSON.stringify(request),
|
|
})
|
|
);
|
|
|
|
if (!response.ok) {
|
|
let detail = response.statusText;
|
|
try {
|
|
const body = await response.json();
|
|
if (body.detail) {
|
|
detail = body.detail;
|
|
}
|
|
} catch {
|
|
// Fall back to statusText if body isn't JSON
|
|
}
|
|
throw new Error(detail);
|
|
}
|
|
|
|
const data = (await response.json()) as CreateSessionResponse;
|
|
return data.chat_session_id;
|
|
}
|
|
|
|
/**
|
|
* Stream a message to the chat
|
|
* Returns an async generator of packets
|
|
*/
|
|
async *streamMessage(params: {
|
|
message: string;
|
|
chatSessionId: string;
|
|
parentMessageId?: number | null;
|
|
signal?: AbortSignal;
|
|
includeCitations?: boolean;
|
|
}): AsyncGenerator<Packet, void, unknown> {
|
|
const request: SendMessageRequest = {
|
|
message: params.message,
|
|
chat_session_id: params.chatSessionId,
|
|
parent_message_id: params.parentMessageId ?? null,
|
|
origin: "widget",
|
|
include_citations: params.includeCitations ?? false,
|
|
};
|
|
|
|
const response = await this.fetchWithRetry(
|
|
`${this.backendUrl}/chat/send-chat-message`,
|
|
async () => ({
|
|
method: "POST",
|
|
headers: await this.getHeaders(),
|
|
body: JSON.stringify(request),
|
|
signal: params.signal,
|
|
})
|
|
);
|
|
|
|
if (!response.ok) {
|
|
let detail = response.statusText;
|
|
try {
|
|
const body = await response.json();
|
|
if (body.detail) {
|
|
detail = body.detail;
|
|
}
|
|
} catch {
|
|
// Fall back to statusText if body isn't JSON
|
|
}
|
|
throw new Error(detail);
|
|
}
|
|
|
|
// Parse SSE stream
|
|
yield* this.parseSSEStream(response);
|
|
}
|
|
|
|
/**
|
|
* Parse Server-Sent Events stream
|
|
* Backend returns newline-delimited JSON packets
|
|
*/
|
|
private async *parseSSEStream(
|
|
response: Response
|
|
): AsyncGenerator<Packet, void, unknown> {
|
|
const reader = response.body?.getReader();
|
|
if (!reader) {
|
|
throw new Error("Response body is not readable");
|
|
}
|
|
|
|
const decoder = new TextDecoder();
|
|
let buffer = "";
|
|
|
|
try {
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
|
|
buffer += decoder.decode(value, { stream: true });
|
|
const lines = buffer.split("\n");
|
|
buffer = lines.pop() || ""; // Keep incomplete line in buffer
|
|
|
|
for (const line of lines) {
|
|
if (line.trim()) {
|
|
try {
|
|
const rawData = JSON.parse(line);
|
|
|
|
// Check if this is a MessageResponseIDInfo (not wrapped in Packet)
|
|
if (
|
|
"user_message_id" in rawData &&
|
|
"reserved_assistant_message_id" in rawData
|
|
) {
|
|
// Wrap it in a Packet structure for consistent handling
|
|
const packet: Packet = {
|
|
obj: rawData as any,
|
|
};
|
|
yield packet;
|
|
} else {
|
|
// Regular packet with placement and obj
|
|
yield rawData as Packet;
|
|
}
|
|
} catch (e) {
|
|
// Fail fast on malformed packets - don't hide backend issues
|
|
throw new Error(
|
|
`Failed to parse SSE packet: ${line}. Error: ${e}`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Process any remaining data in buffer
|
|
if (buffer.trim()) {
|
|
try {
|
|
const rawData = JSON.parse(buffer);
|
|
|
|
// Check if this is a MessageResponseIDInfo (not wrapped in Packet)
|
|
if (
|
|
"user_message_id" in rawData &&
|
|
"reserved_assistant_message_id" in rawData
|
|
) {
|
|
const packet: Packet = {
|
|
obj: rawData as any,
|
|
};
|
|
yield packet;
|
|
} else {
|
|
yield rawData as Packet;
|
|
}
|
|
} catch (e) {
|
|
// Fail fast on malformed final buffer packets
|
|
throw new Error(
|
|
`Failed to parse final packet: ${buffer}. Error: ${e}`
|
|
);
|
|
}
|
|
}
|
|
} finally {
|
|
reader.releaseLock();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch with retry logic for network failures and 5xx errors.
|
|
* Options are rebuilt per attempt so each retry carries a freshly resolved
|
|
* credential; a short-lived token can otherwise expire during the backoff.
|
|
*/
|
|
private async fetchWithRetry(
|
|
url: string,
|
|
buildOptions: () => Promise<RequestInit>,
|
|
retries = 0
|
|
): Promise<Response> {
|
|
try {
|
|
const response = await fetch(url, await buildOptions());
|
|
|
|
// Retry on 5xx errors only (not 4xx — those are permanent)
|
|
if (!response.ok && retries < this.maxRetries) {
|
|
if (response.status >= 500) {
|
|
const delay = this.retryDelay * Math.pow(2, retries);
|
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
return this.fetchWithRetry(url, buildOptions, retries + 1);
|
|
}
|
|
}
|
|
|
|
return response;
|
|
} catch (error) {
|
|
// Don't retry if the request was aborted by the caller
|
|
if (error instanceof Error && error.name === "AbortError") {
|
|
throw error;
|
|
}
|
|
|
|
// Retry on network errors
|
|
if (retries < this.maxRetries) {
|
|
const delay = this.retryDelay * Math.pow(2, retries);
|
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
return this.fetchWithRetry(url, buildOptions, retries + 1);
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get common headers for API requests.
|
|
* The credential is resolved per call so an expiring token can be refreshed
|
|
* by the host between requests.
|
|
*/
|
|
private async getHeaders(): Promise<Record<string, string>> {
|
|
const token = await this.resolveToken();
|
|
if (!token) {
|
|
throw new Error("Onyx credential resolved to an empty value");
|
|
}
|
|
|
|
return {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${token}`,
|
|
};
|
|
}
|
|
}
|