UNPKG

react-native-ajora

Version:

The most complete AI agent UI for React Native

456 lines (407 loc) 13 kB
import EventSource from "react-native-sse"; import { IMessage } from "./types"; import { SourceProps } from "./source/types"; import { SuggestionProps } from "./suggestion/types"; import { Thread } from "./Thread/types"; export interface ApiConfig { baseUrl: string; bearerToken?: string; debug?: boolean; } // UserEvent types to the API export type UserEvent = | { type: "text"; message: IMessage; mode?: string; } | { type: "attachement"; message: IMessage; mode?: string; } | { type: "function_response"; message: IMessage; mode?: string; } | { type: "regenerate"; message: IMessage; mode?: string; } | { type: "tool_confirmation"; message: IMessage }; // AgentEvent types from the API export interface MessageEvent { type: "message"; message: IMessage; } export interface FunctionResponseEvent { type: "function_response"; message: IMessage; } export interface ThreadTitleEvent { type: "thread_title"; // Server may send just a title string or a full Thread object threadTitle: string | Thread; } export interface SourcesEvent { type: "sources"; sources: SourceProps[]; } export interface SuggestionsEvent { type: "suggestions"; suggestions: SuggestionProps[]; } export interface IsThinkingEvent { type: "is_thinking"; is_thinking: boolean; } export interface CompleteEvent { type: "complete"; is_complete: boolean; } export interface ErrorEvent { type: "error"; error: { thread_id: string; message_id: string; error: string; }; } export type AgentEvent = | MessageEvent | FunctionResponseEvent | ThreadTitleEvent | SourcesEvent | SuggestionsEvent | IsThinkingEvent | CompleteEvent | ErrorEvent; export interface StreamResponseOptions { onOpen?: () => void; onChunk: (chunk: AgentEvent) => void; onFunctionResponse: (functionResponse: FunctionResponseEvent) => void; onThreadTitle: (threadTitle: ThreadTitleEvent) => void; onSources: (sources: SourcesEvent) => void; onSuggestions: (suggestions: SuggestionsEvent) => void; onComplete: (complete: AgentEvent) => void; onIsThinking: (isThinking: IsThinkingEvent) => void; onError: (error: ErrorEvent) => void; abortSignal?: AbortSignal; } export class ApiService { private config: ApiConfig; private eventSource: EventSource | null = null; constructor(config: ApiConfig) { this.config = { baseUrl: config.baseUrl, bearerToken: config.bearerToken, debug: config.debug ?? false, }; } private get apiBase(): string { 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) private get streamBase(): string { return this.apiBase; } /** * Stream a response from the API using Server-Sent Events */ streamResponse(query: UserEvent, options: StreamResponseOptions): () => void { 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: Record<string, any> = {}; 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: (() => void) | null = 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: AgentEvent = JSON.parse(event.data); switch (agentEvent.type) { case "thread_title": return onThreadTitle(agentEvent as ThreadTitleEvent); case "complete": console.log("[Ajora]: Complete event received:", agentEvent); onComplete(agentEvent); return; case "sources": return onSources(agentEvent as SourcesEvent); case "suggestions": return onSuggestions(agentEvent as SuggestionsEvent); case "function_response": return onFunctionResponse(agentEvent as FunctionResponseEvent); case "error": this.close(); return onError(agentEvent as ErrorEvent); case "is_thinking": return onIsThinking(agentEvent as IsThinkingEvent); case "message": return onChunk(agentEvent as MessageEvent); 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: any) => { 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(): Promise<Thread[]> { const headers: Record<string, string> = { "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 [] as Thread[]; } 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 as any[]).map((t) => ({ id: t.id ?? t._id, title: t.title ?? "New Conversation", createdAt: t.createdAt || t.created_at, updatedAt: t.updatedAt || t.updated_at, })) as Thread[]; } // If the server wraps the array under a `data` key if (json && Array.isArray(json.data)) { return (json.data as any[]).map((t) => ({ id: t.id ?? t._id, title: t.title ?? "New Conversation", createdAt: t.createdAt || t.created_at, updatedAt: t.updatedAt || t.updated_at, })) as Thread[]; } // Unknown shape; return empty list to avoid runtime errors console.warn("[Ajora]: Unexpected threads response shape"); return [] as Thread[]; } catch (e) { console.warn("[Ajora]: Failed to normalize threads response", e); return [] as Thread[]; } }); } createThread(title?: string): Promise<Thread> { const headers: Record<string, string> = { "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: Thread = { id: (t as any).id ?? (t as any)._id, title: (t as any).title ?? "New Conversation", createdAt: (t as any).createdAt || (t as any).created_at, updatedAt: (t as any).updatedAt || (t as any).updated_at, } as Thread; return normalized; } catch (e) { console.warn("[Ajora]: Failed to normalize createThread response", e); return { id: "", title: "New Conversation" } as Thread; } }); } private close(): void { if (this.eventSource) { try { this.eventSource.close(); } catch {} this.eventSource = null; } } // Message endpoints getMessages( threadId: string, limit?: number, offset?: number ): Promise<{ messages: IMessage[]; pagination: { total: number; limit?: number; offset: number; hasMore: boolean; }; }> { const headers: Record<string, string> = {}; 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: any) => ({ ...message, // Normalize createdAt/updatedAt fields createdAt: message.createdAt || message.created_at, updatedAt: message.updatedAt || message.updated_at, })); } return data; }); } updateConfig(newConfig: Partial<ApiConfig>): void { this.config = { ...this.config, ...newConfig }; } getConfig(): ApiConfig { return { ...this.config }; } } const DEFAULT_CONFIG: ApiConfig = { baseUrl: "http://localhost:4000", }; export const defaultApiService = new ApiService(DEFAULT_CONFIG); export const streamResponse = defaultApiService.streamResponse.bind(defaultApiService); export default ApiService;