mirror of
https://github.com/langchain-ai/langgraphjs.git
synced 2026-07-22 09:05:28 -04:00
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:
committed by
GitHub
parent
4b7832ec39
commit
730dc7c3b8
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@langchain/langgraph-api": patch
|
||||
---
|
||||
|
||||
fix(api): bump @hono/zod-validator
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@langchain/langgraph-checkpoint-validation": patch
|
||||
---
|
||||
|
||||
fix(checkpoint-validation): export primitive as type, use Zod v3 for better compaitiblity
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@langchain/langgraph-sdk": patch
|
||||
---
|
||||
|
||||
fix(sdk): provide proper error message when failing to connect to a server
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@langchain/langgraph": patch
|
||||
---
|
||||
|
||||
fix(core): improved Zod interop
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@langchain/langgraph-sdk": minor
|
||||
---
|
||||
|
||||
feat(sdk): add type-safe tool call streaming with agent type inference
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -25,3 +25,4 @@ dist-ssr
|
||||
|
||||
# LangGraph API
|
||||
.langgraph_api
|
||||
*.tsbuildinfo
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+42
@@ -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}¤t=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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,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"]
|
||||
}
|
||||
|
||||
@@ -25,4 +25,4 @@
|
||||
"persistent": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"] };
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -55,3 +55,5 @@ export type {
|
||||
UpdatesStreamEvent,
|
||||
ValuesStreamEvent,
|
||||
} from "./types.stream.js";
|
||||
|
||||
export type { BagTemplate } from "./types.template.js";
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
>;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,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);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
>;
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user