diff --git a/libs/sdk-js/.gitignore b/libs/sdk-js/.gitignore index 8bb855c55..128c6dc98 100644 --- a/libs/sdk-js/.gitignore +++ b/libs/sdk-js/.gitignore @@ -10,6 +10,14 @@ react.cjs react.js react.d.ts react.d.cts +react-ui.cjs +react-ui.js +react-ui.d.ts +react-ui.d.cts +react-ui/server.cjs +react-ui/server.js +react-ui/server.d.ts +react-ui/server.d.cts node_modules dist .yarn diff --git a/libs/sdk-js/langchain.config.js b/libs/sdk-js/langchain.config.js index b74696d7b..21d796c87 100644 --- a/libs/sdk-js/langchain.config.js +++ b/libs/sdk-js/langchain.config.js @@ -11,7 +11,13 @@ function abs(relativePath) { export const config = { internals: [/react/], - entrypoints: { index: "index", client: "client", react: "react/index" }, + entrypoints: { + index: "index", + client: "client", + react: "react/index", + "react-ui": "react-ui/index", + "react-ui/server": "react-ui/server/index", + }, tsConfigPath: resolve("./tsconfig.json"), cjsSource: "./dist-cjs", cjsDestination: "./dist", diff --git a/libs/sdk-js/package.json b/libs/sdk-js/package.json index b5375664a..0c84c0676 100644 --- a/libs/sdk-js/package.json +++ b/libs/sdk-js/package.json @@ -1,13 +1,13 @@ { "name": "@langchain/langgraph-sdk", - "version": "0.0.46", + "version": "0.0.47", "description": "Client library for interacting with the LangGraph API", "type": "module", "packageManager": "yarn@1.22.19", "scripts": { "clean": "rm -rf dist/ dist-cjs/", "build": "yarn clean && yarn lc_build --create-entrypoints --pre --tree-shaking", - "prepublish": "yarn run build", + "prepack": "yarn run build", "format": "prettier --write src", "lint": "prettier --check src && tsc --noEmit", "test": "NODE_OPTIONS=--experimental-vm-modules jest --testPathIgnorePatterns=\\.int\\.test.ts", @@ -29,7 +29,8 @@ "@types/jest": "^29.5.12", "@types/node": "^20.12.12", "@types/uuid": "^9.0.1", - "@types/react": "18.3.2", + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", "concat-md": "^0.5.1", "jest": "^29.7.0", "prettier": "^3.2.5", @@ -37,7 +38,8 @@ "typedoc": "^0.27.7", "typedoc-plugin-markdown": "^4.4.2", "typescript": "^5.4.5", - "react": "^18.3.1" + "react": "^19.0.0", + "react-dom": "^19.0.0" }, "peerDependencies": { "react": "^18 || ^19", @@ -79,6 +81,24 @@ "import": "./react.js", "require": "./react.cjs" }, + "./react-ui": { + "types": { + "import": "./react-ui.d.ts", + "require": "./react-ui.d.cts", + "default": "./react-ui.d.ts" + }, + "import": "./react-ui.js", + "require": "./react-ui.cjs" + }, + "./react-ui/server": { + "types": { + "import": "./react-ui/server.d.ts", + "require": "./react-ui/server.d.cts", + "default": "./react-ui/server.d.ts" + }, + "import": "./react-ui/server.js", + "require": "./react-ui/server.cjs" + }, "./package.json": "./package.json" }, "files": [ @@ -94,6 +114,14 @@ "react.cjs", "react.js", "react.d.ts", - "react.d.cts" + "react.d.cts", + "react-ui.cjs", + "react-ui.js", + "react-ui.d.ts", + "react-ui.d.cts", + "react-ui/server.cjs", + "react-ui/server.js", + "react-ui/server.d.ts", + "react-ui/server.d.cts" ] } diff --git a/libs/sdk-js/src/react-ui/client.tsx b/libs/sdk-js/src/react-ui/client.tsx new file mode 100644 index 000000000..f30f442d5 --- /dev/null +++ b/libs/sdk-js/src/react-ui/client.tsx @@ -0,0 +1,251 @@ +import { useStream } from "../react/index.js"; +import type { UIMessage } from "./types.js"; + +import * as React from "react"; +import * as ReactDOM from "react-dom"; +import * as JsxRuntime from "react/jsx-runtime"; +import type { UseStream } from "../react/stream.js"; + +const UseStreamContext = React.createContext<{ + stream: ReturnType; + meta: unknown; +}>(null!); + +type BagTemplate = { + ConfigurableType?: Record; + InterruptType?: unknown; + CustomEventType?: unknown; + UpdateType?: unknown; + MetaType?: unknown; +}; + +type GetMetaType = Bag extends { MetaType: unknown } + ? Bag["MetaType"] + : unknown; + +interface UseStreamContext< + StateType extends Record = Record, + Bag extends BagTemplate = BagTemplate, +> extends UseStream { + meta?: GetMetaType; +} + +export function useStreamContext< + StateType extends Record = Record, + Bag extends { + ConfigurableType?: Record; + InterruptType?: unknown; + CustomEventType?: unknown; + UpdateType?: unknown; + MetaType?: unknown; + } = BagTemplate, +>(): UseStreamContext { + const ctx = React.useContext(UseStreamContext); + if (!ctx) { + throw new Error( + "useStreamContext must be used within a LoadExternalComponent", + ); + } + + return new Proxy(ctx, { + get(target, prop: keyof UseStreamContext) { + if (prop === "meta") return target.meta; + return target.stream[prop]; + }, + }) as unknown as UseStreamContext; +} + +interface ComponentTarget { + comp: React.FunctionComponent | React.ComponentClass; + target: HTMLElement; +} + +class ComponentStore { + private cache: Record = {}; + private boundCache: Record< + string, + { + subscribe: (onStoreChange: () => void) => () => void; + getSnapshot: () => ComponentTarget | undefined; + } + > = {}; + private callbacks: Record< + string, + (( + comp: React.FunctionComponent | React.ComponentClass, + el: HTMLElement, + ) => void)[] + > = {}; + + respond( + shadowRootId: string, + comp: React.FunctionComponent | React.ComponentClass, + targetElement: HTMLElement, + ) { + this.cache[shadowRootId] = { comp, target: targetElement }; + this.callbacks[shadowRootId]?.forEach((c) => c(comp, targetElement)); + } + + getBoundStore(shadowRootId: string) { + this.boundCache[shadowRootId] ??= { + subscribe: (onStoreChange: () => void) => { + this.callbacks[shadowRootId] ??= []; + this.callbacks[shadowRootId].push(onStoreChange); + return () => { + this.callbacks[shadowRootId] = this.callbacks[shadowRootId].filter( + (c) => c !== onStoreChange, + ); + }; + }, + getSnapshot: () => this.cache[shadowRootId], + }; + + 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; + + /** + * Map of components that can be rendered directly without fetching the UI code + * from the server. + */ + components?: Record; +} + +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, + meta, + fallback, + components, + ...props +}: LoadExternalComponentProps) { + const ref = React.useRef(null); + const id = React.useId(); + const shadowRootId = `child-shadow-${id}`; + + const store = React.useMemo( + () => COMPONENT_STORE.getBoundStore(shadowRootId), + [shadowRootId], + ); + const state = React.useSyncExternalStore(store.subscribe, store.getSnapshot); + + const clientComponent = components?.[message.name]; + const hasClientComponent = clientComponent != null; + + React.useEffect(() => { + if (hasClientComponent) return; + 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, hasClientComponent]); + + if (hasClientComponent) { + return React.createElement(clientComponent, message.content); + } + + return ( + <> +
+ + + {state?.target != null + ? ReactDOM.createPortal( + React.createElement(state.comp, message.content), + state.target, + ) + : fallback} + + + ); +} + +declare global { + interface Window { + [EXT_STORE_SYMBOL]: ComponentStore; + [REQUIRE_SYMBOL]: (name: string) => unknown; + } +} + +export function bootstrapUiContext() { + if (typeof window === "undefined") { + console.warn( + "Attempting to bootstrap UI context outside of browser environment. " + + "Avoid importing from `@langchain/langgraph-sdk/react-ui` in server context.", + ); + return; + } + + window[EXT_STORE_SYMBOL] = COMPONENT_STORE; + window[REQUIRE_SYMBOL] = (name: string) => { + if (name === "react") return React; + if (name === "react-dom") return ReactDOM; + if (name === "react/jsx-runtime") return JsxRuntime; + if (name === "@langchain/langgraph-sdk/react") return { useStream }; + if (name === "@langchain/langgraph-sdk/react-ui") { + return { + useStreamContext, + LoadExternalComponent: () => { + throw new Error("Nesting LoadExternalComponent is not supported"); + }, + }; + } + + throw new Error(`Unknown module...: ${name}`); + }; +} diff --git a/libs/sdk-js/src/react-ui/index.ts b/libs/sdk-js/src/react-ui/index.ts new file mode 100644 index 000000000..05de0860e --- /dev/null +++ b/libs/sdk-js/src/react-ui/index.ts @@ -0,0 +1,5 @@ +import { bootstrapUiContext } from "./client.js"; +bootstrapUiContext(); + +export { useStreamContext, LoadExternalComponent } from "./client.js"; +export type { UIMessage, RemoveUIMessage } from "./types.js"; diff --git a/libs/sdk-js/src/react-ui/server/index.ts b/libs/sdk-js/src/react-ui/server/index.ts new file mode 100644 index 000000000..45c2c1e78 --- /dev/null +++ b/libs/sdk-js/src/react-ui/server/index.ts @@ -0,0 +1,6 @@ +export { typedUi } from "./server.js"; +export { + uiMessageReducer, + type UIMessage, + type RemoveUIMessage, +} from "../types.js"; diff --git a/libs/sdk-js/src/react-ui/server/server.ts b/libs/sdk-js/src/react-ui/server/server.ts new file mode 100644 index 000000000..e62281484 --- /dev/null +++ b/libs/sdk-js/src/react-ui/server/server.ts @@ -0,0 +1,49 @@ +import { v4 as uuidv4 } from "uuid"; +import type { ComponentPropsWithoutRef, ElementType } from "react"; +import type { RemoveUIMessage, UIMessage } from "../types.js"; + +export const typedUi = >(config: { + writer?: (chunk: unknown) => void; + runId?: string; + metadata?: Record; + tags?: string[]; + runName?: string; +}) => { + type PropMap = { [K in keyof Decl]: ComponentPropsWithoutRef }; + let collect: (UIMessage | RemoveUIMessage)[] = []; + + const runId = (config.metadata?.run_id as string | undefined) ?? config.runId; + if (!runId) throw new Error("run_id is required"); + + const metadata = { + ...config.metadata, + tags: config.tags, + name: config.runName, + run_id: runId, + }; + + const create = ( + name: K, + props: PropMap[K], + ): UIMessage => ({ + type: "ui" as const, + id: uuidv4(), + name, + content: props, + additional_kwargs: metadata, + }); + + const remove = (id: string): RemoveUIMessage => ({ type: "remove-ui", id }); + + return { + create, + remove, + + collect, + write: (name: K, props: PropMap[K]) => { + const evt: UIMessage = create(name, props); + collect.push(evt); + config.writer?.(evt); + }, + }; +}; diff --git a/libs/sdk-js/src/react-ui/types.ts b/libs/sdk-js/src/react-ui/types.ts new file mode 100644 index 000000000..a0cb6279a --- /dev/null +++ b/libs/sdk-js/src/react-ui/types.ts @@ -0,0 +1,40 @@ +export interface UIMessage { + type: "ui"; + + id: string; + name: string; + content: Record; + additional_kwargs: { + run_id: string; + [key: string]: unknown; + }; +} + +export interface RemoveUIMessage { + type: "remove-ui"; + id: string; +} + +export function uiMessageReducer( + state: UIMessage[], + update: UIMessage | RemoveUIMessage | (UIMessage | RemoveUIMessage)[], +) { + const events = Array.isArray(update) ? update : [update]; + let newState = state.slice(); + + for (const event of events) { + if (event.type === "remove-ui") { + newState = newState.filter((ui) => ui.id !== event.id); + continue; + } + + const index = state.findIndex((ui) => ui.id === event.id); + if (index !== -1) { + newState[index] = event; + } else { + newState.push(event); + } + } + + return newState; +} diff --git a/libs/sdk-js/src/react/stream.tsx b/libs/sdk-js/src/react/stream.tsx index abe29ca5c..eb56f2284 100644 --- a/libs/sdk-js/src/react/stream.tsx +++ b/libs/sdk-js/src/react/stream.tsx @@ -482,7 +482,7 @@ interface UseStreamOptions< onThreadId?: (threadId: string) => void; } -interface UseStream< +export interface UseStream< StateType extends Record = Record, Bag extends BagTemplate = BagTemplate, > { diff --git a/libs/sdk-js/yarn.lock b/libs/sdk-js/yarn.lock index 635f0c5f3..68a384289 100644 --- a/libs/sdk-js/yarn.lock +++ b/libs/sdk-js/yarn.lock @@ -1005,17 +1005,16 @@ resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz#56e2cc26c397c038fab0e3a917a12d5c5909e901" integrity sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA== -"@types/prop-types@*": - version "15.7.14" - resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.14.tgz#1433419d73b2a7ebfc6918dcefd2ec0d5cd698f2" - integrity sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ== +"@types/react-dom@^19.0.3": + version "19.0.3" + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-19.0.3.tgz#0804dfd279a165d5a0ad8b53a5b9e65f338050a4" + integrity sha512-0Knk+HJiMP/qOZgMyNFamlIjw9OFCsyC2ZbigmEEyXXixgre6IQpm/4V+r3qH4GC1JPvRJKInw+on2rV6YZLeA== -"@types/react@18.3.2": - version "18.3.2" - resolved "https://registry.yarnpkg.com/@types/react/-/react-18.3.2.tgz#462ae4904973bc212fa910424d901e3d137dbfcd" - integrity sha512-Btgg89dAnqD4vV7R3hlwOxgqobUQKgx3MmrQRi0yYbs/P0ym8XozIAlkqVilPqHQwXs4e9Tf63rrCgl58BcO4w== +"@types/react@^19.0.8": + version "19.0.9" + resolved "https://registry.yarnpkg.com/@types/react/-/react-19.0.9.tgz#675255eb7d978bdaf71f9d08f6b740d41b0b7f32" + integrity sha512-FedNTYgmMwSZmD1Sru/W1gJKuiYCN/3SuBkmZkcxX+FpO5zL76B22A9YNfAKg4HQO3Neh/30AiynP6BELdU0qQ== dependencies: - "@types/prop-types" "*" csstype "^3.0.2" "@types/retry@0.12.0": @@ -2896,7 +2895,7 @@ js-tiktoken@^1.0.12: dependencies: base64-js "^1.5.1" -"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: +js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== @@ -2991,13 +2990,6 @@ longest-streak@^2.0.0: resolved "https://registry.yarnpkg.com/longest-streak/-/longest-streak-2.0.4.tgz#b8599957da5b5dab64dee3fe316fa774597d90e4" integrity sha512-vM6rUVCVUJJt33bnmHiZEvr7wPT78ztX7rojL+LW51bHtLh6HTjx84LA5W4+oa6aKEJA7jJu5LR6vQRBpA5DVg== -loose-envify@^1.1.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" - integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== - dependencies: - js-tokens "^3.0.0 || ^4.0.0" - lru-cache@^10.2.0: version "10.4.3" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119" @@ -3640,17 +3632,22 @@ quick-lru@^4.0.1: resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-4.0.1.tgz#5b8878f113a58217848c6482026c73e1ba57727f" integrity sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g== +react-dom@^19.0.0: + version "19.0.0" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.0.0.tgz#43446f1f01c65a4cd7f7588083e686a6726cfb57" + integrity sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ== + dependencies: + scheduler "^0.25.0" + react-is@^18.0.0: version "18.3.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e" integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg== -react@^18.3.1: - version "18.3.1" - resolved "https://registry.yarnpkg.com/react/-/react-18.3.1.tgz#49ab892009c53933625bd16b2533fc754cab2891" - integrity sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ== - dependencies: - loose-envify "^1.1.0" +react@^19.0.0: + version "19.0.0" + resolved "https://registry.yarnpkg.com/react/-/react-19.0.0.tgz#6e1969251b9f108870aa4bff37a0ce9ddfaaabdd" + integrity sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ== read-pkg-up@^7.0.1: version "7.0.1" @@ -3840,6 +3837,11 @@ safe-regex-test@^1.0.3: es-errors "^1.3.0" is-regex "^1.1.4" +scheduler@^0.25.0: + version "0.25.0" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.25.0.tgz#336cd9768e8cceebf52d3c80e3dcf5de23e7e015" + integrity sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA== + "semver@2 || 3 || 4 || 5": version "5.7.2" resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.2.tgz#48d55db737c3287cd4835e17fa13feace1c41ef8"