diff --git a/docs/docs/cloud/how-tos/generative_ui_react.md b/docs/docs/cloud/how-tos/generative_ui_react.md index 0c7a2e0cf..ab96c4a80 100644 --- a/docs/docs/cloud/how-tos/generative_ui_react.md +++ b/docs/docs/cloud/how-tos/generative_ui_react.md @@ -207,18 +207,6 @@ Behind the scenes, `LoadExternalComponent` will fetch the JS and CSS for the UI ## How-to guides -### Show loading UI when components are loading - -You can provide a fallback UI to be rendered when the components are loading. - -```tsx -Loading...} -/> -``` - ### Provide custom components on the client side If you already have the components loaded in your client application, you can provide a map of such components to be rendered directly without fetching the UI code from LangGraph Platform. @@ -235,6 +223,18 @@ const clientComponents = { />; ``` +### Show loading UI when components are loading + +You can provide a fallback UI to be rendered when the components are loading. + +```tsx +Loading...} +/> +``` + ### Customise the namespace of UI components. By default `LoadExternalComponent` will use the `assistantId` from `useStream()` hook to fetch the code for UI components. You can customise this by providing a `namespace` prop to the `LoadExternalComponent` component. @@ -316,9 +316,9 @@ const WeatherComponent = (props: { city: string }) => { }; ``` -### Streaming UI updates before the node execution is finished +### Streaming UI messages from the server -You can stream UI updates before the node execution is finished by using the `onCustomEvent` callback of the `useStream()` hook. +You can stream UI messages before the node execution is finished by using the `onCustomEvent` callback of the `useStream()` hook. This is especially useful when updating the UI component as the LLM is generating the response. ```tsx import { uiMessageReducer } from "@langchain/langgraph-sdk/react-ui"; @@ -335,6 +335,162 @@ const { thread, submit } = useStream({ }); ``` +Then you can pushing updates to the UI component by calling `ui.push()` / `push_ui_message()` with the same ID as the UI message you wish to update. + +=== "Python" + + ```python + from typing import Annotated, Sequence, TypedDict + + from langchain_anthropic import ChatAnthropic + from langchain_core.messages import AIMessage, AIMessageChunk, BaseMessage + from langgraph.graph import StateGraph + from langgraph.graph.message import add_messages + from langgraph.graph.ui import AnyUIMessage, push_ui_message, ui_message_reducer + + + class AgentState(TypedDict): # noqa: D101 + messages: Annotated[Sequence[BaseMessage], add_messages] + ui: Annotated[Sequence[AnyUIMessage], ui_message_reducer] + + + class CreateTextDocument(TypedDict): + """Prepare a document heading for the user.""" + + title: str + + + async def writer_node(state: AgentState): + model = ChatAnthropic(model="claude-3-5-sonnet-latest") + message: AIMessage = await model.bind_tools( + tools=[CreateTextDocument], + tool_choice={"type": "tool", "name": "CreateTextDocument"}, + ).ainvoke(state["messages"]) + + tool_call = next( + (x["args"] for x in message.tool_calls if x["name"] == "CreateTextDocument"), + None, + ) + + if tool_call: + ui_message = push_ui_message("writer", tool_call, message=message) + ui_message_id = ui_message["id"] + + # We're already streaming the LLM response to the client through UI messages + # so we don't need to stream it again to the `messages` stream mode. + content_stream = model.with_config({"tags": ["nostream"]}).astream( + f"Create a document with the title: {tool_call['title']}" + ) + + content: AIMessageChunk | None = None + async for chunk in content_stream: + content = content + chunk if content else chunk + + push_ui_message( + "writer", + {"content": content.text()}, + id=ui_message_id, + message=message, + # Use `merge=rue` to merge props with the existing UI message + merge=True, + ) + + return {"messages": [message]} + ``` + +=== "JS" + + ```tsx + import { + Annotation, + MessagesAnnotation, + type LangGraphRunnableConfig, + } from "@langchain/langgraph"; + import { z } from "zod"; + import { ChatAnthropic } from "@langchain/anthropic"; + import { + typedUi, + uiMessageReducer, + } from "@langchain/langgraph-sdk/react-ui/server"; + import type { AIMessageChunk } from "@langchain/core/messages"; + + import type ComponentMap from "./ui"; + + const AgentState = Annotation.Root({ + ...MessagesAnnotation.spec, + ui: Annotation({ reducer: uiMessageReducer, default: () => [] }), + }); + + async function writerNode( + state: typeof AgentState.State, + config: LangGraphRunnableConfig + ): Promise { + const ui = typedUi(config); + + const model = new ChatAnthropic({ model: "claude-3-5-sonnet-latest" }); + const message = await model + .bindTools( + [ + { + name: "create_text_document", + description: "Prepare a document heading for the user.", + schema: z.object({ title: z.string() }), + }, + ], + { tool_choice: { type: "tool", name: "create_text_document" } } + ) + .invoke(state.messages); + + type ToolCall = { name: "create_text_document"; args: { title: string } }; + const toolCall = message.tool_calls?.find( + (tool): tool is ToolCall => tool.name === "create_text_document" + ); + + if (toolCall) { + const { id, name } = ui.push( + { name: "writer", props: { title: toolCall.args.title } }, + { message } + ); + + const contentStream = await model + // We're already streaming the LLM response to the client through UI messages + // so we don't need to stream it again to the `messages` stream mode. + .withConfig({ tags: ["nostream"] }) + .stream(`Create a short poem with the topic: ${message.text}`); + + let content: AIMessageChunk | undefined; + for await (const chunk of contentStream) { + content = content?.concat(chunk) ?? chunk; + + ui.push( + { id, name, props: { content: content?.text } }, + // Use `merge: true` to merge props with the existing UI message + { message, merge: true } + ); + } + } + + return { messages: [message] }; + } + ``` + +=== "`ui.tsx`" + + ```tsx + function WriterComponent(props: { title: string; content?: string }) { + return ( +
+

{props.title}

+

{props.content}

+
+ ); + } + + export default { + weather: WriterComponent, + }; + ``` + ### Remove UI messages from state Similar to how messages can be removed from the state by appending a RemoveMessage you can remove an UI message from the state by calling `remove_ui_message` / `ui.delete` with the ID of the UI message. diff --git a/libs/sdk-js/src/react-ui/server/server.ts b/libs/sdk-js/src/react-ui/server/server.ts index 8349121d0..8eb14d17f 100644 --- a/libs/sdk-js/src/react-ui/server/server.ts +++ b/libs/sdk-js/src/react-ui/server/server.ts @@ -46,7 +46,7 @@ export const typedUi = >( metadata?: Record; }, options?: { message?: MessageLike; merge?: boolean }, - ): UIMessage; + ): UIMessage; function handlePush( message: { @@ -56,7 +56,7 @@ export const typedUi = >( metadata?: Record; }, options: { message?: MessageLike; merge: true }, - ): UIMessage; + ): UIMessage>; function handlePush( message: { @@ -66,8 +66,8 @@ export const typedUi = >( metadata?: Record; }, options?: { message?: MessageLike; merge?: boolean }, - ): UIMessage { - const evt: UIMessage = { + ): UIMessage> { + const evt: UIMessage> = { type: "ui" as const, id: message?.id ?? uuidv4(), name: message?.name, diff --git a/libs/sdk-js/src/react-ui/types.ts b/libs/sdk-js/src/react-ui/types.ts index 65efaa2b9..32bfe4188 100644 --- a/libs/sdk-js/src/react-ui/types.ts +++ b/libs/sdk-js/src/react-ui/types.ts @@ -1,9 +1,12 @@ -export interface UIMessage { +export interface UIMessage< + TName extends string = string, + TProps extends Record = Record, +> { type: "ui"; id: string; - name: string; - props: Record; + name: TName; + props: TProps; metadata: { merge?: boolean; run_id?: string;