Merge branch 'main' into upload-images-and-pdfs

This commit is contained in:
Dylan Boudro
2025-05-19 13:45:17 -07:00
committed by GitHub
8 changed files with 471 additions and 149 deletions

View File

@@ -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<TContext = Record<string, unknown>>() {
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 (
<>
<div
onClick={() => setOpen(!open)}
className="cursor-pointer rounded-lg border p-4"
>
<p className="font-medium">{props.title}</p>
<p className="text-sm text-gray-500">{props.description}</p>
</div>
<Artifact title={props.title}>
<p className="p-4 whitespace-pre-wrap">{props.content}</p>
</Artifact>
</>
);
}
```
## 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.

4
pnpm-lock.yaml generated
View File

@@ -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:

View File

@@ -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({

View File

@@ -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 {
<Toaster />
<ThreadProvider>
<StreamProvider>
<ArtifactProvider>
<Thread />
</ArtifactProvider>
</StreamProvider>
</ThreadProvider>
</React.Suspense>

View File

@@ -0,0 +1,189 @@
import {
HTMLAttributes,
ReactNode,
createContext,
useCallback,
useContext,
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
type Setter<T> = (value: T | ((value: T) => T)) => void;
const ArtifactSlotContext = createContext<{
open: [string | null, Setter<string | null>];
mounted: [string | null, Setter<string | null>];
title: [HTMLElement | null, Setter<HTMLElement | null>];
content: [HTMLElement | null, Setter<HTMLElement | null>];
context: [Record<string, unknown>, Setter<Record<string, unknown>>];
}>(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<HTMLDivElement>) {
const context = useContext(ArtifactSlotContext);
const [mounted] = context.mounted;
const ref = useRef<HTMLDivElement>(null);
const [, setStateRef] = context.content;
useLayoutEffect(
() => setStateRef?.(mounted ? ref.current : null),
[setStateRef, mounted],
);
if (!mounted) return null;
return (
<div
{...props}
ref={ref}
/>
);
}
export function ArtifactTitle(props: HTMLAttributes<HTMLDivElement>) {
const context = useContext(ArtifactSlotContext);
const ref = useRef<HTMLDivElement>(null);
const [, setStateRef] = context.title;
useLayoutEffect(() => setStateRef?.(ref.current), [setStateRef]);
return (
<div
{...props}
ref={ref}
/>
);
}
export function ArtifactProvider(props: { children?: ReactNode }) {
const content = useState<HTMLElement | null>(null);
const title = useState<HTMLElement | null>(null);
const open = useState<string | null>(null);
const mounted = useState<string | null>(null);
const context = useState<Record<string, unknown>>({});
return (
<ArtifactSlotContext.Provider
value={{ open, mounted, title, content, context }}
>
{props.children}
</ArtifactSlotContext.Provider>
);
}
/**
* 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 (
<ArtifactSlot
id={id}
title={props.title}
>
{props.children}
</ArtifactSlot>
);
},
[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;
}

View File

@@ -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<string | undefined>(undefined);
const dropRef = useRef<HTMLDivElement>(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,6 +387,13 @@ export function Thread() {
</div>
</motion.div>
</div>
<div
className={cn(
"grid w-full grid-cols-[1fr_0fr] transition-all duration-500",
artifactOpen && "grid-cols-[3fr_2fr]",
)}
>
<motion.div
className={cn(
"relative flex min-w-0 flex-1 flex-col overflow-hidden",
@@ -459,6 +491,7 @@ export function Thread() {
</TooltipIconButton>
</div>
<div className="from-background to-background/0 absolute inset-x-0 top-full h-5 bg-gradient-to-b" />
</div>
)}
@@ -507,17 +540,6 @@ export function Thread() {
</>
}
footer={
<div className="sticky bottom-0 flex flex-col items-center gap-8 bg-white">
{!chatStarted && (
<div className="flex items-center gap-3">
<LangGraphLogoSVG className="h-8 flex-shrink-0" />
<h1 className="text-2xl font-semibold tracking-tight">
Agent Chat
</h1>
</div>
)}
<ScrollToBottom className="animate-in fade-in-0 zoom-in-95 absolute bottom-full left-1/2 mb-4 -translate-x-1/2" />
<div
ref={dropRef}
@@ -598,7 +620,8 @@ export function Thread() {
}}
placeholder="Type your message..."
className="field-sizing-content resize-none border-none bg-transparent p-3.5 pb-0 shadow-none ring-0 outline-none focus:ring-0 focus:outline-none"
/>
=======
)}
<div className="flex items-center justify-between p-2 pt-4">
<div className="flex items-center gap-2">
@@ -631,8 +654,27 @@ export function Thread() {
>
Hide Tool Calls
</Label>
</div>
{stream.isLoading ? (
<Button
key="stop"
onClick={() => stream.stop()}
>
<LoaderCircle className="h-4 w-4 animate-spin" />
Cancel
</Button>
) : (
<Button
type="submit"
className="shadow-md transition-all"
disabled={isLoading || !input.trim()}
>
Send
</Button>
)}
</div>
{stream.isLoading ? (
<Button
key="stop"
@@ -657,12 +699,28 @@ export function Thread() {
)}
</div>
</form>
</div>
</div>
}
/>
</StickToBottom>
</motion.div>
<div className="relative flex flex-col border-l">
<div className="absolute inset-0 flex min-w-[30vw] flex-col">
<div className="grid grid-cols-[1fr_auto] border-b p-4">
<ArtifactTitle className="truncate overflow-hidden" />
<button
onClick={closeArtifact}
className="cursor-pointer"
>
<XIcon className="size-5" />
</button>
</div>
<ArtifactContent className="relative flex-grow" />
</div>
</div>
</div>
</div>
);
}

View File

@@ -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<typeof useStreamContext>;
}) {
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 }}
/>
))}
</Fragment>

View File

@@ -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<string, unknown>;
};
CustomEventType: UIMessage | RemoveUIMessage;
}
@@ -82,10 +85,12 @@ const StreamSession = ({
assistantId,
threadId: threadId ?? null,
onCustomEvent: (event, options) => {
if (isUIMessage(event) || isRemoveUIMessage(event)) {
options.mutate((prev) => {
const ui = uiMessageReducer(prev.ui ?? [], event);
return { ...prev, ui };
});
}
},
onThreadId: (id) => {
setThreadId(id);