react-native-ajora
Version:
The most complete AI agent UI for React Native
298 lines • 11.8 kB
JavaScript
import EventSource from "react-native-sse";
export class ApiService {
constructor(config) {
this.eventSource = null;
this.config = {
baseUrl: config.baseUrl,
bearerToken: config.bearerToken,
debug: config.debug ?? false,
};
}
get apiBase() {
const trimmed = this.config.baseUrl.replace(/\/$/, "");
// If the base already contains an /api segment anywhere (e.g., /api, /api/v3, /api/v3/agent),
// do NOT append another /api
if (/\/api(\/|$)/.test(trimmed)) {
return trimmed;
}
return `${trimmed}/api`;
}
// Stream should use the same API base namespace (e.g., /api/v3/agent)
get streamBase() {
return this.apiBase;
}
/**
* Stream a response from the API using Server-Sent Events
*/
streamResponse(query, options) {
const { onOpen, onChunk, onFunctionResponse, onThreadTitle, onSources, onSuggestions, onIsThinking, onError, onComplete, abortSignal, } = options;
try {
// If already aborted before starting, no-op and let caller handle state
if (abortSignal?.aborted) {
console.info("[Ajora]: Abort signal already set before opening SSE");
return () => { };
}
// Convert query object to URL search parameters
const queryParams = new URLSearchParams();
queryParams.append("type", query.type);
queryParams.append("message", JSON.stringify(query.message));
if ("mode" in query && query.mode) {
queryParams.append("mode", query.mode);
}
// Prepare headers for EventSource
const headers = {};
if (this.config.bearerToken) {
headers.Authorization = {
toString: function () {
return "Bearer " + this.token;
},
token: this.config.bearerToken,
};
}
const url = `${this.streamBase}/stream?${queryParams.toString()}`;
this.eventSource = new EventSource(url, {
pollingInterval: 0,
debug: this.config.debug,
headers,
});
// Hook abort signal to close the SSE connection
let abortHandler = null;
if (abortSignal) {
abortHandler = () => {
console.info("[Ajora]: Abort received. Closing SSE connection.");
this.close();
};
abortSignal.addEventListener("abort", abortHandler);
}
this.eventSource.addEventListener("open", () => {
console.info("[Ajora]: SSE connection opened");
onOpen?.();
});
this.eventSource.addEventListener("message", (event) => {
try {
if (!event.data)
throw new Error("Empty SSE event data");
const agentEvent = JSON.parse(event.data);
switch (agentEvent.type) {
case "thread_title":
return onThreadTitle(agentEvent);
case "complete":
console.log("[Ajora]: Complete event received:", agentEvent);
onComplete(agentEvent);
return;
case "sources":
return onSources(agentEvent);
case "suggestions":
return onSuggestions(agentEvent);
case "function_response":
return onFunctionResponse(agentEvent);
case "error":
this.close();
return onError(agentEvent);
case "is_thinking":
return onIsThinking(agentEvent);
case "message":
return onChunk(agentEvent);
default:
console.warn("[Ajora]: Unknown SSE event type:", agentEvent);
return;
}
}
catch (parseError) {
console.error("[Ajora]: Failed to parse SSE data:", parseError);
this.close();
onError({
type: "error",
error: {
thread_id: "",
message_id: "",
error: "Failed to parse server response",
},
});
}
});
this.eventSource.addEventListener("error", (event) => {
console.error("[Ajora]: SSE connection error:", event);
this.close();
onError({
type: "error",
error: {
thread_id: "",
message_id: "",
error: event?.message || "SSE connection failed",
},
});
});
return () => {
// Clean up abort listener first to avoid duplicate close calls
if (abortSignal && abortHandler) {
try {
abortSignal.removeEventListener("abort", abortHandler);
}
catch { }
}
this.close();
};
}
catch (error) {
console.error("[Ajora]: Error creating SSE connection:", error);
onError({
type: "error",
error: {
thread_id: "",
message_id: "",
error: "Failed to create connection",
},
});
return () => { };
}
}
// Thread endpoints
getThreads() {
const headers = { "Cache-Control": "no-cache" };
if (this.config.bearerToken) {
headers.Authorization = `Bearer ${this.config.bearerToken}`;
}
// Cache-bust to avoid 304/empty bodies on some platforms (okhttp)
const url = `${this.apiBase}/threads?_=${Date.now()}`;
return fetch(url, { headers })
.then(async (res) => {
if (!res.ok) {
console.warn("[Ajora]: getThreads non-OK status", res.status);
return [];
}
return res.json();
})
.then((json) => {
try {
// Debug the raw payload to help diagnose shape mismatches
// If the server already returns an array of threads, pass through
if (Array.isArray(json)) {
return json.map((t) => ({
id: t.id ?? t._id,
title: t.title ?? "New Conversation",
createdAt: t.createdAt || t.created_at,
updatedAt: t.updatedAt || t.updated_at,
}));
}
// If the server wraps the array under a `data` key
if (json && Array.isArray(json.data)) {
return json.data.map((t) => ({
id: t.id ?? t._id,
title: t.title ?? "New Conversation",
createdAt: t.createdAt || t.created_at,
updatedAt: t.updatedAt || t.updated_at,
}));
}
// Unknown shape; return empty list to avoid runtime errors
console.warn("[Ajora]: Unexpected threads response shape");
return [];
}
catch (e) {
console.warn("[Ajora]: Failed to normalize threads response", e);
return [];
}
});
}
createThread(title) {
const headers = {
"Content-Type": "application/json",
Accept: "application/json",
"Cache-Control": "no-cache",
};
if (this.config.bearerToken) {
headers.Authorization = `Bearer ${this.config.bearerToken}`;
}
const url = `${this.apiBase}/threads`;
return fetch(url, {
method: "POST",
headers,
body: JSON.stringify({ title }),
})
.then((res) => res.json())
.then((json) => {
try {
const t = json && json.data ? json.data : json;
if (!t) {
throw new Error("Empty thread response");
}
const normalized = {
id: t.id ?? t._id,
title: t.title ?? "New Conversation",
createdAt: t.createdAt || t.created_at,
updatedAt: t.updatedAt || t.updated_at,
};
return normalized;
}
catch (e) {
console.warn("[Ajora]: Failed to normalize createThread response", e);
return { id: "", title: "New Conversation" };
}
});
}
close() {
if (this.eventSource) {
try {
this.eventSource.close();
}
catch { }
this.eventSource = null;
}
}
// Message endpoints
getMessages(threadId, limit, offset) {
const headers = {};
if (this.config.bearerToken) {
headers.Authorization = `Bearer ${this.config.bearerToken}`;
}
// Build query parameters
const params = new URLSearchParams();
if (limit !== undefined)
params.append("limit", limit.toString());
if (offset !== undefined)
params.append("offset", offset.toString());
// Cache-bust parameter
params.append("_", Date.now().toString());
const url = `${this.apiBase}/threads/${threadId}/messages${params.toString() ? `?${params.toString()}` : ""}`;
console.info("[Ajora]: getMessages request", {
url,
threadId,
limit,
offset,
});
return fetch(url, { headers }).then(async (res) => {
if (!res.ok) {
console.warn("[Ajora]: getMessages non-OK status", res.status, url);
return {
messages: [],
pagination: { total: 0, offset: offset || 0, hasMore: false },
};
}
const data = await res.json();
console.log("[Ajora]: Retrieved messages:", JSON.stringify(data, null, 2));
// Normalize message format to handle both old and new field names
if (data.messages && Array.isArray(data.messages)) {
data.messages = data.messages.map((message) => ({
...message,
// Normalize createdAt/updatedAt fields
createdAt: message.createdAt || message.created_at,
updatedAt: message.updatedAt || message.updated_at,
}));
}
return data;
});
}
updateConfig(newConfig) {
this.config = { ...this.config, ...newConfig };
}
getConfig() {
return { ...this.config };
}
}
const DEFAULT_CONFIG = {
baseUrl: "http://localhost:4000",
};
export const defaultApiService = new ApiService(DEFAULT_CONFIG);
export const streamResponse = defaultApiService.streamResponse.bind(defaultApiService);
export default ApiService;
//# sourceMappingURL=api.js.map