@llamaindex/ui
Version:
A comprehensive UI component library built with React, TypeScript, and Tailwind CSS for LlamaIndex applications
297 lines (281 loc) • 9.37 kB
text/typescript
import {
Client,
EventEnvelopeWithMetadata,
Handler as RawHandler,
getHandlersByHandlerId,
postEventsByHandlerId,
postHandlersByHandlerIdCancel,
} from "@llamaindex/workflows-client";
import { RunStatus } from "../types";
import {
StreamOperation,
StreamSubscriber,
workflowStreamingManager,
} from "../../lib/shared-streaming";
import { logger } from "@shared/logger";
import { isStopEvent, StopEvent, WorkflowEvent } from "./workflow-event";
import { proxy } from "valtio";
import { getOrCreate } from "../../shared/store";
export interface HandlerState
extends Omit<
RawHandler,
"status" | "result" | "updated_at" | "completed_at"
> {
status: RunStatus;
updated_at?: Date;
completed_at?: Date;
result?: StopEvent;
// indicates that there is a current sync operation to query the handler state.
loading: boolean;
// indicates an error loading the actual handler state. See status "failed" and the error field for actual workflow run errors.
loadingError?: string;
}
const emptyState: HandlerState = {
handler_id: "",
workflow_name: "",
status: "not_started",
started_at: "",
updated_at: undefined,
completed_at: undefined,
error: "",
result: undefined,
loading: true,
loadingError: undefined,
};
export const createState = (
rawHandler: Partial<RawHandler> = {}
): HandlerState => {
const state = {
handler_id: rawHandler.handler_id ?? emptyState.handler_id,
workflow_name: rawHandler.workflow_name ?? emptyState.workflow_name,
status: rawHandler.status ?? emptyState.status,
started_at: rawHandler.started_at ?? emptyState.started_at,
updated_at: rawHandler.updated_at
? new Date(rawHandler.updated_at)
: emptyState.updated_at,
completed_at: rawHandler.completed_at
? new Date(rawHandler.completed_at)
: emptyState.completed_at,
error: rawHandler.error,
result: rawHandler.result
? (StopEvent.fromRawEvent(
rawHandler.result as EventEnvelopeWithMetadata
) as StopEvent)
: emptyState.result,
// use "status" field as a canary to indicate that this is a real response
loading: rawHandler.status ? false : emptyState.loading,
loadingError: undefined,
};
return proxy(state);
};
export function createActions(state: HandlerState, client: Client) {
const actions = {
async sendEvent(
event: WorkflowEvent | EventEnvelopeWithMetadata,
step?: string
) {
if (!state.handler_id) {
throw new Error("Handler ID is not yet initialized");
}
// convert to raw event before sending
const rawEvent =
event instanceof WorkflowEvent ? event.toRawEvent() : event;
const data = await postEventsByHandlerId({
client: client,
path: { handler_id: state.handler_id },
body: {
event: rawEvent,
step: step,
},
});
return data.data;
},
async sync() {
state.loading = true;
state.loadingError = undefined;
const resolvedHandlerId = state.handler_id;
if (!resolvedHandlerId) return;
try {
const data = await getHandlersByHandlerId({
client: client,
path: { handler_id: resolvedHandlerId },
});
const updated = createState(data.data ?? {});
Object.assign(state, updated);
} catch (error) {
state.loadingError =
error instanceof Error ? error.message : String(error);
} finally {
state.loading = false;
}
},
subscribeToEvents(
callbacks?: StreamSubscriber<WorkflowEvent>,
includeInternal = false
): StreamOperation<WorkflowEvent> {
if (!state.handler_id) {
throw new Error("Handler ID is not yet initialized");
}
const streamKey = `handler:${state.handler_id}`;
// Convert callback to SharedStreamingManager subscriber
// Be aware that all datetimes below are not synced with server, only client local state update
const subscriber: StreamSubscriber<WorkflowEvent> = {
onStart: () => {
state.status = "running";
callbacks?.onStart?.();
},
onData: (event) => {
state.updated_at = new Date();
callbacks?.onData?.(event);
},
onError: (error) => {
state.status = "failed";
state.completed_at = new Date();
state.updated_at = new Date();
state.error = error.message;
callbacks?.onError?.(error);
},
onSuccess: (events) => {
state.status = "completed";
state.completed_at = new Date();
state.updated_at = new Date();
state.result = events[events.length - 1] as StopEvent;
callbacks?.onSuccess?.(events);
},
onComplete: () => {
state.completed_at = new Date();
state.updated_at = new Date();
callbacks?.onComplete?.();
},
};
const canceler = async () => {
await postHandlersByHandlerIdCancel({
client: client,
path: {
handler_id: state.handler_id,
},
});
};
// Use SharedStreamingManager to handle the streaming with deduplication
const { promise, unsubscribe, disconnect, cancel } =
workflowStreamingManager.subscribe(
streamKey,
subscriber,
async (subscriber, signal) => {
return streamByEventSource(
{
client: client,
handlerId: state.handler_id,
includeInternal: includeInternal,
abortSignal: signal,
},
subscriber,
actions,
state
);
},
canceler
);
return { promise, unsubscribe, disconnect, cancel };
},
};
return actions;
}
function streamByEventSource(
params: {
client: Client;
handlerId: string;
includeInternal?: boolean;
abortSignal?: AbortSignal;
},
callbacks: StreamSubscriber<WorkflowEvent>,
actions: ReturnType<typeof createActions>,
state: HandlerState
) {
return new Promise<WorkflowEvent[]>((resolve) => {
const baseUrl = (params.client.getConfig().baseUrl ?? "").replace(
/\/$/,
""
);
const urlParams = new URLSearchParams();
urlParams.set("sse", "true");
if (params.includeInternal) {
urlParams.set("include_internal", "true");
}
const accumulatedEvents: WorkflowEvent[] = [];
const eventSource = new EventSource(
`${baseUrl}/events/${encodeURIComponent(params.handlerId)}?${urlParams.toString()}`,
{
withCredentials: true,
}
);
if (params.abortSignal) {
params.abortSignal.addEventListener("abort", () => {
eventSource.close();
});
}
eventSource.addEventListener("message", (event) => {
logger.debug("[streamByEventSource] message", JSON.parse(event.data));
const workflowEvent = WorkflowEvent.fromRawEvent(
JSON.parse(event.data) as EventEnvelopeWithMetadata
);
callbacks.onData?.(workflowEvent);
accumulatedEvents.push(workflowEvent);
if (isStopEvent(workflowEvent)) {
eventSource.close();
actions.sync().then(() => {
if (state.status === "completed") {
callbacks.onSuccess?.(accumulatedEvents);
} else if (state.status === "failed") {
callbacks.onError?.(new Error(state.error || "Server Error"));
} else if (state.status === "cancelled") {
callbacks.onCancel?.();
} else {
// This should never happen
throw new Error(
`[This should never happen] Unexpected running status: ${state.status}`
);
}
resolve(accumulatedEvents);
});
}
});
eventSource.addEventListener("error", (event) => {
// Ignore error for now due to EventSource limitations.
// 1. Now py server close sse connection and will always trigger error event even readyState is 2 (CLOSED)
// 2. The error event isself is a general event without any error information
// TODO: swtich to more fetch + stream approach
logger.warn("[streamByEventSource] error", event);
return;
});
eventSource.addEventListener("open", () => {
logger.debug("[streamByEventSource] open");
callbacks.onStart?.();
});
eventSource.addEventListener("close", () => {
logger.debug("[streamByEventSource] close");
callbacks.onSuccess?.(accumulatedEvents);
resolve(accumulatedEvents);
});
});
}
/**
* Get's the handler state from the global store or creates a new one if it doesn't exist, and optionally applies an update
* @param update
* @returns The updated handler state
*/
export function getOrCreateHandler(update: Partial<RawHandler>): HandlerState {
const current = getOrCreate(`handler:${update.handler_id}`, () =>
createState(update)
);
return applyUpdateToHandler(current, update);
}
export function applyUpdateToHandler(
state: HandlerState,
update: Partial<RawHandler>
): HandlerState {
const updated = createState(update);
// mutate existing state instead of creating a new one to maintain global singleton
Object.assign(state, updated);
return state;
}