feat(sdk): add type-safe tool call streaming with agent type inference (#1826)

Co-authored-by: David Duong <david@duong.cz>
This commit is contained in:
Christian Bromann
2026-01-08 14:50:18 -08:00
committed by GitHub
parent 4b7832ec39
commit 730dc7c3b8
90 changed files with 11112 additions and 4843 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@langchain/langgraph-api": patch
---
fix(api): bump @hono/zod-validator
+5
View File
@@ -0,0 +1,5 @@
---
"@langchain/langgraph-checkpoint-validation": patch
---
fix(checkpoint-validation): export primitive as type, use Zod v3 for better compaitiblity
+5
View File
@@ -0,0 +1,5 @@
---
"@langchain/langgraph-sdk": patch
---
fix(sdk): provide proper error message when failing to connect to a server
+5
View File
@@ -0,0 +1,5 @@
---
"@langchain/langgraph": patch
---
fix(core): improved Zod interop
+5
View File
@@ -0,0 +1,5 @@
---
"@langchain/langgraph-sdk": minor
---
feat(sdk): add type-safe tool call streaming with agent type inference
-14
View File
@@ -29,8 +29,6 @@ env:
jobs:
exports-esbuild:
runs-on: ubuntu-latest
strategy:
fail-fast: false
steps:
- uses: actions/checkout@v6
- name: Use Node.js ${{ env.NODE_VERSION }}
@@ -50,8 +48,6 @@ jobs:
exports-esm:
runs-on: ubuntu-latest
strategy:
fail-fast: false
steps:
- uses: actions/checkout@v6
- name: Use Node.js ${{ env.NODE_VERSION }}
@@ -71,8 +67,6 @@ jobs:
exports-cjs:
runs-on: ubuntu-latest
strategy:
fail-fast: false
steps:
- uses: actions/checkout@v6
- name: Use Node.js ${{ env.NODE_VERSION }}
@@ -92,8 +86,6 @@ jobs:
exports-cf:
runs-on: ubuntu-latest
strategy:
fail-fast: false
steps:
- uses: actions/checkout@v6
- name: Use Node.js ${{ env.NODE_VERSION }}
@@ -113,8 +105,6 @@ jobs:
exports-vercel:
runs-on: ubuntu-latest
strategy:
fail-fast: false
steps:
- uses: actions/checkout@v6
- name: Use Node.js ${{ env.NODE_VERSION }}
@@ -134,8 +124,6 @@ jobs:
exports-vite:
runs-on: ubuntu-latest
strategy:
fail-fast: false
steps:
- uses: actions/checkout@v6
- name: Use Node.js ${{ env.NODE_VERSION }}
@@ -155,8 +143,6 @@ jobs:
exports-tsc:
runs-on: ubuntu-latest
strategy:
fail-fast: false
steps:
- uses: actions/checkout@v6
- name: Use Node.js ${{ env.NODE_VERSION }}
+1 -1
View File
@@ -20,7 +20,7 @@
"hono": "^4.8.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"zod": "^3.23.8"
"zod": "^3.25.76"
},
"devDependencies": {
"@types/react": "^19.0.8",
+19 -7
View File
@@ -1,19 +1,30 @@
import type { BaseMessage } from "@langchain/core/messages";
import { StateGraph, MessagesZodMeta, START } from "@langchain/langgraph";
import { registry } from "@langchain/langgraph/zod";
import {
Annotation,
StateGraph,
messagesStateReducer,
START,
} from "@langchain/langgraph";
import { ChatOpenAI } from "@langchain/openai";
import { z } from "zod/v4";
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { z } from "zod/v4";
const llm = new ChatOpenAI({ model: "gpt-4o-mini" });
const schema = z.object({
messages: z.custom<BaseMessage[]>().register(registry, MessagesZodMeta),
const StateAnnotation = Annotation.Root({
messages: Annotation<BaseMessage[]>({
reducer: messagesStateReducer,
default: () => [],
}),
});
const graph = new StateGraph(schema)
const schema = z.object({
messages: z.custom<BaseMessage[]>(),
});
const graph = new StateGraph(StateAnnotation)
.addNode("agent", async ({ messages }) => ({
messages: await llm.invoke(messages),
}))
@@ -25,7 +36,8 @@ export type GraphType = typeof graph;
const app = new Hono();
app.post("/api/stream", async (c) => {
const { input } = z.object({ input: schema }).parse(await c.req.json());
const body = await c.req.json();
const input = schema.parse(body.input);
const stream = await graph.stream(input, {
encoding: "text/event-stream",
+1
View File
@@ -25,3 +25,4 @@ dist-ssr
# LangGraph API
.langgraph_api
*.tsbuildinfo
+8 -3
View File
@@ -4,10 +4,15 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<title>Vite + React + TS</title>
<title>LangGraph Chat</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Geist:wght@100..900&family=Geist+Mono:wght@100..900&display=swap"
rel="stylesheet"
/>
</head>
<body>
<body class="antialiased bg-black">
<div id="root"></div>
<script type="module" src="/src/client.tsx"></script>
</body>
+10 -2
View File
@@ -1,7 +1,15 @@
{
"node_version": "22",
"graphs": {
"agent": "./src/agent.mts:graph"
"agent": "./src/examples/branching-chat/agent.ts:agent",
"custom-streaming": "./src/examples/custom-streaming/agent.ts:agent",
"human-in-the-loop": "./src/examples/human-in-the-loop/agent.ts:agent",
"multi-step-graph": "./src/examples/multi-step-graph/agent.ts:agent",
"parallel-research": "./src/examples/parallel-research/agent.ts:agent",
"reasoning-agent": "./src/examples/reasoning-agent/agent.ts:agent",
"session-persistence": "./src/examples/session-persistence/agent.ts:agent",
"summarization-agent": "./src/examples/summarization-agent/agent.ts:agent",
"tool-calling-agent": "./src/examples/tool-calling-agent/agent.ts:agent"
},
"env": ".env"
}
}
+9 -4
View File
@@ -3,7 +3,7 @@
"private": true,
"type": "module",
"scripts": {
"dev": "turbo dev:client dev:server",
"dev": "yarn turbo:command dev:client dev:server --filter=@examples/ui-react",
"dev:client": "vite",
"dev:server": "langgraphjs dev --no-browser",
"build:internal": "tsc -b && vite build",
@@ -12,20 +12,25 @@
"preview": "vite preview"
},
"dependencies": {
"@langchain/core": "^1.0.1",
"@langchain/core": "^1.1.12",
"@langchain/langgraph": "workspace:*",
"@langchain/langgraph-sdk": "workspace:*",
"@langchain/openai": "^1.0.0",
"@langchain/openai": "^1.2.0",
"langchain": "^1.2.7",
"lucide-react": "^0.561.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"zod": "^3.23.8"
"use-stick-to-bottom": "^1.0.44",
"zod": "^4.2.1"
},
"devDependencies": {
"@langchain/langgraph-cli": "workspace:*",
"@tailwindcss/vite": "^4.1.8",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"@vitejs/plugin-react": "^4.4.1",
"prettier": "^2.8.3",
"tailwindcss": "^4.1.8",
"tsx": "^4.19.3",
"typescript": "^5.4.5",
"vite": "^7.0.0"
-18
View File
@@ -1,18 +0,0 @@
import type { BaseMessage } from "@langchain/core/messages";
import { StateGraph, MessagesZodMeta, START } from "@langchain/langgraph";
import { registry } from "@langchain/langgraph/zod";
import { ChatOpenAI } from "@langchain/openai";
import { z } from "zod/v4";
const llm = new ChatOpenAI({ model: "gpt-4o-mini" });
const schema = z.object({
messages: z.custom<BaseMessage[]>().register(registry, MessagesZodMeta),
});
export const graph = new StateGraph(schema)
.addNode("agent", async ({ messages }) => ({
messages: await llm.invoke(messages),
}))
.addEdge(START, "agent")
.compile();
+3 -48
View File
@@ -1,56 +1,11 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { useStream } from "@langchain/langgraph-sdk/react";
export function App() {
const stream = useStream({
assistantId: "agent",
apiUrl: "http://localhost:2024",
});
return (
<div className="max-w-xl mx-auto">
<div className="flex flex-col gap-2">
{stream.messages.map((message) => (
<div key={message.id} className="whitespace-pre-wrap">
{message.content as string}
</div>
))}
</div>
<form
className="grid grid-cols-[1fr_auto] gap-2"
onSubmit={(e) => {
e.preventDefault();
const form = e.target as HTMLFormElement;
const formData = new FormData(form);
const content = formData.get("content") as string;
form.reset();
stream.submit({ messages: [{ content, type: "human" }] });
}}
>
<textarea
name="content"
className="field-sizing-content"
onKeyDown={(e) => {
const target = e.target as HTMLTextAreaElement;
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
target.form?.requestSubmit();
}
}}
/>
<button type="submit">Submit</button>
</form>
</div>
);
}
import "./styles.css";
import { Layout } from "./components/Layout";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
<Layout />
</StrictMode>
);
+149
View File
@@ -0,0 +1,149 @@
import { useState, useEffect } from "react";
import { Zap } from "lucide-react";
import { Sidebar, SidebarToggle } from "./Sidebar";
import { EXAMPLES, getExample } from "../examples/registry";
/**
* Import all examples to register them
*/
import "../examples/tool-calling-agent";
import "../examples/human-in-the-loop";
import "../examples/multi-step-graph";
import "../examples/summarization-agent";
import "../examples/parallel-research";
import "../examples/reasoning-agent";
import "../examples/custom-streaming";
import "../examples/branching-chat";
import "../examples/session-persistence";
function WelcomeScreen() {
return (
<div className="h-full flex flex-col items-center justify-center text-center px-8">
<div className="w-16 h-16 mb-6 rounded-2xl bg-brand-dark flex items-center justify-center animate-fade-in">
<span className="text-2xl">🦜🔗</span>
</div>
<h1
className="text-2xl font-semibold text-white mb-3 animate-fade-in"
style={{ animationDelay: "100ms" }}
>
LangGraph Streaming Examples
</h1>
<p
className="text-neutral-400 max-w-md mb-8 animate-fade-in"
style={{ animationDelay: "200ms" }}
>
Explore different streaming patterns with LangGraph. Select an example
from the sidebar to get started.
</p>
<div
className="grid gap-4 w-full max-w-md animate-fade-in"
style={{ animationDelay: "300ms" }}
>
{EXAMPLES.filter((e) => e.ready)
.slice(0, 3)
.map((example) => (
<div
key={example.id}
className="flex items-center gap-4 p-4 rounded-xl bg-neutral-900 border border-neutral-800 hover:border-brand-dark/50 transition-colors text-left"
>
<div className="w-10 h-10 rounded-lg bg-brand-dark/20 border border-brand-dark/30 flex items-center justify-center text-brand-accent">
<Zap className="w-5 h-5" />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-medium text-white">
{example.title}
</h3>
<p className="text-xs text-neutral-500 truncate">
{example.description}
</p>
</div>
</div>
))}
</div>
</div>
);
}
function getExampleFromHash(): string | null {
const hash = window.location.hash.slice(1); // Remove the '#'
if (hash && EXAMPLES.some((e) => e.id === hash && e.ready)) {
return hash;
}
return null;
}
export function Layout() {
const [selectedExample, setSelectedExample] = useState<string | null>(() =>
getExampleFromHash()
);
const [sidebarOpen, setSidebarOpen] = useState(false);
// Sync selected example with URL hash
useEffect(() => {
const handleHashChange = () => {
const exampleFromHash = getExampleFromHash();
if (exampleFromHash) {
setSelectedExample(exampleFromHash);
}
};
window.addEventListener("hashchange", handleHashChange);
return () => window.removeEventListener("hashchange", handleHashChange);
}, []);
// Auto-select first example if none selected
useEffect(() => {
if (!selectedExample && EXAMPLES.length > 0) {
const firstReady = EXAMPLES.find((e) => e.ready);
if (firstReady) {
setSelectedExample(firstReady.id);
window.location.hash = firstReady.id;
}
}
}, [selectedExample]);
const currentExample = selectedExample ? getExample(selectedExample) : null;
const ExampleComponent = currentExample?.component;
return (
<div className="h-screen flex bg-black">
<Sidebar
selectedExample={selectedExample}
onSelectExample={(id) => {
setSelectedExample(id);
window.location.hash = id;
setSidebarOpen(false);
}}
isOpen={sidebarOpen}
onToggle={() => setSidebarOpen(!sidebarOpen)}
/>
<main className="flex-1 flex flex-col min-w-0 relative">
<SidebarToggle onClick={() => setSidebarOpen(true)} />
{/* Example header */}
{currentExample && (
<header className="border-b border-neutral-800 px-6 py-3.5 flex items-center gap-4 lg:px-8">
<div className="lg:hidden w-8" />{" "}
{/* Spacer for mobile menu button */}
<div>
<h1 className="text-lg font-semibold text-white">
{currentExample.title}
</h1>
<p className="text-sm text-neutral-500">
{currentExample.description}
</p>
</div>
</header>
)}
{/* Example content */}
<div className="flex-1 overflow-hidden">
{ExampleComponent ? <ExampleComponent /> : <WelcomeScreen />}
</div>
</main>
</div>
);
}
@@ -0,0 +1,21 @@
export function LoadingIndicator() {
return (
<div className="animate-fade-in">
<div className="text-xs font-medium text-neutral-500 mb-2">Assistant</div>
<div className="flex items-center gap-1">
<span
className="w-1.5 h-1.5 bg-neutral-500 rounded-full animate-pulse-subtle"
style={{ animationDelay: "0ms" }}
/>
<span
className="w-1.5 h-1.5 bg-neutral-500 rounded-full animate-pulse-subtle"
style={{ animationDelay: "300ms" }}
/>
<span
className="w-1.5 h-1.5 bg-neutral-500 rounded-full animate-pulse-subtle"
style={{ animationDelay: "600ms" }}
/>
</div>
</div>
);
}
@@ -0,0 +1,237 @@
import { Brain } from "lucide-react";
import type { Message } from "@langchain/langgraph-sdk";
// Styles for each message type - kept separate for readability
const BUBBLE_STYLES = {
human:
"bg-brand-dark text-brand-light rounded-2xl px-4 py-2.5 ml-auto max-w-[85%] md:max-w-[70%] w-fit",
system:
"bg-amber-500/10 border border-amber-500/20 text-amber-200 rounded-lg px-4 py-3",
ai: "text-neutral-100",
} as const;
/**
* Extract text content from a message
*/
function getTextContent(message: Message): string {
if (typeof message.content === "string") {
return message.content;
}
if (Array.isArray(message.content)) {
return message.content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.text)
.join("");
}
return "";
}
/**
* MessageBubble component that renders human and AI text messages.
* Tool calls are handled separately by ToolCallCard.
*/
export function MessageBubble({ message }: { message: Message }) {
const content = getTextContent(message);
/**
* Don't render tool messages as render them separately
*/
if (message.type === "tool") {
return null;
}
if (message.type === "human") {
return <HumanBubble content={content} />;
}
if (message.type === "system") {
return <SystemBubble content={content} />;
}
return <AssistantBubble message={message} />;
}
/**
* Human message bubble - right-aligned with brand colors
*/
function HumanBubble({ content }: { content: string }) {
return (
<div className="animate-fade-in">
<div className={BUBBLE_STYLES.human}>
<div className="whitespace-pre-wrap leading-relaxed text-[15px]">
{content}
</div>
</div>
</div>
);
}
/**
* System message bubble - warning-styled with amber colors
*/
function SystemBubble({ content }: { content: string }) {
return (
<div className="animate-fade-in">
<div className="text-xs font-medium text-neutral-500 mb-2">System</div>
<div className={BUBBLE_STYLES.system}>
<div className="whitespace-pre-wrap leading-relaxed text-[15px]">
{content}
</div>
</div>
</div>
);
}
/**
* Assistant message bubble with reasoning bubble if it exists
*/
function AssistantBubble({ message }: { message: Message }) {
const content = getTextContent(message);
const reasoning = getReasoningFromMessage(message);
return (
<>
{/* Render reasoning bubble if it exists */}
{reasoning && <ReasoningBubble content={reasoning} />}
{content && (
<div className="animate-fade-in">
<div className="text-xs font-medium text-neutral-500 mb-2">
Assistant
</div>
<div className={BUBBLE_STYLES.ai}>
<div className="whitespace-pre-wrap leading-relaxed text-[15px]">
{content}
</div>
</div>
</div>
)}
</>
);
}
/**
* Reasoning bubble component - displays thinking tokens in a separate bubble
*/
function ReasoningBubble({ content }: { content: string }) {
return (
<div className="animate-fade-in">
{/* Label */}
<div className="text-xs font-medium text-amber-400/80 mb-2 flex items-center gap-1.5">
<Brain className="w-3 h-3" />
<span>Reasoning</span>
</div>
{/* Bubble */}
<div className="bg-linear-to-br from-amber-950/50 to-orange-950/40 border border-amber-500/20 rounded-2xl px-4 py-3 max-w-[95%]">
<div className="text-sm text-amber-100/90 whitespace-pre-wrap leading-relaxed">
{content}
</div>
</div>
</div>
);
}
/**
* Extracts reasoning/thinking content from an AI message.
* Supports both OpenAI reasoning (additional_kwargs.reasoning.summary)
* and Anthropic extended thinking (contentBlocks with type "thinking").
*
* @param message - The AI message to extract reasoning from.
* @returns a string of the reasoning/thinking content if found, undefined otherwise.
*
* @example
* ```ts
* const reasoning = getReasoningFromMessage(aiMessage, stream.isLoading);
* if (reasoning) {
* console.log("Model is thinking:", reasoning.content);
* }
* ```
*/
export function getReasoningFromMessage(message: Message): string | undefined {
// Type for accessing additional properties
type MessageWithExtras = Message & {
additional_kwargs?: { reasoning?: OpenAIReasoning };
contentBlocks?: Array<{ type: string; thinking?: string; text?: string }>;
};
const msg = message as MessageWithExtras;
// Check for OpenAI reasoning in additional_kwargs
if (msg.additional_kwargs?.reasoning?.summary) {
const { summary } = msg.additional_kwargs.reasoning;
const content = summary
.filter(
(item): item is ReasoningSummaryItem =>
item.type === "summary_text" && typeof item.text === "string"
)
.map((item) => item.text)
.join("");
if (content.trim()) {
return content;
}
}
// Check for Anthropic thinking in contentBlocks
if (msg.contentBlocks && Array.isArray(msg.contentBlocks)) {
const thinkingBlocks = msg.contentBlocks.filter(
(block): block is ThinkingContentBlock =>
block.type === "thinking" && typeof block.thinking === "string"
);
if (thinkingBlocks.length > 0) {
return thinkingBlocks.map((b) => b.thinking).join("\n");
}
}
// Check for thinking in message.content array
if (Array.isArray(msg.content)) {
const thinkingContent: string[] = [];
for (const block of msg.content) {
if (
typeof block === "object" &&
block !== null &&
"type" in block &&
(block as { type: string }).type === "thinking" &&
"thinking" in block &&
typeof (block as { thinking: unknown }).thinking === "string"
) {
thinkingContent.push((block as { thinking: string }).thinking);
}
}
if (thinkingContent.length > 0) {
return thinkingContent.join("\n");
}
}
return undefined;
}
/**
* Anthropic thinking content block structure.
* Used when streaming extended thinking from Claude models.
*/
export type ThinkingContentBlock = {
type: "thinking";
thinking: string;
};
/**
* OpenAI reasoning summary item structure.
* Used when streaming reasoning tokens from OpenAI models.
*/
export type ReasoningSummaryItem = {
type: "summary_text";
text: string;
index?: number;
};
/**
* OpenAI reasoning structure in additional_kwargs.
*/
export type OpenAIReasoning = {
id?: string;
type: "reasoning";
summary?: ReasoningSummaryItem[];
};
@@ -0,0 +1,71 @@
import { ArrowUp } from "lucide-react";
interface MessageInputProps {
onSubmit: (content: string) => void;
disabled?: boolean;
placeholder?: string;
}
export function MessageInput({
onSubmit,
disabled = false,
placeholder = "Send a message...",
}: MessageInputProps) {
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = e.target as HTMLFormElement;
const formData = new FormData(form);
const content = formData.get("content") as string;
if (!content.trim()) return;
form.reset();
onSubmit(content);
};
return (
<footer className="border-t border-neutral-800">
<div className="max-w-2xl mx-auto px-4 py-4">
<form className="relative" onSubmit={handleSubmit}>
<div className="relative bg-neutral-900 rounded-xl border border-neutral-800 focus-within:border-brand-dark transition-colors">
<textarea
name="content"
placeholder={placeholder}
rows={1}
disabled={disabled}
className="w-full bg-transparent text-white placeholder-neutral-500 px-4 py-3 pr-12 resize-none focus:outline-none text-sm leading-relaxed max-h-[200px] disabled:opacity-50 field-sizing-content"
onKeyDown={(e) => {
const target = e.target as HTMLTextAreaElement;
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
target.form?.requestSubmit();
}
}}
/>
<button
type="submit"
disabled={disabled}
className="absolute right-2 top-1/2 -translate-y-1/2 p-2 rounded-lg bg-brand-accent hover:bg-brand-light disabled:bg-neutral-700 disabled:cursor-not-allowed text-black disabled:text-neutral-500 transition-colors"
>
<ArrowUp className="w-4 h-4" />
</button>
</div>
<p className="text-center text-xs text-neutral-600 mt-3">
<kbd className="px-1.5 py-0.5 rounded bg-neutral-800 text-neutral-400 font-mono text-[10px]">
Enter
</kbd>{" "}
to send ·{" "}
<kbd className="px-1.5 py-0.5 rounded bg-neutral-800 text-neutral-400 font-mono text-[10px]">
Shift + Enter
</kbd>{" "}
for new line
</p>
</form>
</div>
</footer>
);
}
@@ -0,0 +1,186 @@
import {
Wrench,
GitBranch,
Layers,
Code,
MessageCircle,
X,
Menu,
BookOpen,
ExternalLink,
} from "lucide-react";
import {
CATEGORIES,
getExamplesByCategory,
type ExampleMeta,
} from "../examples/registry";
interface SidebarProps {
selectedExample: string | null;
onSelectExample: (id: string) => void;
isOpen: boolean;
onToggle: () => void;
}
const ICONS: Record<ExampleMeta["icon"], React.ReactNode> = {
tool: <Wrench className="w-4 h-4" strokeWidth={1.5} />,
graph: <GitBranch className="w-4 h-4" strokeWidth={1.5} />,
middleware: <Layers className="w-4 h-4" strokeWidth={1.5} />,
code: <Code className="w-4 h-4" strokeWidth={1.5} />,
chat: <MessageCircle className="w-4 h-4" strokeWidth={1.5} />,
};
export function Sidebar({
selectedExample,
onSelectExample,
isOpen,
onToggle,
}: SidebarProps) {
const examplesByCategory = getExamplesByCategory();
return (
<>
{/* Mobile overlay */}
{isOpen && (
<div
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-40 lg:hidden"
onClick={onToggle}
/>
)}
{/* Sidebar */}
<aside
className={`
fixed lg:static inset-y-0 left-0 z-50
w-72 bg-neutral-950 border-r border-neutral-800
transform transition-transform duration-200 ease-out
${isOpen ? "translate-x-0" : "-translate-x-full lg:translate-x-0"}
flex flex-col
`}
>
{/* Header */}
<div className="flex items-center gap-3 px-4 py-5 border-b border-neutral-800">
<div className="h-8 px-2 rounded-lg flex items-center justify-center bg-brand-dark">
<span className="text-sm leading-none whitespace-nowrap">🦜🔗</span>
</div>
<div>
<h1 className="text-sm font-semibold text-white">LangGraph UI</h1>
<p className="text-xs text-neutral-500">Streaming Examples</p>
</div>
<button
onClick={onToggle}
className="lg:hidden ml-auto p-1.5 rounded-lg hover:bg-neutral-800 text-neutral-400"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Examples list */}
<nav className="flex-1 overflow-y-auto py-4 px-3">
{Array.from(examplesByCategory.entries()).map(
([category, examples]) => {
if (examples.length === 0) return null;
const categoryMeta = CATEGORIES[category];
return (
<div key={category} className="mb-6">
<div className="px-2 mb-2">
<h2 className="text-[11px] font-semibold text-neutral-500 uppercase tracking-wider">
{categoryMeta.label}
</h2>
</div>
<div className="space-y-1">
{examples.map((example) => {
const isSelected = selectedExample === example.id;
return (
<button
key={example.id}
onClick={() =>
example.ready && onSelectExample(example.id)
}
disabled={!example.ready}
className={`
w-full flex items-start gap-3 px-3 py-2.5 rounded-lg text-left
transition-all duration-150 border cursor-pointer
${
isSelected
? "bg-brand-dark/30 text-white border-brand-dark/50"
: example.ready
? "text-neutral-400 hover:bg-neutral-800/50 hover:text-neutral-200 border-transparent"
: "text-neutral-600 cursor-not-allowed border-transparent"
}
`}
>
<span
className={`
mt-0.5 shrink-0
${
isSelected
? "text-brand-accent"
: example.ready
? "text-neutral-500"
: "text-neutral-700"
}
`}
>
{ICONS[example.icon]}
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium truncate">
{example.title}
</span>
{!example.ready && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-neutral-800 text-neutral-500 font-medium">
Soon
</span>
)}
</div>
<p className="text-xs text-neutral-500 mt-0.5 line-clamp-2">
{example.description}
</p>
</div>
</button>
);
})}
</div>
</div>
);
}
)}
</nav>
{/* Footer */}
<div className="px-4 py-4 border-t border-neutral-800">
<a
href="https://docs.langchain.com/#typescript"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-xs text-neutral-500 hover:text-brand-accent transition-colors"
>
<BookOpen className="w-4 h-4" strokeWidth={1.5} />
<span>Documentation</span>
<ExternalLink className="w-3 h-3 ml-auto" />
</a>
</div>
</aside>
</>
);
}
/**
* Toggle button for mobile sidebar
*/
export function SidebarToggle({ onClick }: { onClick: () => void }) {
return (
<button
onClick={onClick}
className="lg:hidden fixed top-4 left-4 z-30 p-2 rounded-lg bg-neutral-900 border border-neutral-800 text-neutral-400 hover:text-brand-accent hover:border-brand-dark transition-colors"
>
<Menu className="w-5 h-5" />
</button>
);
}
@@ -0,0 +1,42 @@
import { MessageCircle, type LucideIcon } from "lucide-react";
interface EmptyStateProps {
icon?: LucideIcon;
title?: string;
description?: string;
suggestions?: string[];
onSuggestionClick?: (suggestion: string) => void;
}
export function EmptyState({
icon: Icon = MessageCircle,
title = "How can I help you today?",
description = "Send a message to start a conversation with the AI assistant.",
suggestions = [],
onSuggestionClick,
}: EmptyStateProps) {
return (
<div className="flex-1 flex flex-col items-center justify-center text-center px-4 py-24">
<div className="w-16 h-16 rounded-2xl bg-linear-to-br from-brand-accent/20 to-brand-dark/30 border border-brand-accent/30 flex items-center justify-center mb-6">
<Icon className="w-8 h-8 text-brand-accent" strokeWidth={1.5} />
</div>
<h2 className="text-xl font-semibold text-white mb-2">{title}</h2>
<p className="text-neutral-400 max-w-md mb-6 leading-relaxed">
{description}
</p>
{suggestions.length > 0 && (
<div className="flex flex-wrap gap-2 justify-center">
{suggestions.map((suggestion) => (
<button
key={suggestion}
onClick={() => onSuggestionClick?.(suggestion)}
className="px-3 py-1.5 rounded-full bg-brand-dark/40 hover:bg-brand-dark/60 text-brand-light text-xs transition-colors border border-brand-accent/20 hover:border-brand-accent/40 cursor-pointer"
>
{suggestion}
</button>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,571 @@
import {
Settings,
Cloud,
Loader2,
Calculator,
Check,
MapPin,
Wind,
Droplets,
Equal,
Sun,
CloudRain,
Snowflake,
CloudLightning,
Lightbulb,
FlaskConical,
Landmark,
TreePine,
} from "lucide-react";
import type { ToolMessage } from "@langchain/langgraph-sdk";
import type {
ToolCallWithResult,
ToolCallFromTool,
ToolCallState,
InferAgentToolCalls,
} from "@langchain/langgraph-sdk/react";
import type {
agent as ToolCallingAgent,
getWeather,
} from "../examples/tool-calling-agent/agent.js";
import type { agent as SummarizationAgent } from "../examples/summarization-agent/agent.js";
import type {
agent as BranchingAgent,
getFact,
calculate,
} from "../examples/branching-chat/agent.js";
import type { takeNote } from "../examples/summarization-agent/agent.js";
/**
* Helper type to filter out tool calls with generic `name: string`.
* Keeps only tool calls where the name is a literal type (e.g., "get_weather").
* This ensures discriminated union narrowing works correctly.
*/
type WithLiteralName<T> = T extends { name: infer N }
? string extends N
? never // Exclude if name is generic `string`
: T
: never;
/**
* Define the possible tool call types this component can handle.
*/
export type AgentToolCalls = WithLiteralName<
/**
* Infer tool call from the getWeather tool
*/
| ToolCallFromTool<typeof getWeather>
/**
* Infer tool call from an agent instance
*/
| InferAgentToolCalls<typeof ToolCallingAgent>
| InferAgentToolCalls<typeof SummarizationAgent>
| InferAgentToolCalls<typeof BranchingAgent>
>;
/**
* Helper to parse tool result safely
* @param result - The result of the tool call
* @returns { status: string; content: string } - The parsed result
*/
function parseToolResult(result?: ToolMessage): {
status: string;
content: string;
} {
if (!result) return { status: "pending", content: "" };
try {
return JSON.parse(result.content as string);
} catch {
return { status: "success", content: result.content as string };
}
}
/**
* Component that renders a tool call with its result.
* Demonstrates type-safe tool call handling with discriminated unions.
*/
export function ToolCallCard({
toolCall,
}: {
toolCall: ToolCallWithResult<AgentToolCalls>;
}) {
const { call, result, state } = toolCall;
if (call.name === "get_weather") {
return <WeatherToolCallCard call={call} result={result} state={state} />;
}
if (call.name === "calculate") {
return <CalculatorToolCallCard call={call} result={result} state={state} />;
}
if (call.name === "take_note") {
return <NoteToolCallCard call={call} result={result} state={state} />;
}
if (call.name === "get_fact") {
return <FactToolCallCard call={call} result={result} state={state} />;
}
/**
* Fallback for unknown tools
*/
return <GenericToolCallCard {...toolCall} />;
}
/**
* Generic tool call card for unknown tools
*/
function GenericToolCallCard({
call,
result,
state,
}: {
call: { name: string; args: Record<string, unknown> };
result?: ToolMessage;
state: ToolCallState;
}) {
const isLoading = state === "pending";
const parsedResult = parseToolResult(result);
return (
<div className="bg-neutral-900 rounded-lg p-4 border border-neutral-800 animate-fade-in">
<div className="flex items-center gap-3 mb-3">
<div className="w-8 h-8 rounded-lg bg-neutral-800 border border-neutral-700 flex items-center justify-center">
<Settings className="w-4 h-4 text-neutral-400" />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-white font-mono">
{call.name}
</div>
<div className="text-xs text-neutral-500">
{isLoading ? "Processing..." : "Completed"}
</div>
</div>
{isLoading ? (
<Loader2 className="w-4 h-4 animate-spin text-brand-accent" />
) : (
parsedResult.status === "success" && (
<Check className="w-4 h-4 text-green-400" />
)
)}
</div>
{result && (
<div className="text-sm rounded-lg p-3 bg-black border border-neutral-800 text-neutral-300 font-mono">
{parsedResult.content}
</div>
)}
</div>
);
}
/**
* Parse weather content string into structured data
*/
function parseWeatherContent(content: string): {
location: string;
condition: string;
temperature: string;
wind: string;
humidity: string;
} | null {
// Pattern: "Weather in City, Country: Condition, Temp°C, Wind: X km/h, Humidity: Y%"
const match = content.match(
/Weather in ([^:]+): ([^,]+), ([^,]+), Wind: ([^,]+), Humidity: (.+)/
);
if (!match) return null;
return {
location: match[1],
condition: match[2],
temperature: match[3],
wind: match[4],
humidity: match[5],
};
}
/**
* Get weather icon based on condition
*/
function getWeatherIcon(condition: string) {
const c = condition.toLowerCase();
if (c.includes("rain") || c.includes("drizzle") || c.includes("shower")) {
return <CloudRain className="w-8 h-8 text-sky-300" />;
}
if (c.includes("snow")) {
return <Snowflake className="w-8 h-8 text-blue-200" />;
}
if (c.includes("thunder")) {
return <CloudLightning className="w-8 h-8 text-yellow-300" />;
}
if (c.includes("cloud") || c.includes("overcast") || c.includes("fog")) {
return <Cloud className="w-8 h-8 text-neutral-300" />;
}
return <Sun className="w-8 h-8 text-amber-300" />;
}
/**
* Weather tool call card - Weather station style with live data display
*/
function WeatherToolCallCard({
call,
result,
state,
}: {
call: ToolCallFromTool<typeof getWeather>;
result?: ToolMessage;
state: ToolCallState;
}) {
const isLoading = state === "pending";
const parsedResult = parseToolResult(result);
const isError = parsedResult.status === "error";
const weather = !isError ? parseWeatherContent(parsedResult.content) : null;
return (
<div className="relative overflow-hidden rounded-xl animate-fade-in">
{/* Sky gradient background */}
<div className="absolute inset-0 bg-linear-to-br from-sky-600 via-sky-500 to-indigo-600" />
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,rgba(255,255,255,0.15),transparent_50%)]" />
<div className="relative p-4">
{/* Location header */}
<div className="flex items-center gap-2 text-white/80 text-xs mb-3">
<MapPin className="w-3 h-3" />
<span className="font-medium">{call.args.location}</span>
{isLoading && <Loader2 className="w-3 h-3 animate-spin ml-auto" />}
</div>
{isError ? (
<div className="bg-red-500/20 backdrop-blur-sm rounded-lg p-3 text-red-200 text-sm border border-red-400/30">
{parsedResult.content}
</div>
) : weather ? (
<div className="flex items-start justify-between">
{/* Left: Icon and condition */}
<div className="flex flex-col items-start">
{getWeatherIcon(weather.condition)}
<span className="text-white/90 text-xs mt-1 font-medium">
{weather.condition}
</span>
</div>
{/* Right: Temperature */}
<div className="text-right">
<div className="text-4xl font-light text-white tracking-tight">
{weather.temperature}
</div>
{/* Stats row */}
<div className="flex gap-3 mt-2 text-white/70 text-xs">
<div className="flex items-center gap-1">
<Wind className="w-3 h-3" />
<span>{weather.wind}</span>
</div>
<div className="flex items-center gap-1">
<Droplets className="w-3 h-3" />
<span>{weather.humidity}</span>
</div>
</div>
</div>
</div>
) : (
<div className="flex items-center justify-center py-4">
<div className="flex flex-col items-center gap-2 text-white/60">
<Cloud className="w-8 h-8 animate-pulse" />
<span className="text-xs">Fetching weather...</span>
</div>
</div>
)}
</div>
</div>
);
}
/**
* Parse calculator result to get expression and answer
*/
function parseCalculatorResult(content: string | undefined): {
expression: string;
result: string;
} | null {
if (!content) return null;
// Try to parse as JSON first (from the branching-chat agent)
try {
const parsed = JSON.parse(content);
if (parsed.expression && parsed.result !== undefined) {
return {
expression: parsed.expression,
result: String(parsed.result),
};
}
} catch {
// Not JSON, try regex pattern
}
// Try "expression = result" pattern
const match = content.match(/(.+)\s*=\s*(.+)/);
if (!match) return null;
return { expression: match[1].trim(), result: match[2].trim() };
}
/**
* Calculator tool call card - Retro LCD calculator display
*/
function CalculatorToolCallCard({
call,
result,
state,
}: {
call: ToolCallFromTool<typeof calculate>;
result?: ToolMessage;
state: ToolCallState;
}) {
const isLoading = state === "pending";
const calcResult = parseCalculatorResult(
result?.content as string | undefined
);
const isError = calcResult === null && result != null;
return (
<div className="rounded-xl bg-linear-to-b from-neutral-700 to-neutral-800 p-1 animate-fade-in shadow-lg">
{/* Calculator body */}
<div className="rounded-lg bg-linear-to-b from-neutral-800 to-neutral-900 p-3">
{/* Brand label */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-1.5">
<Calculator className="w-3 h-3 text-neutral-500" />
<span className="text-[10px] text-neutral-500 font-medium tracking-widest uppercase">
LangGraph
</span>
</div>
{isLoading && (
<Loader2 className="w-3 h-3 animate-spin text-amber-400" />
)}
</div>
{/* LCD Display */}
<div className="bg-[#1a2f1a] rounded-md p-3 border border-[#0f1f0f] shadow-inner">
{isError ? (
<div className="font-mono text-red-400 text-sm">
ERR: Could not calculate
</div>
) : (
<>
{/* Expression row */}
<div className="text-[#3a5f3a] font-mono text-xs text-right mb-1 h-4 overflow-hidden">
{call.args.expression ?? ""}
</div>
{/* Result row */}
<div className="flex items-center justify-end gap-2">
{calcResult && <Equal className="w-3 h-3 text-[#4a7f4a]" />}
<span
className="font-mono text-2xl font-bold tracking-wider"
style={{
color: "#7fff7f",
textShadow: "0 0 10px rgba(127, 255, 127, 0.5)",
}}
>
{calcResult?.result ?? (isLoading ? "..." : "0")}
</span>
</div>
</>
)}
</div>
{/* Decorative button hints */}
<div className="flex justify-end gap-1 mt-2">
{["÷", "×", "", "+"].map((op) => (
<div
key={op}
className="w-5 h-5 rounded bg-neutral-700/50 flex items-center justify-center text-neutral-500 text-xs"
>
{op}
</div>
))}
</div>
</div>
</div>
);
}
/**
* Note tool call card - Sticky note / pinned paper aesthetic
*/
function NoteToolCallCard({
call,
result,
state,
}: {
call: ToolCallFromTool<typeof takeNote>;
result?: ToolMessage;
state: ToolCallState;
}) {
const isLoading = state === "pending";
const parsedResult = parseToolResult(result);
const isError = parsedResult.status === "error";
return (
<div className="relative animate-fade-in">
{/* Push pin */}
<div className="absolute -top-1 left-1/2 -translate-x-1/2 z-10">
<div className="w-4 h-4 rounded-full bg-linear-to-br from-red-400 to-red-600 shadow-md border border-red-700/50 flex items-center justify-center">
<div className="w-1.5 h-1.5 rounded-full bg-red-300/50" />
</div>
<div className="w-0.5 h-2 bg-neutral-600 mx-auto -mt-0.5" />
</div>
{/* Note paper */}
<div
className="mt-2 rounded-sm shadow-lg"
style={{
background: "linear-gradient(135deg, #fef3c7 0%, #fde68a 100%)",
transform: "rotate(-1deg)",
}}
>
{/* Lined paper effect */}
<div
className="p-4 pt-5"
style={{
backgroundImage:
"repeating-linear-gradient(transparent, transparent 23px, #d4a574 24px)",
backgroundPosition: "0 12px",
}}
>
{/* Title */}
<div className="flex items-start justify-between mb-2">
<h4
className="font-semibold text-amber-900 text-sm"
style={{ fontFamily: "Georgia, serif" }}
>
{call.args.title ?? "Untitled Note"}
</h4>
{isLoading ? (
<Loader2 className="w-3 h-3 animate-spin text-amber-700" />
) : !isError ? (
<Check className="w-3 h-3 text-emerald-600" />
) : null}
</div>
{/* Content */}
{isError ? (
<p className="text-red-700 text-xs">{parsedResult.content}</p>
) : (
<p
className="text-amber-800 text-sm leading-6"
style={{ fontFamily: "Georgia, serif" }}
>
{call.args.content ??
(result ? parsedResult.content : "Writing...")}
</p>
)}
{/* Decorative corner fold */}
<div
className="absolute bottom-0 right-0 w-6 h-6"
style={{
background:
"linear-gradient(135deg, transparent 50%, #d4a574 50%)",
}}
/>
</div>
</div>
{/* Shadow underneath */}
<div
className="absolute -bottom-1 left-2 right-2 h-2 rounded-full"
style={{
background:
"radial-gradient(ellipse at center, rgba(0,0,0,0.15) 0%, transparent 70%)",
}}
/>
</div>
);
}
/**
* Parse fact result to get topic and fact
*/
function parseFactResult(content: string): {
topic: string;
fact: string;
} | null {
try {
const parsed = JSON.parse(content);
if (parsed.topic && parsed.fact) {
return { topic: parsed.topic, fact: parsed.fact };
}
} catch {
// Try to extract from plain text
const match = content.match(/Topic:\s*(.+?)\s*Fact:\s*(.+)/i);
if (match) {
return { topic: match[1], fact: match[2] };
}
}
return null;
}
/**
* Fact tool call card - Minimal trivia card
*/
function FactToolCallCard({
call,
result,
state,
}: {
call: ToolCallFromTool<typeof getFact>;
result?: ToolMessage;
state: ToolCallState;
}) {
const isLoading = state === "pending";
const parsedResult = parseToolResult(result);
const isError = parsedResult.status === "error";
let factData: { topic: string; fact: string } | null = null;
if (!isError && result) {
factData = parseFactResult(result.content as string);
}
const topic = factData?.topic ?? call.args.topic ?? "trivia";
const topicIcon = topic.toLowerCase().includes("science") ? (
<FlaskConical className="w-4 h-4" />
) : topic.toLowerCase().includes("history") ? (
<Landmark className="w-4 h-4" />
) : topic.toLowerCase().includes("nature") ? (
<TreePine className="w-4 h-4" />
) : (
<Lightbulb className="w-4 h-4" />
);
return (
<div className="bg-neutral-900 rounded-lg p-4 border border-neutral-800 animate-fade-in">
{/* Header */}
<div className="flex items-center gap-3 mb-3">
<div className="w-8 h-8 rounded-lg bg-amber-500/10 border border-amber-500/20 flex items-center justify-center text-amber-400">
{topicIcon}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-white">Did you know?</div>
<div className="text-xs text-neutral-500 capitalize">{topic}</div>
</div>
{isLoading ? (
<Loader2 className="w-4 h-4 animate-spin text-amber-400" />
) : !isError ? (
<Check className="w-4 h-4 text-green-400" />
) : null}
</div>
{/* Content */}
{isError ? (
<div className="text-sm text-red-400">{parsedResult.content}</div>
) : factData ? (
<p className="text-sm text-neutral-300 leading-relaxed">
{factData.fact}
</p>
) : (
<div className="flex items-center gap-2 text-neutral-500 text-sm">
<Lightbulb className="w-4 h-4 animate-pulse" />
<span>Finding a fact...</span>
</div>
)}
</div>
);
}
@@ -0,0 +1,108 @@
import { createAgent, tool } from "langchain";
import { ChatOpenAI } from "@langchain/openai";
import { MemorySaver } from "@langchain/langgraph";
import { z } from "zod/v4";
const model = new ChatOpenAI({ model: "gpt-4o-mini" });
/**
* A simple calculator tool to demonstrate branching with tool calls
*/
export const calculate = tool(
async ({ expression }) => {
try {
// Simple safe evaluation for basic math
const sanitized = expression.replace(/[^0-9+\-*/().%\s]/g, "");
// eslint-disable-next-line no-eval
const result = Function(`"use strict"; return (${sanitized})`)();
return JSON.stringify({
status: "success",
expression,
result: Number(result),
});
} catch {
return JSON.stringify({
status: "error",
content: `Could not evaluate: ${expression}`,
});
}
},
{
name: "calculate",
description: "Evaluate a mathematical expression",
schema: z.object({
expression: z
.string()
.describe("The mathematical expression to evaluate (e.g., '2 + 2')"),
}),
}
);
/**
* A simple facts tool that returns random facts
*/
export const getFact = tool(
async ({ topic }) => {
const facts: Record<string, string[]> = {
science: [
"Honey never spoils - archaeologists have found 3000-year-old honey in Egyptian tombs that was still edible.",
"Octopuses have three hearts and blue blood.",
"A day on Venus is longer than a year on Venus.",
],
history: [
"Cleopatra lived closer in time to the Moon landing than to the construction of the Great Pyramid.",
"Oxford University is older than the Aztec Empire.",
"The Eiffel Tower can grow up to 6 inches taller during summer due to thermal expansion.",
],
nature: [
"Trees can communicate with each other through underground fungal networks.",
"Crows can recognize human faces and hold grudges.",
"Bananas are berries, but strawberries are not.",
],
};
const topicFacts = facts[topic.toLowerCase()] || facts.science;
const randomFact =
topicFacts[Math.floor(Math.random() * topicFacts.length)];
return JSON.stringify({
status: "success",
topic,
fact: randomFact,
});
},
{
name: "get_fact",
description: "Get a random interesting fact about a topic",
schema: z.object({
topic: z
.string()
.describe(
"The topic to get a fact about (science, history, or nature)"
),
}),
}
);
/**
* Branching Chat Agent
*
* A simple agent that demonstrates conversation branching.
* Users can edit previous messages or regenerate AI responses
* to explore different conversation paths.
*/
export const agent = createAgent({
model,
tools: [calculate, getFact],
checkpointer: new MemorySaver(),
systemPrompt: `You are a helpful and curious assistant that loves exploring ideas.
When answering questions:
- Be concise but informative
- If asked about math, use the calculator tool
- If asked for interesting facts, use the get_fact tool
- Be creative and engaging in your responses
Since users can branch conversations and try different questions,
feel free to be playful and offer alternative angles they might explore.`,
});
@@ -0,0 +1,58 @@
import { ChevronLeft, ChevronRight, GitBranch } from "lucide-react";
interface BranchSwitcherProps {
branch: string | undefined;
branchOptions: string[] | undefined;
onSelect: (branch: string) => void;
}
/**
* Component for navigating between conversation branches.
* Shows prev/next controls when multiple branches exist at a fork point.
*/
export function BranchSwitcher({
branch,
branchOptions,
onSelect,
}: BranchSwitcherProps) {
if (!branchOptions || branchOptions.length <= 1 || !branch) {
return null;
}
const index = branchOptions.indexOf(branch);
const hasPrev = index > 0;
const hasNext = index < branchOptions.length - 1;
return (
<div className="inline-flex items-center gap-1 px-2 py-1 rounded-lg bg-neutral-800/60 border border-neutral-700/50">
<GitBranch className="w-3 h-3 text-purple-400" />
<button
type="button"
disabled={!hasPrev}
onClick={() => {
const prevBranch = branchOptions[index - 1];
if (prevBranch) onSelect(prevBranch);
}}
className="p-0.5 rounded hover:bg-neutral-700/50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
title="Previous branch"
>
<ChevronLeft className="w-3.5 h-3.5 text-neutral-300" />
</button>
<span className="text-xs text-neutral-400 font-medium min-w-[3ch] text-center">
{index + 1}/{branchOptions.length}
</span>
<button
type="button"
disabled={!hasNext}
onClick={() => {
const nextBranch = branchOptions[index + 1];
if (nextBranch) onSelect(nextBranch);
}}
className="p-0.5 rounded hover:bg-neutral-700/50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
title="Next branch"
>
<ChevronRight className="w-3.5 h-3.5 text-neutral-300" />
</button>
</div>
);
}
@@ -0,0 +1,369 @@
import { useState, useCallback } from "react";
import { useStickToBottom } from "use-stick-to-bottom";
import {
AlertCircle,
GitBranch,
Pencil,
RefreshCw,
X,
Check,
} from "lucide-react";
import { useStream } from "@langchain/langgraph-sdk/react";
import type { Message } from "@langchain/langgraph-sdk";
import type { InferAgentToolCalls } from "@langchain/langgraph-sdk/react";
import { registerExample } from "../registry";
import { LoadingIndicator } from "../../components/Loading";
import { EmptyState } from "../../components/States";
import { MessageInput } from "../../components/MessageInput";
import { ToolCallCard } from "../../components/ToolCallCard";
import type { agent } from "./agent";
import { BranchSwitcher } from "./components/BranchSwitcher";
const BRANCHING_SUGGESTIONS = [
"Tell me an interesting fact about science",
"What's 15% of 230?",
"Give me a random history fact",
];
/**
* Helper to check if a message has actual text content.
*/
function hasContent(message: Message): boolean {
if (typeof message.content === "string") {
return message.content.trim().length > 0;
}
if (Array.isArray(message.content)) {
return message.content.some(
(c) => c.type === "text" && c.text.trim().length > 0
);
}
return false;
}
/**
* Extract text content from a message
*/
function getTextContent(message: Message): string {
if (typeof message.content === "string") {
return message.content;
}
if (Array.isArray(message.content)) {
return message.content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.text)
.join("");
}
return "";
}
/**
* Inline edit component for human messages
*/
function EditableMessage({
content,
onSave,
onCancel,
}: {
content: string;
onSave: (newContent: string) => void;
onCancel: () => void;
}) {
const [value, setValue] = useState(content);
return (
<div className="flex flex-col gap-2 w-full">
<textarea
value={value}
onChange={(e) => setValue(e.target.value)}
className="w-full px-3 py-2 bg-neutral-800 border border-neutral-600 rounded-lg text-neutral-100 text-sm resize-none focus:outline-none focus:border-purple-500"
rows={3}
autoFocus
/>
<div className="flex gap-2 justify-end">
<button
type="button"
onClick={onCancel}
className="px-3 py-1.5 text-xs rounded-lg bg-neutral-700 hover:bg-neutral-600 text-neutral-300 transition-colors flex items-center gap-1"
>
<X className="w-3 h-3" />
Cancel
</button>
<button
type="button"
onClick={() => onSave(value)}
className="px-3 py-1.5 text-xs rounded-lg bg-purple-600 hover:bg-purple-500 text-white transition-colors flex items-center gap-1"
>
<Check className="w-3 h-3" />
Save & Branch
</button>
</div>
</div>
);
}
export function BranchingChat() {
const stream = useStream<typeof agent>({
assistantId: "agent",
apiUrl: "http://localhost:2024",
// Enable state history fetching for branching support
fetchStateHistory: true,
});
const { scrollRef, contentRef } = useStickToBottom();
const [editingMessageId, setEditingMessageId] = useState<string | null>(null);
const hasMessages = stream.messages.length > 0;
const handleSubmit = useCallback(
(content: string) => {
stream.submit({ messages: [{ content, type: "human" }] });
},
[stream]
);
/**
* Edit a human message - creates a new branch from the parent checkpoint
*/
const handleEditMessage = useCallback(
(
message: Message<InferAgentToolCalls<typeof agent>>,
newContent: string
) => {
const meta = stream.getMessagesMetadata(message);
const parentCheckpoint = meta?.firstSeenState?.parent_checkpoint;
// Submit from the parent checkpoint with the new message content
stream.submit(
{ messages: [{ content: newContent, type: "human" }] },
{ checkpoint: parentCheckpoint }
);
setEditingMessageId(null);
},
[stream]
);
/**
* Regenerate an AI response - creates a new branch from the parent checkpoint
*/
const handleRegenerate = useCallback(
(message: Message<InferAgentToolCalls<typeof agent>>) => {
const meta = stream.getMessagesMetadata(message);
const parentCheckpoint = meta?.firstSeenState?.parent_checkpoint;
// Submit with undefined to regenerate from the parent checkpoint
stream.submit(undefined, { checkpoint: parentCheckpoint });
},
[stream]
);
return (
<div className="h-full flex flex-col">
<main ref={scrollRef} className="flex-1 overflow-y-auto">
<div ref={contentRef} className="max-w-2xl mx-auto px-4 py-8">
{!hasMessages ? (
<EmptyState
icon={GitBranch}
title="Branching Chat"
description="Explore alternate conversation paths! Edit any message to branch the conversation, or regenerate AI responses to see different outcomes. Navigate between branches using the switcher."
suggestions={BRANCHING_SUGGESTIONS}
onSuggestionClick={handleSubmit}
/>
) : (
<div className="flex flex-col gap-6">
{stream.messages.map((message, idx) => {
const meta = stream.getMessagesMetadata(message);
const isEditing = editingMessageId === message.id;
// For AI messages, check if they have tool calls
if (message.type === "ai") {
const toolCalls = stream.getToolCalls(message);
// Render tool calls if present
if (toolCalls.length > 0) {
return (
<div key={message.id} className="flex flex-col gap-3">
{toolCalls.map((toolCall) => (
<ToolCallCard key={toolCall.id} toolCall={toolCall} />
))}
{/* Regenerate button for tool calls */}
<div className="flex items-center gap-2">
<BranchSwitcher
branch={meta?.branch}
branchOptions={meta?.branchOptions}
onSelect={(branch) => stream.setBranch(branch)}
/>
<button
type="button"
onClick={() => handleRegenerate(message)}
disabled={stream.isLoading}
className="inline-flex items-center gap-1 px-2 py-1 text-xs text-neutral-400 hover:text-purple-400 hover:bg-neutral-800/50 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Regenerate (creates a new branch)"
>
<RefreshCw className="w-3 h-3" />
Regenerate
</button>
</div>
</div>
);
}
// Skip AI messages without content
if (!hasContent(message)) {
return null;
}
// Render AI message with regenerate option
return (
<div key={message.id ?? idx} className="animate-fade-in">
<div className="flex items-center gap-2 mb-2">
<span className="text-xs font-medium text-neutral-500">
Assistant
</span>
<BranchSwitcher
branch={meta?.branch}
branchOptions={meta?.branchOptions}
onSelect={(branch) => stream.setBranch(branch)}
/>
</div>
<div className="text-neutral-100">
<div className="whitespace-pre-wrap leading-relaxed text-[15px]">
{getTextContent(message)}
</div>
</div>
{/* Regenerate button */}
<div className="mt-2 flex items-center gap-2">
<button
type="button"
onClick={() => handleRegenerate(message)}
disabled={stream.isLoading}
className="inline-flex items-center gap-1 px-2 py-1 text-xs text-neutral-400 hover:text-purple-400 hover:bg-neutral-800/50 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Regenerate response (creates a new branch)"
>
<RefreshCw className="w-3 h-3" />
Regenerate
</button>
</div>
</div>
);
}
// Human messages with edit option
if (message.type === "human") {
return (
<div key={message.id ?? idx} className="animate-fade-in">
<div className="flex items-center gap-2 mb-2 justify-end">
<BranchSwitcher
branch={meta?.branch}
branchOptions={meta?.branchOptions}
onSelect={(branch) => stream.setBranch(branch)}
/>
</div>
<div className="flex justify-end">
{isEditing ? (
<div className="w-full max-w-[85%] md:max-w-[70%]">
<EditableMessage
content={getTextContent(message)}
onSave={(newContent) =>
handleEditMessage(message, newContent)
}
onCancel={() => setEditingMessageId(null)}
/>
</div>
) : (
<div className="group relative">
<div className="bg-brand-dark text-brand-light rounded-2xl px-4 py-2.5 max-w-[85%] md:max-w-[70%] w-fit">
<div className="whitespace-pre-wrap leading-relaxed text-[15px]">
{getTextContent(message)}
</div>
</div>
{/* Edit button */}
<button
type="button"
onClick={() => setEditingMessageId(message.id!)}
disabled={stream.isLoading}
className="absolute -left-8 top-1/2 -translate-y-1/2 p-1.5 text-neutral-500 hover:text-purple-400 hover:bg-neutral-800/50 rounded opacity-0 group-hover:opacity-100 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
title="Edit message (creates a new branch)"
>
<Pencil className="w-3.5 h-3.5" />
</button>
</div>
)}
</div>
</div>
);
}
// Tool messages are handled by ToolCallCard
if (message.type === "tool") {
return null;
}
// System messages
return (
<div key={message.id ?? idx} className="animate-fade-in">
<div className="text-xs font-medium text-neutral-500 mb-2">
System
</div>
<div className="bg-amber-500/10 border border-amber-500/20 text-amber-200 rounded-lg px-4 py-3">
<div className="whitespace-pre-wrap leading-relaxed text-[15px]">
{getTextContent(message)}
</div>
</div>
</div>
);
})}
{/* Show loading indicator when streaming */}
{stream.isLoading &&
!stream.messages.some(
(m) => m.type === "ai" && hasContent(m)
) &&
stream.toolCalls.length === 0 && <LoadingIndicator />}
</div>
)}
</div>
</main>
{stream.error != null && (
<div className="max-w-2xl mx-auto px-4 pb-3">
<div className="bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3 text-red-400 text-sm">
<div className="flex items-center gap-2">
<AlertCircle className="w-4 h-4 shrink-0" />
<span>
{stream.error instanceof Error
? stream.error.message
: "An error occurred"}
</span>
</div>
</div>
</div>
)}
<MessageInput
disabled={stream.isLoading || editingMessageId !== null}
placeholder={
editingMessageId
? "Finish editing the message first..."
: "Ask me anything..."
}
onSubmit={handleSubmit}
/>
</div>
);
}
// Register this example
registerExample({
id: "branching-chat",
title: "Branching Chat",
description:
"Explore alternate conversation paths by editing messages or regenerating responses",
category: "advanced",
icon: "graph",
ready: true,
component: BranchingChat,
});
export default BranchingChat;
@@ -0,0 +1,193 @@
import { z } from "zod/v3";
import { tool, type ToolRuntime } from "langchain";
import { ChatOpenAI } from "@langchain/openai";
import type { BaseMessageLike } from "@langchain/core/messages";
import {
StateGraph,
MessagesAnnotation,
START,
END,
} from "@langchain/langgraph";
import { MemorySaver } from "@langchain/langgraph";
import { ToolNode } from "@langchain/langgraph/prebuilt";
import type { ProgressData, StatusData, FileStatusData } from "./types";
/**
* The model to use for the agent
*/
const model = new ChatOpenAI({
model: "gpt-4o-mini",
});
/**
* Data analysis tool - demonstrates custom streaming events
* Emits progress updates during execution using typed custom events
*/
const analyzeDataTool = tool(
async (
{ dataSource, analysisType },
config: ToolRuntime
): Promise<string> => {
const steps = [
{ step: "connecting", message: `Connecting to ${dataSource}...` },
{ step: "fetching", message: "Fetching data records..." },
{ step: "processing", message: `Running ${analysisType} analysis...` },
{ step: "generating", message: "Generating insights..." },
];
// Use a unique ID for this analysis to make progress parts persistent
// Parts with an 'id' field are added to message.parts (not transient)
const analysisId = `analysis-${Date.now()}`;
for (let i = 0; i < steps.length; i++) {
// Emit progress events with typed custom data
// The adapter will convert { type: 'progress', ... } to data-progress
// The 'id' field makes this part persistent (added to message.parts)
config.writer?.({
type: "progress",
id: analysisId, // Same ID to update the progress in place
step: steps[i].step,
message: steps[i].message,
progress: Math.round(((i + 1) / steps.length) * 100),
totalSteps: steps.length,
currentStep: i + 1,
} satisfies ProgressData);
// Simulate processing time
await new Promise((resolve) =>
setTimeout(resolve, 500 + Math.random() * 500)
);
}
// Emit completion event with unique ID
config.writer?.({
type: "status",
id: `${analysisId}-status`,
status: "complete",
message: "Analysis finished successfully",
} satisfies StatusData);
// Return the result to the LLM
const results = {
dataSource,
analysisType,
recordsProcessed: Math.floor(Math.random() * 10000) + 1000,
insights: [
"Key trend: 23% increase in Q4",
"Anomaly detected in region B",
"Correlation found between X and Y metrics",
],
confidence: 0.94,
};
return JSON.stringify(results, null, 2);
},
{
name: "analyze_data",
description:
"Analyze data from various sources. Streams progress updates during analysis.",
schema: z.object({
dataSource: z
.enum(["sales", "inventory", "customers", "transactions"])
.describe("The data source to analyze"),
analysisType: z
.enum(["trends", "anomalies", "correlations", "summary"])
.describe("The type of analysis to perform"),
}),
}
);
/**
* File processing tool - demonstrates status updates
*/
const processFileTool = tool(
async ({ filename, operation }, config: ToolRuntime) => {
// Use a unique ID for this file operation to make it persistent
const fileOpId = `file-${filename}-${Date.now()}`;
// Emit file operation status with ID for persistence
config.writer?.({
type: "file-status",
id: fileOpId,
filename,
operation,
status: "started",
} satisfies FileStatusData);
await new Promise((resolve) => setTimeout(resolve, 1000));
// Update the same part with completed status
config.writer?.({
type: "file-status",
id: fileOpId,
filename,
operation,
status: "completed",
size: `${Math.floor(Math.random() * 1000) + 100}KB`,
} satisfies FileStatusData);
return `Successfully ${operation}ed file: ${filename}`;
},
{
name: "process_file",
description: "Process a file with various operations",
schema: z.object({
filename: z.string().describe("The filename to process"),
operation: z
.enum(["read", "compress", "validate", "transform"])
.describe("The operation to perform"),
}),
}
);
/**
* Type cast needed due to package version compatibility between langchain and @langchain/langgraph
* @todo(@christian-bromann): fix this
*/
const tools = [analyzeDataTool, processFileTool];
const toolNode = new ToolNode(tools as any);
/**
* Call the model with tools bound
*/
async function callModel(state: typeof MessagesAnnotation.State) {
const modelWithTools = model.bindTools(tools);
const response = await modelWithTools.invoke(
state.messages as unknown as BaseMessageLike[]
);
return { messages: [response] };
}
/**
* Determine if we should continue to tools or end
*/
function shouldContinue(state: typeof MessagesAnnotation.State) {
const lastMessage = state.messages[state.messages.length - 1];
if (
lastMessage &&
"tool_calls" in lastMessage &&
Array.isArray(lastMessage.tool_calls) &&
lastMessage.tool_calls.length > 0
) {
return "tools";
}
return END;
}
/**
* Create the LangGraph workflow
*/
const workflow = new StateGraph(MessagesAnnotation)
.addNode("agent", callModel)
.addNode("tools", toolNode)
.addEdge(START, "agent")
.addConditionalEdges("agent", shouldContinue)
.addEdge("tools", "agent");
/**
* Compile the graph with a memory checkpointer
*/
export const agent = workflow.compile({
checkpointer: new MemorySaver(),
});
@@ -0,0 +1,112 @@
import {
FileText,
Archive,
ShieldCheck,
RefreshCw,
CheckCircle2,
Loader2,
type LucideIcon,
} from "lucide-react";
import type { FileStatusData } from "../types";
interface FileOperationCardProps {
data: FileStatusData;
}
const OPERATION_CONFIG: Record<
FileStatusData["operation"],
{ icon: LucideIcon; label: string; gradient: string; border: string }
> = {
read: {
icon: FileText,
label: "Reading",
gradient: "from-sky-950/60 to-cyan-950/40",
border: "border-sky-500/30",
},
compress: {
icon: Archive,
label: "Compressing",
gradient: "from-amber-950/60 to-orange-950/40",
border: "border-amber-500/30",
},
validate: {
icon: ShieldCheck,
label: "Validating",
gradient: "from-emerald-950/60 to-teal-950/40",
border: "border-emerald-500/30",
},
transform: {
icon: RefreshCw,
label: "Transforming",
gradient: "from-purple-950/60 to-fuchsia-950/40",
border: "border-purple-500/30",
},
};
/**
* FileOperationCard displays file processing status from streaming events.
* Shows the filename, operation type, and current status with appropriate styling.
*/
export function FileOperationCard({ data }: FileOperationCardProps) {
const config = OPERATION_CONFIG[data.operation];
const Icon = config.icon;
const isCompleted = data.status === "completed";
const isError = data.status === "error";
return (
<div className="animate-fade-in">
<div
className={`bg-linear-to-br ${config.gradient} border ${config.border} rounded-xl p-4 backdrop-blur-sm`}
>
<div className="flex items-center gap-3">
{/* Icon */}
<div className="w-10 h-10 rounded-lg bg-white/5 flex items-center justify-center">
{isCompleted ? (
<CheckCircle2 className="w-5 h-5 text-emerald-400" />
) : isError ? (
<Icon className="w-5 h-5 text-red-400" />
) : (
<Icon className="w-5 h-5 text-neutral-300 animate-pulse" />
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-white truncate">
{data.filename}
</span>
{!isCompleted && !isError && (
<Loader2 className="w-3.5 h-3.5 text-neutral-400 animate-spin shrink-0" />
)}
</div>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-xs text-neutral-400">
{config.label} file
</span>
{data.size && (
<>
<span className="text-neutral-600">·</span>
<span className="text-xs text-neutral-500">{data.size}</span>
</>
)}
</div>
</div>
{/* Status badge */}
<div
className={`px-2.5 py-1 rounded-md text-xs font-medium uppercase tracking-wide ${
isCompleted
? "bg-emerald-500/20 text-emerald-300"
: isError
? "bg-red-500/20 text-red-300"
: "bg-white/10 text-neutral-300"
}`}
>
{data.status}
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,76 @@
import { Activity, CheckCircle2, Loader2 } from "lucide-react";
import type { ProgressData } from "../types";
interface ProgressCardProps {
data: ProgressData;
isComplete?: boolean;
}
/**
* ProgressCard displays streaming progress updates from the data analysis tool.
* Shows a progress bar, current step, and status message.
*/
export function ProgressCard({ data, isComplete = false }: ProgressCardProps) {
const progressPercent = Math.min(100, Math.max(0, data.progress));
return (
<div className="animate-fade-in">
<div className="bg-linear-to-br from-indigo-950/60 to-violet-950/40 border border-indigo-500/30 rounded-xl p-4 backdrop-blur-sm">
{/* Header */}
<div className="flex items-center gap-3 mb-4">
<div className="w-9 h-9 rounded-lg bg-indigo-500/20 flex items-center justify-center">
{isComplete ? (
<CheckCircle2 className="w-5 h-5 text-emerald-400" />
) : (
<Activity className="w-5 h-5 text-indigo-400 animate-pulse" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h4 className="text-sm font-medium text-white">Data Analysis</h4>
{!isComplete && (
<Loader2 className="w-3.5 h-3.5 text-indigo-400 animate-spin" />
)}
</div>
<p className="text-xs text-indigo-300/70 truncate">
Step {data.currentStep} of {data.totalSteps}
</p>
</div>
<div className="text-right">
<span className="text-lg font-bold text-indigo-300">
{progressPercent}%
</span>
</div>
</div>
{/* Progress bar */}
<div className="relative h-2 bg-indigo-950/80 rounded-full overflow-hidden mb-3">
<div
className="absolute inset-y-0 left-0 bg-linear-to-r from-indigo-500 to-violet-500 rounded-full transition-all duration-500 ease-out"
style={{ width: `${progressPercent}%` }}
/>
{!isComplete && (
<div
className="absolute inset-y-0 left-0 bg-linear-to-r from-transparent via-white/20 to-transparent rounded-full animate-shimmer"
style={{ width: `${progressPercent}%` }}
/>
)}
</div>
{/* Current step message */}
<div className="flex items-center gap-2 text-sm">
<span
className={`px-2 py-0.5 rounded text-xs font-medium uppercase tracking-wide ${
isComplete
? "bg-emerald-500/20 text-emerald-300"
: "bg-indigo-500/20 text-indigo-300"
}`}
>
{data.step}
</span>
<span className="text-indigo-200/80">{data.message}</span>
</div>
</div>
</div>
);
}
@@ -0,0 +1,50 @@
import { CheckCircle2, XCircle, Sparkles } from "lucide-react";
import type { StatusData } from "../types";
interface StatusBadgeProps {
data: StatusData;
}
/**
* StatusBadge displays completion or error status from streaming events.
* Shows a compact badge with icon and message.
*/
export function StatusBadge({ data }: StatusBadgeProps) {
const isComplete = data.status === "complete";
return (
<div className="animate-fade-in">
<div
className={`inline-flex items-center gap-2.5 px-4 py-2.5 rounded-xl border backdrop-blur-sm ${
isComplete
? "bg-emerald-950/40 border-emerald-500/30"
: "bg-red-950/40 border-red-500/30"
}`}
>
<div
className={`w-7 h-7 rounded-lg flex items-center justify-center ${
isComplete ? "bg-emerald-500/20" : "bg-red-500/20"
}`}
>
{isComplete ? (
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
) : (
<XCircle className="w-4 h-4 text-red-400" />
)}
</div>
<div className="flex items-center gap-2">
<span
className={`text-sm font-medium ${
isComplete ? "text-emerald-300" : "text-red-300"
}`}
>
{data.message}
</span>
{isComplete && (
<Sparkles className="w-3.5 h-3.5 text-emerald-400/60" />
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,238 @@
import { useCallback, useState } from "react";
import { useStickToBottom } from "use-stick-to-bottom";
import { AlertCircle, Radio } from "lucide-react";
import { useStream } from "@langchain/langgraph-sdk/react";
import type { Message } from "@langchain/langgraph-sdk";
import { registerExample } from "../registry";
import { LoadingIndicator } from "../../components/Loading";
import { EmptyState } from "../../components/States";
import { MessageBubble } from "../../components/MessageBubble";
import { MessageInput } from "../../components/MessageInput";
import type { agent } from "./agent";
import {
isProgressData,
isStatusData,
isFileStatusData,
type ProgressData,
type StatusData,
type FileStatusData,
} from "./types";
import { ProgressCard } from "./components/ProgressCard";
import { StatusBadge } from "./components/StatusBadge";
import { FileOperationCard } from "./components/FileOperationCard";
const CUSTOM_STREAMING_SUGGESTIONS = [
"Analyze sales data for trends",
"Process and compress report.pdf",
"Analyze inventory for anomalies",
"Validate config.json and transform data.csv",
];
/**
* Helper to check if a message has actual text content.
*/
function hasContent(message: Message): boolean {
if (typeof message.content === "string") {
return message.content.trim().length > 0;
}
if (Array.isArray(message.content)) {
return message.content.some(
(c) => c.type === "text" && c.text.trim().length > 0
);
}
return false;
}
export function CustomStreaming() {
/**
* State to store custom streaming events received via onCustomEvent
*/
const [customEvents, setCustomEvents] = useState<{
progressData: Map<string, ProgressData>;
statusData: Map<string, StatusData>;
fileStatusData: Map<string, FileStatusData>;
}>({
progressData: new Map(),
statusData: new Map(),
fileStatusData: new Map(),
});
/**
* Handle custom events from the stream
* The callback receives (data: unknown, options) - we type-check data inside
*/
const handleCustomEvent = useCallback((data: unknown) => {
setCustomEvents((prev) => {
if (isProgressData(data)) {
const newProgressData = new Map(prev.progressData);
newProgressData.set(data.id, data);
return { ...prev, progressData: newProgressData };
} else if (isStatusData(data)) {
const newStatusData = new Map(prev.statusData);
newStatusData.set(data.id, data);
return { ...prev, statusData: newStatusData };
} else if (isFileStatusData(data)) {
const newFileStatusData = new Map(prev.fileStatusData);
newFileStatusData.set(data.id, data);
return { ...prev, fileStatusData: newFileStatusData };
}
return prev;
});
}, []);
const stream = useStream<typeof agent>({
assistantId: "custom-streaming",
apiUrl: "http://localhost:2024",
onCustomEvent: handleCustomEvent,
});
const { scrollRef, contentRef } = useStickToBottom();
/**
* Reset custom events when starting a new conversation
*/
const handleSubmit = useCallback(
(content: string) => {
// Clear previous custom events when sending a new message
setCustomEvents({
progressData: new Map(),
statusData: new Map(),
fileStatusData: new Map(),
});
stream.submit({ messages: [{ content, type: "human" } as any] });
},
[stream]
);
const hasMessages = stream.messages.length > 0;
/**
* Convert maps to arrays for rendering
*/
const progressDataArray = Array.from(customEvents.progressData.values());
const statusDataArray = Array.from(customEvents.statusData.values());
const fileStatusDataArray = Array.from(customEvents.fileStatusData.values());
/**
* Check if we have any custom streaming data
*/
const hasCustomData =
progressDataArray.length > 0 ||
statusDataArray.length > 0 ||
fileStatusDataArray.length > 0;
/**
* Check if all progress is complete (100%)
*/
const isProgressComplete =
progressDataArray.length > 0 &&
progressDataArray.every((p) => p.progress === 100);
return (
<div className="h-full flex flex-col">
<main ref={scrollRef} className="flex-1 overflow-y-auto">
<div ref={contentRef} className="max-w-2xl mx-auto px-4 py-8">
{!hasMessages ? (
<EmptyState
icon={Radio}
title="Custom Streaming Events"
description="Demonstrate streaming custom data from LangGraph nodes to the UI. Watch progress bars, status updates, and file operations stream in real-time as tools execute."
suggestions={CUSTOM_STREAMING_SUGGESTIONS}
onSuggestionClick={handleSubmit}
/>
) : (
<div className="flex flex-col gap-6">
{stream.messages.map((message, idx) => (
<MessageBubble key={message.id ?? idx} message={message} />
))}
{/* Render custom streaming data */}
{hasCustomData && (
<div className="flex flex-col gap-4">
{/* Progress cards */}
{progressDataArray.map((data) => (
<ProgressCard
key={data.id}
data={data}
isComplete={data.progress === 100}
/>
))}
{/* File operation cards */}
{fileStatusDataArray.map((data) => (
<FileOperationCard key={data.id} data={data} />
))}
{/* Status badges */}
{statusDataArray.length > 0 && (
<div className="flex flex-wrap gap-2">
{statusDataArray.map((data) => (
<StatusBadge key={data.id} data={data} />
))}
</div>
)}
</div>
)}
{/* Show loading indicator when streaming and no content yet */}
{stream.isLoading &&
!stream.messages.some(
(m) => m.type === "ai" && hasContent(m)
) &&
!hasCustomData && <LoadingIndicator />}
{/* Show streaming indicator when we have active progress */}
{stream.isLoading && hasCustomData && !isProgressComplete && (
<div className="flex items-center gap-3 text-indigo-400/70">
<div className="flex items-center gap-1">
<span className="w-1.5 h-1.5 bg-indigo-400 rounded-full animate-ping" />
<span className="w-1.5 h-1.5 bg-indigo-400 rounded-full animate-ping [animation-delay:150ms]" />
<span className="w-1.5 h-1.5 bg-indigo-400 rounded-full animate-ping [animation-delay:300ms]" />
</div>
<span className="text-sm">Streaming custom events...</span>
</div>
)}
</div>
)}
</div>
</main>
{stream.error != null && (
<div className="max-w-2xl mx-auto px-4 pb-3">
<div className="bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3 text-red-400 text-sm">
<div className="flex items-center gap-2">
<AlertCircle className="w-4 h-4 shrink-0" />
<span>
{stream.error instanceof Error
? stream.error.message
: "An error occurred. Make sure OPENAI_API_KEY is set."}
</span>
</div>
</div>
</div>
)}
<MessageInput
disabled={stream.isLoading}
placeholder="Ask me to analyze data or process files..."
onSubmit={handleSubmit}
/>
</div>
);
}
// Register this example
registerExample({
id: "custom-streaming",
title: "Custom Streaming Events",
description:
"Stream custom data like progress bars and status updates from LangGraph tools",
category: "advanced",
icon: "code",
ready: true,
component: CustomStreaming,
});
export default CustomStreaming;
@@ -0,0 +1,75 @@
/**
* Progress data emitted during data analysis
*/
export interface ProgressData {
type: "progress";
id: string;
step: string;
message: string;
progress: number;
totalSteps: number;
currentStep: number;
}
/**
* Status data for completion events
*/
export interface StatusData {
type: "status";
id: string;
status: "complete" | "error";
message: string;
}
/**
* File status data for file operations
*/
export interface FileStatusData {
type: "file-status";
id: string;
filename: string;
operation: "read" | "compress" | "validate" | "transform";
status: "started" | "completed" | "error";
size?: string;
}
/**
* Union type for all custom streaming data types
*/
export type CustomStreamData = ProgressData | StatusData | FileStatusData;
/**
* Type guard for ProgressData
*/
export function isProgressData(data: unknown): data is ProgressData {
return (
typeof data === "object" &&
data !== null &&
"type" in data &&
(data as ProgressData).type === "progress"
);
}
/**
* Type guard for StatusData
*/
export function isStatusData(data: unknown): data is StatusData {
return (
typeof data === "object" &&
data !== null &&
"type" in data &&
(data as StatusData).type === "status"
);
}
/**
* Type guard for FileStatusData
*/
export function isFileStatusData(data: unknown): data is FileStatusData {
return (
typeof data === "object" &&
data !== null &&
"type" in data &&
(data as FileStatusData).type === "file-status"
);
}
@@ -0,0 +1,118 @@
import { createAgent, tool, humanInTheLoopMiddleware } from "langchain";
import { ChatOpenAI } from "@langchain/openai";
import { MemorySaver } from "@langchain/langgraph";
import { z } from "zod/v4";
const model = new ChatOpenAI({ model: "gpt-4o-mini" });
/**
* A tool that simulates sending an email - requires human approval
*/
export const sendEmail = tool(
async ({ to, subject, body: _body }) => {
/**
* Simulate sending email
*/
return {
status: "success",
content: `Email sent successfully to ${to} with subject "${subject}"`,
};
},
{
name: "send_email",
description: "Send an email to a recipient. Requires human approval.",
schema: z.object({
to: z.string().describe("The email address to send to"),
subject: z.string().describe("The email subject line"),
body: z.string().describe("The email body content"),
}),
}
);
/**
* A tool that simulates deleting a file - requires human approval
*/
export const deleteFile = tool(
async ({ path }) => {
/**
* Simulate file deletion
*/
return {
status: "success",
content: `File "${path}" has been deleted successfully`,
};
},
{
name: "delete_file",
description: "Delete a file from the system. Requires human approval.",
schema: z.object({
path: z.string().describe("The file path to delete"),
}),
}
);
/**
* A safe tool that doesn't require approval
*/
export const readFile = tool(
async ({ path }) => {
/**
* Simulate reading file content
*/
const content = `Contents of ${path}:\n---\nThis is example file content for demonstration purposes.`;
return {
status: "success",
content,
};
},
{
name: "read_file",
description:
"Read the contents of a file. Safe operation, no approval needed.",
schema: z.object({
path: z.string().describe("The file path to read"),
}),
}
);
/**
* Create a ReAct agent with Human-in-the-Loop middleware.
*
* The middleware intercepts tool calls for sensitive operations and
* pauses execution until human approval is given.
*/
export const agent = createAgent({
model,
tools: [sendEmail, deleteFile, readFile],
middleware: [
humanInTheLoopMiddleware({
interruptOn: {
/**
* Email sending requires approval with all options
*/
send_email: {
allowedDecisions: ["approve", "edit", "reject"],
description: "📧 Review email before sending",
},
/**
* File deletion requires approval but no editing
*/
delete_file: {
allowedDecisions: ["approve", "reject"],
description: "🗑️ Confirm file deletion",
},
/**
* Reading files is safe, no approval needed (false = auto-approve)
*/
read_file: false,
},
descriptionPrefix: "Action requires approval",
}),
],
// Required for HITL - persists state across interrupts
checkpointer: new MemorySaver(),
systemPrompt: `You are a helpful assistant that can manage files and send emails.
When asked to send an email, propose one with appropriate subject and body.
When asked to delete files, first try to read them to show the user what will be deleted.
Always be helpful and explain what actions you're about to take.`,
});
@@ -0,0 +1,223 @@
import { useState } from "react";
import { Check, X, Pencil, Mail, Trash2, ShieldAlert } from "lucide-react";
import type { ActionRequest, ReviewConfig } from "langchain";
export const DEFAULT_REJECT_REASON = "User rejected the action";
/**
* Component for displaying a pending tool call that requires approval
*/
export function PendingApprovalCard({
actionRequest,
reviewConfig,
onApprove,
onReject,
onEdit,
isProcessing,
}: {
actionRequest: ActionRequest;
reviewConfig: ReviewConfig;
onApprove: () => void;
onReject: (reason: string) => void;
onEdit: (editedArgs: Record<string, unknown>) => void;
isProcessing: boolean;
}) {
const [isEditing, setIsEditing] = useState(false);
const [editedArgs, setEditedArgs] = useState<Record<string, unknown>>(
actionRequest.args
);
const [rejectReason, setRejectReason] = useState("");
const [showRejectInput, setShowRejectInput] = useState(false);
const getIcon = () => {
switch (actionRequest.name) {
case "send_email":
return <Mail className="w-5 h-5" />;
case "delete_file":
return <Trash2 className="w-5 h-5" />;
default:
return <ShieldAlert className="w-5 h-5" />;
}
};
const getTitle = () => {
switch (actionRequest.name) {
case "send_email":
return "Send Email";
case "delete_file":
return "Delete File";
default:
return actionRequest.name;
}
};
// Derive capabilities from config
const canEdit = reviewConfig.allowedDecisions.includes("edit");
return (
<div className="bg-amber-950/30 border border-amber-500/30 rounded-xl p-5 animate-fade-in">
{/* Header */}
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-lg bg-amber-500/20 border border-amber-500/30 flex items-center justify-center text-amber-400">
{getIcon()}
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-amber-200">
{getTitle()}
</span>
<span className="px-2 py-0.5 rounded-full bg-amber-500/20 text-amber-400 text-xs font-medium">
Awaiting Approval
</span>
</div>
<p className="text-xs text-amber-300/70 mt-0.5">
{actionRequest.description || "This action requires your approval"}
</p>
</div>
</div>
{/* Arguments */}
<div className="bg-black/40 rounded-lg p-4 mb-4 border border-amber-500/10">
{isEditing ? (
<div className="space-y-3">
{Object.entries(editedArgs).map(([key, value]) => (
<div key={key}>
<label className="block text-xs font-medium text-amber-300/80 mb-1">
{key}
</label>
{key === "body" ? (
<textarea
value={String(value)}
onChange={(e) =>
setEditedArgs({ ...editedArgs, [key]: e.target.value })
}
className="w-full bg-neutral-900 border border-amber-500/30 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-amber-400 resize-none"
rows={3}
/>
) : (
<input
type="text"
value={String(value)}
onChange={(e) =>
setEditedArgs({ ...editedArgs, [key]: e.target.value })
}
className="w-full bg-neutral-900 border border-amber-500/30 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-amber-400"
/>
)}
</div>
))}
</div>
) : (
<div className="space-y-2">
{Object.entries(actionRequest.args).map(([key, value]) => (
<div key={key} className="flex gap-2">
<span className="text-xs font-mono text-amber-300/60 min-w-[80px]">
{key}:
</span>
<span className="text-xs text-white break-all">
{String(value)}
</span>
</div>
))}
</div>
)}
</div>
{/* Reject reason input */}
{showRejectInput && (
<div className="mb-4">
<label className="block text-xs font-medium text-neutral-400 mb-1">
Reason for rejection (optional)
</label>
<input
type="text"
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
placeholder="Enter reason..."
className="w-full bg-neutral-900 border border-neutral-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-red-400"
/>
</div>
)}
{/* Action buttons */}
<div className="flex gap-2">
{isEditing ? (
<>
<button
onClick={() => {
onEdit(editedArgs);
setIsEditing(false);
}}
disabled={isProcessing}
className="flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg bg-amber-500 hover:bg-amber-400 text-black font-medium text-sm transition-colors disabled:opacity-50"
>
<Check className="w-4 h-4" />
Save & Approve
</button>
<button
onClick={() => {
setEditedArgs(actionRequest.args);
setIsEditing(false);
}}
disabled={isProcessing}
className="px-4 py-2.5 rounded-lg bg-neutral-800 hover:bg-neutral-700 text-white font-medium text-sm transition-colors disabled:opacity-50"
>
Cancel
</button>
</>
) : showRejectInput ? (
<>
<button
onClick={() => {
onReject(rejectReason ?? DEFAULT_REJECT_REASON);
setShowRejectInput(false);
}}
disabled={isProcessing}
className="flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg bg-red-500 hover:bg-red-400 text-white font-medium text-sm transition-colors disabled:opacity-50"
>
<X className="w-4 h-4" />
Confirm Reject
</button>
<button
onClick={() => setShowRejectInput(false)}
disabled={isProcessing}
className="px-4 py-2.5 rounded-lg bg-neutral-800 hover:bg-neutral-700 text-white font-medium text-sm transition-colors disabled:opacity-50"
>
Cancel
</button>
</>
) : (
<>
<button
onClick={onApprove}
disabled={isProcessing}
className="flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg bg-green-600 hover:bg-green-500 text-white font-medium text-sm transition-colors disabled:opacity-50"
>
<Check className="w-4 h-4" />
Approve
</button>
{canEdit && (
<button
onClick={() => setIsEditing(true)}
disabled={isProcessing}
className="flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg bg-amber-600 hover:bg-amber-500 text-white font-medium text-sm transition-colors disabled:opacity-50"
>
<Pencil className="w-4 h-4" />
Edit
</button>
)}
<button
onClick={() => setShowRejectInput(true)}
disabled={isProcessing}
className="flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg bg-red-600/80 hover:bg-red-500 text-white font-medium text-sm transition-colors disabled:opacity-50"
>
<X className="w-4 h-4" />
Reject
</button>
</>
)}
</div>
</div>
);
}
@@ -0,0 +1,83 @@
import { Check, X, Mail, Trash2, FileText, ShieldAlert } from "lucide-react";
import type { ToolCallWithResult } from "@langchain/langgraph-sdk/react";
import type { AgentToolCalls } from "../types";
/**
* Tool call card component for completed/pending tools
*/
export function ToolCallCard({
toolCall,
}: {
toolCall: ToolCallWithResult<AgentToolCalls>;
}) {
const { call, result, state } = toolCall;
const isLoading = state === "pending";
const getIcon = () => {
switch (call.name) {
case "send_email":
return <Mail className="w-4 h-4 text-brand-accent" />;
case "delete_file":
return <Trash2 className="w-4 h-4 text-red-400" />;
case "read_file":
return <FileText className="w-4 h-4 text-blue-400" />;
default:
return <ShieldAlert className="w-4 h-4 text-neutral-400" />;
}
};
const parsedResult = result
? typeof result.content === "string"
? result.content.startsWith("{")
? JSON.parse(result.content as string)
: (result as { status: string; content: string })
: { status: "success", content: result.content }
: { status: "pending", content: "" };
const getTitle = () => {
switch (call.name) {
case "send_email":
return parsedResult.status === "success"
? "Email Sent"
: "Email Sent Rejected";
case "delete_file":
return parsedResult.status === "success"
? "File Deleted"
: "File Deletion Rejected";
case "read_file":
return parsedResult.status === "success"
? "File Read"
: "File Read Rejected";
default:
throw new Error(`Unknown tool call: ${JSON.stringify(call)}`);
}
};
return (
<div className="bg-neutral-900 rounded-lg p-4 border border-neutral-800 animate-fade-in">
<div className="flex items-center gap-3 mb-3">
<div className="w-8 h-8 rounded-lg bg-neutral-800 border border-neutral-700 flex items-center justify-center">
{getIcon()}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-white">{getTitle()}</div>
<div className="text-xs text-neutral-500">
{isLoading ? "Processing..." : "Completed"}
</div>
</div>
{parsedResult.status === "success" ? (
<Check className="w-4 h-4 text-green-400" />
) : parsedResult.status === "error" ? (
<X className="w-4 h-4 text-red-400" />
) : null}
</div>
{result && (
<div className="text-sm rounded-lg p-3 bg-black border border-neutral-800 text-neutral-300">
{parsedResult.content}
</div>
)}
</div>
);
}
@@ -0,0 +1,289 @@
import { useState, useCallback } from "react";
import { useStickToBottom } from "use-stick-to-bottom";
import { AlertCircle, UserCheck } from "lucide-react";
import {
useStream,
type ToolCallWithResult,
} from "@langchain/langgraph-sdk/react";
import type { Message } from "@langchain/langgraph-sdk";
import type { HITLRequest, HITLResponse } from "langchain";
import { registerExample } from "../registry";
import { LoadingIndicator } from "../../components/Loading";
import { EmptyState } from "../../components/States";
import { MessageBubble } from "../../components/MessageBubble";
import { MessageInput } from "../../components/MessageInput";
import type { agent } from "./agent";
import { ToolCallCard } from "./components/ToolCallCard";
import type { AgentToolCalls } from "./types";
import {
PendingApprovalCard,
DEFAULT_REJECT_REASON,
} from "./components/PendingApprovalCard";
const HITL_SUGGESTIONS = [
"Send an email to john@example.com",
"Delete the file test.txt",
"Read the contents of config.json",
];
/**
* Helper to check if a message has actual text content.
*/
function hasContent(message: Message): boolean {
if (typeof message.content === "string") {
return message.content.trim().length > 0;
}
if (Array.isArray(message.content)) {
return message.content.some(
(c) => c.type === "text" && c.text.trim().length > 0
);
}
return false;
}
export function HumanInTheLoop() {
const stream = useStream<typeof agent, { InterruptType: HITLRequest }>({
assistantId: "human-in-the-loop",
apiUrl: "http://localhost:2024",
});
const { scrollRef, contentRef } = useStickToBottom();
const [isProcessing, setIsProcessing] = useState(false);
/**
* Type assertion needed because the generic inference doesn't properly propagate
*/
const hitlRequest = stream.interrupt?.value as HITLRequest | undefined;
const handleSubmit = useCallback(
(content: string) => {
stream.submit({ messages: [{ content, type: "human" }] });
},
[stream]
);
/**
* Handle approval for a specific action
*/
const handleApprove = async (index: number) => {
if (!hitlRequest) return;
setIsProcessing(true);
try {
const decisions: HITLResponse["decisions"] =
hitlRequest.actionRequests.map((_, i) => {
if (i === index) {
return { type: "approve" as const };
}
/**
* For other actions, also approve them
*/
return { type: "approve" as const };
});
await stream.submit(null, {
command: {
resume: { decisions } as HITLResponse,
},
});
} finally {
setIsProcessing(false);
}
};
/**
* Handle rejection for a specific action
*/
const handleReject = async (index: number, reason: string) => {
if (!hitlRequest) return;
setIsProcessing(true);
try {
const decisions: HITLResponse["decisions"] =
hitlRequest.actionRequests.map((_, i) => {
if (i === index) {
return {
type: "reject" as const,
message: reason || DEFAULT_REJECT_REASON,
};
}
/**
* For other actions, also reject them to be safe
*/
return {
type: "reject" as const,
message: "Rejected along with other actions",
};
});
await stream.submit(null, {
command: {
resume: { decisions } as HITLResponse,
},
});
} finally {
setIsProcessing(false);
}
};
/**
* Handle edit for a specific action
*/
const handleEdit = async (
index: number,
editedArgs: Record<string, unknown>
) => {
if (!hitlRequest) return;
setIsProcessing(true);
try {
const originalAction = hitlRequest.actionRequests[index];
const decisions: HITLResponse["decisions"] =
hitlRequest.actionRequests.map((_, i) => {
if (i === index) {
return {
type: "edit" as const,
editedAction: {
name: originalAction.name,
args: editedArgs,
},
};
}
/**
* For other actions, approve them
*/
return { type: "approve" as const };
});
await stream.submit(null, {
command: {
resume: { decisions } as HITLResponse,
},
});
} finally {
setIsProcessing(false);
}
};
const hasMessages = stream.messages.length > 0;
return (
<div className="h-full flex flex-col">
<main ref={scrollRef} className="flex-1 overflow-y-auto">
<div ref={contentRef} className="max-w-2xl mx-auto px-4 py-8">
{!hasMessages && !stream.interrupt ? (
<EmptyState
icon={UserCheck}
title="Human in the Loop"
description="An agent that requires your approval for sensitive actions. Try sending an email, deleting a file, or reading file contents."
suggestions={HITL_SUGGESTIONS}
onSuggestionClick={handleSubmit}
/>
) : (
<div className="flex flex-col gap-6">
{stream.messages.map((message, idx) => {
/**
* For AI messages, check if they have tool calls
*/
if (message.type === "ai") {
const toolCalls = stream.getToolCalls(message);
/**
* Render tool calls if present
*/
if (toolCalls.length > 0) {
return (
<div key={message.id} className="flex flex-col gap-3">
{toolCalls.map((toolCall) => (
<ToolCallCard
key={toolCall.id}
toolCall={
toolCall as ToolCallWithResult<AgentToolCalls>
}
/>
))}
</div>
);
}
/**
* Skip AI messages without content
*/
if (!hasContent(message)) {
return null;
}
}
return (
<MessageBubble key={message.id ?? idx} message={message} />
);
})}
{/* Show interrupt UI when awaiting approval */}
{hitlRequest && hitlRequest.actionRequests.length > 0 && (
<div className="flex flex-col gap-4">
{hitlRequest.actionRequests.map((actionRequest, idx) => (
<PendingApprovalCard
key={idx}
actionRequest={actionRequest}
reviewConfig={hitlRequest.reviewConfigs[idx]}
onApprove={() => handleApprove(idx)}
onReject={(reason) => handleReject(idx, reason)}
onEdit={(editedArgs) => handleEdit(idx, editedArgs)}
isProcessing={isProcessing}
/>
))}
</div>
)}
{/* Show loading indicator when streaming and no content yet */}
{stream.isLoading &&
!stream.interrupt &&
!stream.messages.some(hasContent) &&
stream.toolCalls.length === 0 && <LoadingIndicator />}
</div>
)}
</div>
</main>
{stream.error != null && (
<div className="max-w-2xl mx-auto px-4 pb-3">
<div className="bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3 text-red-400 text-sm">
<div className="flex items-center gap-2">
<AlertCircle className="w-4 h-4 shrink-0" />
<span>
{stream.error instanceof Error
? stream.error.message
: "An error occurred"}
</span>
</div>
</div>
</div>
)}
<MessageInput
disabled={stream.isLoading || isProcessing || !!stream.interrupt}
placeholder={
stream.interrupt
? "Please approve or reject the pending action..."
: "Ask me to send an email or delete a file..."
}
onSubmit={handleSubmit}
/>
</div>
);
}
/**
* Register this example
*/
registerExample({
id: "human-in-the-loop",
title: "Human in the Loop",
description:
"ReAct agent with interrupts for approving, editing, or rejecting tool calls",
category: "agents",
icon: "chat",
ready: true,
component: HumanInTheLoop,
});
export default HumanInTheLoop;
@@ -0,0 +1,11 @@
import type { ToolCallFromTool } from "@langchain/langgraph-sdk/react";
import type { sendEmail, deleteFile, readFile } from "./agent";
/**
* Type for tool calls from our agent
*/
export type AgentToolCalls =
| ToolCallFromTool<typeof sendEmail>
| ToolCallFromTool<typeof deleteFile>
| ToolCallFromTool<typeof readFile>;
@@ -0,0 +1,265 @@
import { ChatOpenAI } from "@langchain/openai";
import { StateGraph, Annotation, START, END } from "@langchain/langgraph";
import { BaseMessage, AIMessage } from "langchain";
const model = new ChatOpenAI({ model: "gpt-4o-mini" });
/**
* Content Writer Pipeline - A multi-node LangGraph that demonstrates
* state transitions and branching logic.
*
* Workflow:
* 1. research_node - Gathers information about the topic
* 2. analyze_node - Analyzes the research for key insights
* 3. decide_node - Decides if we have enough info or need more research
* 4. draft_node - Creates a draft based on the analysis
* 5. review_node - Reviews and refines the draft
*/
// Define the state annotation with reducer for messages
const StateAnnotation = Annotation.Root({
messages: Annotation<BaseMessage[]>({
reducer: (left: BaseMessage[], right: BaseMessage | BaseMessage[]) => {
return Array.isArray(right) ? left.concat(right) : left.concat([right]);
},
default: () => [],
}),
topic: Annotation<string>({
reducer: (_, right) => right,
default: () => "",
}),
research: Annotation<string>({
reducer: (_, right) => right,
default: () => "",
}),
analysis: Annotation<string>({
reducer: (_, right) => right,
default: () => "",
}),
draft: Annotation<string>({
reducer: (_, right) => right,
default: () => "",
}),
finalContent: Annotation<string>({
reducer: (_, right) => right,
default: () => "",
}),
researchIterations: Annotation<number>({
reducer: (_, right) => right,
default: () => 0,
}),
currentNode: Annotation<string>({
reducer: (_, right) => right,
default: () => "",
}),
});
type State = typeof StateAnnotation.State;
/**
* Extract the topic from the user's message
*/
async function extractTopicNode(state: State): Promise<Partial<State>> {
const lastMessage = state.messages[state.messages.length - 1];
const userInput =
typeof lastMessage.content === "string" ? lastMessage.content : "";
const response = await model.invoke([
{
role: "system",
content:
"Extract the main topic or subject the user wants to write about. Return only the topic, nothing else.",
},
{ role: "user", content: userInput },
]);
return {
topic: typeof response.content === "string" ? response.content : "",
currentNode: "extract_topic",
messages: [
new AIMessage({
content: `📌 **Topic Identified:** ${response.content}`,
name: "extract_topic",
}),
],
};
}
/**
* Research node - Gathers information about the topic
*/
async function researchNode(state: State): Promise<Partial<State>> {
const iteration = state.researchIterations + 1;
const response = await model.invoke([
{
role: "system",
content: `You are a research assistant. Gather key facts, statistics, and interesting points about the topic.
Be thorough but concise. This is research iteration ${iteration}.
${state.research ? `Previous research: ${state.research}` : ""}`,
},
{ role: "user", content: `Research the topic: ${state.topic}` },
]);
const researchContent =
typeof response.content === "string" ? response.content : "";
return {
research: state.research
? `${state.research}\n\n--- Additional Research ---\n${researchContent}`
: researchContent,
researchIterations: iteration,
currentNode: "research_node",
messages: [
new AIMessage({
content: `🔍 **Research (Iteration ${iteration}):**\n${researchContent}`,
name: "research_node",
}),
],
};
}
/**
* Analyze node - Analyzes the research for key insights
*/
async function analyzeNode(state: State): Promise<Partial<State>> {
const response = await model.invoke([
{
role: "system",
content:
"You are an analyst. Review the research and identify the most important insights, themes, and angles for content creation. Be structured and insightful.",
},
{
role: "user",
content: `Analyze this research about "${state.topic}":\n\n${state.research}`,
},
]);
const analysisContent =
typeof response.content === "string" ? response.content : "";
return {
analysis: analysisContent,
currentNode: "analyze",
messages: [
new AIMessage({
content: `🧠 **Analysis:**\n${analysisContent}`,
name: "analyze",
}),
],
};
}
/**
* Decide if we need more research or can proceed to drafting
*/
function shouldContinueResearch(state: State): "research_node" | "draft_node" {
// If we've done less than 2 iterations and the analysis suggests gaps, do more research
if (
(state.researchIterations < 2 &&
state.analysis.toLowerCase().includes("need more")) ||
state.analysis.toLowerCase().includes("insufficient") ||
state.analysis.toLowerCase().includes("gaps")
) {
return "research_node";
}
return "draft_node";
}
/**
* Draft node - Creates a draft based on the analysis
*/
async function draftNode(state: State): Promise<Partial<State>> {
const response = await model.invoke([
{
role: "system",
content: `You are a skilled content writer. Create a compelling, well-structured draft based on the research and analysis provided.
Write in an engaging, professional tone. Include:
- A catchy opening
- Clear main points
- Supporting details
- A strong conclusion`,
},
{
role: "user",
content: `Create a draft about "${state.topic}"\n\nResearch:\n${state.research}\n\nAnalysis:\n${state.analysis}`,
},
]);
const draftContent =
typeof response.content === "string" ? response.content : "";
return {
draft: draftContent,
currentNode: "draft_node",
messages: [
new AIMessage({
content: `✍️ **Draft:**\n${draftContent}`,
name: "draft_node",
}),
],
};
}
/**
* Review node - Reviews and refines the draft
*/
async function reviewNode(state: State): Promise<Partial<State>> {
const response = await model.invoke([
{
role: "system",
content: `You are a senior editor. Review the draft and provide an improved, polished version.
Focus on:
- Clarity and flow
- Grammar and style
- Impact and engagement
- Overall coherence
Return the final polished content.`,
},
{
role: "user",
content: `Review and improve this draft:\n\n${state.draft}`,
},
]);
const finalContent =
typeof response.content === "string" ? response.content : "";
return {
finalContent,
currentNode: "review",
messages: [
new AIMessage({
content: `✅ **Final Content:**\n${finalContent}`,
name: "review",
}),
],
};
}
// Build the graph
const workflow = new StateGraph(StateAnnotation)
// Add all nodes
.addNode("extract_topic", extractTopicNode)
.addNode("research_node", researchNode)
.addNode("analyze", analyzeNode)
.addNode("draft_node", draftNode)
.addNode("review", reviewNode)
// Define the flow
.addEdge(START, "extract_topic")
.addEdge("extract_topic", "research_node")
.addEdge("research_node", "analyze")
// Conditional edge: decide if we need more research
.addConditionalEdges("analyze", shouldContinueResearch, {
research_node: "research_node",
draft_node: "draft_node",
})
.addEdge("draft_node", "review")
.addEdge("review", END);
// Compile and export
export const agent = workflow.compile();
@@ -0,0 +1,384 @@
import { useCallback } from "react";
import { useStickToBottom } from "use-stick-to-bottom";
import {
AlertCircle,
Search,
Brain,
FileEdit,
CheckCircle,
Target,
} from "lucide-react";
import type { ContentBlock } from "langchain";
import { useStream } from "@langchain/langgraph-sdk/react";
import type { AIMessage, Message } from "@langchain/langgraph-sdk";
import { registerExample } from "../registry";
import { LoadingIndicator } from "../../components/Loading";
import { EmptyState } from "../../components/States";
import { MessageInput } from "../../components/MessageInput";
import type { agent } from "./agent";
/**
* Node configuration for visual display
*/
const NODE_CONFIG: Record<
string,
{
icon: React.ReactNode;
label: string;
color: string;
bgColor: string;
borderColor: string;
}
> = {
extract_topic: {
icon: <Target className="w-4 h-4" />,
label: "Topic Extraction",
color: "text-violet-400",
bgColor: "bg-violet-500/10",
borderColor: "border-violet-500/30",
},
research_node: {
icon: <Search className="w-4 h-4" />,
label: "Research",
color: "text-blue-400",
bgColor: "bg-blue-500/10",
borderColor: "border-blue-500/30",
},
analyze: {
icon: <Brain className="w-4 h-4" />,
label: "Analysis",
color: "text-amber-400",
bgColor: "bg-amber-500/10",
borderColor: "border-amber-500/30",
},
draft_node: {
icon: <FileEdit className="w-4 h-4" />,
label: "Drafting",
color: "text-emerald-400",
bgColor: "bg-emerald-500/10",
borderColor: "border-emerald-500/30",
},
review: {
icon: <CheckCircle className="w-4 h-4" />,
label: "Review",
color: "text-rose-400",
bgColor: "bg-rose-500/10",
borderColor: "border-rose-500/30",
},
};
/**
* Pipeline visualization showing the workflow
*/
function PipelineVisualization({ currentNode }: { currentNode?: string }) {
const nodes = [
"extract_topic",
"research_node",
"analyze",
"draft_node",
"review",
];
const currentIndex = currentNode ? nodes.indexOf(currentNode) : -1;
return (
<div className="fixed right-6 top-1/2 -translate-y-1/2 z-50 bg-neutral-900/90 backdrop-blur-sm rounded-xl p-4 border border-neutral-800 shadow-xl">
<div className="text-xs text-neutral-500 uppercase tracking-wider mb-4 font-medium text-center">
Pipeline
</div>
<div className="flex flex-col items-center gap-1">
{nodes.map((node, idx) => {
const config = NODE_CONFIG[node];
const isActive = idx === currentIndex;
const isComplete = idx < currentIndex;
return (
<div key={node} className="flex flex-row items-start gap-3">
{/* Icon column with connector */}
<div className="flex flex-col items-center">
<div
className={`
w-10 h-10 rounded-lg flex items-center justify-center
transition-all duration-300
${
isActive
? "opacity-100"
: isComplete
? "opacity-80"
: "opacity-40"
}
${
isActive
? `${config.bgColor} ${
config.borderColor
} border-2 ring-2 ring-offset-2 ring-offset-neutral-900 ${config.borderColor.replace(
"border",
"ring"
)}`
: isComplete
? `${config.bgColor} ${config.borderColor} border`
: "bg-neutral-800 border border-neutral-700"
}
`}
>
<span
className={
isActive || isComplete ? config.color : "text-neutral-500"
}
>
{config.icon}
</span>
</div>
{idx < nodes.length - 1 && (
<div
className={`
w-0.5 h-6 my-1
${
idx < currentIndex
? "bg-linear-to-b from-emerald-500/50 to-emerald-500/20"
: "bg-neutral-700"
}
`}
/>
)}
</div>
{/* Label */}
<span
className={`
text-xs font-medium w-24 pt-2.5
${
isActive
? "opacity-100"
: isComplete
? "opacity-80"
: "opacity-40"
}
${
isActive
? config.color
: isComplete
? "text-neutral-400"
: "text-neutral-600"
}
`}
>
{config.label}
</span>
</div>
);
})}
</div>
</div>
);
}
/**
* Node output card
*/
function NodeOutputCard({ message }: { message: Message }) {
/**
* Extract node name from message
*/
const nodeName = typeof message.name === "string" ? message.name : undefined;
const config = nodeName ? NODE_CONFIG[nodeName] : null;
/**
* Extract content
*/
const content =
typeof message.content === "string"
? message.content
: Array.isArray(message.content)
? message.content.find(
(c): c is ContentBlock.Text => c.type === "text" && "text" in c
)?.text || ""
: "";
if (!config) {
/**
* Render as regular message if no node config
*/
return (
<div className="bg-neutral-800/50 rounded-xl p-4 border border-neutral-700">
<div className="text-sm text-neutral-200 whitespace-pre-wrap leading-relaxed">
{content}
</div>
</div>
);
}
return (
<div
className={`${config.bgColor} rounded-xl p-4 border ${config.borderColor} animate-fade-in`}
>
<div className="flex items-center gap-2 mb-3">
<div
className={`w-8 h-8 rounded-lg ${config.bgColor} border ${config.borderColor} flex items-center justify-center ${config.color}`}
>
{config.icon}
</div>
<span className={`text-sm font-semibold ${config.color}`}>
{config.label}
</span>
</div>
<div className="text-sm text-neutral-200 whitespace-pre-wrap leading-relaxed">
{content.replace(/^[📌🔍🧠✍️✅]\s*\*\*.*?\*\*:?\n?/, "")}
</div>
</div>
);
}
const CONTENT_WRITER_SUGGESTIONS = [
"Write about AI in healthcare",
"Create a guide on remote work",
"Explain quantum computing",
];
export function MultiStepGraph() {
const stream = useStream<typeof agent>({
assistantId: "multi-step-graph",
apiUrl: "http://localhost:2024",
});
const { scrollRef, contentRef } = useStickToBottom();
const hasMessages = stream.messages.length > 0;
/**
* Get current node from the latest AI message
*/
const currentNode = stream.messages
.filter((m): m is AIMessage => Boolean(m.type === "ai" && m.name))
.pop()?.name;
const handleSubmit = useCallback(
(content: string) => {
/**
* @todo(@christian-bromann): Fix this type error.
*/
stream.submit({ messages: [{ content, type: "human" } as any] });
},
[stream]
);
return (
<div className="h-full flex flex-col">
<main ref={scrollRef} className="flex-1 overflow-y-auto">
<div ref={contentRef} className="max-w-2xl mx-auto px-4 py-8">
{/* Fixed pipeline visualization on the right side */}
{hasMessages && <PipelineVisualization currentNode={currentNode} />}
{!hasMessages ? (
<EmptyState
icon={FileEdit}
title="Content Writer Pipeline"
description="A multi-node LangGraph workflow that researches, analyzes, drafts, and reviews content. Watch as your idea flows through each step of the pipeline."
suggestions={CONTENT_WRITER_SUGGESTIONS}
onSuggestionClick={handleSubmit}
/>
) : (
<>
<div className="flex flex-col gap-4">
{stream.messages.map((message, idx) => {
/**
* User messages
*/
if (message.type === "human") {
return (
<div key={message.id ?? idx} className="flex justify-end">
<div className="bg-brand-accent/20 border border-brand-accent/30 rounded-xl px-4 py-2.5 max-w-[80%]">
<p className="text-sm text-white">
{typeof message.content === "string"
? message.content
: ""}
</p>
</div>
</div>
);
}
/**
* AI messages - render as node output cards
*/
if (message.type === "ai") {
const content =
typeof message.content === "string"
? message.content
: Array.isArray(message.content)
? message.content.find(
(c): c is ContentBlock.Text =>
c.type === "text" && "text" in c
)?.text || ""
: "";
if (!content.trim()) return null;
return (
<NodeOutputCard
key={message.id ?? idx}
message={message}
/>
);
}
return null;
})}
{/* Loading indicator with current node info */}
{stream.isLoading && (
<div className="flex items-center gap-3 text-neutral-400 animate-pulse">
<LoadingIndicator />
{currentNode && NODE_CONFIG[currentNode] && (
<span className="text-sm">
Processing in {NODE_CONFIG[currentNode].label}...
</span>
)}
</div>
)}
</div>
</>
)}
</div>
</main>
{stream.error != null && (
<div className="max-w-2xl mx-auto px-4 pb-3">
<div className="bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3 text-red-400 text-sm">
<div className="flex items-center gap-2">
<AlertCircle className="w-4 h-4 shrink-0" />
<span>
{stream.error instanceof Error
? stream.error.message
: "An error occurred"}
</span>
</div>
</div>
</div>
)}
<MessageInput
disabled={stream.isLoading}
placeholder="What would you like me to write about?"
onSubmit={handleSubmit}
/>
</div>
);
}
/**
* Register this example
*/
registerExample({
id: "multi-step-graph",
title: "Multi-Step Graph",
description:
"A content writer pipeline with multiple nodes showing state transitions and branching logic",
category: "langgraph",
icon: "graph",
ready: true,
component: MultiStepGraph,
});
export default MultiStepGraph;
@@ -0,0 +1,241 @@
import { ChatOpenAI } from "@langchain/openai";
import { StateGraph, START, END, Send } from "@langchain/langgraph";
import { withLangGraph } from "@langchain/langgraph/zod";
import { BaseMessage, AIMessage } from "@langchain/core/messages";
import { z } from "zod";
/**
* Parallel Research Pipeline - A LangGraph that demonstrates
* parallel node execution where 3 different "research models" analyze
* a topic simultaneously and stream their results.
*
* Workflow:
* 1. dispatcher - Receives the topic and fans out to 3 parallel researchers
* 2. researcher_analytical - Analytical/data-driven research style
* 3. researcher_creative - Creative/storytelling research style
* 4. researcher_practical - Practical/actionable research style
* 5. All stream in parallel, user picks their preferred result
*/
// Use different model instances to get variety in responses
const analyticalModel = new ChatOpenAI({
model: "gpt-4o-mini",
temperature: 0.3,
});
const creativeModel = new ChatOpenAI({
model: "gpt-4o-mini",
temperature: 0.9,
});
const practicalModel = new ChatOpenAI({
model: "gpt-4o-mini",
temperature: 0.5,
});
// Define the state schema with Zod
const StateAnnotation = z.object({
messages: withLangGraph(z.custom<BaseMessage[]>(), {
reducer: {
fn: (left: BaseMessage[], right: BaseMessage | BaseMessage[]) => {
return Array.isArray(right) ? left.concat(right) : left.concat([right]);
},
},
default: () => [],
}),
topic: z.string().default(""),
analyticalResearch: z.string().default(""),
creativeResearch: z.string().default(""),
practicalResearch: z.string().default(""),
selectedResearch: z.string().default(""),
currentNode: z.string().default(""),
});
type State = z.infer<typeof StateAnnotation>;
/**
* Dispatcher node - Extracts topic and triggers parallel research
*/
async function dispatcherNode(state: State): Promise<Partial<State>> {
const lastMessage = state.messages[state.messages.length - 1];
const userInput =
typeof lastMessage.content === "string" ? lastMessage.content : "";
return {
topic: userInput,
currentNode: "dispatcher",
messages: [
new AIMessage({
content: `🎯 **Research Topic:** ${userInput}\n\nDispatching to 3 parallel research models...`,
name: "dispatcher",
}),
],
};
}
/**
* Fan-out function to trigger parallel researchers
*/
function fanOutToResearchers(state: State): Send[] {
return [
new Send("researcher_analytical", state),
new Send("researcher_creative", state),
new Send("researcher_practical", state),
];
}
/**
* Analytical Researcher - Data-driven, structured approach
*/
async function analyticalResearcherNode(state: State): Promise<Partial<State>> {
const response = await analyticalModel.invoke([
{
role: "system",
content: `You are an analytical research expert. Your approach is data-driven, methodical, and structured.
When researching a topic, you focus on:
- Statistics and hard data
- Logical frameworks and models
- Evidence-based conclusions
- Structured analysis with clear sections
- Pros/cons and trade-offs
Write a comprehensive but concise research summary (about 200-300 words).
Use markdown formatting with headers, bullet points, and emphasis where appropriate.`,
},
{
role: "user",
content: `Research this topic with your analytical approach: ${state.topic}`,
},
]);
const content = typeof response.content === "string" ? response.content : "";
return {
analyticalResearch: content,
currentNode: "researcher_analytical",
messages: [
new AIMessage({
content: content,
name: "researcher_analytical",
}),
],
};
}
/**
* Creative Researcher - Narrative-driven, imaginative approach
*/
async function creativeResearcherNode(state: State): Promise<Partial<State>> {
const response = await creativeModel.invoke([
{
role: "system",
content: `You are a creative research storyteller. Your approach is narrative-driven, engaging, and imaginative.
When researching a topic, you focus on:
- Compelling narratives and stories
- Human impact and emotional connections
- Unexpected angles and perspectives
- Metaphors and vivid descriptions
- Future possibilities and "what ifs"
Write a compelling research narrative (about 200-300 words).
Use markdown formatting with creative headers and engaging prose.`,
},
{
role: "user",
content: `Research this topic with your creative storytelling approach: ${state.topic}`,
},
]);
const content = typeof response.content === "string" ? response.content : "";
return {
creativeResearch: content,
currentNode: "researcher_creative",
messages: [
new AIMessage({
content: content,
name: "researcher_creative",
}),
],
};
}
/**
* Practical Researcher - Action-oriented, hands-on approach
*/
async function practicalResearcherNode(state: State): Promise<Partial<State>> {
const response = await practicalModel.invoke([
{
role: "system",
content: `You are a practical research advisor. Your approach is action-oriented, hands-on, and immediately useful.
When researching a topic, you focus on:
- Actionable insights and next steps
- Real-world applications
- Common pitfalls to avoid
- Quick wins and long-term strategies
- Tools, resources, and recommendations
Write a practical research guide (about 200-300 words).
Use markdown formatting with clear action items and recommendations.`,
},
{
role: "user",
content: `Research this topic with your practical, actionable approach: ${state.topic}`,
},
]);
const content = typeof response.content === "string" ? response.content : "";
return {
practicalResearch: content,
currentNode: "researcher_practical",
messages: [
new AIMessage({
content: content,
name: "researcher_practical",
}),
],
};
}
/**
* Collector node - Gathers all research and presents options
*/
async function collectorNode(): Promise<Partial<State>> {
return {
currentNode: "collector",
messages: [
new AIMessage({
content: `✅ **All research complete!**\n\nThree different perspectives are now available. Review each approach and select the one that best fits your needs.`,
name: "collector",
}),
],
};
}
// Build the graph
const workflow = new StateGraph(StateAnnotation)
// Add all nodes
.addNode("dispatcher", dispatcherNode)
.addNode("researcher_analytical", analyticalResearcherNode)
.addNode("researcher_creative", creativeResearcherNode)
.addNode("researcher_practical", practicalResearcherNode)
.addNode("collector", collectorNode)
// Start with dispatcher
.addEdge(START, "dispatcher")
// Fan out to parallel researchers
.addConditionalEdges("dispatcher", fanOutToResearchers)
// All researchers lead to collector
.addEdge("researcher_analytical", "collector")
.addEdge("researcher_creative", "collector")
.addEdge("researcher_practical", "collector")
// Collector ends the flow
.addEdge("collector", END);
// Compile and export
export const agent = workflow.compile();
@@ -0,0 +1,128 @@
import { useRef, useEffect } from "react";
import { Check, Loader2 } from "lucide-react";
import type { ResearchConfig } from "../types";
/**
* Research card component showing streamed content
*/
export function ResearchCard({
config,
content,
isLoading,
isSelected,
onSelect,
disabled,
}: {
config: ResearchConfig;
content: string;
isLoading: boolean;
isSelected: boolean;
onSelect: () => void;
disabled: boolean;
}) {
const scrollRef = useRef<HTMLDivElement>(null);
// Auto-scroll as content streams in
useEffect(() => {
if (scrollRef.current && isLoading) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [content, isLoading]);
return (
<div
className={`
relative flex flex-col h-full rounded-2xl border-2 transition-all duration-300
${
isSelected
? `${
config.borderColor
} ring-2 ring-offset-2 ring-offset-black ${config.borderColor.replace(
"border",
"ring"
)}`
: "border-neutral-800 hover:border-neutral-700"
}
${config.bgColor}
`}
>
{/* Card Header */}
<div
className={`
flex items-center gap-3 px-4 py-3 border-b border-neutral-800/50
bg-linear-to-r ${config.gradient} rounded-t-xl
`}
>
<div
className={`
w-10 h-10 rounded-xl flex items-center justify-center
${config.iconBg} ${config.accentColor}
`}
>
{config.icon}
</div>
<div className="flex-1 min-w-0">
<h3 className={`font-semibold ${config.accentColor}`}>
{config.name}
</h3>
<p className="text-xs text-neutral-500 truncate">
{config.description}
</p>
</div>
{isLoading && (
<Loader2 className={`w-5 h-5 animate-spin ${config.accentColor}`} />
)}
</div>
{/* Content Area */}
<div ref={scrollRef} className="flex-1 overflow-y-auto px-4 py-4 min-h-0">
{content ? (
<div className="text-sm text-neutral-300 whitespace-pre-wrap leading-relaxed">
{content}
</div>
) : isLoading ? (
<div className="flex items-center gap-2 text-neutral-500 animate-pulse">
<div className="w-2 h-2 rounded-full bg-current" />
<div className="w-2 h-2 rounded-full bg-current animation-delay-150" />
<div className="w-2 h-2 rounded-full bg-current animation-delay-300" />
<span className="text-sm ml-2">Researching...</span>
</div>
) : (
<p className="text-neutral-600 text-sm italic">
Waiting for research to begin...
</p>
)}
</div>
{/* Select Button */}
{content && !isLoading && (
<div className="px-4 py-3 border-t border-neutral-800/50">
<button
onClick={onSelect}
disabled={disabled || isSelected}
className={`
w-full py-2.5 px-4 rounded-xl font-medium text-sm
flex items-center justify-center gap-2 transition-all
${
isSelected
? `${config.bgColor} ${config.accentColor} border ${config.borderColor}`
: "bg-neutral-800 text-neutral-300 hover:bg-neutral-700 hover:text-white"
}
disabled:opacity-50 disabled:cursor-not-allowed
`}
>
{isSelected ? (
<>
<Check className="w-4 h-4" />
Selected
</>
) : (
"Select This Research"
)}
</button>
</div>
)}
</div>
);
}
@@ -0,0 +1,42 @@
import type { ResearchConfig } from "../types";
/**
* Selected research display
*/
export function SelectedResearchDisplay({
config,
content,
}: {
config: ResearchConfig;
content: string;
}) {
return (
<div className="mt-6 animate-fade-in">
<div className="p-1 rounded-2xl bg-linear-to-r from-brand-accent/20 via-transparent to-brand-dark/20">
<div className="p-6 rounded-xl bg-neutral-900 border border-neutral-800">
<div className="flex items-center gap-3 mb-4">
<div
className={`
w-10 h-10 rounded-xl flex items-center justify-center
${config.iconBg} ${config.accentColor}
`}
>
{config.icon}
</div>
<div>
<p className="text-xs text-neutral-500 uppercase tracking-wider">
Your Selection
</p>
<h3 className={`font-semibold ${config.accentColor}`}>
{config.name} Research
</h3>
</div>
</div>
<div className="text-sm text-neutral-300 whitespace-pre-wrap leading-relaxed">
{content}
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,22 @@
import { GitFork } from "lucide-react";
/**
* Topic display bar
*/
export function TopicBar({ topic }: { topic: string }) {
return (
<div className="mb-6 p-4 rounded-xl bg-linear-to-r from-neutral-900 to-neutral-800 border border-neutral-700">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-brand-dark/40 border border-brand-accent/30 flex items-center justify-center">
<GitFork className="w-5 h-5 text-brand-accent" />
</div>
<div>
<p className="text-xs text-neutral-500 uppercase tracking-wider">
Research Topic
</p>
<p className="text-white font-medium">{topic}</p>
</div>
</div>
</div>
);
}
@@ -0,0 +1,297 @@
import { useRef, useCallback, useState, useMemo } from "react";
import {
AlertCircle,
BarChart3,
Sparkles,
Wrench,
GitFork,
Loader2,
} from "lucide-react";
import { useStream } from "@langchain/langgraph-sdk/react";
import { registerExample } from "../registry";
import { EmptyState } from "../../components/States";
import { MessageInput } from "../../components/MessageInput";
import { ResearchCard } from "./components/ResearchCard";
import { TopicBar } from "./components/TopicBar";
import { SelectedResearchDisplay } from "./components/SelectedResearchDisplay";
import type { ResearchContents, ResearchId, ResearchConfig } from "./types";
import type { agent } from "./agent";
const RESEARCH_CONFIGS: ResearchConfig[] = [
{
id: "analytical",
name: "Analytical",
nodeName: "researcher_analytical",
icon: <BarChart3 className="w-5 h-5" />,
description:
"Data-driven, structured analysis with evidence-based insights",
gradient: "from-cyan-500/20 to-blue-600/20",
borderColor: "border-cyan-500/40",
bgColor: "bg-cyan-950/30",
iconBg: "bg-cyan-500/20",
accentColor: "text-cyan-400",
},
{
id: "creative",
name: "Creative",
nodeName: "researcher_creative",
icon: <Sparkles className="w-5 h-5" />,
description: "Narrative-driven storytelling with imaginative perspectives",
gradient: "from-purple-500/20 to-pink-600/20",
borderColor: "border-purple-500/40",
bgColor: "bg-purple-950/30",
iconBg: "bg-purple-500/20",
accentColor: "text-purple-400",
},
{
id: "practical",
name: "Practical",
nodeName: "researcher_practical",
icon: <Wrench className="w-5 h-5" />,
description: "Action-oriented guide with hands-on recommendations",
gradient: "from-emerald-500/20 to-teal-600/20",
borderColor: "border-emerald-500/40",
bgColor: "bg-emerald-950/30",
iconBg: "bg-emerald-500/20",
accentColor: "text-emerald-400",
},
];
const PARALLEL_RESEARCH_SUGGESTIONS = [
"The future of renewable energy",
"How AI is transforming healthcare",
"Remote work best practices",
"Cryptocurrency and blockchain explained",
];
export function ParallelResearch() {
const stream = useStream<typeof agent>({
assistantId: "parallel-research",
apiUrl: "http://localhost:2024",
});
const [selectedResearch, setSelectedResearch] = useState<ResearchId | null>(
null
);
const containerRef = useRef<HTMLDivElement>(null);
/**
* Extract streaming research content from messages using metadata
* This allows us to show content as it streams in real-time
*/
const streamingContents = useMemo((): ResearchContents => {
const contents: ResearchContents = {
analytical: "",
creative: "",
practical: "",
};
/**
* Track streaming messages by their langgraph_node metadata
*/
for (const message of stream.messages) {
if (message.type !== "ai") continue;
/**
* Get the stream metadata for this message
*/
const metadata = stream.getMessagesMetadata?.(message);
const nodeFromMetadata = metadata?.streamMetadata?.langgraph_node as
| string
| undefined;
/**
* Also check the message name (set after node completion)
*/
const nodeName = (message as { name?: string }).name;
const node = nodeFromMetadata || nodeName;
if (!node) continue;
const content =
typeof message.content === "string" ? message.content : "";
if (node === "researcher_analytical" && content) {
contents.analytical = content;
} else if (node === "researcher_creative" && content) {
contents.creative = content;
} else if (node === "researcher_practical" && content) {
contents.practical = content;
}
}
return contents;
}, [stream.messages, stream.getMessagesMetadata]);
/**
* Get research contents - prefer streaming content, fall back to state values
*/
const researchContents = useMemo((): ResearchContents => {
return {
analytical:
streamingContents.analytical || stream.values?.analyticalResearch || "",
creative:
streamingContents.creative || stream.values?.creativeResearch || "",
practical:
streamingContents.practical || stream.values?.practicalResearch || "",
};
}, [
streamingContents,
stream.values?.analyticalResearch,
stream.values?.creativeResearch,
stream.values?.practicalResearch,
]);
/**
* Get the current topic directly from state
*/
const currentTopic = stream.values?.topic || null;
/**
* Check which researchers are currently loading (streaming but not complete)
* Since all 3 researchers run in parallel, they're all "loading" until the collector runs
*/
const loadingStates = useMemo(() => {
const activeNodes = new Set<ResearchId>();
const currentNode = stream.values?.currentNode || "";
// If we're loading and the collector hasn't finished, all researchers are considered active
if (stream.isLoading && currentTopic && currentNode !== "collector") {
activeNodes.add("analytical");
activeNodes.add("creative");
activeNodes.add("practical");
}
return activeNodes;
}, [stream.isLoading, currentTopic, stream.values?.currentNode]);
/**
* Check if all research is complete
*/
const isResearchComplete = useMemo(() => {
const currentNode = stream.values?.currentNode || "";
return (
!stream.isLoading &&
currentNode === "collector" &&
Boolean(researchContents.analytical) &&
Boolean(researchContents.creative) &&
Boolean(researchContents.practical)
);
}, [stream.isLoading, stream.values?.currentNode, researchContents]);
const hasStarted = Boolean(stream.values?.topic);
const handleSubmit = useCallback(
(content: string) => {
setSelectedResearch(null);
/**
* @todo(@christian-bromann): Fix this type error.
*/
stream.submit({ messages: [{ content, type: "human" } as any] });
},
[stream]
);
const handleSelectResearch = useCallback((researchId: ResearchId) => {
setSelectedResearch(researchId);
}, []);
return (
<div className="h-full flex flex-col">
<main className="flex-1 overflow-y-auto" ref={containerRef}>
<div className="max-w-6xl mx-auto px-4 py-8">
{!hasStarted ? (
<EmptyState
icon={GitFork}
title="Parallel Research Explorer"
description="Enter a topic and watch as three different AI research models analyze it simultaneously. Each brings a unique perspective: analytical, creative, and practical. Choose the approach that works best for you."
suggestions={PARALLEL_RESEARCH_SUGGESTIONS}
onSuggestionClick={handleSubmit}
/>
) : (
<>
{/* Topic Bar */}
{currentTopic && <TopicBar topic={currentTopic} />}
{/* Three Column Research Grid */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 lg:gap-6">
{RESEARCH_CONFIGS.map((config) => (
<div key={config.id} className="h-[500px]">
<ResearchCard
config={config}
content={researchContents[config.id] || ""}
isLoading={loadingStates.has(config.id)}
isSelected={selectedResearch === config.id}
onSelect={() => handleSelectResearch(config.id)}
disabled={!isResearchComplete}
/>
</div>
))}
</div>
{/* Selected Research Display */}
{selectedResearch && (
<SelectedResearchDisplay
config={
RESEARCH_CONFIGS.find((c) => c.id === selectedResearch)!
}
content={researchContents[selectedResearch] || ""}
/>
)}
{/* Loading Status */}
{stream.isLoading && !isResearchComplete && (
<div className="mt-6 flex items-center justify-center gap-3 text-neutral-400">
<Loader2 className="w-5 h-5 animate-spin text-brand-accent" />
<span className="text-sm">
Research streaming in parallel...
</span>
</div>
)}
</>
)}
</div>
</main>
{stream.error != null && (
<div className="max-w-6xl mx-auto px-4 pb-3">
<div className="bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3 text-red-400 text-sm">
<div className="flex items-center gap-2">
<AlertCircle className="w-4 h-4 shrink-0" />
<span>
{stream.error instanceof Error
? stream.error.message
: "An error occurred"}
</span>
</div>
</div>
</div>
)}
<MessageInput
disabled={stream.isLoading}
placeholder="Enter a research topic..."
onSubmit={handleSubmit}
/>
</div>
);
}
/**
* Register this example
*/
registerExample({
id: "parallel-research",
title: "Parallel Research",
description:
"Watch 3 AI models research a topic in parallel, then pick your favorite result",
category: "langgraph",
icon: "graph",
ready: true,
component: ParallelResearch,
});
export default ParallelResearch;
@@ -0,0 +1,26 @@
/**
* Research content record type
*/
export type ResearchContents = {
analytical: string;
creative: string;
practical: string;
};
export type ResearchId = keyof ResearchContents;
/**
* Research model configuration for visual display
*/
export interface ResearchConfig {
id: ResearchId;
name: string;
nodeName: string;
icon: React.ReactNode;
description: string;
gradient: string;
borderColor: string;
bgColor: string;
iconBg: string;
accentColor: string;
}
@@ -0,0 +1,46 @@
import { createAgent } from "langchain";
import { ChatOpenAI } from "@langchain/openai";
import { MemorySaver } from "@langchain/langgraph";
/**
* Create an OpenAI model with extended reasoning enabled.
*
* Extended reasoning allows the model to think through complex problems step-by-step,
* with the reasoning process visible in the response. This is particularly useful
* for math, logic, coding, and multi-step reasoning tasks.
*/
const model = new ChatOpenAI({
model: "gpt-5.2",
reasoning: {
effort: "low", // 'low' | 'medium' | 'high' - controls reasoning depth
summary: "auto", // Enable reasoning summary output for streaming
},
// Extended thinking requires a higher max_tokens value
maxTokens: 16000,
});
/**
* Reasoning Agent - Demonstrates streaming of extended thinking/reasoning tokens.
*
* When you ask a complex question, the model will:
* 1. First show its thinking/reasoning process (reasoning tokens)
* 2. Then provide the final answer (text tokens)
*
* This creates a more transparent AI experience where users can see
* how the model arrives at its conclusions.
*/
export const agent = createAgent({
model,
tools: [], // No tools needed for reasoning demo
checkpointer: new MemorySaver(),
systemPrompt: `You are a helpful assistant that excels at reasoning through complex problems.
When presented with questions that require careful thought, use your extended thinking capabilities to:
1. Break down the problem into steps
2. Consider different approaches
3. Work through the logic carefully
4. Arrive at a well-reasoned conclusion
Be thorough in your thinking process, as users can see and learn from how you reason.
Focus on mathematical problems, logic puzzles, and analytical questions to showcase your reasoning abilities.`,
});
@@ -0,0 +1,101 @@
import { useCallback } from "react";
import { useStickToBottom } from "use-stick-to-bottom";
import { AlertCircle, Brain } from "lucide-react";
import { useStream } from "@langchain/langgraph-sdk/react";
import { registerExample } from "../registry";
import { LoadingIndicator } from "../../components/Loading";
import { EmptyState } from "../../components/States";
import { MessageInput } from "../../components/MessageInput";
import { MessageBubble } from "../../components/MessageBubble";
import type { agent } from "./agent";
const REASONING_SUGGESTIONS = [
"A bat and a ball cost $1.10 in total. The bat costs $1.00 more than the ball. How much does the ball cost?",
"If it takes 5 machines 5 minutes to make 5 widgets, how long would it take 100 machines to make 100 widgets?",
];
export function ReasoningAgent() {
const stream = useStream<typeof agent>({
assistantId: "reasoning-agent",
apiUrl: "http://localhost:2024",
});
const { scrollRef, contentRef } = useStickToBottom();
const hasMessages = stream.messages.length > 0;
const handleSubmit = useCallback(
(content: string) => {
stream.submit({ messages: [{ content, type: "human" }] });
},
[stream]
);
return (
<div className="h-full flex flex-col">
<main ref={scrollRef} className="flex-1 overflow-y-auto">
<div ref={contentRef} className="max-w-2xl mx-auto px-4 py-8">
{!hasMessages ? (
<EmptyState
icon={Brain}
title="Reasoning Agent"
description="Watch the model think through complex problems with extended reasoning. The thinking process is streamed to a separate bubble in real-time, showing you how the AI arrives at its conclusions."
suggestions={REASONING_SUGGESTIONS}
onSuggestionClick={handleSubmit}
/>
) : (
<div className="flex flex-col gap-6">
{stream.messages.map((message, idx) => (
<MessageBubble key={message.id ?? idx} message={message} />
))}
{/* Show loading indicator when streaming and no content yet, e.g. we don't have a stream of the AI response yet */}
{stream.isLoading && stream.messages.length <= 2 && (
<div className="flex items-center gap-3 text-amber-400/70">
<LoadingIndicator />
<span className="text-sm">Thinking...</span>
</div>
)}
</div>
)}
</div>
</main>
{stream.error != null && (
<div className="max-w-2xl mx-auto px-4 pb-3">
<div className="bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3 text-red-400 text-sm">
<div className="flex items-center gap-2">
<AlertCircle className="w-4 h-4 shrink-0" />
<span>
{stream.error instanceof Error
? stream.error.message
: "An error occurred. Make sure OPENAI_API_KEY is set."}
</span>
</div>
</div>
</div>
)}
<MessageInput
disabled={stream.isLoading}
placeholder="Ask a complex reasoning question..."
onSubmit={handleSubmit}
/>
</div>
);
}
// Register this example
registerExample({
id: "reasoning-agent",
title: "Reasoning Agent",
description: "Streaming reasoning tokens to a separate bubble in real-time",
category: "advanced",
icon: "code",
ready: true,
component: ReasoningAgent,
});
export default ReasoningAgent;
@@ -0,0 +1,97 @@
import type { ComponentType } from "react";
/**
* Example metadata for display in the sidebar
*/
export interface ExampleMeta {
/** Unique identifier for the example */
id: string;
/** Display title in the sidebar */
title: string;
/** Short description of what the example demonstrates */
description: string;
/** Category for grouping examples */
category: "agents" | "middleware" | "langgraph" | "advanced";
/** Icon name (matched in Sidebar component) */
icon: "tool" | "graph" | "middleware" | "code" | "chat";
/** Whether this example is ready (false = coming soon) */
ready: boolean;
}
/**
* Full example definition including the component
*/
export interface ExampleDefinition extends ExampleMeta {
/** The React component that renders the example */
component: ComponentType;
}
/**
* Category metadata for sidebar grouping
*/
export const CATEGORIES: Record<
ExampleMeta["category"],
{ label: string; description: string }
> = {
agents: {
label: "Agents",
description: "Tool-calling and agentic workflows",
},
langgraph: {
label: "LangGraph",
description: "Graph-based conversation flows",
},
middleware: {
label: "Middleware",
description: "Custom middleware patterns",
},
advanced: {
label: "Advanced",
description: "Complex streaming scenarios",
},
};
/**
* Registry of all available examples.
* To add a new example:
* 1. Create a new file in src/examples/your-example/
* 2. Export your component and metadata
* 3. Import and add to this array
*/
export const EXAMPLES: ExampleDefinition[] = [];
/**
* Register an example. Use this to add examples from their own modules.
*/
export function registerExample(example: ExampleDefinition): void {
EXAMPLES.push(example);
}
/**
* Get an example by ID
*/
export function getExample(id: string): ExampleDefinition | undefined {
return EXAMPLES.find((e) => e.id === id);
}
/**
* Get examples grouped by category
*/
export function getExamplesByCategory(): Map<
ExampleMeta["category"],
ExampleDefinition[]
> {
const grouped = new Map<ExampleMeta["category"], ExampleDefinition[]>();
for (const category of Object.keys(CATEGORIES) as ExampleMeta["category"][]) {
grouped.set(category, []);
}
for (const example of EXAMPLES) {
const categoryExamples = grouped.get(example.category) || [];
categoryExamples.push(example);
grouped.set(example.category, categoryExamples);
}
return grouped;
}
@@ -0,0 +1,31 @@
import { createAgent } from "langchain";
import { ChatOpenAI } from "@langchain/openai";
import { MemorySaver } from "@langchain/langgraph";
const model = new ChatOpenAI({ model: "gpt-4o-mini" });
/**
* Stream Reconnection Agent
*
* A conversational agent designed to generate longer responses,
* making it easier to test the stream reconnection feature.
*
* The checkpointer (MemorySaver) stores conversation state, which
* combined with reconnectOnMount in the React hook, enables
* resuming streams after page refresh.
*/
export const agent = createAgent({
model,
tools: [],
checkpointer: new MemorySaver(),
systemPrompt: `You are a helpful assistant that provides detailed, comprehensive responses.
When asked to write stories, explain concepts, or create guides:
- Provide thorough, well-structured responses
- Use headings and bullet points for organization
- Include examples and details
- Aim for responses that take 10-20 seconds to stream
This helps demonstrate the stream reconnection feature - users can refresh
the page mid-response and the stream will automatically resume.`,
});
@@ -0,0 +1,346 @@
import { useState, useCallback, useEffect } from "react";
import { useStickToBottom } from "use-stick-to-bottom";
import {
AlertCircle,
RotateCcw,
Wifi,
Hash,
RefreshCw,
Zap,
CheckCircle2,
Radio,
} from "lucide-react";
import { useStream } from "@langchain/langgraph-sdk/react";
import type { Message } from "@langchain/langgraph-sdk";
import { registerExample } from "../registry";
import { LoadingIndicator } from "../../components/Loading";
import { EmptyState } from "../../components/States";
import { MessageInput } from "../../components/MessageInput";
import type { agent } from "./agent";
/**
* Extract text content from a message
*/
function getTextContent(message: Message): string {
if (typeof message.content === "string") {
return message.content;
}
if (Array.isArray(message.content)) {
return message.content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.text)
.join("");
}
return "";
}
/**
* Custom hook to manage thread ID in URL search params
*/
function useThreadIdParam() {
const [threadId, setThreadId] = useState<string | null>(() => {
if (typeof window === "undefined") return null;
const params = new URLSearchParams(window.location.search);
return params.get("threadId");
});
const updateThreadId = useCallback((newThreadId: string | null) => {
setThreadId(newThreadId);
const url = new URL(window.location.href);
if (newThreadId == null) {
url.searchParams.delete("threadId");
} else {
url.searchParams.set("threadId", newThreadId);
}
window.history.replaceState({}, "", url.toString());
}, []);
return [threadId, updateThreadId] as const;
}
/**
* Connection status indicator
*/
function ConnectionStatus({
isLoading,
isReconnecting,
threadId,
}: {
isLoading: boolean;
isReconnecting: boolean;
threadId: string | null;
}) {
return (
<div className="flex items-center gap-3 px-4 py-2 rounded-lg bg-neutral-800/80 border border-neutral-700">
{/* Connection indicator */}
<div className="flex items-center gap-2">
{isReconnecting ? (
<>
<Radio className="w-4 h-4 text-amber-400 animate-pulse" />
<span className="text-xs text-amber-400">Reconnecting...</span>
</>
) : isLoading ? (
<>
<Radio className="w-4 h-4 text-green-400 animate-pulse" />
<span className="text-xs text-green-400">Streaming</span>
</>
) : (
<>
<Wifi className="w-4 h-4 text-neutral-500" />
<span className="text-xs text-neutral-400">Ready</span>
</>
)}
</div>
{/* Thread ID */}
{threadId && (
<>
<div className="w-px h-4 bg-neutral-700" />
<div className="flex items-center gap-1.5">
<Hash className="w-3 h-3 text-neutral-500" />
<code className="text-xs text-neutral-500 font-mono">
{threadId.slice(0, 8)}...
</code>
</div>
</>
)}
</div>
);
}
/**
* Reconnection banner shown when stream is resumed
*/
function ReconnectedBanner({ onDismiss }: { onDismiss: () => void }) {
useEffect(() => {
const timer = setTimeout(onDismiss, 5000);
return () => clearTimeout(timer);
}, [onDismiss]);
return (
<div className="flex items-center gap-3 px-4 py-3 rounded-xl bg-green-950/40 border border-green-500/30 animate-fade-in">
<CheckCircle2 className="w-5 h-5 text-green-400" />
<div className="flex-1">
<div className="text-sm font-medium text-green-300">
Stream Reconnected
</div>
<div className="text-xs text-green-400/70">
Successfully resumed the in-flight stream after page refresh
</div>
</div>
</div>
);
}
/**
* Message bubble component
*/
function MessageBubble({ message }: { message: Message }) {
const isHuman = message.type === "human";
const content = getTextContent(message);
if (!content) return null;
return (
<div className="animate-fade-in">
{!isHuman && (
<div className="text-xs font-medium text-neutral-500 mb-2">
Assistant
</div>
)}
<div
className={`${
isHuman
? "bg-brand-dark text-brand-light rounded-2xl px-4 py-2.5 ml-auto max-w-[85%] md:max-w-[70%] w-fit"
: "text-neutral-100"
}`}
>
<div className="whitespace-pre-wrap leading-relaxed text-[15px]">
{content}
</div>
</div>
</div>
);
}
const SUGGESTIONS = [
"Write me a long story about a robot learning to paint",
"Explain quantum computing in detail with examples",
"Create a comprehensive guide to learning TypeScript",
];
export function SessionPersistence() {
const [threadId, setThreadId] = useThreadIdParam();
const [showReconnectedBanner, setShowReconnectedBanner] = useState(false);
const [hasReconnected, setHasReconnected] = useState(false);
/**
* useStream with reconnectOnMount enabled.
*
* This automatically resumes an ongoing stream after page refresh.
* The run ID is stored in sessionStorage and used to rejoin the stream.
*
* See: https://docs.langchain.com/langsmith/use-stream-react#resume-a-stream-after-page-refresh
*/
const stream = useStream<typeof agent>({
assistantId: "session-persistence",
apiUrl: "http://localhost:2024",
threadId: threadId ?? undefined,
onThreadId: setThreadId,
// Enable automatic stream reconnection after page refresh
reconnectOnMount: true,
// Called when the stream finishes
onFinish: () => {
// Check if we reconnected to a stream
if (hasReconnected) {
setShowReconnectedBanner(true);
setHasReconnected(false);
}
},
});
// Detect if we're reconnecting to an existing stream
useEffect(() => {
if (stream.isLoading && threadId) {
// Check if there's a stored run ID for this thread (indicates reconnection)
const storedRunId = window.sessionStorage.getItem(
`lg:stream:${threadId}`
);
if (storedRunId) {
setHasReconnected(true);
}
}
}, [stream.isLoading, threadId]);
const { scrollRef, contentRef } = useStickToBottom();
const handleSubmit = useCallback(
(content: string) => {
stream.submit({ messages: [{ content, type: "human" }] });
},
[stream]
);
/**
* Simulate a page refresh to demonstrate reconnection
*/
const handleSimulateRefresh = useCallback(() => {
window.location.reload();
}, []);
const hasMessages = stream.messages.length > 0;
return (
<div className="h-full flex flex-col">
{/* Header with connection status */}
<div className="border-b border-neutral-800 px-8 py-2 flex items-center justify-between">
<ConnectionStatus
isLoading={stream.isLoading}
isReconnecting={hasReconnected && stream.isLoading}
threadId={threadId}
/>
{/* Refresh button - only show during streaming */}
{stream.isLoading && (
<button
onClick={handleSimulateRefresh}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-amber-500/10 border border-amber-500/30 text-amber-400 text-xs hover:bg-amber-500/20 transition-colors"
title="Refresh page to test stream reconnection"
>
<RefreshCw className="w-3.5 h-3.5" />
Refresh to Test Reconnect
</button>
)}
</div>
<main ref={scrollRef} className="flex-1 overflow-y-auto">
<div ref={contentRef} className="max-w-2xl mx-auto px-4 py-8">
{!hasMessages ? (
<EmptyState
icon={RotateCcw}
title="Stream Reconnection Demo"
description="This example demonstrates reconnectOnMount - the ability to resume an in-flight stream after a page refresh. Start a long response, then click 'Refresh to Test Reconnect' while streaming."
suggestions={SUGGESTIONS}
onSuggestionClick={handleSubmit}
/>
) : (
<div className="flex flex-col gap-6">
{/* Reconnected banner */}
{showReconnectedBanner && (
<ReconnectedBanner
onDismiss={() => setShowReconnectedBanner(false)}
/>
)}
{/* How it works info box */}
{hasMessages && stream.messages.length <= 2 && (
<div className="flex items-start gap-3 px-4 py-3 rounded-xl bg-blue-950/30 border border-blue-500/20 animate-fade-in">
<Zap className="w-5 h-5 text-blue-400 mt-0.5" />
<div>
<div className="text-sm font-medium text-blue-300 mb-1">
Try refreshing during the response!
</div>
<div className="text-xs text-blue-400/70">
The{" "}
<code className="px-1 py-0.5 rounded bg-blue-900/50">
reconnectOnMount: true
</code>{" "}
option automatically resumes the stream. The run ID is
stored in sessionStorage.
</div>
</div>
</div>
)}
{stream.messages.map((message, idx) => (
<MessageBubble key={message.id ?? idx} message={message} />
))}
{/* Loading indicator */}
{stream.isLoading && <LoadingIndicator />}
</div>
)}
</div>
</main>
{stream.error != null && (
<div className="max-w-2xl mx-auto px-4 pb-3">
<div className="bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3 text-red-400 text-sm">
<div className="flex items-center gap-2">
<AlertCircle className="w-4 h-4 shrink-0" />
<span>
{stream.error instanceof Error
? stream.error.message
: "An error occurred"}
</span>
</div>
</div>
</div>
)}
<MessageInput
disabled={stream.isLoading}
placeholder="Ask for a long response, then refresh mid-stream..."
onSubmit={handleSubmit}
/>
</div>
);
}
// Register this example
registerExample({
id: "session-persistence",
title: "Stream Reconnection",
description:
"Resume an in-flight stream after page refresh with reconnectOnMount",
category: "langgraph",
icon: "graph",
ready: true,
component: SessionPersistence,
});
export default SessionPersistence;
@@ -0,0 +1,100 @@
import { createAgent, tool, summarizationMiddleware } from "langchain";
import { ChatOpenAI } from "@langchain/openai";
import { MemorySaver } from "@langchain/langgraph";
import { z } from "zod/v4";
const model = new ChatOpenAI({ model: "gpt-4o-mini" });
/**
* Simple calculator tool to make conversations more interactive
*/
export const calculate = tool(
async ({ expression }) => {
try {
// Safe evaluation using Function constructor
// Only allow numbers and basic operators
if (!/^[\d\s+\-*/().]+$/.test(expression)) {
return JSON.stringify({
status: "error",
content:
"Invalid expression. Only numbers and basic operators (+, -, *, /, parentheses) are allowed.",
});
}
const result = new Function(`return ${expression}`)();
return JSON.stringify({
status: "success",
content: `${expression} = ${result}`,
});
} catch (e) {
return JSON.stringify({
status: "error",
content: `Failed to evaluate expression: ${(e as Error).message}`,
});
}
},
{
name: "calculate",
description: "Perform a mathematical calculation",
schema: z.object({
expression: z
.string()
.describe(
"The mathematical expression to evaluate, e.g., '2 + 2' or '(10 * 5) / 2'"
),
}),
}
);
/**
* Simple note-taking tool to make the conversation more dynamic
*/
export const takeNote = tool(
async ({ title, content }) => {
return JSON.stringify({
status: "success",
content: `Note saved: "${title}" - ${content}`,
});
},
{
name: "take_note",
description: "Save a note for later reference",
schema: z.object({
title: z.string().describe("The title of the note"),
content: z.string().describe("The content of the note"),
}),
}
);
/**
* Create a ReAct agent with summarization middleware.
*
* The summarization middleware will automatically condense the conversation
* history when it exceeds the trigger threshold, preserving the most recent
* messages while summarizing older ones.
*/
export const agent = createAgent({
model,
tools: [calculate, takeNote],
middleware: [
summarizationMiddleware({
model,
// Trigger summarization when messages exceed 8 AND the conversation has been going
trigger: { messages: 8 },
// Keep the 4 most recent messages after summarization
keep: { messages: 4 },
// Custom prefix for the summary message
summaryPrefix: "📋 **Conversation Summary:**\n\n",
}),
],
// Required for maintaining state across turns and for summarization to persist
checkpointer: new MemorySaver(),
systemPrompt: `You are a helpful travel planning assistant. You help users plan trips, provide recommendations, and answer questions about destinations.
When summarization occurs (you see a summary message), acknowledge it naturally by saying something like "I see we've been having quite a conversation! Let me continue helping you..."
You have access to these tools:
- calculate: For any math calculations (currency conversion estimates, budget calculations, etc.)
- take_note: To save important notes for the user
Be friendly, informative, and proactive in offering helpful suggestions.`,
});
@@ -0,0 +1,447 @@
import { useEffect, useCallback, useState } from "react";
import { useStickToBottom } from "use-stick-to-bottom";
import {
AlertCircle,
Sparkles,
MessageSquare,
Layers,
Zap,
RefreshCw,
} from "lucide-react";
import { useStream } from "@langchain/langgraph-sdk/react";
import type { Message } from "@langchain/langgraph-sdk";
import { ToolCallCard } from "../../components/ToolCallCard";
import { registerExample } from "../registry";
import { LoadingIndicator } from "../../components/Loading";
import { MessageInput } from "../../components/MessageInput";
import type { agent } from "./agent";
import { getPrefilledMessages } from "./prefilled-messages";
/**
* Extract text content from a message
*/
function getContent(message: Message): string {
if (typeof message.content === "string") {
return message.content;
}
if (Array.isArray(message.content)) {
return message.content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.text)
.join("");
}
return "";
}
/**
* Check if a message is a summary message
*/
function isSummaryMessage(message: Message): boolean {
const content = getContent(message);
return (
content.includes("📋 **Conversation Summary:**") ||
content.includes("Conversation Summary:") ||
content.toLowerCase().includes("summary of our conversation")
);
}
/**
* Toast notification for summarization events
*/
function SummarizationToast({ onClose }: { onClose: () => void }) {
return (
<div className="fixed top-24 right-4 z-100 animate-fade-in">
<div className="bg-neutral-900/95 backdrop-blur-md border border-violet-500/40 rounded-xl px-4 py-3 shadow-2xl shadow-violet-500/10 flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-violet-500/20 border border-violet-500/30 flex items-center justify-center shrink-0">
<Layers className="w-4 h-4 text-violet-400" />
</div>
<div className="flex-1">
<div className="text-sm font-medium text-violet-200 flex items-center gap-1.5">
Conversation Summarized
<Sparkles className="w-3.5 h-3.5 text-fuchsia-400" />
</div>
<p className="text-xs text-neutral-400">
Older messages condensed to maintain context
</p>
</div>
<button
onClick={onClose}
className="text-neutral-500 hover:text-neutral-300 transition-colors p-1"
>
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
);
}
/**
* Summary message card with special styling
*/
function SummaryMessageCard({ message }: { message: Message }) {
const content = getContent(message);
return (
<div className="bg-linear-to-br from-violet-950/40 to-fuchsia-950/30 border border-violet-500/20 rounded-xl p-5 animate-fade-in">
<div className="flex items-start gap-3 mb-4">
<div className="w-8 h-8 rounded-lg bg-violet-500/20 border border-violet-500/30 flex items-center justify-center shrink-0">
<Layers className="w-4 h-4 text-violet-400" />
</div>
<div className="flex-1 min-w-0">
<div className="text-xs font-medium text-violet-400 mb-1 flex items-center gap-1">
<span>Summarized Context</span>
<Sparkles className="w-3 h-3 text-fuchsia-400" />
</div>
<div className="text-sm text-neutral-200 whitespace-pre-wrap leading-relaxed">
{content.replace(/📋 \*\*Conversation Summary:\*\*\n\n?/, "")}
</div>
</div>
</div>
</div>
);
}
/**
* Regular message bubble with type-aware styling
*/
function MessageBubble({ message }: { message: Message }) {
const isHuman = message.type === "human";
const content = getContent(message);
if (!content) return null;
// Check if this is a summary message
if (!isHuman && isSummaryMessage(message)) {
return <SummaryMessageCard message={message} />;
}
return (
<div className="animate-fade-in">
{!isHuman && (
<div className="text-xs font-medium text-neutral-500 mb-2 flex items-center gap-1">
<MessageSquare className="w-3 h-3" />
<span>Assistant</span>
</div>
)}
<div
className={`${
isHuman
? "bg-brand-dark text-brand-light rounded-2xl px-4 py-2.5 ml-auto max-w-[85%] md:max-w-[70%] w-fit"
: "text-neutral-100"
}`}
>
<div className="whitespace-pre-wrap leading-relaxed text-[15px]">
{content}
</div>
</div>
</div>
);
}
/**
* Stats panel showing conversation metrics
*/
function ConversationStats({
messageCount,
hasSummary,
}: {
messageCount: number;
hasSummary: boolean;
}) {
return (
<div className="fixed right-6 top-1/2 -translate-y-1/2 z-50 bg-neutral-900/95 backdrop-blur-sm rounded-xl p-4 border border-neutral-800 shadow-xl w-52">
<div className="text-xs text-neutral-500 uppercase tracking-wider mb-3 font-medium">
Context Status
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-xs text-neutral-400">Messages</span>
<span className="text-sm font-mono text-neutral-200">
{messageCount}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-neutral-400">Status</span>
<span
className={`text-xs font-medium px-2 py-0.5 rounded-full ${
hasSummary
? "bg-violet-500/20 text-violet-300"
: messageCount >= 6
? "bg-amber-500/20 text-amber-300"
: "bg-emerald-500/20 text-emerald-300"
}`}
>
{hasSummary
? "Summarized"
: messageCount >= 6
? "Near Limit"
: "Normal"}
</span>
</div>
{!hasSummary && messageCount >= 4 && (
<div className="pt-2 border-t border-neutral-800">
<div className="flex items-center gap-2 text-xs text-amber-400/80">
<RefreshCw className="w-3 h-3" />
<span>Summarization at ~8 messages</span>
</div>
</div>
)}
{hasSummary && (
<div className="pt-2 border-t border-neutral-800">
<div className="flex items-center gap-2 text-xs text-violet-400/80">
<Sparkles className="w-3 h-3" />
<span>Context has been compressed</span>
</div>
</div>
)}
</div>
</div>
);
}
/**
* Empty state component
*/
function EmptyState({
onPrefill,
onSuggestionClick,
isPrefilling,
}: {
onPrefill: () => void;
onSuggestionClick: (text: string) => void;
isPrefilling: boolean;
}) {
return (
<div className="flex flex-col items-center justify-center h-full py-12">
<div className="w-16 h-16 rounded-2xl bg-linear-to-br from-violet-500/20 to-fuchsia-500/20 border border-violet-500/30 flex items-center justify-center mb-6">
<Layers className="w-8 h-8 text-violet-400" />
</div>
<h2 className="text-xl font-semibold text-white mb-2">
Summarization Middleware Demo
</h2>
<p className="text-neutral-400 text-center max-w-md mb-8">
See how the summarization middleware automatically condenses long
conversations while preserving context. Start with a pre-filled
conversation to trigger summarization.
</p>
<div className="flex flex-col gap-3 w-full max-w-sm">
<button
onClick={onPrefill}
disabled={isPrefilling}
className="flex items-center justify-center gap-2 px-6 py-3 rounded-xl bg-linear-to-r from-violet-600 to-fuchsia-600 hover:from-violet-500 hover:to-fuchsia-500 text-white font-medium transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{isPrefilling ? (
<>
<LoadingIndicator />
<span>Loading conversation...</span>
</>
) : (
<>
<Zap className="w-4 h-4" />
<span>Load Pre-filled Conversation</span>
</>
)}
</button>
<div className="flex items-center gap-2 text-neutral-500 text-sm">
<div className="flex-1 h-px bg-neutral-800" />
<span>or start fresh</span>
<div className="flex-1 h-px bg-neutral-800" />
</div>
<div className="flex flex-wrap gap-2">
{[
"Tell me about Tokyo",
"Help me plan a trip",
"What's 1500 * 0.85?",
].map((suggestion) => (
<button
key={suggestion}
onClick={() => onSuggestionClick(suggestion)}
className="px-3 py-1.5 rounded-lg bg-neutral-800 hover:bg-neutral-700 text-neutral-300 text-sm transition-colors"
>
{suggestion}
</button>
))}
</div>
</div>
</div>
);
}
export function SummarizationAgent() {
const stream = useStream<typeof agent>({
assistantId: "summarization-agent",
apiUrl: "http://localhost:2024",
});
const { scrollRef, contentRef } = useStickToBottom();
const [isPrefilling, setIsPrefilling] = useState(false);
const [showSummarizationBanner, setShowSummarizationBanner] = useState(false);
// Check for summary messages
const hasSummary = stream.messages.some((m) => isSummaryMessage(m));
// Show banner when summary first appears
useEffect(() => {
if (hasSummary && !showSummarizationBanner) {
setShowSummarizationBanner(true);
// Hide banner after 5 seconds
const timer = setTimeout(() => setShowSummarizationBanner(false), 5000);
return () => clearTimeout(timer);
}
}, [hasSummary, showSummarizationBanner]);
const hasMessages = stream.messages.length > 0;
const handleSubmit = useCallback(
(content: string) => {
stream.submit({ messages: [{ content, type: "human" }] });
},
[stream]
);
const handlePrefill = useCallback(async () => {
setIsPrefilling(true);
try {
// Get pre-filled messages (already in the correct format)
const prefilledMessages = getPrefilledMessages();
// Submit all pre-filled messages followed by a new message to trigger processing
stream.submit({
messages: [
...prefilledMessages,
{
content:
"Thanks for all that information! Now I'd like to know more about the visa requirements for US citizens visiting Japan.",
type: "human",
},
],
});
} finally {
setIsPrefilling(false);
}
}, [stream]);
return (
<div className="h-full flex flex-col">
{/* Toast notification for summarization */}
{showSummarizationBanner && (
<SummarizationToast onClose={() => setShowSummarizationBanner(false)} />
)}
<main ref={scrollRef} className="flex-1 overflow-y-auto">
<div ref={contentRef} className="max-w-2xl mx-auto px-4 py-8">
{/* Stats panel */}
{hasMessages && (
<ConversationStats
messageCount={
stream.messages.filter((m) => m.type !== "tool").length
}
hasSummary={hasSummary}
/>
)}
{!hasMessages ? (
<EmptyState
onPrefill={handlePrefill}
onSuggestionClick={handleSubmit}
isPrefilling={isPrefilling}
/>
) : (
<div className="flex flex-col gap-6">
{stream.messages.map((message, idx) => {
// For AI messages, check if they have tool calls
if (message.type === "ai") {
const toolCalls = stream.getToolCalls(message);
// Render tool calls if present
if (toolCalls.length > 0) {
return (
<div key={message.id} className="flex flex-col gap-3">
{toolCalls.map((toolCall) => (
<ToolCallCard key={toolCall.id} toolCall={toolCall} />
))}
</div>
);
}
// Skip AI messages without content
if (getContent(message).trim().length === 0) {
return null;
}
}
return (
<MessageBubble key={message.id ?? idx} message={message} />
);
})}
{/* Loading indicator */}
{stream.isLoading && <LoadingIndicator />}
</div>
)}
</div>
</main>
{stream.error != null && (
<div className="max-w-2xl mx-auto px-4 pb-3">
<div className="bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3 text-red-400 text-sm">
<div className="flex items-center gap-2">
<AlertCircle className="w-4 h-4 shrink-0" />
<span>
{stream.error instanceof Error
? stream.error.message
: "An error occurred"}
</span>
</div>
</div>
</div>
)}
<MessageInput
disabled={stream.isLoading || isPrefilling}
placeholder="Ask me anything about travel planning..."
onSubmit={handleSubmit}
/>
</div>
);
}
/**
* Register this example
*/
registerExample({
id: "summarization-agent",
title: "Summarization Middleware",
description:
"Agent with automatic context summarization when conversation gets long",
category: "middleware",
icon: "middleware",
ready: true,
component: SummarizationAgent,
});
export default SummarizationAgent;
@@ -0,0 +1,319 @@
/**
* Pre-filled conversation messages for demonstrating summarization.
* This file is separate from agent.ts so it can be imported in the browser
* without pulling in the langchain dependencies that require Node.js APIs.
*/
export interface PrefilledMessage {
content: string;
type: "human" | "ai";
}
/**
* Generate pre-filled conversation history to demonstrate summarization.
* This creates a lengthy conversation that will trigger the summarization middleware.
*/
export function getPrefilledMessages(): PrefilledMessage[] {
return [
{
type: "human",
content:
"Hi! I'm planning a trip to Japan and need help organizing everything.",
},
{
type: "ai",
content: `Hello! I'd be happy to help you plan your trip to Japan! That's an exciting destination with so much to offer. To help you best, could you tell me:
1. When are you planning to travel?
2. How long will you be staying?
3. What are your main interests (culture, food, nature, technology, etc.)?
4. What's your approximate budget?
5. Will you be traveling solo or with others?`,
},
{
type: "human",
content:
"I'm going in April for 2 weeks. I'll be traveling with my partner. We love food and culture, and we'd like to see both cities and countryside. Budget is around $5000 total for both of us.",
},
{
type: "ai",
content: `Perfect timing! April is wonderful for visiting Japan - you'll catch the cherry blossom season (sakura), which is absolutely magical. With 2 weeks and $5000 for two people, you have a good budget to work with.
Here's a suggested itinerary framework:
**Week 1 - Cities:**
- Days 1-4: Tokyo (Shibuya, Shinjuku, Asakusa, teamLab Borderless)
- Days 5-7: Kyoto (temples, geisha district, bamboo grove)
**Week 2 - Mix of City & Countryside:**
- Days 8-9: Nara & Osaka (deer park, street food)
- Days 10-11: Hakone or Takayama (hot springs, nature)
- Days 12-14: Back to Tokyo for departure
For food lovers, I'd highly recommend:
- Tsukiji Outer Market in Tokyo
- Nishiki Market in Kyoto
- Dotonbori in Osaka
Would you like me to dive deeper into any specific part of this plan?`,
},
{
type: "human",
content:
"That sounds amazing! Can you tell me more about the JR Pass? Is it worth it?",
},
{
type: "ai",
content: `Great question! The Japan Rail Pass (JR Pass) is definitely worth considering for your itinerary.
**JR Pass Details:**
- 14-day pass costs approximately $450 USD per person
- Covers unlimited travel on JR trains including the Shinkansen (bullet trains)
- Also covers JR buses and the JR ferry to Miyajima
**For your route, the JR Pass would cover:**
1. Tokyo ↔ Kyoto Shinkansen (~$270 round trip per person)
2. Kyoto ↔ Nara (included)
3. Kyoto ↔ Osaka (included)
4. Tokyo ↔ Hakone (partially covered)
**Cost comparison:**
- Without JR Pass: ~$350-400 per person for all major routes
- With 14-day JR Pass: ~$450 per person
The JR Pass becomes more valuable if you:
- Take the Shinkansen multiple times
- Do side trips (like to Hiroshima or Kanazawa)
My recommendation: For your 2-week trip with the routes I suggested, the JR Pass is borderline worth it. If you add one more long-distance trip (like a day trip to Hiroshima), it becomes clearly worth it.
Would you like me to help optimize your route to maximize the JR Pass value?`,
},
{
type: "human",
content:
"Yes, let's add Hiroshima! Also, what about accommodation recommendations?",
},
{
type: "ai",
content: `Excellent choice! Adding Hiroshima makes the JR Pass definitely worthwhile, and it's an incredibly meaningful destination.
**Updated Itinerary with Hiroshima:**
- Days 1-4: Tokyo
- Days 5-7: Kyoto
- Day 8: Day trip to Hiroshima & Miyajima (from Kyoto)
- Days 9-10: Nara & Osaka
- Days 11-12: Hakone
- Days 13-14: Tokyo
**Accommodation Recommendations:**
**Tokyo (Budget: ~$100-150/night for 2):**
- Shinjuku area: Great for nightlife and transport hub
- Asakusa area: Traditional feel, near temples
- Recommended: Tokyu Stay or Dormy Inn chains (great value)
**Kyoto (Budget: ~$80-120/night for 2):**
- Gion area: Traditional, near geisha district
- Kyoto Station area: Convenient for transport
- Consider: One night in a traditional ryokan (~$200-300)
**Osaka (Budget: ~$80-100/night for 2):**
- Namba/Dotonbori: Best for food and nightlife
- Recommended: Cross Hotel or Hotel Monterey
**Hakone (Budget: ~$150-200/night for 2):**
- Splurge on a ryokan with private onsen (hot spring bath)
- Recommended: Hakone Yuryo or Ichinoyu Honkan
**Total Accommodation Estimate:** ~$1,400-1,800 for 14 nights
Would you like specific booking tips or should we discuss food and dining options?`,
},
{
type: "human",
content: "Let's talk about food! What should we definitely try?",
},
{
type: "ai",
content: `Oh, you're in for a treat! Japanese cuisine is incredible, and each region has specialties. Here's your essential food guide:
**Must-Try Foods by Region:**
**Tokyo:**
- 🍣 Sushi - Tsukiji Outer Market for fresh breakfast sushi
- 🍜 Ramen - Try a late-night bowl at Ichiran (solo booth experience!)
- 🍱 Tempura - Tsunahachi in Shinjuku for affordable excellence
- 🥘 Wagyu beef - Satou in Kichijoji for A5 menchi katsu
**Kyoto:**
- 🍵 Kaiseki - Multi-course traditional dinner (splurge ~$100-150/person)
- 🍡 Matcha everything - Nakamura Tokichi for matcha desserts
- 🍢 Yudofu - Hot tofu in a broth (Junsei restaurant)
- 🥢 Obanzai - Home-style Kyoto cooking
**Osaka (Food Capital!):**
- 🐙 Takoyaki - Octopus balls from street vendors in Dotonbori
- 🥞 Okonomiyaki - Savory pancakes at Mizuno
- 🍢 Kushikatsu - Deep-fried skewers at Daruma
- 🦀 Kani Doraku - Famous crab restaurant
**Hiroshima:**
- 🍜 Hiroshima-style okonomiyaki - Layered, not mixed!
- 🦪 Oysters from Miyajima - Grilled fresh
**Pro Tips:**
1. Download Tabelog app (Japanese Yelp) for restaurant ratings
2. Make reservations for popular spots, especially kaiseki
3. Convenience store food (7-Eleven, Lawson) is surprisingly amazing
4. Try a depachika (department store basement food hall)
Budget for food: ~$50-80/day for two eating well but not extravagantly.
Shall I help with specific restaurant reservations or discuss cultural etiquette for dining?`,
},
{
type: "human",
content: "What about cultural etiquette I should know?",
},
{
type: "ai",
content: `Great question! Japanese culture has many nuances, and showing respect will enhance your experience. Here's what you need to know:
**General Etiquette:**
- 🙇 Bowing: A slight nod is fine for tourists, but matching their bow shows respect
- 🗣️ Volume: Speak quietly in public, especially on trains
- 📱 Phone calls: Never on trains or buses; texting is fine
- 🚮 Trash: No public bins; carry a small bag for your trash
**Restaurant Etiquette:**
- 🍜 Slurping noodles is encouraged - it shows appreciation!
- 🥢 Never stick chopsticks upright in rice (funeral ritual)
- 🥢 Don't pass food chopstick to chopstick
- 💰 No tipping - it can be considered rude
- 🙏 Say "Itadakimasu" before eating and "Gochisousama" after
**Temple & Shrine Etiquette:**
- 👟 Remove shoes when entering buildings
- 🚿 Purify at the temizuya (water basin): left hand, right hand, rinse mouth
- 🙏 At shrines: bow twice, clap twice, pray, bow once
- 📸 Ask before photographing inside temples
**Onsen (Hot Spring) Etiquette:**
- 🧼 Wash thoroughly before entering the bath
- 🚫 No swimsuits - baths are nude
- 🧴 Don't put towel in water
- 🤫 Keep quiet and relaxed
- ❌ Tattoos may be restricted (check ahead)
**Other Important Notes:**
- 💳 Cash is still king in many places
- 🛒 Stand on left side of escalators in Tokyo (right in Osaka)
- 🎁 Gift-giving is appreciated; bring something from home
Would you like me to teach you some useful Japanese phrases?`,
},
{
type: "human",
content: "Yes please! What phrases will be most helpful?",
},
{
type: "ai",
content: `Here are the most useful Japanese phrases for your trip! I'll include pronunciation guides.
**Essential Basics:**
- Hello: Konnichiwa (kon-nee-chee-wah)
- Thank you: Arigatou gozaimasu (ah-ree-gah-toh go-zai-mas)
- Excuse me/Sorry: Sumimasen (sue-mee-mah-sen)
- Please: Onegaishimasu (oh-neh-guy-shee-mas)
- Yes: Hai (hi) / No: Iie (ee-eh)
**At Restaurants:**
- Before eating: Itadakimasu (ee-tah-dah-kee-mas)
- After eating: Gochisousama deshita (go-chee-soh-sama desh-ta)
- Delicious!: Oishii! (oy-shee)
- Check please: Okaikei onegaishimasu (oh-kai-kei oh-neh-guy-shee-mas)
- Water please: Omizu kudasai (oh-mee-zoo koo-dah-sai)
**Getting Around:**
- Where is ___?: ___ wa doko desu ka? (wah doh-koh des kah)
- How much?: Ikura desu ka? (ee-koo-rah des kah)
- Train station: Eki (eh-kee)
- I don't understand: Wakarimasen (wah-kah-ree-mah-sen)
**Shopping:**
- Do you have ___?: ___ wa arimasu ka?
- I'll take this: Kore kudasai (koh-reh koo-dah-sai)
- Can I pay by card?: Kaado de ii desu ka?
**Emergencies:**
- Help!: Tasukete! (tas-keh-teh)
- I'm lost: Michi ni mayoimashita
**Pro Tip:** Most Japanese people appreciate any attempt to speak Japanese, even if imperfect. Don't be shy!
Would you like me to help with anything else for your trip planning?`,
},
{
type: "human",
content:
"This is so helpful! Can you summarize everything we've discussed so I can save it?",
},
{
type: "ai",
content: `Absolutely! Here's a comprehensive summary of your Japan trip plan:
**📅 TRIP OVERVIEW**
- Duration: 2 weeks in April (cherry blossom season!)
- Travelers: You and your partner
- Budget: $5,000 total
**🗺️ ITINERARY**
1. Days 1-4: Tokyo
2. Days 5-7: Kyoto
3. Day 8: Hiroshima & Miyajima day trip
4. Days 9-10: Nara & Osaka
5. Days 11-12: Hakone
6. Days 13-14: Tokyo (departure)
**🚄 TRANSPORTATION**
- 14-day JR Pass: ~$450/person
- Covers all Shinkansen and most JR trains
- Worth it with Hiroshima added!
**🏨 ACCOMMODATION (~$1,400-1,800 total)**
- Tokyo: Shinjuku/Asakusa area, ~$100-150/night
- Kyoto: Near Gion, one ryokan night recommended
- Osaka: Namba/Dotonbori, ~$80-100/night
- Hakone: Ryokan with onsen, ~$150-200/night
**🍜 FOOD HIGHLIGHTS**
- Tokyo: Sushi, ramen, tempura
- Kyoto: Kaiseki, matcha, yudofu
- Osaka: Takoyaki, okonomiyaki, kushikatsu
- Hiroshima: Hiroshima-style okonomiyaki, oysters
- Budget: ~$50-80/day for two
**📝 KEY ETIQUETTE**
- Quiet on trains, no phone calls
- Slurp noodles, no tipping
- Remove shoes indoors
- Bow slightly to show respect
**🗣️ KEY PHRASES**
- Arigatou gozaimasu (thank you)
- Sumimasen (excuse me)
- Itadakimasu (before eating)
- Oishii (delicious!)
**💰 BUDGET BREAKDOWN**
- Flights: ~$1,500-2,000 (not discussed, but estimate)
- JR Pass: ~$900 (both)
- Accommodation: ~$1,400-1,800
- Food: ~$700-1,100
- Activities: ~$400-600
Is there anything you'd like me to add or clarify?`,
},
];
}
@@ -0,0 +1,87 @@
import { createAgent, tool } from "langchain";
import { ChatOpenAI, tools } from "@langchain/openai";
import { z } from "zod/v4";
const model = new ChatOpenAI({ model: "gpt-4o-mini" });
/**
* Custom weather tool
*/
export const getWeather = tool(
async ({ location }) => {
// Use Open-Meteo geocoding API to get coordinates
const geoResponse = await fetch(
`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(
location
)}&count=1`
);
const geoData = await geoResponse.json();
if (!geoData.results?.length) {
return JSON.stringify({
status: "error",
content: `Could not find location: ${location}`,
});
}
const { latitude, longitude, name, country } = geoData.results[0];
// Fetch weather from Open-Meteo API (no API key required)
const weatherResponse = await fetch(
`https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}&current=temperature_2m,weather_code,wind_speed_10m,relative_humidity_2m`
);
const weatherData = await weatherResponse.json();
const {
temperature_2m,
weather_code,
wind_speed_10m,
relative_humidity_2m,
} = weatherData.current;
// Map weather codes to descriptions
const weatherDescriptions: Record<number, string> = {
0: "Clear sky",
1: "Mainly clear",
2: "Partly cloudy",
3: "Overcast",
45: "Foggy",
48: "Depositing rime fog",
51: "Light drizzle",
53: "Moderate drizzle",
55: "Dense drizzle",
61: "Slight rain",
63: "Moderate rain",
65: "Heavy rain",
71: "Slight snow",
73: "Moderate snow",
75: "Heavy snow",
80: "Slight rain showers",
81: "Moderate rain showers",
82: "Violent rain showers",
95: "Thunderstorm",
};
const description =
weatherDescriptions[weather_code] || "Unknown conditions";
return JSON.stringify({
status: "success",
content: `Weather in ${name}, ${country}: ${description}, ${temperature_2m}°C, Wind: ${wind_speed_10m} km/h, Humidity: ${relative_humidity_2m}%`,
});
},
{
name: "get_weather",
description: "Get the current weather for a location",
schema: z.object({
location: z.string().describe("The city or location to get weather for"),
}),
}
);
export const agent = createAgent({
model,
tools: [getWeather, tools.webSearch()],
systemPrompt:
"You are a helpful assistant that can answer questions and help with tasks.",
});
@@ -0,0 +1,141 @@
import { useCallback } from "react";
import { useStickToBottom } from "use-stick-to-bottom";
import { AlertCircle, Wrench } from "lucide-react";
import { useStream } from "@langchain/langgraph-sdk/react";
import type { Message } from "@langchain/langgraph-sdk";
import { registerExample } from "../registry";
import { LoadingIndicator } from "../../components/Loading";
import { EmptyState } from "../../components/States";
import { MessageBubble } from "../../components/MessageBubble";
import { MessageInput } from "../../components/MessageInput";
import { ToolCallCard } from "../../components/ToolCallCard";
import type { agent } from "./agent";
const TOOL_AGENT_SUGGESTIONS = [
"What's the weather in San Francisco?",
"Search for the latest AI news",
"What's the weather in Tokyo?",
];
/**
* Helper to check if a message has actual text content.
*/
function hasContent(message: Message): boolean {
if (typeof message.content === "string") {
return message.content.trim().length > 0;
}
if (Array.isArray(message.content)) {
return message.content.some(
(c) => c.type === "text" && c.text.trim().length > 0
);
}
return false;
}
export function ToolCallingAgent() {
const { scrollRef, contentRef } = useStickToBottom();
const stream = useStream<typeof agent>({
assistantId: "tool-calling-agent",
apiUrl: "http://localhost:2024",
});
const hasMessages = stream.messages.length > 0;
const handleSubmit = useCallback(
(content: string) => {
stream.submit({ messages: [{ content, type: "human" }] });
},
[stream]
);
return (
<div className="h-full flex flex-col">
<main ref={scrollRef} className="flex-1 overflow-y-auto">
<div ref={contentRef} className="max-w-2xl mx-auto px-4 py-8">
{!hasMessages ? (
<EmptyState
icon={Wrench}
title="Tool Calling Agent"
description="A smart agent with weather and search tools. Ask about the weather anywhere or search for information."
suggestions={TOOL_AGENT_SUGGESTIONS}
onSuggestionClick={handleSubmit}
/>
) : (
<div className="flex flex-col gap-6">
{stream.messages.map((message, idx) => {
// For AI messages, check if they have tool calls
if (message.type === "ai") {
const toolCalls = stream.getToolCalls(message);
// Render tool calls if present
if (toolCalls.length > 0) {
return (
<div key={message.id} className="flex flex-col gap-3">
{toolCalls.map((toolCall) => (
<ToolCallCard key={toolCall.id} toolCall={toolCall} />
))}
</div>
);
}
// Skip AI messages without content
if (!hasContent(message)) {
return null;
}
}
return (
<MessageBubble key={message.id ?? idx} message={message} />
);
})}
{/* Show loading indicator when streaming and no content yet */}
{stream.isLoading &&
!stream.messages.some(
(m) => m.type === "ai" && hasContent(m)
) &&
stream.toolCalls.length === 0 && <LoadingIndicator />}
</div>
)}
</div>
</main>
{stream.error != null && (
<div className="max-w-2xl mx-auto px-4 pb-3">
<div className="bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3 text-red-400 text-sm">
<div className="flex items-center gap-2">
<AlertCircle className="w-4 h-4 shrink-0" />
<span>
{stream.error instanceof Error
? stream.error.message
: "An error occurred"}
</span>
</div>
</div>
</div>
)}
<MessageInput
disabled={stream.isLoading}
placeholder="Ask me about weather or search for info..."
onSubmit={handleSubmit}
/>
</div>
);
}
// Register this example
registerExample({
id: "tool-calling-agent",
title: "Tool Calling Agent",
description:
"Simple agent with weather and search tools demonstrating streaming tool calls",
category: "agents",
icon: "tool",
ready: true,
component: ToolCallingAgent,
});
export default ToolCallingAgent;
+102
View File
@@ -0,0 +1,102 @@
@import "tailwindcss";
@theme {
--font-sans: "Geist", system-ui, sans-serif;
--font-mono: "Geist Mono", ui-monospace, monospace;
--animate-fade-in: fade-in 0.2s ease-out forwards;
--animate-slide-up: slide-up 0.3s ease-out forwards;
--animate-slide-in-down: slide-in-down 0.3s ease-out forwards;
--animate-pulse-subtle: pulse-subtle 2s ease-in-out infinite;
--animate-shimmer: shimmer 2s ease-in-out infinite;
/* LangChain Brand Colors */
--color-brand-dark: #1e4545;
--color-brand-accent: #beb4fd;
--color-brand-light: #eae8fe;
}
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slide-up {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slide-in-down {
from {
opacity: 0;
transform: translate(-50%, -100%);
}
to {
opacity: 1;
transform: translate(-50%, 0);
}
}
@keyframes pulse-subtle {
0%,
100% {
opacity: 0.4;
}
50% {
opacity: 1;
}
}
@keyframes shimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
/* Custom scrollbar styling */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.2);
}
/* Firefox scrollbar */
* {
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.1) transparent;
}
/* Disable text area resize handle */
textarea {
resize: none;
}
/* Selection color */
::selection {
background: rgba(190, 180, 253, 0.3);
}
-3
View File
@@ -1,6 +1,5 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
@@ -19,9 +18,7 @@
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}
+1 -4
View File
@@ -1,7 +1,6 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
@@ -17,9 +16,7 @@
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
+1 -1
View File
@@ -25,4 +25,4 @@
"persistent": true
}
}
}
}
+2 -1
View File
@@ -1,8 +1,9 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
plugins: [tailwindcss(), react()],
clearScreen: false,
});
@@ -4,6 +4,10 @@ set -euxo pipefail
export CI=true
# TypeScript in these export tests can use a lot of memory in CI; raise the heap limit
# to avoid sporadic OOM failures on GitHub runners.
export NODE_OPTIONS="--max-old-space-size=6144 ${NODE_OPTIONS:-}"
# enable extended globbing for omitting build artifacts
shopt -s extglob
@@ -9,7 +9,7 @@
"description": "TSC Tests for the things exported by the langchain package",
"main": "./index.mjs",
"scripts": {
"build": "tsc -m nodenext src/*.ts --outDir dist",
"build": "tsc -p tsconfig.json",
"test": "npm run test:tsc && npm run test:zod",
"test:tsc": "node dist/main.js",
"test:zod": "node dist/zod.js"
@@ -22,6 +22,6 @@
"@langchain/langgraph-checkpoint": "workspace:*",
"@types/node": "^18.15.11",
"typescript": "5.5.4",
"zod": "^3.25.32"
"zod": "^3.25.76"
}
}
@@ -30,19 +30,23 @@ const WeatherSchema = z.object({
condition: z.string(),
location: z.string(),
});
type Weather = z.infer<typeof WeatherSchema>;
// @ts-ignore - zod version mismatch between test and @langchain/core
const WeatherGraphState = z.object({
messages: z.array(z.any()),
// @ts-ignore - zod version mismatch between test and @langchain/core
weather: withLangGraph(WeatherSchema, {
default: () => ({ temperature: 0, condition: "unknown", location: "" }),
reducer: {
schema: WeatherSchema,
fn: (a, b) => b,
fn: (_: Weather, b: Weather): Weather => b,
},
}),
});
type WeatherGraphStateType = z.infer<typeof WeatherGraphState>;
// @ts-ignore - zod version mismatch between test and @langchain/core
const weatherGraph = new StateGraph(WeatherGraphState)
.addNode("get_weather", async (state: WeatherGraphStateType) => {
return {
+1 -1
View File
@@ -2,7 +2,7 @@ import type { BaseCheckpointSaver } from "@langchain/langgraph-checkpoint";
import { specTest } from "./spec/index.js";
import type { CheckpointerTestInitializer } from "./types.js";
export { CheckpointerTestInitializer as CheckpointSaverTestInitializer } from "./types.js";
export { type CheckpointerTestInitializer as CheckpointSaverTestInitializer } from "./types.js";
export {
getTupleTests,
listTests,
+1 -1
View File
@@ -1,5 +1,5 @@
import type { BaseCheckpointSaver } from "@langchain/langgraph-checkpoint";
import { z } from "zod";
import { z } from "zod/v3";
export interface CheckpointerTestInitializer<
CheckpointerT extends BaseCheckpointSaver
+1 -1
View File
@@ -64,7 +64,7 @@
"dependencies": {
"@babel/code-frame": "^7.26.2",
"@hono/node-server": "^1.12.0",
"@hono/zod-validator": "^0.2.2",
"@hono/zod-validator": "^0.7.6",
"@langchain/langgraph-ui": "workspace:*",
"@types/json-schema": "^7.0.15",
"@typescript/vfs": "^1.6.0",
+2 -2
View File
@@ -156,12 +156,12 @@ describe("assistants", () => {
messages: {
type: "array",
items: {
$ref: "#/definitions/BaseMessage<MessageStructure,MessageType>",
$ref: "#/definitions/BaseMessage<MessageStructure<MessageToolSet>,MessageType>",
},
},
},
definitions: {
"BaseMessage<MessageStructure,MessageType>": {
"BaseMessage<MessageStructure<MessageToolSet>,MessageType>": {
type: "object",
},
},
+39 -33
View File
@@ -10,12 +10,14 @@ test.concurrent("graph factories", { timeout: 30_000 }, () => {
messages: {
type: "array",
items: {
$ref: "#/definitions/BaseMessage<MessageStructure,MessageType>",
$ref: "#/definitions/BaseMessage<MessageStructure<MessageToolSet>,MessageType>",
},
},
},
definitions: {
"BaseMessage<MessageStructure,MessageType>": { type: "object" },
"BaseMessage<MessageStructure<MessageToolSet>,MessageType>": {
type: "object",
},
},
$schema: "http://json-schema.org/draft-07/schema#",
};
@@ -137,12 +139,14 @@ describe.concurrent("subgraphs", { timeout: 30_000 }, () => {
messages: {
type: "array",
items: {
$ref: "#/definitions/BaseMessage<MessageStructure,MessageType>",
$ref: "#/definitions/BaseMessage<MessageStructure<MessageToolSet>,MessageType>",
},
},
},
definitions: {
"BaseMessage<MessageStructure,MessageType>": { type: "object" },
"BaseMessage<MessageStructure<MessageToolSet>,MessageType>": {
type: "object",
},
},
$schema: "http://json-schema.org/draft-07/schema#",
});
@@ -157,12 +161,12 @@ describe.concurrent("subgraphs", { timeout: 30_000 }, () => {
messages: {
type: "array",
items: {
$ref: "#/definitions/BaseMessage<MessageStructure,MessageType>",
$ref: "#/definitions/BaseMessage<MessageStructure<MessageToolSet>,MessageType>",
},
},
},
definitions: {
"BaseMessage<MessageStructure,MessageType>": {
"BaseMessage<MessageStructure<MessageToolSet>,MessageType>": {
type: "object",
},
},
@@ -179,12 +183,12 @@ describe.concurrent("subgraphs", { timeout: 30_000 }, () => {
messages: {
type: "array",
items: {
$ref: "#/definitions/BaseMessage<MessageStructure,MessageType>",
$ref: "#/definitions/BaseMessage<MessageStructure<MessageToolSet>,MessageType>",
},
},
},
definitions: {
"BaseMessage<MessageStructure,MessageType>": {
"BaseMessage<MessageStructure<MessageToolSet>,MessageType>": {
type: "object",
},
},
@@ -267,12 +271,12 @@ describe.concurrent("subgraphs", { timeout: 30_000 }, () => {
messages: {
type: "array",
items: {
$ref: "#/definitions/BaseMessage<MessageStructure,MessageType>",
$ref: "#/definitions/BaseMessage<MessageStructure<MessageToolSet>,MessageType>",
},
},
},
definitions: {
"BaseMessage<MessageStructure,MessageType>": {
"BaseMessage<MessageStructure<MessageToolSet>,MessageType>": {
type: "object",
},
},
@@ -289,12 +293,14 @@ describe.concurrent("subgraphs", { timeout: 30_000 }, () => {
messages: {
type: "array",
items: {
$ref: "#/definitions/BaseMessage<MessageStructure,MessageType>",
$ref: "#/definitions/BaseMessage<MessageStructure<MessageToolSet>,MessageType>",
},
},
},
definitions: {
"BaseMessage<MessageStructure,MessageType>": { type: "object" },
"BaseMessage<MessageStructure<MessageToolSet>,MessageType>": {
type: "object",
},
},
$schema: "http://json-schema.org/draft-07/schema#",
});
@@ -309,12 +315,12 @@ describe.concurrent("subgraphs", { timeout: 30_000 }, () => {
messages: {
type: "array",
items: {
$ref: "#/definitions/BaseMessage<MessageStructure,MessageType>",
$ref: "#/definitions/BaseMessage<MessageStructure<MessageToolSet>,MessageType>",
},
},
},
definitions: {
"BaseMessage<MessageStructure,MessageType>": {
"BaseMessage<MessageStructure<MessageToolSet>,MessageType>": {
type: "object",
},
},
@@ -460,12 +466,12 @@ describe.concurrent("subgraphs", { timeout: 30_000 }, () => {
messages: {
type: "array",
items: {
$ref: "#/definitions/BaseMessage<MessageStructure,MessageType>",
$ref: "#/definitions/BaseMessage<MessageStructure<MessageToolSet>,MessageType>",
},
},
},
definitions: {
"BaseMessage<MessageStructure,MessageType>": {
"BaseMessage<MessageStructure<MessageToolSet>,MessageType>": {
type: "object",
},
},
@@ -482,12 +488,12 @@ describe.concurrent("subgraphs", { timeout: 30_000 }, () => {
messages: {
type: "array",
items: {
$ref: "#/definitions/BaseMessage<MessageStructure,MessageType>",
$ref: "#/definitions/BaseMessage<MessageStructure<MessageToolSet>,MessageType>",
},
},
},
definitions: {
"BaseMessage<MessageStructure,MessageType>": {
"BaseMessage<MessageStructure<MessageToolSet>,MessageType>": {
type: "object",
},
},
@@ -504,12 +510,12 @@ describe.concurrent("subgraphs", { timeout: 30_000 }, () => {
messages: {
type: "array",
items: {
$ref: "#/definitions/BaseMessage<MessageStructure,MessageType>",
$ref: "#/definitions/BaseMessage<MessageStructure<MessageToolSet>,MessageType>",
},
},
},
definitions: {
"BaseMessage<MessageStructure,MessageType>": {
"BaseMessage<MessageStructure<MessageToolSet>,MessageType>": {
type: "object",
},
},
@@ -592,12 +598,12 @@ describe.concurrent("subgraphs", { timeout: 30_000 }, () => {
messages: {
type: "array",
items: {
$ref: "#/definitions/BaseMessage<MessageStructure,MessageType>",
$ref: "#/definitions/BaseMessage<MessageStructure<MessageToolSet>,MessageType>",
},
},
},
definitions: {
"BaseMessage<MessageStructure,MessageType>": {
"BaseMessage<MessageStructure<MessageToolSet>,MessageType>": {
type: "object",
},
},
@@ -614,12 +620,12 @@ describe.concurrent("subgraphs", { timeout: 30_000 }, () => {
messages: {
type: "array",
items: {
$ref: "#/definitions/BaseMessage<MessageStructure,MessageType>",
$ref: "#/definitions/BaseMessage<MessageStructure<MessageToolSet>,MessageType>",
},
},
},
definitions: {
"BaseMessage<MessageStructure,MessageType>": {
"BaseMessage<MessageStructure<MessageToolSet>,MessageType>": {
type: "object",
},
},
@@ -636,12 +642,12 @@ describe.concurrent("subgraphs", { timeout: 30_000 }, () => {
messages: {
type: "array",
items: {
$ref: "#/definitions/BaseMessage<MessageStructure,MessageType>",
$ref: "#/definitions/BaseMessage<MessageStructure<MessageToolSet>,MessageType>",
},
},
},
definitions: {
"BaseMessage<MessageStructure,MessageType>": {
"BaseMessage<MessageStructure<MessageToolSet>,MessageType>": {
type: "object",
},
},
@@ -714,12 +720,12 @@ describe.concurrent("subgraphs", { timeout: 30_000 }, () => {
messages: {
type: "array",
items: {
$ref: "#/definitions/BaseMessage<MessageStructure,MessageType>",
$ref: "#/definitions/BaseMessage<MessageStructure<MessageToolSet>,MessageType>",
},
},
},
definitions: {
"BaseMessage<MessageStructure,MessageType>": {
"BaseMessage<MessageStructure<MessageToolSet>,MessageType>": {
type: "object",
},
},
@@ -736,12 +742,12 @@ describe.concurrent("subgraphs", { timeout: 30_000 }, () => {
messages: {
type: "array",
items: {
$ref: "#/definitions/BaseMessage<MessageStructure,MessageType>",
$ref: "#/definitions/BaseMessage<MessageStructure<MessageToolSet>,MessageType>",
},
},
},
definitions: {
"BaseMessage<MessageStructure,MessageType>": {
"BaseMessage<MessageStructure<MessageToolSet>,MessageType>": {
type: "object",
},
},
@@ -758,12 +764,12 @@ describe.concurrent("subgraphs", { timeout: 30_000 }, () => {
messages: {
type: "array",
items: {
$ref: "#/definitions/BaseMessage<MessageStructure,MessageType>",
$ref: "#/definitions/BaseMessage<MessageStructure<MessageToolSet>,MessageType>",
},
},
},
definitions: {
"BaseMessage<MessageStructure,MessageType>": {
"BaseMessage<MessageStructure<MessageToolSet>,MessageType>": {
type: "object",
},
},
@@ -1026,7 +1032,7 @@ test.concurrent("`strictFunctionTypes: false`", { timeout: 30_000 }, () => {
messages: {
type: "array",
items: {
$ref: "#/definitions/BaseMessage<MessageStructure,MessageType>",
$ref: "#/definitions/BaseMessage<MessageStructure<MessageToolSet>,MessageType>",
},
},
state: { type: "string" },
+1 -1
View File
@@ -20,4 +20,4 @@ export {
REMOVE_ALL_MESSAGES,
type Messages,
} from "./message.js";
export { CommandInstance, CommandParams } from "../constants.js";
export { CommandInstance, type CommandParams } from "../constants.js";
+38 -40
View File
@@ -1,15 +1,24 @@
import { z as zd } from "zod";
import { z as z3 } from "zod/v3";
import { getInteropZodDefaultGetter } from "@langchain/core/utils/types";
import {
getInteropZodDefaultGetter,
InteropZodType,
} from "@langchain/core/utils/types";
import { SchemaMeta, withLangGraph } from "./meta.js";
const metaSymbol = Symbol.for("langgraph-zod");
interface ZodLangGraphTypes<T extends zd.ZodTypeAny, Output> {
reducer<Input = zd.output<T>>(
transform: (a: Output, arg: Input) => Output,
options?: zd.ZodType<Input>
): zd.ZodType<Output, zd.ZodEffectsDef<T>, Input>;
interface ZodLangGraphTypes<T, Output> {
// Overload 1: with explicit reducer schema - captures input type from the schema
reducer<TReducerSchema extends { _output: unknown }>(
transform: (a: Output, arg: TReducerSchema["_output"]) => Output,
options: TReducerSchema
): T & { lg_reducer_schema: TReducerSchema };
// Overload 2: without reducer schema - uses Output as input type
reducer(
transform: (a: Output, arg: Output) => Output
): T & { lg_reducer_schema: T };
metadata(payload: {
langgraph_nodes?: string[];
@@ -47,46 +56,35 @@ if (!(metaSymbol in globalThis)) {
(globalThis as PluginGlobalType)[metaSymbol] = new WeakSet();
}
function applyPluginPrototype<
PrototypeToExtend extends
| typeof zd.ZodType.prototype
| typeof z3.ZodType.prototype
>(prototype: PrototypeToExtend) {
function applyPluginPrototype(prototype: object) {
const cache = (globalThis as PluginGlobalType)[metaSymbol]!;
if (cache.has(prototype)) {
return; // Already applied
}
Object.defineProperty(
prototype,
"langgraph" satisfies keyof PrototypeToExtend,
{
get(): zd.ZodType["langgraph"] {
// Return type is any, actual type provided by module augmentation
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const zodThis = this as zd.ZodTypeAny;
type Output = zd.infer<typeof zodThis>;
Object.defineProperty(prototype, "langgraph", {
get(this: InteropZodType) {
// Actual return type is provided by module augmentation
// eslint-disable-next-line @typescript-eslint/no-this-alias
const zodThis = this;
return {
metadata(
jsonSchemaExtra: SchemaMeta<Output, Output>["jsonSchemaExtra"]
) {
return withLangGraph(zodThis, { jsonSchemaExtra });
},
reducer<Input>(
fn: (a: Output, arg: Input) => Output,
schema?: zd.ZodType<Input>
) {
const defaultFn = getInteropZodDefaultGetter(zodThis);
return withLangGraph(zodThis, {
default: defaultFn,
reducer: { schema, fn },
});
},
};
},
}
);
return {
metadata(jsonSchemaExtra: SchemaMeta["jsonSchemaExtra"]) {
return withLangGraph(zodThis, { jsonSchemaExtra });
},
reducer(
fn: (a: unknown, arg: unknown) => unknown,
schema?: InteropZodType
) {
const defaultFn = getInteropZodDefaultGetter(zodThis);
return withLangGraph(zodThis, {
default: defaultFn,
reducer: { schema, fn },
});
},
};
},
});
cache.add(prototype);
}
+5 -3
View File
@@ -3831,7 +3831,9 @@ graph TD;
description: "Searches the API for the query",
});
const toolsByName = { [searchApi.name]: searchApi };
const toolsByName: Record<string, typeof searchApi> = {
[searchApi.name]: searchApi,
};
const model = new FakeChatModel({
responses: [
new AIMessage({
@@ -10236,9 +10238,9 @@ graph TD;
})
.addNode("subgraph", subgraph)
.addNode("two", (state) => {
if (state.items.length < 2) {
if (state.items!.length < 2) {
throw new Error(
`Expected at least 2 items, got ${state.items.length}`
`Expected at least 2 items, got ${state.items!.length}`
);
}
return { foo: "done", items: ["two"] };
+1 -1
View File
@@ -24,7 +24,7 @@ import {
CacheFullKey,
InMemoryCache,
} from "@langchain/langgraph-checkpoint";
import { z } from "zod";
import { z } from "zod/v3";
import { BaseTracer, Run } from "@langchain/core/tracers/base";
import { Pregel, PregelInputType, PregelOutputType } from "../pregel/index.js";
import { StrRecord } from "../pregel/algo.js";
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { z } from "zod";
import { z } from "zod/v3";
import * as z4 from "zod/v4";
import { StateGraph } from "../graph/state.js";
import { END, START } from "../constants.js";
+1 -1
View File
@@ -72,7 +72,7 @@ module.exports = {
},
overrides: [
{
files: ["src/tests/**/*.ts"],
files: ["src/tests/**/*.ts", "src/**/tests/**/*.ts"],
rules: {
"no-instanceof/no-instanceof": 0,
"@typescript-eslint/no-explicit-any": 0,
+3 -3
View File
@@ -22,9 +22,9 @@
},
"license": "MIT",
"dependencies": {
"p-queue": "^6.6.2",
"p-retry": "4",
"uuid": "^9.0.0"
"p-queue": "^9.0.1",
"p-retry": "^7.1.1",
"uuid": "^13.0.0"
},
"devDependencies": {
"@langchain/core": "^1.1.2",
+2
View File
@@ -55,3 +55,5 @@ export type {
UpdatesStreamEvent,
ValuesStreamEvent,
} from "./types.stream.js";
export type { BagTemplate } from "./types.template.js";
+2 -15
View File
@@ -6,20 +6,13 @@ import * as JsxRuntime from "react/jsx-runtime";
import type { UIMessage } from "./types.js";
import { useStream } from "../react/index.js";
import type { UseStream } from "../react/types.js";
import type { BagTemplate } from "../types.template.js";
const UseStreamContext = React.createContext<{
stream: ReturnType<typeof useStream>;
meta: unknown;
}>(null!);
type BagTemplate = {
ConfigurableType?: Record<string, unknown>;
InterruptType?: unknown;
CustomEventType?: unknown;
UpdateType?: unknown;
MetaType?: unknown;
};
type GetMetaType<Bag extends BagTemplate> = Bag extends { MetaType: unknown }
? Bag["MetaType"]
: unknown;
@@ -33,13 +26,7 @@ interface UseStreamContext<
export function useStreamContext<
StateType extends Record<string, unknown> = Record<string, unknown>,
Bag extends {
ConfigurableType?: Record<string, unknown>;
InterruptType?: unknown;
CustomEventType?: unknown;
UpdateType?: unknown;
MetaType?: unknown;
} = BagTemplate
Bag extends BagTemplate = BagTemplate
>(): UseStreamContext<StateType, Bag> {
const ctx = React.useContext(UseStreamContext);
if (!ctx) {
+14 -3
View File
@@ -2,10 +2,21 @@ export { useStream } from "./stream.js";
export { FetchStreamTransport } from "./stream.custom.js";
export type {
MessageMetadata,
UseStream,
UseStreamOptions,
UseStreamCustom,
UseStreamCustomOptions,
UseStreamTransport,
UseStreamThread,
} from "./types.js";
GetToolCallsType,
// Agent type extraction helpers
AgentTypeConfigLike,
IsAgentLike,
ExtractAgentConfig,
InferAgentToolCalls,
} from "../ui/types.js";
export type {
ToolCallWithResult,
ToolCallState,
DefaultToolCall,
ToolCallFromTool,
ToolCallsFromTools,
} from "../types.messages.js";
+26 -13
View File
@@ -5,24 +5,26 @@
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import { EventStreamEvent, StreamManager } from "../ui/manager.js";
import type {
BagTemplate,
GetUpdateType,
GetCustomEventType,
GetInterruptType,
GetToolCallsType,
RunCallbackMeta,
GetConfigurableType,
UseStreamCustomOptions,
UseStreamCustom,
UseStreamTransport,
UseStreamCustomOptions,
CustomSubmitOptions,
} from "./types.js";
import type { Message } from "../types.messages.js";
} from "../ui/types.js";
import type { UseStreamCustom } from "./types.js";
import { type Message } from "../types.messages.js";
import { getToolCallsWithResults } from "../utils/tools.js";
import { MessageTupleManager } from "../ui/messages.js";
import { Interrupt } from "../schema.js";
import { BytesLineDecoder, SSEDecoder } from "../utils/sse.js";
import { IterableReadableStream } from "../utils/stream.js";
import { useControllableThreadId } from "./thread.js";
import { Command } from "../types.js";
import type { BagTemplate } from "../types.template.js";
interface FetchStreamTransportOptions {
/**
@@ -99,12 +101,7 @@ export class FetchStreamTransport<
export function useStreamCustom<
StateType extends Record<string, unknown> = Record<string, unknown>,
Bag extends {
ConfigurableType?: Record<string, unknown>;
InterruptType?: unknown;
CustomEventType?: unknown;
UpdateType?: unknown;
} = BagTemplate
Bag extends BagTemplate = BagTemplate
>(
options: UseStreamCustomOptions<StateType, Bag>
): UseStreamCustom<StateType, Bag> {
@@ -112,6 +109,7 @@ export function useStreamCustom<
type CustomType = GetCustomEventType<Bag>;
type InterruptType = GetInterruptType<Bag>;
type ConfigurableType = GetConfigurableType<Bag>;
type ToolCallType = GetToolCallsType<StateType>;
const [messageManager] = useState(() => new MessageTupleManager());
const [stream] = useState(
@@ -140,7 +138,9 @@ export function useStreamCustom<
const getMessages = (value: StateType): Message[] => {
const messagesKey = options.messagesKey ?? "messages";
return Array.isArray(value[messagesKey]) ? value[messagesKey] : [];
return Array.isArray(value[messagesKey])
? (value[messagesKey] as Message[])
: [];
};
const setMessages = (current: StateType, messages: Message[]): StateType => {
@@ -244,9 +244,22 @@ export function useStreamCustom<
return undefined;
},
get messages() {
get messages(): Message<ToolCallType>[] {
if (!stream.values) return [];
return getMessages(stream.values);
},
get toolCalls() {
if (!stream.values) return [];
const msgs = getMessages(stream.values);
return getToolCallsWithResults<ToolCallType>(msgs);
},
getToolCalls(message) {
if (!stream.values) return [];
const msgs = getMessages(stream.values);
const allToolCalls = getToolCallsWithResults<ToolCallType>(msgs);
return allToolCalls.filter((tc) => tc.aiMessage.id === message.id);
},
};
}
+26 -14
View File
@@ -16,24 +16,25 @@ import { StreamError } from "../ui/errors.js";
import { getBranchContext } from "../ui/branching.js";
import { EventStreamEvent, StreamManager } from "../ui/manager.js";
import type {
BagTemplate,
UseStreamOptions,
UseStream,
GetUpdateType,
GetCustomEventType,
GetInterruptType,
GetConfigurableType,
GetToolCallsType,
RunCallbackMeta,
SubmitOptions,
MessageMetadata,
UseStreamThread,
} from "./types.js";
} from "../ui/types.js";
import type { UseStream, SubmitOptions } from "./types.js";
import { Client, getClientConfigHash } from "../client.js";
import type { Message } from "../types.messages.js";
import { type Message } from "../types.messages.js";
import { getToolCallsWithResults } from "../utils/tools.js";
import type { Interrupt, ThreadState } from "../schema.js";
import type { StreamMode } from "../types.stream.js";
import { MessageTupleManager } from "../ui/messages.js";
import { useControllableThreadId } from "./thread.js";
import type { BagTemplate } from "../types.template.js";
function getFetchHistoryKey(
client: Client,
@@ -154,17 +155,13 @@ function useThreadHistory<StateType extends Record<string, unknown>>(
export function useStreamLGP<
StateType extends Record<string, unknown> = Record<string, unknown>,
Bag extends {
ConfigurableType?: Record<string, unknown>;
InterruptType?: unknown;
CustomEventType?: unknown;
UpdateType?: unknown;
} = BagTemplate
Bag extends BagTemplate = BagTemplate
>(options: UseStreamOptions<StateType, Bag>): UseStream<StateType, Bag> {
type UpdateType = GetUpdateType<Bag, StateType>;
type CustomType = GetCustomEventType<Bag>;
type InterruptType = GetInterruptType<Bag>;
type ConfigurableType = GetConfigurableType<Bag>;
type ToolCallType = GetToolCallsType<StateType>;
const reconnectOnMountRef = useRef(options.reconnectOnMount);
const runMetadataStorage = useMemo(() => {
@@ -276,7 +273,9 @@ export function useStreamLGP<
const getMessages = (value: StateType): Message[] => {
const messagesKey = options.messagesKey ?? "messages";
return Array.isArray(value[messagesKey]) ? value[messagesKey] : [];
return Array.isArray(value[messagesKey])
? (value[messagesKey] as Message[])
: [];
};
const setMessages = (current: StateType, messages: Message[]): StateType => {
@@ -668,13 +667,26 @@ export function useStreamLGP<
return interrupts.at(-1) as Interrupt<InterruptType> | undefined;
},
get messages() {
get messages(): Message<ToolCallType>[] {
trackStreamMode("messages-tuple", "values");
return getMessages(values);
},
get toolCalls() {
trackStreamMode("messages-tuple", "values");
const msgs = getMessages(values) as Message<ToolCallType>[];
return getToolCallsWithResults<ToolCallType>(msgs);
},
getToolCalls(message) {
trackStreamMode("messages-tuple", "values");
const msgs = getMessages(values) as Message<ToolCallType>[];
const allToolCalls = getToolCallsWithResults<ToolCallType>(msgs);
return allToolCalls.filter((tc) => tc.aiMessage.id === message.id);
},
getMessagesMetadata(
message: Message,
message: Message<ToolCallType>,
index?: number
): MessageMetadata<StateType> | undefined {
trackStreamMode("values");
+136 -66
View File
@@ -1,22 +1,18 @@
import { useState } from "react";
import { useStreamLGP } from "./stream.lgp.js";
import { useStreamCustom } from "./stream.custom.js";
import {
BagTemplate,
import type { UseStreamOptions, InferAgentToolCalls } from "../ui/types.js";
import type { Message } from "../types.messages.js";
import type { BagTemplate } from "../types.template.js";
import type {
UseStream,
UseStreamCustom,
UseStreamCustomOptions,
UseStreamOptions,
} from "./types.js";
function isCustomOptions<
StateType extends Record<string, unknown> = Record<string, unknown>,
Bag extends {
ConfigurableType?: Record<string, unknown>;
InterruptType?: unknown;
CustomEventType?: unknown;
UpdateType?: unknown;
} = BagTemplate
Bag extends BagTemplate = BagTemplate
>(
options:
| UseStreamOptions<StateType, Bag>
@@ -26,30 +22,36 @@ function isCustomOptions<
}
/**
* A React hook that provides seamless integration with LangGraph streaming capabilities.
*
* The `useStream` hook handles all the complexities of streaming, state management, and branching logic,
* letting you focus on building great chat experiences. It provides automatic state management for
* messages, interrupts, loading states, and errors.
*
* @template StateType The type of the thread state (default: `Record<string, unknown>`)
* @template Bag Type configuration bag containing:
* - `ConfigurableType`: Type for the `config.configurable` property
* - `InterruptType`: Type for interrupt values
* - `CustomEventType`: Type for custom events
* - `UpdateType`: Type for the submit function updates
*
* @see {@link https://docs.langchain.com/langgraph-platform/use-stream-react | LangGraph React Integration Guide}
* Helper type that infers StateType based on whether T is an agent-like type, a CompiledGraph/Pregel instance, or a state type.
* - If T has `~agentTypes`, returns a state with typed messages based on the agent's tools
* - If T has `~RunOutput` (CompiledGraph/CompiledStateGraph), returns the state type
* - If T has `~OutputType` (Pregel), returns the output type as state
* - Otherwise, returns T (direct state type)
*/
export function useStream<
StateType extends Record<string, unknown> = Record<string, unknown>,
Bag extends {
ConfigurableType?: Record<string, unknown>;
InterruptType?: unknown;
CustomEventType?: unknown;
UpdateType?: unknown;
} = BagTemplate
>(options: UseStreamOptions<StateType, Bag>): UseStream<StateType, Bag>;
type InferStateType<T> = T extends { "~agentTypes": unknown }
? { messages: Message<InferAgentToolCalls<T>>[] }
: T extends { "~RunOutput": infer S }
? S extends Record<string, unknown>
? S
: Record<string, unknown>
: T extends { "~OutputType": infer O }
? O extends Record<string, unknown>
? O
: Record<string, unknown>
: T extends Record<string, unknown>
? T
: Record<string, unknown>;
/**
* Helper type that infers Bag based on whether T is an agent-like type.
* - If T has `~agentTypes`, extracts bag from the agent's tools
* - Otherwise, returns the default BagTemplate
*/
type InferBag<T, B extends BagTemplate = BagTemplate> = T extends {
"~agentTypes": unknown;
}
? BagTemplate
: B;
/**
* A React hook that provides seamless integration with LangGraph streaming capabilities.
@@ -58,7 +60,94 @@ export function useStream<
* letting you focus on building great chat experiences. It provides automatic state management for
* messages, interrupts, loading states, and errors.
*
* @template StateType The type of the thread state (default: `Record<string, unknown>`)
* ## Usage with ReactAgent (recommended for createAgent users)
*
* When using `createAgent` from `@langchain/langgraph`, you can pass `typeof agent` as the
* type parameter to automatically infer tool call types:
*
* @example
* ```typescript
* // In your agent file (e.g., agent.ts)
* import { createAgent, tool } from "@langchain/langgraph";
* import { z } from "zod";
*
* const getWeather = tool(
* async ({ location }) => `Weather in ${location}`,
* { name: "get_weather", schema: z.object({ location: z.string() }) }
* );
*
* export const agent = createAgent({
* model: "openai:gpt-4o",
* tools: [getWeather],
* });
*
* // In your React component
* import { agent } from "./agent";
*
* function Chat() {
* // Tool calls are automatically typed from the agent's tools!
* const stream = useStream<typeof agent>({
* assistantId: "agent",
* apiUrl: "http://localhost:2024",
* });
*
* // stream.toolCalls[0].call.name is typed as "get_weather"
* // stream.toolCalls[0].call.args is typed as { location: string }
* }
* ```
*
* ## Usage with StateGraph (for custom LangGraph applications)
*
* When building custom graphs with `StateGraph`, embed your tool call types directly
* in your state's messages property using `Message<MyToolCalls>`:
*
* @example
* ```typescript
* import { Message } from "@langchain/langgraph-sdk";
*
* // Define your tool call types as a discriminated union
* type MyToolCalls =
* | { name: "search"; args: { query: string }; id?: string }
* | { name: "calculate"; args: { expression: string }; id?: string };
*
* // Embed tool call types in your state's messages
* interface MyGraphState {
* messages: Message<MyToolCalls>[];
* context?: string;
* }
*
* function Chat() {
* const stream = useStream<MyGraphState>({
* assistantId: "my-graph",
* apiUrl: "http://localhost:2024",
* });
*
* // stream.values is typed as MyGraphState
* // stream.toolCalls[0].call.name is typed as "search" | "calculate"
* }
* ```
*
* @example
* ```typescript
* // With additional type configuration (interrupts, configurable)
* interface MyGraphState {
* messages: Message<MyToolCalls>[];
* }
*
* function Chat() {
* const stream = useStream<MyGraphState, {
* InterruptType: { question: string };
* ConfigurableType: { userId: string };
* }>({
* assistantId: "my-graph",
* apiUrl: "http://localhost:2024",
* });
*
* // stream.interrupt is typed as { question: string } | undefined
* }
* ```
*
* @template T Either a ReactAgent type (with `~agentTypes`) or a state type (`Record<string, unknown>`)
* @template Bag Type configuration bag containing:
* - `ConfigurableType`: Type for the `config.configurable` property
* - `InterruptType`: Type for interrupt values
@@ -68,16 +157,11 @@ export function useStream<
* @see {@link https://docs.langchain.com/langgraph-platform/use-stream-react | LangGraph React Integration Guide}
*/
export function useStream<
StateType extends Record<string, unknown> = Record<string, unknown>,
Bag extends {
ConfigurableType?: Record<string, unknown>;
InterruptType?: unknown;
CustomEventType?: unknown;
UpdateType?: unknown;
} = BagTemplate
T = Record<string, unknown>,
Bag extends BagTemplate = BagTemplate
>(
options: UseStreamCustomOptions<StateType, Bag>
): UseStreamCustom<StateType, Bag>;
options: UseStreamOptions<InferStateType<T>, InferBag<T, Bag>>
): UseStream<InferStateType<T>, InferBag<T, Bag>>;
/**
* A React hook that provides seamless integration with LangGraph streaming capabilities.
@@ -86,7 +170,7 @@ export function useStream<
* letting you focus on building great chat experiences. It provides automatic state management for
* messages, interrupts, loading states, and errors.
*
* @template StateType The type of the thread state (default: `Record<string, unknown>`)
* @template T Either a ReactAgent type (with `~agentTypes`) or a state type (`Record<string, unknown>`)
* @template Bag Type configuration bag containing:
* - `ConfigurableType`: Type for the `config.configurable` property
* - `InterruptType`: Type for interrupt values
@@ -96,36 +180,22 @@ export function useStream<
* @see {@link https://docs.langchain.com/langgraph-platform/use-stream-react | LangGraph React Integration Guide}
*/
export function useStream<
StateType extends Record<string, unknown> = Record<string, unknown>,
Bag extends {
ConfigurableType?: Record<string, unknown>;
InterruptType?: unknown;
CustomEventType?: unknown;
UpdateType?: unknown;
} = BagTemplate
>(options: UseStreamOptions<StateType, Bag>): UseStream<StateType, Bag>;
export function useStream<
StateType extends Record<string, unknown> = Record<string, unknown>,
Bag extends {
ConfigurableType?: Record<string, unknown>;
InterruptType?: unknown;
CustomEventType?: unknown;
UpdateType?: unknown;
} = BagTemplate
T = Record<string, unknown>,
Bag extends BagTemplate = BagTemplate
>(
options:
| UseStreamOptions<StateType, Bag>
| UseStreamCustomOptions<StateType, Bag>
): UseStream<StateType, Bag> | UseStreamCustom<StateType, Bag> {
options: UseStreamCustomOptions<InferStateType<T>, InferBag<T, Bag>>
): UseStreamCustom<InferStateType<T>, InferBag<T, Bag>>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function useStream(options: any): any {
// Store this in useState to make sure we're not changing the implementation in re-renders
const [isCustom] = useState(isCustomOptions(options));
if (isCustom) {
// eslint-disable-next-line react-hooks/rules-of-hooks
return useStreamCustom(options as UseStreamCustomOptions<StateType, Bag>);
return useStreamCustom(options);
}
// eslint-disable-next-line react-hooks/rules-of-hooks
return useStreamLGP(options as UseStreamOptions<StateType, Bag>);
return useStreamLGP(options);
}
+113 -405
View File
@@ -1,308 +1,46 @@
/* __LC_ALLOW_ENTRYPOINT_SIDE_EFFECTS__ */
import type { Client, ClientConfig } from "../client.js";
import type { Client } from "../client.js";
import type { ThreadState, Interrupt } from "../schema.js";
import type {
ThreadState,
Interrupt,
Config,
Checkpoint,
Metadata,
} from "../schema.js";
import type {
Command,
MultitaskStrategy,
OnCompletionBehavior,
DisconnectMode,
Durability,
} from "../types.js";
import type { Message } from "../types.messages.js";
import type {
UpdatesStreamEvent,
CustomStreamEvent,
MetadataStreamEvent,
EventsStreamEvent,
DebugStreamEvent,
CheckpointsStreamEvent,
TasksStreamEvent,
StreamMode,
} from "../types.stream.js";
Message,
AIMessage,
ToolCallWithResult,
} from "../types.messages.js";
import type { StreamMode } from "../types.stream.js";
import type { Sequence } from "../ui/branching.js";
import type {
GetUpdateType,
GetConfigurableType,
GetInterruptType,
GetToolCallsType,
MessageMetadata,
UseStreamThread,
UseStreamOptions,
UseStreamTransport,
UseStreamCustomOptions,
SubmitOptions,
CustomSubmitOptions,
RunCallbackMeta,
} from "../ui/types.js";
import type { BagTemplate } from "../types.template.js";
export type MessageMetadata<StateType extends Record<string, unknown>> = {
/**
* The ID of the message used.
*/
messageId: string;
/**
* The first thread state the message was seen in.
*/
firstSeenState: ThreadState<StateType> | undefined;
/**
* The branch of the message.
*/
branch: string | undefined;
/**
* The list of branches this message is part of.
* This is useful for displaying branching controls.
*/
branchOptions: string[] | undefined;
/**
* Metadata sent alongside the message during run streaming.
* @remarks This metadata only exists temporarily in browser memory during streaming and is not persisted after completion.
*/
streamMetadata: Record<string, unknown> | undefined;
// Re-export types from ui/types.ts
export type {
GetUpdateType,
GetConfigurableType,
GetInterruptType,
GetToolCallsType,
MessageMetadata,
UseStreamThread,
UseStreamOptions,
UseStreamTransport,
UseStreamCustomOptions,
SubmitOptions,
CustomSubmitOptions,
RunCallbackMeta,
};
export type BagTemplate = {
ConfigurableType?: Record<string, unknown>;
InterruptType?: unknown;
CustomEventType?: unknown;
UpdateType?: unknown;
};
export type GetUpdateType<
Bag extends BagTemplate,
StateType extends Record<string, unknown>
> = Bag extends { UpdateType: unknown }
? Bag["UpdateType"]
: Partial<StateType>;
export type GetConfigurableType<Bag extends BagTemplate> = Bag extends {
ConfigurableType: Record<string, unknown>;
}
? Bag["ConfigurableType"]
: Record<string, unknown>;
export type GetInterruptType<Bag extends BagTemplate> = Bag extends {
InterruptType: unknown;
}
? Bag["InterruptType"]
: unknown;
export type GetCustomEventType<Bag extends BagTemplate> = Bag extends {
CustomEventType: unknown;
}
? Bag["CustomEventType"]
: unknown;
export interface RunCallbackMeta {
run_id: string;
thread_id: string;
}
export interface UseStreamThread<StateType extends Record<string, unknown>> {
data: ThreadState<StateType>[] | null | undefined;
error: unknown;
isLoading: boolean;
mutate: (
mutateId?: string
) => Promise<ThreadState<StateType>[] | null | undefined>;
}
export interface UseStreamOptions<
StateType extends Record<string, unknown> = Record<string, unknown>,
Bag extends BagTemplate = BagTemplate
> {
/**
* The ID of the assistant to use.
*/
assistantId: string;
/**
* Client used to send requests.
*/
client?: Client;
/**
* The URL of the API to use.
*/
apiUrl?: ClientConfig["apiUrl"];
/**
* The API key to use.
*/
apiKey?: ClientConfig["apiKey"];
/**
* Custom call options, such as custom fetch implementation.
*/
callerOptions?: ClientConfig["callerOptions"];
/**
* Default headers to send with requests.
*/
defaultHeaders?: ClientConfig["defaultHeaders"];
/**
* Specify the key within the state that contains messages.
* Defaults to "messages".
*
* @default "messages"
*/
messagesKey?: string;
/**
* Callback that is called when an error occurs.
*/
onError?: (error: unknown, run: RunCallbackMeta | undefined) => void;
/**
* Callback that is called when the stream is finished.
*/
onFinish?: (
state: ThreadState<StateType>,
run: RunCallbackMeta | undefined
) => void;
/**
* Callback that is called when a new stream is created.
*/
onCreated?: (run: RunCallbackMeta) => void;
/**
* Callback that is called when an update event is received.
*/
onUpdateEvent?: (
data: UpdatesStreamEvent<GetUpdateType<Bag, StateType>>["data"],
options: {
namespace: string[] | undefined;
mutate: (
update: Partial<StateType> | ((prev: StateType) => Partial<StateType>)
) => void;
}
) => void;
/**
* Callback that is called when a custom event is received.
*/
onCustomEvent?: (
data: CustomStreamEvent<GetCustomEventType<Bag>>["data"],
options: {
namespace: string[] | undefined;
mutate: (
update: Partial<StateType> | ((prev: StateType) => Partial<StateType>)
) => void;
}
) => void;
/**
* Callback that is called when a metadata event is received.
*/
onMetadataEvent?: (data: MetadataStreamEvent["data"]) => void;
/**
* Callback that is called when a LangChain event is received.
* @see https://langchain-ai.github.io/langgraph/cloud/how-tos/stream_events/#stream-graph-in-events-mode for more details.
*/
onLangChainEvent?: (data: EventsStreamEvent["data"]) => void;
/**
* Callback that is called when a debug event is received.
* @internal This API is experimental and subject to change.
*/
onDebugEvent?: (
data: DebugStreamEvent["data"],
options: { namespace: string[] | undefined }
) => void;
/**
* Callback that is called when a checkpoints event is received.
*/
onCheckpointEvent?: (
data: CheckpointsStreamEvent<StateType>["data"],
options: { namespace: string[] | undefined }
) => void;
/**
* Callback that is called when a tasks event is received.
*/
onTaskEvent?: (
data: TasksStreamEvent<StateType, GetUpdateType<Bag, StateType>>["data"],
options: { namespace: string[] | undefined }
) => void;
/**
* Callback that is called when the stream is stopped by the user.
* Provides a mutate function to update the stream state immediately
* without requiring a server roundtrip.
*
* @example
* ```typescript
* onStop: ({ mutate }) => {
* mutate((prev) => ({
* ...prev,
* ui: prev.ui?.map(component =>
* component.props.isLoading
* ? { ...component, props: { ...component.props, stopped: true, isLoading: false }}
* : component
* )
* }));
* }
* ```
*/
onStop?: (options: {
mutate: (
update: Partial<StateType> | ((prev: StateType) => Partial<StateType>)
) => void;
}) => void;
/**
* The ID of the thread to fetch history and current values from.
*/
threadId?: string | null;
/**
* Callback that is called when the thread ID is updated (ie when a new thread is created).
*/
onThreadId?: (threadId: string) => void;
/** Will reconnect the stream on mount */
reconnectOnMount?: boolean | (() => RunMetadataStorage);
/**
* Initial values to display immediately when loading a thread.
* Useful for displaying cached thread data while official history loads.
* These values will be replaced when official thread data is fetched.
*
* Note: UI components from initialValues will render immediately if they're
* predefined in LoadExternalComponent's components prop, providing instant
* cached UI display without server fetches.
*/
initialValues?: StateType | null;
/**
* Whether to fetch the history of the thread.
* If true, the history will be fetched from the server. Defaults to 10 entries.
* If false, only the last state will be fetched from the server.
* @default true
*/
fetchStateHistory?: boolean | { limit: number };
/**
* Manage the thread state externally.
*/
thread?: UseStreamThread<StateType>;
/**
* Throttle the stream.
* If a number is provided, the stream will be throttled to the given number of milliseconds.
* If `true`, updates are batched in a single macrotask.
* If `false`, updates are not throttled or batched.
* @default true
*/
throttle?: number | boolean;
}
interface RunMetadataStorage {
getItem(key: `lg:stream:${string}`): string | null;
setItem(key: `lg:stream:${string}`, value: string): void;
removeItem(key: `lg:stream:${string}`): void;
}
export interface UseStream<
StateType extends Record<string, unknown> = Record<string, unknown>,
Bag extends BagTemplate = BagTemplate
@@ -369,19 +107,85 @@ export interface UseStream<
/**
* Messages inferred from the thread.
* Will automatically update with incoming message chunks.
* Includes all message types including ToolMessage.
*/
messages: Message[];
messages: Message<GetToolCallsType<StateType>>[];
/**
* Tool calls paired with their results.
* Useful for rendering tool invocations and their outputs together.
*
* Each item contains the tool call from an AI message paired with its
* corresponding ToolMessage result (if available), along with lifecycle state.
*
* @example
* ```tsx
* // With type-safe tool calls - embed the type in your messages
* type MyToolCalls =
* | { name: "get_weather"; args: { location: string }; id?: string }
* | { name: "search"; args: { query: string }; id?: string };
*
* interface MyState {
* messages: Message<MyToolCalls>[];
* }
*
* const stream = useStream<MyState>({ ... });
*
* {stream.toolCalls.map(({ id, call, result, state }) => {
* if (call.name === "get_weather") {
* // call.args is { location: string }
* return (
* <WeatherCard
* key={id}
* location={call.args.location}
* result={result?.content}
* isLoading={state === "pending"}
* />
* );
* }
* })}
* ```
*/
toolCalls: ToolCallWithResult<GetToolCallsType<StateType>>[];
/**
* Get tool calls for a specific AI message.
* Useful when rendering messages and their associated tool calls together.
*
* @param message - The AI message to get tool calls for.
* @returns Array of tool calls initiated by the message.
*
* @example
* ```tsx
* {stream.uiMessages.map((message) => {
* if (message.type === "ai") {
* const toolCalls = stream.getToolCalls(message);
* if (toolCalls.length > 0) {
* return (
* <div key={message.id}>
* {toolCalls.map(tc => <ToolCard key={tc.id} toolCall={tc} />)}
* </div>
* );
* }
* }
* return <MessageBubble key={message.id} message={message} />;
* })}
* ```
*/
getToolCalls: (
message: AIMessage<GetToolCallsType<StateType>>
) => ToolCallWithResult<GetToolCallsType<StateType>>[];
/**
* Get the metadata for a message, such as first thread state the message
* was seen in and branch information.
*
* @param message - The message to get the metadata for.
* @param index - The index of the message in the thread.
* @returns The metadata for the message.
*/
getMessagesMetadata: (
message: Message,
message: Message<GetToolCallsType<StateType>>,
index?: number
) => MessageMetadata<StateType> | undefined;
@@ -405,118 +209,22 @@ export interface UseStream<
) => Promise<void>;
}
type ConfigWithConfigurable<ConfigurableType extends Record<string, unknown>> =
Config & { configurable?: ConfigurableType };
export interface SubmitOptions<
StateType extends Record<string, unknown> = Record<string, unknown>,
ContextType extends Record<string, unknown> = Record<string, unknown>
> {
config?: ConfigWithConfigurable<ContextType>;
context?: ContextType;
checkpoint?: Omit<Checkpoint, "thread_id"> | null;
command?: Command;
interruptBefore?: "*" | string[];
interruptAfter?: "*" | string[];
metadata?: Metadata;
multitaskStrategy?: MultitaskStrategy;
onCompletion?: OnCompletionBehavior;
onDisconnect?: DisconnectMode;
feedbackKeys?: string[];
streamMode?: Array<StreamMode>;
runId?: string;
optimisticValues?:
| Partial<StateType>
| ((prev: StateType) => Partial<StateType>);
/**
* Whether or not to stream the nodes of any subgraphs called
* by the assistant.
* @default false
*/
streamSubgraphs?: boolean;
/**
* Mark the stream as resumable. All events emitted during the run will be temporarily persisted
* in order to be re-emitted if the stream is re-joined.
* @default false
*/
streamResumable?: boolean;
/**
* Whether to checkpoint during the run (or only at the end/interruption).
* - `"async"`: Save checkpoint asynchronously while the next step executes (default).
* - `"sync"`: Save checkpoint synchronously before the next step starts.
* - `"exit"`: Save checkpoint only when the graph exits.
* @default "async"
*/
durability?: Durability;
/**
* The ID to use when creating a new thread. When provided, this ID will be used
* for thread creation when threadId is `null` or `undefined`.
* This enables optimistic UI updates where you know the thread ID
* before the thread is actually created.
*/
threadId?: string;
}
/**
* Transport used to stream the thread.
* Only applicable for custom endpoints using `toLangGraphEventStream` or `toLangGraphEventStreamResponse`.
*/
export interface UseStreamTransport<
StateType extends Record<string, unknown> = Record<string, unknown>,
Bag extends BagTemplate = BagTemplate
> {
stream: (payload: {
input: GetUpdateType<Bag, StateType> | null | undefined;
context: GetConfigurableType<Bag> | undefined;
command: Command | undefined;
config: ConfigWithConfigurable<GetConfigurableType<Bag>> | undefined;
signal: AbortSignal;
}) => Promise<AsyncGenerator<{ id?: string; event: string; data: unknown }>>;
}
export type UseStreamCustomOptions<
StateType extends Record<string, unknown> = Record<string, unknown>,
Bag extends BagTemplate = BagTemplate
> = Pick<
UseStreamOptions<StateType, Bag>,
| "messagesKey"
| "threadId"
| "onThreadId"
| "onError"
| "onCreated"
| "onUpdateEvent"
| "onCustomEvent"
| "onMetadataEvent"
| "onLangChainEvent"
| "onDebugEvent"
| "onCheckpointEvent"
| "onTaskEvent"
| "onStop"
| "initialValues"
| "throttle"
> & { transport: UseStreamTransport<StateType, Bag> };
export type UseStreamCustom<
StateType extends Record<string, unknown> = Record<string, unknown>,
Bag extends BagTemplate = BagTemplate
> = Pick<
UseStream<StateType, Bag>,
"values" | "error" | "isLoading" | "stop" | "interrupt" | "messages"
| "values"
| "error"
| "isLoading"
| "stop"
| "interrupt"
| "messages"
| "toolCalls"
| "getToolCalls"
> & {
submit: (
values: GetUpdateType<Bag, StateType> | null | undefined,
options?: CustomSubmitOptions<StateType, GetConfigurableType<Bag>>
) => Promise<void>;
};
export type CustomSubmitOptions<
StateType extends Record<string, unknown> = Record<string, unknown>,
ConfigurableType extends Record<string, unknown> = Record<string, unknown>
> = Pick<
SubmitOptions<StateType, ConfigurableType>,
"optimisticValues" | "context" | "command" | "config"
>;
+282
View File
@@ -0,0 +1,282 @@
import { describe, test, expect } from "vitest";
import { getToolCallsWithResults } from "../utils/tools.js";
import type {
Message,
AIMessage,
HumanMessage,
ToolMessage,
} from "../types.messages.js";
describe("getToolCallsWithResults", () => {
test("returns empty array for empty messages", () => {
const result = getToolCallsWithResults([]);
expect(result).toEqual([]);
});
test("returns empty array when no AI messages with tool calls", () => {
const messages: Message[] = [
{ type: "human", content: "Hello" },
{ type: "ai", content: "Hi there!" },
{ type: "system", content: "You are a helpful assistant" },
];
const result = getToolCallsWithResults(messages);
expect(result).toEqual([]);
});
test("returns empty array for AI message with empty tool_calls array", () => {
const messages: Message[] = [{ type: "ai", content: "", tool_calls: [] }];
const result = getToolCallsWithResults(messages);
expect(result).toEqual([]);
});
test("pairs tool call with its result", () => {
const aiMessage: AIMessage = {
type: "ai",
content: "",
tool_calls: [
{ name: "get_weather", args: { location: "NYC" }, id: "tc1" },
],
};
const toolMessage: ToolMessage = {
type: "tool",
content: "Sunny, 72°F",
tool_call_id: "tc1",
};
const messages: Message[] = [aiMessage, toolMessage];
const result = getToolCallsWithResults(messages);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
id: "tc1",
call: { name: "get_weather", args: { location: "NYC" }, id: "tc1" },
result: toolMessage,
aiMessage,
index: 0,
state: "completed",
});
});
test("returns undefined result when tool call has no matching result", () => {
const aiMessage: AIMessage = {
type: "ai",
content: "",
tool_calls: [
{ name: "get_weather", args: { location: "NYC" }, id: "tc1" },
],
};
const messages: Message[] = [aiMessage];
const result = getToolCallsWithResults(messages);
expect(result).toHaveLength(1);
expect(result[0].call).toEqual({
name: "get_weather",
args: { location: "NYC" },
id: "tc1",
});
expect(result[0].result).toBeUndefined();
expect(result[0].aiMessage).toBe(aiMessage);
expect(result[0].index).toBe(0);
});
test("returns undefined result when tool call has no id", () => {
const aiMessage: AIMessage = {
type: "ai",
content: "",
tool_calls: [{ name: "get_weather", args: { location: "NYC" } }],
};
const toolMessage: ToolMessage = {
type: "tool",
content: "Sunny",
tool_call_id: "some_id",
};
const messages: Message[] = [aiMessage, toolMessage];
const result = getToolCallsWithResults(messages);
expect(result).toHaveLength(1);
expect(result[0].result).toBeUndefined();
});
test("handles multiple tool calls in single AI message", () => {
const aiMessage: AIMessage = {
type: "ai",
content: "",
tool_calls: [
{ name: "get_weather", args: { location: "NYC" }, id: "tc1" },
{ name: "get_weather", args: { location: "LA" }, id: "tc2" },
],
};
const toolMessage1: ToolMessage = {
type: "tool",
content: "Sunny, 72°F",
tool_call_id: "tc1",
};
const toolMessage2: ToolMessage = {
type: "tool",
content: "Cloudy, 65°F",
tool_call_id: "tc2",
};
const messages: Message[] = [aiMessage, toolMessage1, toolMessage2];
const result = getToolCallsWithResults(messages);
expect(result).toHaveLength(2);
expect(result[0].call.name).toBe("get_weather");
expect(result[0].result).toBe(toolMessage1);
expect(result[0].index).toBe(0);
expect(result[1].call.name).toBe("get_weather");
expect(result[1].result).toBe(toolMessage2);
expect(result[1].index).toBe(1);
});
test("handles multiple AI messages with tool calls", () => {
const aiMessage1: AIMessage = {
type: "ai",
content: "",
tool_calls: [{ name: "search", args: { query: "test" }, id: "tc1" }],
};
const toolMessage1: ToolMessage = {
type: "tool",
content: "Search results",
tool_call_id: "tc1",
};
const aiMessage2: AIMessage = {
type: "ai",
content: "",
tool_calls: [
{ name: "get_weather", args: { location: "NYC" }, id: "tc2" },
],
};
const toolMessage2: ToolMessage = {
type: "tool",
content: "Sunny",
tool_call_id: "tc2",
};
const messages: Message[] = [
aiMessage1,
toolMessage1,
aiMessage2,
toolMessage2,
];
const result = getToolCallsWithResults(messages);
expect(result).toHaveLength(2);
expect(result[0].call.name).toBe("search");
expect(result[0].aiMessage).toBe(aiMessage1);
expect(result[0].result).toBe(toolMessage1);
expect(result[1].call.name).toBe("get_weather");
expect(result[1].aiMessage).toBe(aiMessage2);
expect(result[1].result).toBe(toolMessage2);
});
test("handles tool result appearing before AI message in array", () => {
const toolMessage: ToolMessage = {
type: "tool",
content: "Result",
tool_call_id: "tc1",
};
const aiMessage: AIMessage = {
type: "ai",
content: "",
tool_calls: [{ name: "test_tool", args: {}, id: "tc1" }],
};
// Tool message comes before AI message (unusual but should still work)
const messages: Message[] = [toolMessage, aiMessage];
const result = getToolCallsWithResults(messages);
expect(result).toHaveLength(1);
expect(result[0].result).toBe(toolMessage);
});
test("works with custom tool call types", () => {
type MyToolCall =
| { name: "get_weather"; args: { location: string }; id: string }
| { name: "search"; args: { query: string }; id: string };
const aiMessage: AIMessage<MyToolCall> = {
type: "ai",
content: "",
tool_calls: [
{ name: "get_weather", args: { location: "NYC" }, id: "tc1" },
],
};
const toolMessage: ToolMessage = {
type: "tool",
content: "Sunny",
tool_call_id: "tc1",
};
const messages: Message<MyToolCall>[] = [aiMessage, toolMessage];
const result = getToolCallsWithResults<MyToolCall>(messages);
expect(result).toHaveLength(1);
expect(result[0].call.name).toBe("get_weather");
// Type narrowing works - args.location is typed
if (result[0].call.name === "get_weather") {
expect(result[0].call.args.location).toBe("NYC");
}
});
test("ignores other message types when collecting results", () => {
const humanMessage: HumanMessage = { type: "human", content: "Hi" };
const aiMessage: AIMessage = {
type: "ai",
content: "",
tool_calls: [{ name: "greet", args: {}, id: "tc1" }],
};
const toolMessage: ToolMessage = {
type: "tool",
content: "Hello!",
tool_call_id: "tc1",
};
const systemMessage: Message = {
type: "system",
content: "System prompt",
};
const messages: Message[] = [
humanMessage,
systemMessage,
aiMessage,
toolMessage,
];
const result = getToolCallsWithResults(messages);
expect(result).toHaveLength(1);
expect(result[0].call.name).toBe("greet");
});
test("handles partial results (some tool calls with results, some without)", () => {
const aiMessage: AIMessage = {
type: "ai",
content: "",
tool_calls: [
{ name: "tool1", args: {}, id: "tc1" },
{ name: "tool2", args: {}, id: "tc2" },
{ name: "tool3", args: {}, id: "tc3" },
],
};
const toolMessage1: ToolMessage = {
type: "tool",
content: "Result 1",
tool_call_id: "tc1",
};
const toolMessage3: ToolMessage = {
type: "tool",
content: "Result 3",
tool_call_id: "tc3",
};
const messages: Message[] = [aiMessage, toolMessage1, toolMessage3];
const result = getToolCallsWithResults(messages);
expect(result).toHaveLength(3);
expect(result[0].result).toBe(toolMessage1);
expect(result[1].result).toBeUndefined(); // tc2 has no result
expect(result[2].result).toBe(toolMessage3);
});
});
+228 -37
View File
@@ -26,44 +26,80 @@ export type HumanMessage = BaseMessage & {
example?: boolean | undefined;
};
export type AIMessage = BaseMessage & {
type: "ai";
example?: boolean | undefined;
tool_calls?:
/**
* Default tool call type when no specific tool definitions are provided.
*/
export type DefaultToolCall = {
name: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
args: { [x: string]: any };
id?: string | undefined;
type?: "tool_call" | undefined;
};
/**
* Invalid tool call type.
*/
export type InvalidToolCall = {
name?: string | undefined;
args?: string | undefined;
id?: string | undefined;
error?: string | undefined;
type?: "invalid_tool_call" | undefined;
};
/**
* Usage metadata for AI messages.
*/
export type UsageMetadata = {
input_tokens: number;
output_tokens: number;
total_tokens: number;
input_token_details?:
| {
name: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
args: { [x: string]: any };
id?: string | undefined;
type?: "tool_call" | undefined;
}[]
| undefined;
invalid_tool_calls?:
| {
name?: string | undefined;
args?: string | undefined;
id?: string | undefined;
error?: string | undefined;
type?: "invalid_tool_call" | undefined;
}[]
| undefined;
usage_metadata?:
| {
input_tokens: number;
output_tokens: number;
total_tokens: number;
input_token_details?:
| {
audio?: number | undefined;
cache_read?: number | undefined;
cache_creation?: number | undefined;
}
| undefined;
output_token_details?:
| { audio?: number | undefined; reasoning?: number | undefined }
| undefined;
audio?: number | undefined;
cache_read?: number | undefined;
cache_creation?: number | undefined;
}
| undefined;
output_token_details?:
| { audio?: number | undefined; reasoning?: number | undefined }
| undefined;
};
/**
* AI message type that can be parameterized with custom tool call types.
*
* @template ToolCall The type of tool calls, defaults to DefaultToolCall.
* Provide a discriminated union for type-safe tool call handling.
*
* @example
* ```ts
* // Define typed tool calls as a discriminated union
* type MyToolCalls =
* | { name: "get_weather"; args: { location: string }; id?: string }
* | { name: "search"; args: { query: string; limit?: number }; id?: string };
*
* // Use with AIMessage
* const message: AIMessage<MyToolCalls> = ...;
*
* // Now tool.name === "get_weather" narrows tool.args type
* if (message.tool_calls) {
* for (const tool of message.tool_calls) {
* if (tool.name === "get_weather") {
* // tool.args is now { location: string }
* console.log(tool.args.location);
* }
* }
* }
* ```
*/
export type AIMessage<ToolCall = DefaultToolCall> = BaseMessage & {
type: "ai";
example?: boolean | undefined;
tool_calls?: ToolCall[] | undefined;
invalid_tool_calls?: InvalidToolCall[] | undefined;
usage_metadata?: UsageMetadata | undefined;
};
export type ToolMessage = BaseMessage & {
@@ -93,10 +129,165 @@ export type RemoveMessage = BaseMessage & {
type: "remove";
};
export type Message =
/**
* Union of all message types.
*
* @template ToolCall The type of tool calls for AIMessage, defaults to DefaultToolCall.
*/
export type Message<ToolCall = DefaultToolCall> =
| HumanMessage
| AIMessage
| AIMessage<ToolCall>
| ToolMessage
| SystemMessage
| FunctionMessage
| RemoveMessage;
/**
* Helper type to infer schema input type, supporting both Zod v3 and v4.
* - Zod v4 uses `_zod.input` property
* - Zod v3 uses `_input` property
*
* Note: Zod v4 is checked first as it's the more specific structure.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type InferSchemaInput<S> = S extends { _zod: { input: infer Args } }
? Args
: S extends { _input: infer Args }
? Args
: never;
/**
* Helper type to extract the input type from a DynamicStructuredTool's _call method.
* This is more reliable than trying to infer from the schema directly because
* DynamicStructuredTool has the input type baked into its _call signature.
*/
type InferToolInput<T> = T extends {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
_call: (arg: infer Args, ...rest: any[]) => any;
}
? Args
: T extends { schema: infer S }
? InferSchemaInput<S>
: never;
/**
* Infer a tool call type from a single tool.
* Works with tools created via `tool()` from `@langchain/core/tools`.
*
* For DynamicStructuredTool, this extracts the input type from the _call method,
* which is the most reliable source as it's the pre-computed input type.
*
* @template T The tool type (e.g., DynamicStructuredTool)
*
* @example
* ```ts
* import { tool } from "@langchain/core/tools";
* import { z } from "zod";
*
* const getWeather = tool(
* async ({ location }) => `Weather in ${location}`,
* { name: "get_weather", schema: z.object({ location: z.string() }) }
* );
*
* // Infer: { name: "get_weather"; args: { location: string }; id?: string; type?: "tool_call" }
* type WeatherToolCall = ToolCallFromTool<typeof getWeather>;
* ```
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type ToolCallFromTool<T> = T extends { name: infer N }
? InferToolInput<T> extends infer Args
? Args extends never
? never
: // eslint-disable-next-line @typescript-eslint/no-explicit-any
Args extends Record<string, any>
? { name: N; args: Args; id?: string; type?: "tool_call" }
: never
: never
: never;
/**
* Infer a union of tool call types from an array of tools.
* Works with tools created via `tool()` from `@langchain/core/tools`.
*
* @template T A tuple/array of tools
*
* @example
* ```ts
* import { tool } from "@langchain/core/tools";
* import { z } from "zod";
*
* const getWeather = tool(
* async ({ location }) => `Weather in ${location}`,
* { name: "get_weather", schema: z.object({ location: z.string() }) }
* );
*
* const search = tool(
* async ({ query }) => `Results for ${query}`,
* { name: "search", schema: z.object({ query: z.string() }) }
* );
*
* const tools = [getWeather, search] as const;
*
* // Infer union:
* // | { name: "get_weather"; args: { location: string }; id?: string; type?: "tool_call" }
* // | { name: "search"; args: { query: string }; id?: string; type?: "tool_call" }
* type MyToolCalls = ToolCallsFromTools<typeof tools>;
* ```
*/
export type ToolCallsFromTools<T extends readonly unknown[]> =
T extends readonly [infer First, ...infer Rest]
? ToolCallFromTool<First> | ToolCallsFromTools<Rest>
: never;
/**
* The lifecycle state of a tool call.
*
* - `pending`: Tool call received, awaiting result
* - `completed`: Tool execution finished successfully
* - `error`: Tool execution failed (result.status === "error")
*/
export type ToolCallState = "pending" | "completed" | "error";
/**
* Represents a tool call paired with its result.
* Useful for rendering tool invocations and their outputs together.
*
* @template ToolCall The type of the tool call.
*/
export type ToolCallWithResult<ToolCall = DefaultToolCall> = {
/**
* Unique identifier for this tool call.
* Uses the tool call's id if available, otherwise generates one from aiMessage.id and index.
*/
id: string;
/**
* The tool call from the AI message.
*/
call: ToolCall;
/**
* The result message from tool execution.
* `undefined` if the tool is still being executed or no result was received.
*/
result: ToolMessage | undefined;
/**
* The AI message that initiated this tool call.
*/
aiMessage: AIMessage<ToolCall>;
/**
* Index of this tool call within the AI message's tool_calls array.
*/
index: number;
/**
* The current lifecycle state of the tool call.
*
* - `pending`: No result yet
* - `completed`: Has result with success status
* - `error`: Has result with error status
*/
state: ToolCallState;
};
+15
View File
@@ -0,0 +1,15 @@
/**
* Template for the bag type.
* @template ConfigurableType - Type for the `config.configurable` property
* @template InterruptType - Type for interrupt values
* @template CustomEventType - Type for custom events
* @template UpdateType - Type for the submit function updates
* @template MetaType - Type for the meta property
*/
export type BagTemplate = {
ConfigurableType?: Record<string, unknown>;
InterruptType?: unknown;
CustomEventType?: unknown;
UpdateType?: unknown;
MetaType?: unknown;
};
+1 -1
View File
@@ -63,7 +63,7 @@ export function getBranchSequence<StateType extends Record<string, unknown>>(
// with the latest checkpoint and mark it as the root.
const maxId = (...ids: (string | null)[]) =>
ids
.filter((i) => i != null)
.filter((i): i is string => i != null)
.sort((a, b) => a.localeCompare(b))
.at(-1)!;
+14 -8
View File
@@ -14,13 +14,13 @@ import type {
import { MessageTupleManager, toMessageDict } from "./messages.js";
import { StreamError } from "./errors.js";
import type { Message } from "../types.messages.js";
import type { BagTemplate } from "../types.template.js";
type BagTemplate = {
ConfigurableType?: Record<string, unknown>;
InterruptType?: unknown;
CustomEventType?: unknown;
UpdateType?: unknown;
};
/**
* Special ID used by LangGraph's messagesStateReducer to signal
* that all messages should be removed from the state.
*/
export const REMOVE_ALL_MESSAGES = "__remove_all__";
type GetUpdateType<
Bag extends BagTemplate,
@@ -326,13 +326,19 @@ export class StreamManager<
const values = { ...options.initialValues, ...streamValues };
// Assumption: we're concatenating the message
const messages = options.getMessages(values).slice();
let messages = options.getMessages(values).slice();
const { chunk, index } =
this.messages.get(messageId, messages.length) ?? {};
if (!chunk || index == null) return values;
if (chunk.getType() === "remove") {
messages.splice(index, 1);
// Check for special REMOVE_ALL_MESSAGES sentinel
if (chunk.id === REMOVE_ALL_MESSAGES) {
// Clear all messages when __remove_all__ is received
messages = [];
} else {
messages.splice(index, 1);
}
} else {
messages[index] = toMessageDict(chunk);
}
+584
View File
@@ -0,0 +1,584 @@
import type { Client, ClientConfig } from "../client.js";
import type { ThreadState, Config, Checkpoint, Metadata } from "../schema.js";
import type {
Command,
MultitaskStrategy,
OnCompletionBehavior,
DisconnectMode,
Durability,
} from "../types.js";
import type {
UpdatesStreamEvent,
CustomStreamEvent,
MetadataStreamEvent,
EventsStreamEvent,
DebugStreamEvent,
CheckpointsStreamEvent,
TasksStreamEvent,
StreamMode,
} from "../types.stream.js";
import type { DefaultToolCall, AIMessage } from "../types.messages.js";
import type { BagTemplate } from "../types.template.js";
// ============================================================================
// Agent Type Extraction Helpers
// ============================================================================
// These types enable extracting type information from a ReactAgent instance
// created with `createAgent` from @langchain/langgraph, without requiring
// langchain as a dependency.
/**
* Minimal interface matching the structure of AgentTypeConfig from @langchain/langgraph.
* This allows type inference from ReactAgent without requiring the langchain dependency.
*/
export interface AgentTypeConfigLike {
Response: unknown;
State: unknown;
Context: unknown;
Middleware: unknown;
Tools: unknown;
}
/**
* Check if a type is agent-like (has `~agentTypes` phantom property).
* This property is present on `ReactAgent` instances created with `createAgent`.
*/
export type IsAgentLike<T> = T extends { "~agentTypes": AgentTypeConfigLike }
? true
: false;
/**
* Extract the AgentTypeConfig from an agent-like type.
*
* @example
* ```ts
* const agent = createAgent({ ... });
* type Config = ExtractAgentConfig<typeof agent>;
* // Config is the AgentTypeConfig with Response, State, Context, Middleware, Tools
* ```
*/
export type ExtractAgentConfig<T> = T extends { "~agentTypes": infer Config }
? Config extends AgentTypeConfigLike
? Config
: never
: never;
/**
* Helper type to infer schema input type, supporting both Zod v3 and v4.
* - Zod v4 uses `_zod.input` property
* - Zod v3 uses `_input` property
*/
type InferSchemaInput<S> = S extends { _zod: { input: infer Args } }
? Args
: S extends { _input: infer Args }
? Args
: never;
/**
* Helper type to extract the input type from a DynamicStructuredTool's _call method.
* This is more reliable than trying to infer from the schema directly because
* DynamicStructuredTool has the input type baked into its _call signature.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type InferToolInput<T> = T extends {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
_call: (arg: infer Args, ...rest: any[]) => any;
}
? Args
: T extends { schema: infer S }
? InferSchemaInput<S>
: never;
/**
* Extract a tool call type from a single tool.
* Works with tools created via `tool()` from `@langchain/core/tools`.
*
* This extracts the literal name type from DynamicStructuredTool's NameT parameter
* and the args type from the _call method or schema's input property.
*/
type ToolCallFromAgentTool<T> = T extends { name: infer N }
? N extends string
? InferToolInput<T> extends infer Args
? Args extends never
? never
: // eslint-disable-next-line @typescript-eslint/no-explicit-any
Args extends Record<string, any>
? { name: N; args: Args; id?: string; type?: "tool_call" }
: never
: never
: never
: never;
/**
* Extract tool calls type from an agent's tools.
* Converts the tools array to a discriminated union of tool calls.
*
* This handles both tuple types (e.g., `readonly [Tool1, Tool2]`) and
* array-of-union types (e.g., `readonly (Tool1 | Tool2)[]`) which is how
* `createAgent` captures tool types.
*
* @example
* ```ts
* const agent = createAgent({ tools: [getWeather, search], ... });
* type ToolCalls = InferAgentToolCalls<typeof agent>;
* // ToolCalls is:
* // | { name: "get_weather"; args: { location: string }; id?: string }
* // | { name: "search"; args: { query: string }; id?: string }
* ```
*/
export type InferAgentToolCalls<T> =
ExtractAgentConfig<T>["Tools"] extends readonly (infer Tool)[]
? ToolCallFromAgentTool<Tool> extends never
? DefaultToolCall
: ToolCallFromAgentTool<Tool>
: DefaultToolCall;
// ============================================================================
// StateType Tool Call Extraction Helpers
// ============================================================================
// These types enable extracting tool call types from the messages property
// of a StateType, providing a single canonical way to specify tool call types.
/**
* Extract the tool call type parameter from an AIMessage in a message union.
* Returns `never` if the message is not an AIMessage or uses DefaultToolCall.
*
* The key distinction: custom tool calls have literal `name` types (e.g., "get_weather"),
* while DefaultToolCall has `name: string`. We check if `string extends TC["name"]` -
* if true, it's DefaultToolCall; if false, it's a custom type with literal names.
*/
type ExtractToolCallFromMessageUnion<M> = M extends AIMessage<infer TC>
? TC extends { name: infer N }
? // If string extends N, then N is just `string` (DefaultToolCall)
// If not, N is a literal type like "get_weather" (custom type)
string extends N
? never
: TC
: never
: never;
/**
* Extract the tool call type from a StateType's messages property.
* This is the primary way to specify tool call types when using useStream.
*
* @example
* ```ts
* // Define state with typed messages
* type MyToolCalls =
* | { name: "get_weather"; args: { location: string }; id?: string }
* | { name: "search"; args: { query: string }; id?: string };
*
* interface MyState {
* messages: Message<MyToolCalls>[];
* }
*
* // ExtractToolCallsFromState<MyState> = MyToolCalls
* ```
*/
export type ExtractToolCallsFromState<
StateType extends Record<string, unknown>
> = StateType extends { messages: infer Messages }
? Messages extends readonly (infer M)[]
? ExtractToolCallFromMessageUnion<M>
: Messages extends (infer M)[]
? ExtractToolCallFromMessageUnion<M>
: never
: never;
export type MessageMetadata<StateType extends Record<string, unknown>> = {
/**
* The ID of the message used.
*/
messageId: string;
/**
* The first thread state the message was seen in.
*/
firstSeenState: ThreadState<StateType> | undefined;
/**
* The branch of the message.
*/
branch: string | undefined;
/**
* The list of branches this message is part of.
* This is useful for displaying branching controls.
*/
branchOptions: string[] | undefined;
/**
* Metadata sent alongside the message during run streaming.
* @remarks This metadata only exists temporarily in browser memory during streaming and is not persisted after completion.
*/
streamMetadata: Record<string, unknown> | undefined;
};
export type GetUpdateType<
Bag extends BagTemplate,
StateType extends Record<string, unknown>
> = Bag extends { UpdateType: unknown }
? Bag["UpdateType"]
: Partial<StateType>;
export type GetConfigurableType<Bag extends BagTemplate> = Bag extends {
ConfigurableType: Record<string, unknown>;
}
? Bag["ConfigurableType"]
: Record<string, unknown>;
export type GetInterruptType<Bag extends BagTemplate> = Bag extends {
InterruptType: unknown;
}
? Bag["InterruptType"]
: unknown;
export type GetCustomEventType<Bag extends BagTemplate> = Bag extends {
CustomEventType: unknown;
}
? Bag["CustomEventType"]
: unknown;
/**
* Extract the tool call type from a StateType's messages property.
* This is the canonical way to get typed tool calls in useStream.
*
* Tool call types are now extracted from the messages property of StateType,
* rather than being specified separately in the Bag.
*
* @example
* ```ts
* // Define state with typed messages
* type MyToolCalls =
* | { name: "get_weather"; args: { location: string }; id?: string }
* | { name: "search"; args: { query: string }; id?: string };
*
* interface MyState {
* messages: Message<MyToolCalls>[];
* }
*
* // GetToolCallsType<MyState> = MyToolCalls
* ```
*/
export type GetToolCallsType<StateType extends Record<string, unknown>> =
ExtractToolCallsFromState<StateType> extends never
? DefaultToolCall
: ExtractToolCallsFromState<StateType>;
export interface RunCallbackMeta {
run_id: string;
thread_id: string;
}
export interface UseStreamThread<StateType extends Record<string, unknown>> {
data: ThreadState<StateType>[] | null | undefined;
error: unknown;
isLoading: boolean;
mutate: (
mutateId?: string
) => Promise<ThreadState<StateType>[] | null | undefined>;
}
export interface UseStreamOptions<
StateType extends Record<string, unknown> = Record<string, unknown>,
Bag extends BagTemplate = BagTemplate
> {
/**
* The ID of the assistant to use.
*/
assistantId: string;
/**
* Client used to send requests.
*/
client?: Client;
/**
* The URL of the API to use.
*/
apiUrl?: ClientConfig["apiUrl"];
/**
* The API key to use.
*/
apiKey?: ClientConfig["apiKey"];
/**
* Custom call options, such as custom fetch implementation.
*/
callerOptions?: ClientConfig["callerOptions"];
/**
* Default headers to send with requests.
*/
defaultHeaders?: ClientConfig["defaultHeaders"];
/**
* Specify the key within the state that contains messages.
* Defaults to "messages".
*
* @default "messages"
*/
messagesKey?: string;
/**
* Callback that is called when an error occurs.
*/
onError?: (error: unknown, run: RunCallbackMeta | undefined) => void;
/**
* Callback that is called when the stream is finished.
*/
onFinish?: (
state: ThreadState<StateType>,
run: RunCallbackMeta | undefined
) => void;
/**
* Callback that is called when a new stream is created.
*/
onCreated?: (run: RunCallbackMeta) => void;
/**
* Callback that is called when an update event is received.
*/
onUpdateEvent?: (
data: UpdatesStreamEvent<GetUpdateType<Bag, StateType>>["data"],
options: {
namespace: string[] | undefined;
mutate: (
update: Partial<StateType> | ((prev: StateType) => Partial<StateType>)
) => void;
}
) => void;
/**
* Callback that is called when a custom event is received.
*/
onCustomEvent?: (
data: CustomStreamEvent<GetCustomEventType<Bag>>["data"],
options: {
namespace: string[] | undefined;
mutate: (
update: Partial<StateType> | ((prev: StateType) => Partial<StateType>)
) => void;
}
) => void;
/**
* Callback that is called when a metadata event is received.
*/
onMetadataEvent?: (data: MetadataStreamEvent["data"]) => void;
/**
* Callback that is called when a LangChain event is received.
* @see https://langchain-ai.github.io/langgraph/cloud/how-tos/stream_events/#stream-graph-in-events-mode for more details.
*/
onLangChainEvent?: (data: EventsStreamEvent["data"]) => void;
/**
* Callback that is called when a debug event is received.
* @internal This API is experimental and subject to change.
*/
onDebugEvent?: (
data: DebugStreamEvent["data"],
options: { namespace: string[] | undefined }
) => void;
/**
* Callback that is called when a checkpoints event is received.
*/
onCheckpointEvent?: (
data: CheckpointsStreamEvent<StateType>["data"],
options: { namespace: string[] | undefined }
) => void;
/**
* Callback that is called when a tasks event is received.
*/
onTaskEvent?: (
data: TasksStreamEvent<StateType, GetUpdateType<Bag, StateType>>["data"],
options: { namespace: string[] | undefined }
) => void;
/**
* Callback that is called when the stream is stopped by the user.
* Provides a mutate function to update the stream state immediately
* without requiring a server roundtrip.
*
* @example
* ```typescript
* onStop: ({ mutate }) => {
* mutate((prev) => ({
* ...prev,
* ui: prev.ui?.map(component =>
* component.props.isLoading
* ? { ...component, props: { ...component.props, stopped: true, isLoading: false }}
* : component
* )
* }));
* }
* ```
*/
onStop?: (options: {
mutate: (
update: Partial<StateType> | ((prev: StateType) => Partial<StateType>)
) => void;
}) => void;
/**
* The ID of the thread to fetch history and current values from.
*/
threadId?: string | null;
/**
* Callback that is called when the thread ID is updated (ie when a new thread is created).
*/
onThreadId?: (threadId: string) => void;
/** Will reconnect the stream on mount */
reconnectOnMount?: boolean | (() => RunMetadataStorage);
/**
* Initial values to display immediately when loading a thread.
* Useful for displaying cached thread data while official history loads.
* These values will be replaced when official thread data is fetched.
*
* Note: UI components from initialValues will render immediately if they're
* predefined in LoadExternalComponent's components prop, providing instant
* cached UI display without server fetches.
*/
initialValues?: StateType | null;
/**
* Whether to fetch the history of the thread.
* If true, the history will be fetched from the server. Defaults to 10 entries.
* If false, only the last state will be fetched from the server.
* @default true
*/
fetchStateHistory?: boolean | { limit: number };
/**
* Manage the thread state externally.
*/
thread?: UseStreamThread<StateType>;
/**
* Throttle the stream.
* If a number is provided, the stream will be throttled to the given number of milliseconds.
* If `true`, updates are batched in a single macrotask.
* If `false`, updates are not throttled or batched.
* @default true
*/
throttle?: number | boolean;
}
interface RunMetadataStorage {
getItem(key: `lg:stream:${string}`): string | null;
setItem(key: `lg:stream:${string}`, value: string): void;
removeItem(key: `lg:stream:${string}`): void;
}
type ConfigWithConfigurable<ConfigurableType extends Record<string, unknown>> =
Config & { configurable?: ConfigurableType };
export interface SubmitOptions<
StateType extends Record<string, unknown> = Record<string, unknown>,
ContextType extends Record<string, unknown> = Record<string, unknown>
> {
config?: ConfigWithConfigurable<ContextType>;
context?: ContextType;
checkpoint?: Omit<Checkpoint, "thread_id"> | null;
command?: Command;
interruptBefore?: "*" | string[];
interruptAfter?: "*" | string[];
metadata?: Metadata;
multitaskStrategy?: MultitaskStrategy;
onCompletion?: OnCompletionBehavior;
onDisconnect?: DisconnectMode;
feedbackKeys?: string[];
streamMode?: Array<StreamMode>;
runId?: string;
optimisticValues?:
| Partial<StateType>
| ((prev: StateType) => Partial<StateType>);
/**
* Whether or not to stream the nodes of any subgraphs called
* by the assistant.
* @default false
*/
streamSubgraphs?: boolean;
/**
* Mark the stream as resumable. All events emitted during the run will be temporarily persisted
* in order to be re-emitted if the stream is re-joined.
* @default false
*/
streamResumable?: boolean;
/**
* Whether to checkpoint during the run (or only at the end/interruption).
* - `"async"`: Save checkpoint asynchronously while the next step executes (default).
* - `"sync"`: Save checkpoint synchronously before the next step starts.
* - `"exit"`: Save checkpoint only when the graph exits.
* @default "async"
*/
durability?: Durability;
/**
* The ID to use when creating a new thread. When provided, this ID will be used
* for thread creation when threadId is `null` or `undefined`.
* This enables optimistic UI updates where you know the thread ID
* before the thread is actually created.
*/
threadId?: string;
}
/**
* Transport used to stream the thread.
* Only applicable for custom endpoints using `toLangGraphEventStream` or `toLangGraphEventStreamResponse`.
*/
export interface UseStreamTransport<
StateType extends Record<string, unknown> = Record<string, unknown>,
Bag extends BagTemplate = BagTemplate
> {
stream: (payload: {
input: GetUpdateType<Bag, StateType> | null | undefined;
context: GetConfigurableType<Bag> | undefined;
command: Command | undefined;
config: ConfigWithConfigurable<GetConfigurableType<Bag>> | undefined;
signal: AbortSignal;
}) => Promise<AsyncGenerator<{ id?: string; event: string; data: unknown }>>;
}
export type UseStreamCustomOptions<
StateType extends Record<string, unknown> = Record<string, unknown>,
Bag extends BagTemplate = BagTemplate
> = Pick<
UseStreamOptions<StateType, Bag>,
| "messagesKey"
| "threadId"
| "onThreadId"
| "onError"
| "onCreated"
| "onUpdateEvent"
| "onCustomEvent"
| "onMetadataEvent"
| "onLangChainEvent"
| "onDebugEvent"
| "onCheckpointEvent"
| "onTaskEvent"
| "onStop"
| "initialValues"
| "throttle"
> & { transport: UseStreamTransport<StateType, Bag> };
export type CustomSubmitOptions<
StateType extends Record<string, unknown> = Record<string, unknown>,
ConfigurableType extends Record<string, unknown> = Record<string, unknown>
> = Pick<
SubmitOptions<StateType, ConfigurableType>,
"optimisticValues" | "context" | "command" | "config"
>;
+19 -4
View File
@@ -154,11 +154,12 @@ export class AsyncCaller {
}
}),
{
async onFailedAttempt(error) {
async onFailedAttempt({ error }) {
const errorMessage = error.message ?? "";
if (
error.message.startsWith("Cancel") ||
error.message.startsWith("TimeoutError") ||
error.message.startsWith("AbortError")
errorMessage.startsWith("Cancel") ||
errorMessage.startsWith("TimeoutError") ||
errorMessage.startsWith("AbortError")
) {
throw error;
}
@@ -167,6 +168,20 @@ export class AsyncCaller {
throw error;
}
// Check for connection refused errors (server not running)
if (
errorMessage.includes("ECONNREFUSED") ||
errorMessage.includes("fetch failed") ||
errorMessage.includes("Failed to fetch") ||
errorMessage.includes("NetworkError")
) {
const connectionError = new Error(
`Unable to connect to LangGraph server. Please ensure the server is running and accessible. Original error: ${errorMessage}`
);
connectionError.name = "ConnectionError";
throw connectionError;
}
// eslint-disable-next-line no-instanceof/no-instanceof
if (error instanceof HTTPError) {
if (STATUS_NO_RETRY.includes(error.status)) {
@@ -0,0 +1,230 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { AsyncCaller } from "../async_caller.js";
describe("AsyncCaller", () => {
afterEach(() => {
vi.restoreAllMocks();
});
describe("error handling with undefined/null message", () => {
it("should handle errors with undefined message without crashing", async () => {
const caller = new AsyncCaller({ maxRetries: 0 });
// Create an error without a message property
const errorWithoutMessage = new Error();
// @ts-expect-error - deliberately removing message for testing
delete errorWithoutMessage.message;
const failingCallable = vi.fn(() => Promise.reject(errorWithoutMessage));
// The key test: this should not crash with "Cannot read properties of undefined"
await expect(caller.call(failingCallable)).rejects.toThrow();
expect(failingCallable).toHaveBeenCalled();
});
it("should handle errors with null message without crashing", async () => {
const caller = new AsyncCaller({ maxRetries: 0 });
const errorWithNullMessage = new Error();
// @ts-expect-error - deliberately setting message to null for testing
errorWithNullMessage.message = null;
const failingCallable = vi.fn(() => Promise.reject(errorWithNullMessage));
// The key test: this should not crash with "Cannot read properties of null"
await expect(caller.call(failingCallable)).rejects.toThrow();
expect(failingCallable).toHaveBeenCalled();
});
it("should handle empty error object without crashing", async () => {
const caller = new AsyncCaller({ maxRetries: 0 });
// Create a minimal error-like object
const minimalError = { name: "Error" };
const failingCallable = vi.fn(() => Promise.reject(minimalError));
// Should not crash
await expect(caller.call(failingCallable)).rejects.toThrow();
});
});
describe("successful calls", () => {
it("should return result from successful call", async () => {
const caller = new AsyncCaller({ maxRetries: 3 });
const successfulCallable = vi.fn(() =>
Promise.resolve({ data: "success" })
);
const result = await caller.call(successfulCallable);
expect(result).toEqual({ data: "success" });
expect(successfulCallable).toHaveBeenCalledTimes(1);
});
it("should pass arguments to callable", async () => {
const caller = new AsyncCaller({ maxRetries: 3 });
const successfulCallable = vi.fn((a: number, b: string) =>
Promise.resolve(`${a}-${b}`)
);
const result = await caller.call(successfulCallable, 42, "test");
expect(result).toBe("42-test");
expect(successfulCallable).toHaveBeenCalledWith(42, "test");
});
});
describe("fetch method", () => {
it("should use custom fetch when provided", async () => {
const mockFetch = vi.fn(() =>
Promise.resolve({
ok: true,
status: 200,
statusText: "OK",
text: () => Promise.resolve(""),
json: () => Promise.resolve({}),
headers: new Headers(),
} as Response)
);
const caller = new AsyncCaller({
maxRetries: 0,
fetch: mockFetch,
});
await caller.fetch("http://example.com/api");
expect(mockFetch).toHaveBeenCalledWith("http://example.com/api");
});
it("should reject non-ok responses", async () => {
const mockFetch = vi.fn(() =>
Promise.resolve({
ok: false,
status: 500,
statusText: "Internal Server Error",
text: () => Promise.resolve("Server error"),
headers: new Headers(),
} as Response)
);
const caller = new AsyncCaller({
maxRetries: 0,
fetch: mockFetch,
});
await expect(caller.fetch("http://example.com/api")).rejects.toThrow(
/HTTP 500/
);
});
});
describe("callWithOptions", () => {
it("should work without signal option", async () => {
const caller = new AsyncCaller({ maxRetries: 3 });
const successfulCallable = vi.fn(() => Promise.resolve("success"));
const result = await caller.callWithOptions({}, successfulCallable);
expect(result).toBe("success");
});
it("should pass through to call when no signal provided", async () => {
const caller = new AsyncCaller({ maxRetries: 3 });
const successfulCallable = vi.fn((x: number) => Promise.resolve(x * 2));
const result = await caller.callWithOptions({}, successfulCallable, 21);
expect(result).toBe(42);
expect(successfulCallable).toHaveBeenCalledWith(21);
});
});
describe("HTTP error handling", () => {
it("should convert response-like objects to HTTPError", async () => {
const caller = new AsyncCaller({ maxRetries: 0 });
// Simulate a response object being rejected (like from fetch)
const responseError = {
status: 404,
statusText: "Not Found",
text: () => Promise.resolve("Resource not found"),
};
const failingCallable = vi.fn(() => Promise.reject(responseError));
await expect(caller.call(failingCallable)).rejects.toThrow(/HTTP 404/);
});
it("should include response text in HTTPError message", async () => {
const caller = new AsyncCaller({ maxRetries: 0 });
const responseError = {
status: 400,
statusText: "Bad Request",
text: () => Promise.resolve("Invalid parameters"),
};
const failingCallable = vi.fn(() => Promise.reject(responseError));
await expect(caller.call(failingCallable)).rejects.toThrow(
"HTTP 400: Invalid parameters"
);
});
});
describe("onFailedResponseHook", () => {
it("should call onFailedResponseHook when provided and request fails with response", async () => {
const onFailedResponseHook = vi.fn(() => Promise.resolve(false));
const caller = new AsyncCaller({
maxRetries: 0,
onFailedResponseHook,
});
const mockResponse = {
ok: false,
status: 503,
statusText: "Service Unavailable",
text: () => Promise.resolve("Service down"),
};
const failingCallable = vi.fn(() => Promise.reject(mockResponse));
await expect(caller.call(failingCallable)).rejects.toThrow(/HTTP 503/);
});
});
describe("maxConcurrency", () => {
it("should default to Infinity for maxConcurrency", async () => {
const caller = new AsyncCaller({});
// Just verify we can create a caller with default options
expect(caller).toBeDefined();
});
it("should respect maxConcurrency setting", async () => {
const caller = new AsyncCaller({ maxConcurrency: 1 });
const order: number[] = [];
const callable = vi.fn(async (id: number) => {
order.push(id);
await new Promise((resolve) => {
setTimeout(resolve, 10);
});
return id;
});
// Start multiple calls - with maxConcurrency=1, they should run sequentially
const results = await Promise.all([
caller.call(callable, 1),
caller.call(callable, 2),
caller.call(callable, 3),
]);
expect(results).toEqual([1, 2, 3]);
expect(order).toEqual([1, 2, 3]); // Should run in order due to concurrency limit
});
});
});
+70
View File
@@ -0,0 +1,70 @@
import type {
Message,
AIMessage,
ToolMessage,
ToolCallState,
ToolCallWithResult,
DefaultToolCall,
} from "../types.messages.js";
/**
* Extracts tool calls with their results from a list of messages.
*
* @template ToolCall The type of tool calls.
* @param messages The list of messages to extract tool calls from.
* @returns An array of ToolCallWithResult objects.
*
* @example
* ```ts
* const toolCalls = getToolCallsWithResults(messages);
* for (const { call, result } of toolCalls) {
* if (call.name === "get_weather") {
* console.log(`Weather for ${call.args.location}:`, result?.content);
* }
* }
* ```
*/
/**
* Computes the lifecycle state of a tool call based on its result.
*/
function computeToolCallState(result: ToolMessage | undefined): ToolCallState {
if (!result) return "pending";
return result.status === "error" ? "error" : "completed";
}
export function getToolCallsWithResults<ToolCall = DefaultToolCall>(
messages: Message<ToolCall>[]
): ToolCallWithResult<ToolCall>[] {
const results: ToolCallWithResult<ToolCall>[] = [];
// Create a map of tool_call_id to ToolMessage for quick lookup
const toolResultsById = new Map<string, ToolMessage>();
for (const msg of messages) {
if (msg.type === "tool") {
toolResultsById.set(msg.tool_call_id, msg);
}
}
// Find all AI messages with tool calls and pair them with results
for (const msg of messages) {
if (msg.type === "ai" && msg.tool_calls && msg.tool_calls.length > 0) {
const aiMessage = msg as AIMessage<ToolCall>;
for (let i = 0; i < aiMessage.tool_calls!.length; i += 1) {
const call = aiMessage.tool_calls![i] as ToolCall & { id?: string };
const callId = call.id as string | undefined;
const result = callId ? toolResultsById.get(callId) : undefined;
results.push({
id: callId ?? `${aiMessage.id ?? "unknown"}-${i}`,
call,
result,
aiMessage,
index: i,
state: computeToolCallState(result),
});
}
}
}
return results;
}
+2662 -4079
View File
File diff suppressed because it is too large Load Diff