diff --git a/README.md b/README.md index 09bb71a..6a0a74e 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,67 @@ return { messages: [result] }; This approach guarantees the message remains completely hidden from the user interface. +## Rendering Artifacts + +The Agent Chat UI supports rendering artifacts in the chat. Artifacts are rendered in a side panel to the right of the chat. To render an artifact, you can obtain the artifact context from the `thread.meta.artifact` field. Here's a sample utility hook for obtaining the artifact context: + +```tsx +export function useArtifact>() { + type Component = (props: { + children: React.ReactNode; + title?: React.ReactNode; + }) => React.ReactNode; + + type Context = TContext | undefined; + + type Bag = { + open: boolean; + setOpen: (value: boolean | ((prev: boolean) => boolean)) => void; + + context: Context; + setContext: (value: Context | ((prev: Context) => Context)) => void; + }; + + const thread = useStreamContext< + { messages: Message[]; ui: UIMessage[] }, + { MetaType: { artifact: [Component, Bag] } } + >(); + + return thread.meta?.artifact; +} +``` + +After which you can render additional content using the `Artifact` component from the `useArtifact` hook: + +```tsx +import { useArtifact } from "../utils/use-artifact"; +import { LoaderIcon } from "lucide-react"; + +export function Writer(props: { + title?: string; + content?: string; + description?: string; +}) { + const [Artifact, { open, setOpen }] = useArtifact(); + + return ( + <> +
setOpen(!open)} + className="cursor-pointer rounded-lg border p-4" + > +

{props.title}

+

{props.description}

+
+ + +

{props.content}

+
+ + ); +} +``` + ## Going to Production Once you're ready to go to production, you'll need to update how you connect, and authenticate requests to your deployment. By default, the Agent Chat UI is setup for local development, and connects to your LangGraph server directly from the client. This is not possible if you want to go to production, because it requires every user to have their own LangSmith API key, and set the LangGraph configuration themselves. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c1d433c..d160665 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4658,7 +4658,9 @@ snapshots: '@langchain/core': 0.3.56(openai@4.100.0(ws@8.18.2)(zod@3.24.4)) uuid: 10.0.0 + '@langchain/langgraph-sdk@0.0.73(@langchain/core@0.3.56(openai@4.100.0(ws@8.18.2)(zod@3.24.4)))(react@19.1.0)': + dependencies: '@types/json-schema': 7.0.15 p-queue: 6.6.2 @@ -4670,9 +4672,11 @@ snapshots: '@langchain/langgraph@0.2.72(@langchain/core@0.3.56(openai@4.100.0(ws@8.18.2)(zod@3.24.4)))(react@19.1.0)(zod-to-json-schema@3.24.5(zod@3.24.4))': dependencies: + '@langchain/core': 0.3.56(openai@4.100.0(ws@8.18.2)(zod@3.24.4)) '@langchain/langgraph-checkpoint': 0.0.17(@langchain/core@0.3.56(openai@4.100.0(ws@8.18.2)(zod@3.24.4))) '@langchain/langgraph-sdk': 0.0.73(@langchain/core@0.3.56(openai@4.100.0(ws@8.18.2)(zod@3.24.4)))(react@19.1.0) + uuid: 10.0.0 zod: 3.24.4 optionalDependencies: diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 234f21d..af31f8e 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -11,8 +11,8 @@ const inter = Inter({ }); export const metadata: Metadata = { - title: "Agent Inbox", - description: "Agent Inbox UX by LangChain", + title: "Agent Chat", + description: "Agent Chat UX by LangChain", }; export default function RootLayout({ diff --git a/src/app/page.tsx b/src/app/page.tsx index e84940a..19675de 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -3,6 +3,7 @@ import { Thread } from "@/components/thread"; import { StreamProvider } from "@/providers/Stream"; import { ThreadProvider } from "@/providers/Thread"; +import { ArtifactProvider } from "@/components/thread/artifact"; import { Toaster } from "@/components/ui/sonner"; import React from "react"; @@ -12,7 +13,9 @@ export default function DemoPage(): React.ReactNode { - + + + diff --git a/src/components/thread/artifact.tsx b/src/components/thread/artifact.tsx new file mode 100644 index 0000000..0497b2a --- /dev/null +++ b/src/components/thread/artifact.tsx @@ -0,0 +1,189 @@ +import { + HTMLAttributes, + ReactNode, + createContext, + useCallback, + useContext, + useEffect, + useId, + useLayoutEffect, + useRef, + useState, +} from "react"; +import { createPortal } from "react-dom"; + +type Setter = (value: T | ((value: T) => T)) => void; + +const ArtifactSlotContext = createContext<{ + open: [string | null, Setter]; + mounted: [string | null, Setter]; + + title: [HTMLElement | null, Setter]; + content: [HTMLElement | null, Setter]; + + context: [Record, Setter>]; +}>(null!); + +/** + * Headless component that will obtain the title and content of the artifact + * and render them in place of the `ArtifactContent` and `ArtifactTitle` components via + * React Portals. + */ +const ArtifactSlot = (props: { + id: string; + children?: ReactNode; + title?: ReactNode; +}) => { + const context = useContext(ArtifactSlotContext); + + const [ctxMounted, ctxSetMounted] = context.mounted; + const [content] = context.content; + const [title] = context.title; + + const isMounted = ctxMounted === props.id; + const isEmpty = props.children == null && props.title == null; + + useEffect(() => { + if (isEmpty) { + ctxSetMounted((open) => (open === props.id ? null : open)); + } + }, [isEmpty, ctxSetMounted, props.id]); + + if (!isMounted) return null; + return ( + <> + {title != null ? createPortal(<>{props.title}, title) : null} + {content != null ? createPortal(<>{props.children}, content) : null} + + ); +}; + +export function ArtifactContent(props: HTMLAttributes) { + const context = useContext(ArtifactSlotContext); + + const [mounted] = context.mounted; + const ref = useRef(null); + const [, setStateRef] = context.content; + + useLayoutEffect( + () => setStateRef?.(mounted ? ref.current : null), + [setStateRef, mounted], + ); + + if (!mounted) return null; + return ( +
+ ); +} + +export function ArtifactTitle(props: HTMLAttributes) { + const context = useContext(ArtifactSlotContext); + + const ref = useRef(null); + const [, setStateRef] = context.title; + + useLayoutEffect(() => setStateRef?.(ref.current), [setStateRef]); + + return ( +
+ ); +} + +export function ArtifactProvider(props: { children?: ReactNode }) { + const content = useState(null); + const title = useState(null); + + const open = useState(null); + const mounted = useState(null); + const context = useState>({}); + + return ( + + {props.children} + + ); +} + +/** + * Provides a value to be passed into `meta.artifact` field + * of the `LoadExternalComponent` component, to be consumed by the `useArtifact` hook + * on the generative UI side. + */ +export function useArtifact() { + const id = useId(); + const context = useContext(ArtifactSlotContext); + const [ctxOpen, ctxSetOpen] = context.open; + const [ctxContext, ctxSetContext] = context.context; + const [, ctxSetMounted] = context.mounted; + + const open = ctxOpen === id; + const setOpen = useCallback( + (value: boolean | ((value: boolean) => boolean)) => { + if (typeof value === "boolean") { + ctxSetOpen(value ? id : null); + } else { + ctxSetOpen((open) => (open === id ? null : id)); + } + + ctxSetMounted(id); + }, + [ctxSetOpen, ctxSetMounted, id], + ); + + const ArtifactContent = useCallback( + (props: { title?: React.ReactNode; children: React.ReactNode }) => { + return ( + + {props.children} + + ); + }, + [id], + ); + + return [ + ArtifactContent, + { open, setOpen, context: ctxContext, setContext: ctxSetContext }, + ] as [ + typeof ArtifactContent, + { + open: typeof open; + setOpen: typeof setOpen; + context: typeof ctxContext; + setContext: typeof ctxSetContext; + }, + ]; +} + +/** + * General hook for detecting if any artifact is open. + */ +export function useArtifactOpen() { + const context = useContext(ArtifactSlotContext); + const [ctxOpen, setCtxOpen] = context.open; + + const open = ctxOpen !== null; + const onClose = useCallback(() => setCtxOpen(null), [setCtxOpen]); + + return [open, onClose] as const; +} + +/** + * Artifacts may at their discretion provide additional context + * that will be used when creating a new run. + */ +export function useArtifactContext() { + const context = useContext(ArtifactSlotContext); + return context.context; +} diff --git a/src/components/thread/index.tsx b/src/components/thread/index.tsx index 1bb5fa3..14c5975 100644 --- a/src/components/thread/index.tsx +++ b/src/components/thread/index.tsx @@ -22,6 +22,7 @@ import { SquarePen, Plus, CircleX, + XIcon, } from "lucide-react"; import { useQueryState, parseAsBoolean } from "nuqs"; import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom"; @@ -39,6 +40,13 @@ import { } from "../ui/tooltip"; import { fileToImageBlock, fileToPDFBlock } from "@/lib/multimodal-utils"; import type { Base64ContentBlock } from "@langchain/core/messages"; +import { + useArtifactOpen, + ArtifactContent, + ArtifactTitle, + useArtifactContext, +} from "./artifact"; + function StickyToBottomContent(props: { content: ReactNode; @@ -106,7 +114,10 @@ function OpenGitHubRepo() { } export function Thread() { - const [threadId, setThreadId] = useQueryState("threadId"); + const [artifactContext, setArtifactContext] = useArtifactContext(); + const [artifactOpen, closeArtifact] = useArtifactOpen(); + + const [threadId, _setThreadId] = useQueryState("threadId"); const [chatHistoryOpen, setChatHistoryOpen] = useQueryState( "chatHistoryOpen", parseAsBoolean.withDefault(false), @@ -127,8 +138,17 @@ export function Thread() { const lastError = useRef(undefined); + const dropRef = useRef(null); + const setThreadId = (id: string | null) => { + _setThreadId(id); + + // close artifact and reset artifact context + closeArtifact(); + setArtifactContext({}); + }; + useEffect(() => { if (!stream.error) { lastError.current = undefined; @@ -198,12 +218,17 @@ export function Thread() { console.log("Message content:", newHumanMessage.content); const toolMessages = ensureToolCallsHaveResponses(stream.messages); + + const context = + Object.keys(artifactContext).length > 0 ? artifactContext : undefined; + stream.submit( - { messages: [...toolMessages, newHumanMessage] }, + { messages: [...toolMessages, newHumanMessage], context }, { streamMode: ["values"], optimisticValues: (prev) => ({ ...prev, + context, messages: [ ...(prev.messages ?? []), ...toolMessages, @@ -362,52 +387,36 @@ export function Thread() {
- - {!chatStarted && ( -
-
- {(!chatHistoryOpen || !isLargeScreen) && ( - - )} -
-
- -
-
- )} - {chatStarted && ( -
-
-
+ + {!chatStarted && ( +
+
{(!chatHistoryOpen || !isLargeScreen) && (
- setThreadId(null)} - animate={{ - marginLeft: !chatHistoryOpen ? 48 : 0, - }} - transition={{ - type: "spring", - stiffness: 300, - damping: 30, - }} - > - - - Agent Chat - - -
- -
-
+
- setThreadId(null)} - > - -
- -
-
- )} - - - - {messages - .filter((m) => !m.id?.startsWith(DO_NOT_RENDER_ID_PREFIX)) - .map((message, index) => - message.type === "human" ? ( - - ) : ( - - ), + )} + {chatStarted && ( +
+
+
+ {(!chatHistoryOpen || !isLargeScreen) && ( + )} - {/* Special rendering case where there are no AI/tool messages, but there is an interrupt. - We need to render it outside of the messages list, since there are no messages to render */} - {hasNoAIOrToolMessages && !!stream.interrupt && ( - + setThreadId(null)} + animate={{ + marginLeft: !chatHistoryOpen ? 48 : 0, + }} + transition={{ + type: "spring", + stiffness: 300, + damping: 30, + }} + > + - )} - {isLoading && !firstTokenReceived && ( - - )} - - } - footer={ -
- {!chatStarted && ( -
- -

- Agent Chat -

-
- )} + + Agent Chat + + +
- +
+
+ +
+ setThreadId(null)} + > + + +
+ +
+
+ )} + + + + {messages + .filter((m) => !m.id?.startsWith(DO_NOT_RENDER_ID_PREFIX)) + .map((message, index) => + message.type === "human" ? ( + + ) : ( + + ), + )} + {/* Special rendering case where there are no AI/tool messages, but there is an interrupt. + We need to render it outside of the messages list, since there are no messages to render */} + {hasNoAIOrToolMessages && !!stream.interrupt && ( + + )} + {isLoading && !firstTokenReceived && ( + + )} + + } + footer={ +
+======= + )}
@@ -631,8 +654,27 @@ export function Thread() { > Hide Tool Calls +
+ {stream.isLoading ? ( + + ) : ( + + )}
+ {stream.isLoading ? (
-
- } - /> - - + } + /> + + +
+
+
+ + +
+ +
+
+
); } diff --git a/src/components/thread/messages/ai.tsx b/src/components/thread/messages/ai.tsx index b092431..af5eac2 100644 --- a/src/components/thread/messages/ai.tsx +++ b/src/components/thread/messages/ai.tsx @@ -13,6 +13,7 @@ import { isAgentInboxInterruptSchema } from "@/lib/agent-inbox-interrupt"; import { ThreadView } from "../agent-inbox"; import { useQueryState, parseAsBoolean } from "nuqs"; import { GenericInterruptView } from "./generic-interrupt"; +import { useArtifact } from "../artifact"; function CustomComponent({ message, @@ -21,6 +22,7 @@ function CustomComponent({ message: Message; thread: ReturnType; }) { + const artifact = useArtifact(); const { values } = useStreamContext(); const customComponents = values.ui?.filter( (ui) => ui.metadata?.message_id === message.id, @@ -34,7 +36,7 @@ function CustomComponent({ key={customComponent.id} stream={thread} message={customComponent} - meta={{ ui: customComponent }} + meta={{ ui: customComponent, artifact }} /> ))} diff --git a/src/providers/Stream.tsx b/src/providers/Stream.tsx index c0c471c..b6d0c09 100644 --- a/src/providers/Stream.tsx +++ b/src/providers/Stream.tsx @@ -9,6 +9,8 @@ import { useStream } from "@langchain/langgraph-sdk/react"; import { type Message } from "@langchain/langgraph-sdk"; import { uiMessageReducer, + isUIMessage, + isRemoveUIMessage, type UIMessage, type RemoveUIMessage, } from "@langchain/langgraph-sdk/react-ui"; @@ -31,6 +33,7 @@ const useTypedStream = useStream< UpdateType: { messages?: Message[] | Message | string; ui?: (UIMessage | RemoveUIMessage)[] | UIMessage | RemoveUIMessage; + context?: Record; }; CustomEventType: UIMessage | RemoveUIMessage; } @@ -82,10 +85,12 @@ const StreamSession = ({ assistantId, threadId: threadId ?? null, onCustomEvent: (event, options) => { - options.mutate((prev) => { - const ui = uiMessageReducer(prev.ui ?? [], event); - return { ...prev, ui }; - }); + if (isUIMessage(event) || isRemoveUIMessage(event)) { + options.mutate((prev) => { + const ui = uiMessageReducer(prev.ui ?? [], event); + return { ...prev, ui }; + }); + } }, onThreadId: (id) => { setThreadId(id);