UNPKG

@gensx/react

Version:

React hooks and components for GenSX AI workflows.

352 lines (348 loc) 13.8 kB
/** * Check out the docs at https://www.gensx.com/docs * Find us on Github https://github.com/gensx-inc/gensx * Find us on Discord https://discord.gg/F5BSU8Kc */ import { useState, useRef, useCallback, startTransition, useMemo, useEffect } from 'react'; /** * Hook for interacting with GenSX workflows via your API endpoint * * @example * ```tsx * const workflow = useWorkflow({ * config: { * baseUrl: '/api/gensx', * }, * onEvent: (event) => console.log('Event:', event) * }); * * // Run the workflow * await workflow.run({ inputs: { userMessage: 'Hello' } }); * * // Stream strongly-typed objects * const currentProgress = useObject<ProgressEvent>(workflow.execution, 'progress'); * * // Process events as they come in * const allSteps = useEvents<StepEvent>(workflow.execution, 'step-completed', (step) => { * console.log('Step completed:', step); * }); * * // Clear workflow state * workflow.clear(); * ``` */ function useWorkflow(options) { const { config, tools, onStart, onComplete, onError, onEvent, outputTransformer, } = options; const { baseUrl, headers = {} } = config; // State const [inProgress, setInProgress] = useState(false); const [error, setError] = useState(null); const [output, setOutput] = useState(null); const [events, setEvents] = useState([]); // Refs const abortControllerRef = useRef(null); const outputRef = useRef(null); const accumulatedStringRef = useRef(""); const executionId = useRef(null); // Process a single WorkflowMessage event const processEvent = useCallback((event) => { // Batch all state updates for this event to prevent race conditions startTransition(async () => { // Add event to events array setEvents((prev) => [...prev, event]); // Fire the onEvent callback for all events onEvent?.(event); // Handle specific event types and fire callbacks switch (event.type) { case "start": executionId.current = event.workflowExecutionId ?? null; setInProgress(true); onStart?.(event.workflowName); break; case "data": // Handle streaming content from "data" events const content = typeof event.data === "string" ? event.data : JSON.stringify(event.data); // Accumulate content outside of state setter to avoid race conditions accumulatedStringRef.current += content; const currentAccumulatedString = accumulatedStringRef.current; // Process output transformation outside of state setter let newOutput = null; try { // Use custom transformer if provided if (outputTransformer) { newOutput = outputTransformer(currentAccumulatedString); } else if (currentAccumulatedString === "") { newOutput = null; } else { // Try to parse as JSON for complex types try { newOutput = JSON.parse(currentAccumulatedString); } catch { // If JSON parsing fails, return as string (for string output types) newOutput = currentAccumulatedString; } } } catch (error) { console.warn("Output transformation failed:", error); // Fallback to accumulated string newOutput = currentAccumulatedString; } // Update refs and state with the processed output outputRef.current = newOutput; setOutput(newOutput); break; case "event": // Handle simple workflow events if (event.label === "workflow-start") { setInProgress(true); } else if (event.label === "workflow-end") { setInProgress(false); } break; case "end": setInProgress(false); onComplete?.(outputRef.current); break; case "error": setError(event.error); setInProgress(false); onError?.(event.error); break; case "external-tool": if (!executionId.current) { console.error("[GenSX] Cannot resolve tool call, execution ID is not set."); break; } const toolImpl = tools?.[event.toolName]; // Handle external tool calls from workflow if (toolImpl) { const result = await toolImpl.execute(event.params); // Send this to the API const response = await fetch(`${baseUrl}/workflowExecutions/${executionId.current}/fulfill/${event.nodeId}`, { method: "POST", headers: { "Content-Type": "application/json", ...headers, }, body: JSON.stringify(result), }); if (!response.ok) { throw new Error(`Failed to resume workflow: ${response.status} ${response.statusText}`); } break; } console.warn("[GenSX] Tool implementation not found:", event.toolName); // If there is no tool implementation, return a well-known object as the result so the workflow can continue const response = await fetch(`${baseUrl}/workflowExecutions/${executionId.current}/fulfill/${event.nodeId}`, { method: "POST", headers: { "Content-Type": "application/json", ...headers, }, body: JSON.stringify({ __gensxMissingToolImplementation: true, toolName: event.toolName, }), }); if (!response.ok) { throw new Error(`Failed to resume workflow: ${response.status} ${response.statusText}`); } break; } }); }, [ onStart, onComplete, onError, onEvent, outputTransformer, tools, executionId, ]); // Parse streaming response const parseStream = useCallback(async (response) => { const reader = response.body?.getReader(); if (!reader) { throw new Error("No response body"); } const decoder = new TextDecoder(); let buffer = ""; try { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 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()) continue; try { const event = JSON.parse(line.trim()); processEvent(event); } catch (_e) { console.warn("Failed to parse event:", line); } } } // Process any remaining buffer if (buffer.trim()) { try { const event = JSON.parse(buffer); processEvent(event); } catch (_e) { console.warn("Failed to parse final event:", buffer); } } } finally { reader.releaseLock(); } }, [processEvent]); // Clear state const clear = useCallback(() => { setInProgress(false); setError(null); setOutput(null); setEvents([]); outputRef.current = null; accumulatedStringRef.current = ""; }, []); // Stop current workflow const stop = useCallback(() => { if (abortControllerRef.current) { abortControllerRef.current.abort(); abortControllerRef.current = null; } setInProgress(false); }, []); // Build request payload - just pass inputs since API route handles workflow config const buildPayload = useCallback((runConfig) => { return { ...runConfig.inputs, }; }, []); // Run workflow in streaming mode const start = useCallback(async (runConfig) => { // Reset state clear(); setInProgress(true); // Create abort controller abortControllerRef.current = new AbortController(); try { const response = await fetch(`${baseUrl}/start`, { method: "POST", headers: { "Content-Type": "application/json", ...headers, }, body: JSON.stringify(buildPayload(runConfig)), signal: abortControllerRef.current.signal, }); if (!response.ok) { throw new Error(`Failed to run workflow: ${response.status} ${response.statusText}`); } const { executionId: newExecutionId } = (await response.json()); executionId.current = newExecutionId; // Connect to progress events const progressResponse = await fetch(`${baseUrl}/workflowExecutions/${executionId.current}/progress`, { method: "POST", }); // Parse the stream await parseStream(progressResponse); // onComplete is already called in processEvent when 'end' event is received } catch (err) { const errorMessage = err instanceof Error ? err.message : "Unknown error"; setError(errorMessage); throw err; } finally { setInProgress(false); abortControllerRef.current = null; } }, [baseUrl, headers, clear, parseStream, buildPayload]); // Run workflow in streaming mode const run = useCallback(async (runConfig) => { // Reset state clear(); setInProgress(true); // Create abort controller abortControllerRef.current = new AbortController(); try { const response = await fetch(baseUrl, { method: "POST", headers: { "Content-Type": "application/json", ...headers, }, body: JSON.stringify(buildPayload(runConfig)), signal: abortControllerRef.current.signal, }); if (!response.ok) { throw new Error(`Failed to run workflow: ${response.status} ${response.statusText}`); } // Parse the stream await parseStream(response); // onComplete is already called in processEvent when 'end' event is received } catch (err) { const errorMessage = err instanceof Error ? err.message : "Unknown error"; setError(errorMessage); throw err; } finally { setInProgress(false); abortControllerRef.current = null; } }, [baseUrl, headers, clear, parseStream, buildPayload]); return { inProgress, error, output, execution: events, run, start, stop, clear, }; } // New hook to get all events by label from WorkflowMessage events function useEvents(events, label, onEvent) { const eventList = useMemo(() => { const list = []; for (const event of events) { if (event.type === "event" && event.label === label) { list.push(event.data); } } return list; }, [events, label]); // Track the last processed event index to prevent duplicate notifications const lastProcessedIndexRef = useRef(-1); // Call onEvent callback for new events only useEffect(() => { if (onEvent && eventList.length > 0) { // Only process events that haven't been processed yet for (let i = lastProcessedIndexRef.current + 1; i < eventList.length; i++) { onEvent(eventList[i]); } // Update the last processed index lastProcessedIndexRef.current = eventList.length - 1; } }, [eventList, onEvent]); return eventList; } export { useEvents, useWorkflow }; //# sourceMappingURL=use-gensx.js.map