diff --git a/libs/sdk-js/src/react-ui/client.tsx b/libs/sdk-js/src/react-ui/client.tsx index af2e7f111..d2f633cdb 100644 --- a/libs/sdk-js/src/react-ui/client.tsx +++ b/libs/sdk-js/src/react-ui/client.tsx @@ -78,51 +78,88 @@ class ComponentStore { > = {}; respond( - name: string, + shadowRootId: string, comp: React.FunctionComponent | React.ComponentClass, targetElement: HTMLElement, ) { - this.cache[name] = { comp, target: targetElement }; - this.callbacks[name]?.forEach((c) => c(comp, targetElement)); + this.cache[shadowRootId] = { comp, target: targetElement }; + this.callbacks[shadowRootId]?.forEach((c) => c(comp, targetElement)); } - getBoundStore(name: string) { - this.boundCache[name] ??= { + getBoundStore(shadowRootId: string) { + this.boundCache[shadowRootId] ??= { subscribe: (onStoreChange: () => void) => { - this.callbacks[name] ??= []; - this.callbacks[name].push(onStoreChange); + this.callbacks[shadowRootId] ??= []; + this.callbacks[shadowRootId].push(onStoreChange); return () => { - this.callbacks[name] = this.callbacks[name].filter( + this.callbacks[shadowRootId] = this.callbacks[shadowRootId].filter( (c) => c !== onStoreChange, ); }; }, - getSnapshot: () => this.cache[name], + getSnapshot: () => this.cache[shadowRootId], }; - return this.boundCache[name]; + return this.boundCache[shadowRootId]; } } const COMPONENT_STORE = new ComponentStore(); +const COMPONENT_PROMISE_CACHE: Record | undefined> = {}; + const EXT_STORE_SYMBOL = Symbol.for("LGUI_EXT_STORE"); const REQUIRE_SYMBOL = Symbol.for("LGUI_REQUIRE"); +interface LoadExternalComponentProps + extends Pick, "style" | "className"> { + /** API URL of the LangGraph Platform */ + apiUrl?: string; + + /** ID of the assistant */ + assistantId: string; + + /** Stream of the assistant */ + stream: ReturnType; + + /** UI message to be rendered */ + message: UIMessage; + + /** Additional context to be passed to the child component */ + meta?: unknown; + + /** Fallback to be rendered when the component is loading */ + fallback?: React.ReactNode; +} + +function fetchComponent( + apiUrl: string, + assistantId: string, + agentName: string, +): Promise { + const cacheKey = `${apiUrl}-${assistantId}-${agentName}`; + if (COMPONENT_PROMISE_CACHE[cacheKey] != null) { + return COMPONENT_PROMISE_CACHE[cacheKey] as Promise; + } + + const request: Promise = fetch(`${apiUrl}/ui/${assistantId}`, { + headers: { Accept: "text/html", "Content-Type": "application/json" }, + method: "POST", + body: JSON.stringify({ name: agentName }), + }).then((a) => a.text()); + + COMPONENT_PROMISE_CACHE[cacheKey] = request; + return request; +} + export function LoadExternalComponent({ apiUrl = "http://localhost:2024", assistantId, stream, message, - className, meta, -}: { - apiUrl?: string; - assistantId: string; - stream: ReturnType; - message: UIMessage; - className?: string; - meta?: unknown; -}) { + fallback, + ...props +}: LoadExternalComponentProps) { const ref = React.useRef(null); const id = React.useId(); const shadowRootId = `child-shadow-${id}`; @@ -134,31 +171,30 @@ export function LoadExternalComponent({ const state = React.useSyncExternalStore(store.subscribe, store.getSnapshot); React.useEffect(() => { - fetch(`${apiUrl}/ui/${assistantId}`, { - headers: { "Content-Type": "application/json" }, - method: "POST", - body: JSON.stringify({ name: message.name, shadowRootId }), - }) - .then((a) => a.text()) - .then((html) => { - const dom = ref.current; - if (!dom) return; - const root = dom.shadowRoot ?? dom.attachShadow({ mode: "open" }); - const fragment = document.createRange().createContextualFragment(html); - root.appendChild(fragment); - }); + fetchComponent(apiUrl, assistantId, message.name).then((html) => { + const dom = ref.current; + if (!dom) return; + const root = dom.shadowRoot ?? dom.attachShadow({ mode: "open" }); + const fragment = document + .createRange() + .createContextualFragment( + html.replace("{{shadowRootId}}", shadowRootId), + ); + root.appendChild(fragment); + }); }, [apiUrl, assistantId, message.name, shadowRootId]); return ( <> -
+
- {state?.target && - ReactDOM.createPortal( - React.createElement(state.comp, message.content), - state.target, - )} + {state?.target != null + ? ReactDOM.createPortal( + React.createElement(state.comp, message.content), + state.target, + ) + : fallback} );