UNPKG

react-native-ajora

Version:

The most complete AI agent UI for React Native

332 lines 14.3 kB
import { useEffect, useReducer, useRef } from "react"; import ApiService from "../api"; import uuid from "react-native-uuid"; import { mergeFunctionCallsAndResponses } from "../utils/index"; import { ajoraReducer } from "./ajoraReducer"; const useAjora = ({ initialMessages = {}, initialThreads = [], baseUrl = "http://localhost:3000", bearerToken, debug = false, }) => { const [ajora, dispatch] = useReducer(ajoraReducer, { stream: [], isThinking: false, messages: mergeFunctionCallsAndResponses(initialMessages), threads: initialThreads, activeThreadId: null, loadEarlier: false, isLoadingMessages: false, mode: "assistant", baseUrl, apiService: null, isComplete: true, attachement: undefined, isRecording: false, }); const apiServiceRef = useRef(null); const cleanupRef = useRef(null); const abortControllerRef = useRef(null); // Initialize the API service useEffect(() => { if (!apiServiceRef.current) { apiServiceRef.current = new ApiService({ baseUrl, bearerToken, debug }); } else { // Update existing API service with new config apiServiceRef.current.updateConfig({ bearerToken, debug }); } return () => { if (cleanupRef.current) cleanupRef.current(); }; }, [baseUrl, bearerToken, debug]); // Get the threads from the API service useEffect(() => { if (apiServiceRef.current) { getThreads(); } }, [apiServiceRef.current]); // Get the threads from the API service const getThreads = async () => { try { if (!apiServiceRef.current) { console.warn("[Ajora]: API service not initialized"); return; } const threads = await apiServiceRef.current.getThreads(); // Debug logs right after fetching threads try { const isArray = Array.isArray(threads); const length = isArray ? threads.length : 0; const sample = isArray ? threads[0] : threads; } catch (logErr) { console.warn("[Ajora]: Failed to log threads debug info", logErr); } dispatch({ type: "SET_THREADS", payload: { threads: threads ?? [] } }); } catch (error) { console.error("[Ajora]: Error fetching threads:", error); dispatch({ type: "SET_THREADS", payload: { threads: [] } }); } }; // Get the messages for the active thread const getMessages = async (threadId) => { try { if (!apiServiceRef.current) { console.warn("[Ajora]: API service not initialized"); return; } dispatch({ type: "SET_LOADING_MESSAGES", payload: { isLoading: true } }); const resp = await apiServiceRef.current.getMessages(threadId); const messages = resp?.messages; if (Array.isArray(messages)) { dispatch({ type: "SET_MESSAGES", payload: { messages: messages ?? [], threadId }, }); } } catch (error) { console.error("[Ajora]: Error fetching messages:", error); // Set empty messages array on error dispatch({ type: "SET_MESSAGES", payload: { messages: [], threadId }, }); } finally { dispatch({ type: "SET_LOADING_MESSAGES", payload: { isLoading: false } }); } }; // Auto-load messages when active thread changes and not yet loaded useEffect(() => { if (!ajora.activeThreadId) return; const threadId = ajora.activeThreadId; const hasMessages = (ajora.messages[threadId] || []).length > 0; if (!hasMessages) { getMessages(threadId); } }, [ajora.activeThreadId]); const submitQuery = async (query) => { let threadId = query.message.thread_id; // If no thread id, create a new thread and switch to it if (!threadId) { if (!apiServiceRef.current) { throw new Error("[Ajora]: API service not initialized."); } const newThread = await apiServiceRef.current.createThread(); if (!newThread?.id) { throw new Error("[Ajora]: Failed to create a new thread."); } threadId = newThread.id; // Ensure the new thread is available in state immediately dispatch({ type: "SET_THREADS", payload: { threads: [...(ajora.threads || []), newThread] }, }); // Switch to the newly created thread immediately dispatch({ type: "SWITCH_THREAD", payload: { threadId } }); } else { // If the thread id is not the active thread, switch to it if (threadId !== ajora.activeThreadId) { dispatch({ type: "SWITCH_THREAD", payload: { threadId } }); } } // Ensure outgoing message has the thread id query.message.thread_id = threadId; // Add the current mode to the query const queryWithMode = { ...query, mode: ajora.mode }; // If the query is a tool confirmation, do not add the message to the messages array as it already exists in the messages array if (query.type !== "tool_confirmation") { dispatch({ type: "ADD_MESSAGES", payload: { messages: [query.message] }, }); } // Do not set isComplete locally; rely on server 'complete' events dispatch({ type: "SET_THINKING", payload: { isThinking: true } }); try { if (!apiServiceRef.current) { throw new Error("[Ajora]: API service not initialized."); } // Abort any existing stream before starting a new one if (abortControllerRef.current) { try { abortControllerRef.current.abort(); } catch { } } abortControllerRef.current = new AbortController(); const cleanup = apiServiceRef.current.streamResponse(queryWithMode, { onIsThinking: (isThinking) => { dispatch({ type: "SET_THINKING", payload: { isThinking: isThinking.is_thinking }, }); }, onChunk: (agentEvent) => { if (agentEvent?.type === "message") { dispatch({ type: "UPDATE_STREAMING_MESSAGE", payload: { message: agentEvent.message }, }); } }, onFunctionResponse: (fr) => { const { message } = fr; if (!message._id || !message.thread_id) return; dispatch({ type: "ADD_FUNCTION_RESPONSE", payload: { message }, }); }, onThreadTitle: (tt) => { const incoming = tt.threadTitle; // Server may send a plain title string or a full Thread const normalizedThread = typeof incoming === "string" ? { id: threadId, title: incoming } : incoming && incoming.id ? incoming : null; if (!normalizedThread) return; dispatch({ type: "UPDATE_THREAD_TITLE", payload: { thread: normalizedThread }, }); }, onSources: (sources) => { console.info("[Ajora]: Sources received:", sources); }, onSuggestions: (suggestions) => { console.info("[Ajora]: Suggestions received:", suggestions); }, onComplete: (evt) => { const complete = evt?.is_complete === true; if (complete) { if (cleanupRef.current) { cleanupRef.current(); cleanupRef.current = null; } // Clear abort controller on normal completion abortControllerRef.current = null; } // Update completion state based on server signal dispatch({ type: "SET_COMPLETE", payload: { isComplete: complete } }); }, onError: (err) => { console.error("[Ajora]: Error in streaming:", err); const errorMessage = { _id: uuid.v4(), thread_id: threadId, role: "model", parts: [ { text: "An error occurred. Please try again.", }, ], createdAt: new Date().toISOString(), }; console.error("[Ajora]:", errorMessage); dispatch({ type: "ADD_MESSAGES", payload: { messages: [errorMessage] }, }); if (cleanupRef.current) { cleanupRef.current(); cleanupRef.current = null; } // Clear abort controller on error; server controls isComplete signal abortControllerRef.current = null; dispatch({ type: "SET_THINKING", payload: { isThinking: false } }); }, abortSignal: abortControllerRef.current.signal, }); cleanupRef.current = cleanup ?? null; } catch (error) { console.error("[Ajora]:", error); dispatch({ type: "SET_THINKING", payload: { isThinking: false } }); const errorMessage = { _id: uuid.v4(), role: "model", thread_id: threadId, parts: [{ text: "Failed to send message. Please try again." }], createdAt: new Date().toISOString(), }; dispatch({ type: "ADD_MESSAGES", payload: { messages: [errorMessage] }, }); } }; const stopStreaming = () => { // Signal abortion to the SSE connection if (abortControllerRef.current) { try { abortControllerRef.current.abort(); } catch { } abortControllerRef.current = null; } // Additionally run local cleanup to close EventSource and handlers if (cleanupRef.current) { cleanupRef.current(); cleanupRef.current = null; } dispatch({ type: "SET_THINKING", payload: { isThinking: false } }); // On client-initiated abort, server cannot send a final event after the stream is closed. // To exit streaming state, mark complete=true locally. dispatch({ type: "SET_COMPLETE", payload: { isComplete: true } }); }; const regenerateMessage = (message) => { if (!message._id || !ajora.activeThreadId) { throw new Error("[Ajora]: Message ID and thread ID are required to regenerate."); } const currentThreadMessages = ajora.messages[ajora.activeThreadId] || []; const messageIndex = currentThreadMessages.findIndex((m) => m._id === message._id); if (messageIndex === -1) { throw new Error("[Ajora]: Message not found."); } const userMessage = currentThreadMessages .slice(messageIndex) .find((m) => m.role === "user"); if (!userMessage) { throw new Error("[Ajora]: Could not find the corresponding user message to regenerate."); } dispatch({ type: "REMOVE_MESSAGE", payload: { messageId: message._id, threadId: ajora.activeThreadId }, }); dispatch({ type: "REMOVE_MESSAGE", payload: { messageId: userMessage._id, threadId: ajora.activeThreadId }, }); submitQuery({ type: "regenerate", message: userMessage, mode: ajora.mode }); }; // Compute messagesByThread from the current active thread const messagesByThread = ajora.activeThreadId ? ajora.messages[ajora.activeThreadId] || [] : []; return { ...ajora, messagesByThread, submitQuery, stopStreaming, addNewThread: () => dispatch({ type: "ADD_NEW_THREAD" }), switchThread: (threadId) => dispatch({ type: "SWITCH_THREAD", payload: { threadId } }), getThreads, getMessages, setIsThinking: (isThinking) => dispatch({ type: "SET_THINKING", payload: { isThinking } }), setIsLoadingEarlier: (loadEarlier) => dispatch({ type: "SET_LOADING_EARLIER", payload: { loadEarlier } }), setMode: (mode) => dispatch({ type: "SET_MODE", payload: { mode } }), regenerateMessage, setIsComplete: (isComplete) => dispatch({ type: "SET_COMPLETE", payload: { isComplete } }), setAttachement: (attachement) => dispatch({ type: "SET_ATTACHEMENT", payload: { attachement } }), updateAttachement: (attachement) => dispatch({ type: "UPDATE_ATTACHEMENT", payload: { attachement } }), clearAttachement: () => dispatch({ type: "CLEAR_ATTACHEMENT" }), setIsRecording: (isRecording) => dispatch({ type: "SET_IS_RECORDING", payload: { isRecording } }), }; }; export default useAjora; //# sourceMappingURL=useAjora.js.map