feat: Add web package (#34)

This commit is contained in:
Brace Sproul 2025-05-28 12:26:47 -07:00 • committed by GitHub
parent ea793b9912
commit 76dadeeb72
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
70 changed files with 9493 additions and 40 deletions

View file

@ -0,0 +1 @@
productionize

4
apps/web/.dockerignore Normal file
View file

@ -0,0 +1,4 @@
node_modules
.next
.git
.env

13
apps/web/.env.example Normal file
View file

@ -0,0 +1,13 @@
# LangGraph Configuration
NEXT_PUBLIC_API_URL=http://localhost:2024
NEXT_PUBLIC_ASSISTANT_ID=agent
# Do NOT prefix this with "NEXT_PUBLIC_" as we do not want this exposed in the client.
LANGSMITH_API_KEY=
# Production LangGraph Configuration (quickstart) - Uncomment to use
# NEXT_PUBLIC_ASSISTANT_ID="agent"
# This should be the deployment URL of your LangGraph server
# LANGGRAPH_API_URL="https://my-agent.default.us.langgraph.app"
# This should be the URL of your website + "/api". This is how you connect to the API proxy
# NEXT_PUBLIC_API_URL="https://my-website.com/api"
# LANGSMITH_API_KEY="lsv2_..."

30
apps/web/.gitignore vendored Normal file
View file

@ -0,0 +1,30 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# LangGraph API
.langgraph_api
.env
.next/
next-env.d.ts

6
apps/web/.prettierignore Normal file
View file

@ -0,0 +1,6 @@
# Ignore artifacts:
build
coverage
#
pnpm-lock.yaml

242
apps/web/README.md Normal file
View file

@ -0,0 +1,242 @@
# Agent Chat UI
Agent Chat UI is a Next.js application which enables chatting with any LangGraph server with a `messages` key through a chat interface.
> [!NOTE]
> 🎥 Watch the video setup guide [here](https://youtu.be/lInrwVnZ83o).
## Setup
> [!TIP]
> Don't want to run the app locally? Use the deployed site here: [agentchat.vercel.app](https://agentchat.vercel.app)!
First, clone the repository, or run the [`npx` command](https://www.npmjs.com/package/create-agent-chat-app):
```bash
npx create-agent-chat-app
```
or
```bash
git clone https://github.com/langchain-ai/agent-chat-ui.git
cd agent-chat-ui
```
Install dependencies:
```bash
pnpm install
```
Run the app:
```bash
pnpm dev
```
The app will be available at `http://localhost:3000`.
## Usage
Once the app is running (or if using the deployed site), you'll be prompted to enter:
- **Deployment URL**: The URL of the LangGraph server you want to chat with. This can be a production or development URL.
- **Assistant/Graph ID**: The name of the graph, or ID of the assistant to use when fetching, and submitting runs via the chat interface.
- **LangSmith API Key**: (only required for connecting to deployed LangGraph servers) Your LangSmith API key to use when authenticating requests sent to LangGraph servers.
After entering these values, click `Continue`. You'll then be redirected to a chat interface where you can start chatting with your LangGraph server.
## Environment Variables
You can bypass the initial setup form by setting the following environment variables:
```bash
NEXT_PUBLIC_API_URL=http://localhost:2024
NEXT_PUBLIC_ASSISTANT_ID=agent
```
> [!TIP]
> If you want to connect to a production LangGraph server, read the [Going to Production](#going-to-production) section.
To use these variables:
1. Copy the `.env.example` file to a new file named `.env`
2. Fill in the values in the `.env` file
3. Restart the application
When these environment variables are set, the application will use them instead of showing the setup form.
## Hiding Messages in the Chat
You can control the visibility of messages within the Agent Chat UI in two main ways:
**1. Prevent Live Streaming:**
To stop messages from being displayed _as they stream_ from an LLM call, add the `langsmith:nostream` tag to the chat model's configuration. The UI normally uses `on_chat_model_stream` events to render streaming messages; this tag prevents those events from being emitted for the tagged model.
_Python Example:_
```python
from langchain_anthropic import ChatAnthropic
# Add tags via the .with_config method
model = ChatAnthropic().with_config(
config={"tags": ["langsmith:nostream"]}
)
```
_TypeScript Example:_
```typescript
import { ChatAnthropic } from "@langchain/anthropic";
const model = new ChatAnthropic()
// Add tags via the .withConfig method
.withConfig({ tags: ["langsmith:nostream"] });
```
**Note:** Even if streaming is hidden this way, the message will still appear after the LLM call completes if it's saved to the graph's state without further modification.
**2. Hide Messages Permanently:**
To ensure a message is _never_ displayed in the chat UI (neither during streaming nor after being saved to state), prefix its `id` field with `do-not-render-` _before_ adding it to the graph's state, along with adding the `langsmith:do-not-render` tag to the chat model's configuration. The UI explicitly filters out any message whose `id` starts with this prefix.
_Python Example:_
```python
result = model.invoke([messages])
# Prefix the ID before saving to state
result.id = f"do-not-render-{result.id}"
return {"messages": [result]}
```
_TypeScript Example:_
```typescript
const result = await model.invoke([messages]);
// Prefix the ID before saving to state
result.id = `do-not-render-${result.id}`;
return { messages: [result] };
```
This approach guarantees the message remains completely hidden from the user interface.
## Rendering Artifacts
The Agent Chat UI supports rendering artifacts in the chat. Artifacts are rendered in a side panel to the right of the chat. To render an artifact, you can obtain the artifact context from the `thread.meta.artifact` field. Here's a sample utility hook for obtaining the artifact context:
```tsx
export function useArtifact<TContext = Record<string, unknown>>() {
type Component = (props: {
children: React.ReactNode;
title?: React.ReactNode;
}) => React.ReactNode;
type Context = TContext | undefined;
type Bag = {
open: boolean;
setOpen: (value: boolean | ((prev: boolean) => boolean)) => void;
context: Context;
setContext: (value: Context | ((prev: Context) => Context)) => void;
};
const thread = useStreamContext<
{ messages: Message[]; ui: UIMessage[] },
{ MetaType: { artifact: [Component, Bag] } }
>();
return thread.meta?.artifact;
}
```
After which you can render additional content using the `Artifact` component from the `useArtifact` hook:
```tsx
import { useArtifact } from "../utils/use-artifact";
import { LoaderIcon } from "lucide-react";
export function Writer(props: {
title?: string;
content?: string;
description?: string;
}) {
const [Artifact, { open, setOpen }] = useArtifact();
return (
<>
<div
onClick={() => setOpen(!open)}
className="cursor-pointer rounded-lg border p-4"
>
<p className="font-medium">{props.title}</p>
<p className="text-sm text-gray-500">{props.description}</p>
</div>
<Artifact title={props.title}>
<p className="p-4 whitespace-pre-wrap">{props.content}</p>
</Artifact>
</>
);
}
```
## Going to Production
Once you're ready to go to production, you'll need to update how you connect, and authenticate requests to your deployment. By default, the Agent Chat UI is setup for local development, and connects to your LangGraph server directly from the client. This is not possible if you want to go to production, because it requires every user to have their own LangSmith API key, and set the LangGraph configuration themselves.
### Production Setup
To productionize the Agent Chat UI, you'll need to pick one of two ways to authenticate requests to your LangGraph server. Below, I'll outline the two options:
### Quickstart - API Passthrough
The quickest way to productionize the Agent Chat UI is to use the [API Passthrough](https://github.com/langchain-ai/langgraph-nextjs-api-passthrough) package. This package provides a simple way to proxy requests to your LangGraph server, and handle authentication for you.
This repository already contains all of the code you need to start using this method. The only configuration you need to do is set the proper environment variables.
```bash
NEXT_PUBLIC_ASSISTANT_ID="agent"
# This should be the deployment URL of your LangGraph server
LANGGRAPH_API_URL="https://my-agent.default.us.langgraph.app"
# This should be the URL of your website + "/api". This is how you connect to the API proxy
NEXT_PUBLIC_API_URL="https://my-website.com/api"
# Your LangSmith API key which is injected into requests inside the API proxy
LANGSMITH_API_KEY="lsv2_..."
```
Let's cover what each of these environment variables does:
- `NEXT_PUBLIC_ASSISTANT_ID`: The ID of the assistant you want to use when fetching, and submitting runs via the chat interface. This still needs the `NEXT_PUBLIC_` prefix, since it's not a secret, and we use it on the client when submitting requests.
- `LANGGRAPH_API_URL`: The URL of your LangGraph server. This should be the production deployment URL.
- `NEXT_PUBLIC_API_URL`: The URL of your website + `/api`. This is how you connect to the API proxy. For the [Agent Chat demo](https://agentchat.vercel.app), this would be set as `https://agentchat.vercel.app/api`. You should set this to whatever your production URL is.
- `LANGSMITH_API_KEY`: Your LangSmith API key to use when authenticating requests sent to LangGraph servers. Once again, do _not_ prefix this with `NEXT_PUBLIC_` since it's a secret, and is only used on the server when the API proxy injects it into the request to your deployed LangGraph server.
For in depth documentation, consult the [LangGraph Next.js API Passthrough](https://www.npmjs.com/package/langgraph-nextjs-api-passthrough) docs.
### Advanced Setup - Custom Authentication
Custom authentication in your LangGraph deployment is an advanced, and more robust way of authenticating requests to your LangGraph server. Using custom authentication, you can allow requests to be made from the client, without the need for a LangSmith API key. Additionally, you can specify custom access controls on requests.
To set this up in your LangGraph deployment, please read the LangGraph custom authentication docs for [Python](https://langchain-ai.github.io/langgraph/tutorials/auth/getting_started/), and [TypeScript here](https://langchain-ai.github.io/langgraphjs/how-tos/auth/custom_auth/).
Once you've set it up on your deployment, you should make the following changes to the Agent Chat UI:
1. Configure any additional API requests to fetch the authentication token from your LangGraph deployment which will be used to authenticate requests from the client.
2. Set the `NEXT_PUBLIC_API_URL` environment variable to your production LangGraph deployment URL.
3. Set the `NEXT_PUBLIC_ASSISTANT_ID` environment variable to the ID of the assistant you want to use when fetching, and submitting runs via the chat interface.
4. Modify the [`useTypedStream`](src/providers/Stream.tsx) (extension of `useStream`) hook to pass your authentication token through headers to the LangGraph server:
```tsx
const streamValue = useTypedStream({
apiUrl: process.env.NEXT_PUBLIC_API_URL,
assistantId: process.env.NEXT_PUBLIC_ASSISTANT_ID,
// ... other fields
defaultHeaders: {
Authentication: `Bearer ${addYourTokenHere}`, // this is where you would pass your authentication token
},
});
```

21
apps/web/components.json Normal file
View file

@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

33
apps/web/eslint.config.js Normal file
View file

@ -0,0 +1,33 @@
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["dist"] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
"@typescript-eslint/no-explicit-any": 0,
"@typescript-eslint/no-unused-vars": [
"warn",
{ args: "none", argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
"react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true },
],
},
},
);

10
apps/web/next.config.mjs Normal file
View file

@ -0,0 +1,10 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
serverActions: {
bodySizeLimit: "10mb",
},
},
};
export default nextConfig;

88
apps/web/package.json Normal file
View file

@ -0,0 +1,88 @@
{
"name": "@open-swe/web",
"readme": "https://github.com/langchain-ai/open-swe/blob/main/apps/web/README.md",
"homepage": "https://github.com/langchain-ai/open-swe/blob/main/README.md",
"repository": {
"type": "git",
"url": "git+https://github.com/langchain-ai/open-swe.git"
},
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"lint:fix": "next lint --fix",
"format": "prettier --write .",
"format:check": "prettier --check ."
},
"dependencies": {
"@langchain/core": "^0.3.57",
"@langchain/langgraph": "^0.2.73",
"@langchain/langgraph-sdk": "^0.0.78",
"@radix-ui/react-avatar": "^1.1.3",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-switch": "^1.1.3",
"@radix-ui/react-tooltip": "^1.1.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"esbuild": "^0.25.0",
"esbuild-plugin-tailwindcss": "^2.0.1",
"framer-motion": "^12.4.9",
"katex": "^0.16.21",
"langgraph-nextjs-api-passthrough": "^0.0.4",
"lodash": "^4.17.21",
"lucide-react": "^0.476.0",
"next-themes": "^0.4.4",
"nuqs": "^2.4.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-markdown": "^10.0.1",
"react-syntax-highlighter": "^15.5.0",
"recharts": "^2.15.1",
"rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"sonner": "^2.0.1",
"tailwind-merge": "^3.0.2",
"tailwindcss-animate": "^1.0.7",
"use-stick-to-bottom": "^1.0.46",
"uuid": "^11.1.0",
"zod": "^3.24.2"
},
"devDependencies": {
"@eslint/js": "^9.19.0",
"@tailwindcss/postcss": "^4.0.13",
"@types/lodash": "^4.17.16",
"@types/node": "^22.13.5",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/uuid": "^10.0.0",
"autoprefixer": "^10.4.20",
"dotenv": "^16.4.7",
"eslint": "^9.19.0",
"eslint-config-next": "15.2.2",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.18",
"globals": "^15.14.0",
"next": "^15.2.3",
"postcss": "^8.5.3",
"prettier": "^3.5.3",
"prettier-plugin-tailwindcss": "^0.6.11",
"tailwind-scrollbar": "^4.0.1",
"tailwindcss": "^4.0.13",
"typescript": "~5.7.2",
"typescript-eslint": "^8.22.0"
},
"overrides": {
"react-is": "^19.0.0-rc-69d4b800-20241021"
},
"packageManager": "yarn@3.5.1"
}

View file

@ -0,0 +1,5 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};

View file

@ -0,0 +1,11 @@
/**
* @see https://prettier.io/docs/configuration
* @type {import("prettier").Config}
*/
const config = {
endOfLine: "auto",
singleAttributePerLine: true,
plugins: ["prettier-plugin-tailwindcss"],
};
export default config;

8
apps/web/public/logo.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.8 KiB

View file

@ -0,0 +1,11 @@
import { initApiPassthrough } from "langgraph-nextjs-api-passthrough";
// This file acts as a proxy for requests to your LangGraph server.
// Read the [Going to Production](https://github.com/langchain-ai/agent-chat-ui?tab=readme-ov-file#going-to-production) section for more information.
export const { GET, POST, PUT, PATCH, DELETE, OPTIONS, runtime } =
initApiPassthrough({
apiUrl: process.env.LANGGRAPH_API_URL ?? "remove-me", // default, if not defined it will attempt to read process.env.LANGGRAPH_API_URL
apiKey: process.env.LANGSMITH_API_KEY ?? "remove-me", // default, if not defined it will attempt to read process.env.LANGSMITH_API_KEY
runtime: "edge", // default
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View file

@ -0,0 +1,151 @@
@import "tailwindcss";
@plugin "tailwindcss-animate";
@custom-variant dark (&:is(.dark *));
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.87 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.87 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.145 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.145 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.985 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.396 0.141 25.723);
--destructive-foreground: oklch(0.637 0.237 25.331);
--border: oklch(0.269 0 0);
--input: oklch(0.269 0 0);
--ring: oklch(0.439 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(0.269 0 0);
--sidebar-ring: oklch(0.439 0 0);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
:root {
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
}
.dark {
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
}
@layer utilities {
.shadow-inner-right {
box-shadow: inset -9px 0 6px -1px rgb(0 0 0 / 0.02);
}
.shadow-inner-left {
box-shadow: inset 9px 0 6px -1px rgb(0 0 0 / 0.02);
}
}

View file

@ -0,0 +1,30 @@
import type { Metadata } from "next";
import "./globals.css";
import { Inter } from "next/font/google";
import React from "react";
import { NuqsAdapter } from "nuqs/adapters/next/app";
const inter = Inter({
subsets: ["latin"],
preload: true,
display: "swap",
});
export const metadata: Metadata = {
title: "Agent Chat",
description: "Agent Chat UX by LangChain",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={inter.className}>
<NuqsAdapter>{children}</NuqsAdapter>
</body>
</html>
);
}

23
apps/web/src/app/page.tsx Normal file
View file

@ -0,0 +1,23 @@
"use client";
import { Thread } from "@/components/thread";
import { StreamProvider } from "@/providers/Stream";
import { ThreadProvider } from "@/providers/Thread";
import { ArtifactProvider } from "@/components/thread/artifact";
import { Toaster } from "@/components/ui/sonner";
import React from "react";
export default function DemoPage(): React.ReactNode {
return (
<React.Suspense fallback={<div>Loading (layout)...</div>}>
<Toaster />
<ThreadProvider>
<StreamProvider>
<ArtifactProvider>
<Thread />
</ArtifactProvider>
</StreamProvider>
</ThreadProvider>
</React.Suspense>
);
}

View file

@ -0,0 +1,12 @@
export const GitHubSVG = ({ width = "100%", height = "100%" }) => (
<svg
role="img"
viewBox="0 0 24 24"
width={width}
height={height}
xmlns="http://www.w3.org/2000/svg"
>
<title>GitHub</title>
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
</svg>
);

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,37 @@
import React from "react";
import type { Base64ContentBlock } from "@langchain/core/messages";
import { MultimodalPreview } from "./MultimodalPreview";
import { cn } from "@/lib/utils";
interface ContentBlocksPreviewProps {
blocks: Base64ContentBlock[];
onRemove: (idx: number) => void;
size?: "sm" | "md" | "lg";
className?: string;
}
/**
* Renders a preview of content blocks with optional remove functionality.
* Uses cn utility for robust class merging.
*/
export const ContentBlocksPreview: React.FC<ContentBlocksPreviewProps> = ({
blocks,
onRemove,
size = "md",
className,
}) => {
if (!blocks.length) return null;
return (
<div className={cn("flex flex-wrap gap-2 p-3.5 pb-0", className)}>
{blocks.map((block, idx) => (
<MultimodalPreview
key={idx}
block={block}
removable
onRemove={() => onRemove(idx)}
size={size}
/>
))}
</div>
);
};

View file

@ -0,0 +1,120 @@
import React from "react";
import { File, Image as ImageIcon, X as XIcon } from "lucide-react";
import type { Base64ContentBlock } from "@langchain/core/messages";
import { cn } from "@/lib/utils";
import Image from "next/image";
export interface MultimodalPreviewProps {
block: Base64ContentBlock;
removable?: boolean;
onRemove?: () => void;
className?: string;
size?: "sm" | "md" | "lg";
}
export const MultimodalPreview: React.FC<MultimodalPreviewProps> = ({
block,
removable = false,
onRemove,
className,
size = "md",
}) => {
// Image block
if (
block.type === "image" &&
block.source_type === "base64" &&
typeof block.mime_type === "string" &&
block.mime_type.startsWith("image/")
) {
const url = `data:${block.mime_type};base64,${block.data}`;
let imgClass: string = "rounded-md object-cover h-16 w-16 text-lg";
if (size === "sm") imgClass = "rounded-md object-cover h-10 w-10 text-base";
if (size === "lg") imgClass = "rounded-md object-cover h-24 w-24 text-xl";
return (
<div className={cn("relative inline-block", className)}>
<Image
src={url}
alt={String(block.metadata?.name || "uploaded image")}
className={imgClass}
width={size === "sm" ? 16 : size === "md" ? 32 : 48}
height={size === "sm" ? 16 : size === "md" ? 32 : 48}
/>
{removable && (
<button
type="button"
className="absolute top-1 right-1 z-10 rounded-full bg-gray-500 text-white hover:bg-gray-700"
onClick={onRemove}
aria-label="Remove image"
>
<XIcon className="h-4 w-4" />
</button>
)}
</div>
);
}
// PDF block
if (
block.type === "file" &&
block.source_type === "base64" &&
block.mime_type === "application/pdf"
) {
const filename =
block.metadata?.filename || block.metadata?.name || "PDF file";
return (
<div
className={cn(
"relative flex items-start gap-2 rounded-md border bg-gray-100 px-3 py-2",
className,
)}
>
<div className="flex flex-shrink-0 flex-col items-start justify-start">
<File
className={cn(
"text-teal-700",
size === "sm" ? "h-5 w-5" : "h-7 w-7",
)}
/>
</div>
<span
className={cn("min-w-0 flex-1 text-sm break-all text-gray-800")}
style={{ wordBreak: "break-all", whiteSpace: "pre-wrap" }}
>
{String(filename)}
</span>
{removable && (
<button
type="button"
className="ml-2 self-start rounded-full bg-gray-200 p-1 text-teal-700 hover:bg-gray-300"
onClick={onRemove}
aria-label="Remove PDF"
>
<XIcon className="h-4 w-4" />
</button>
)}
</div>
);
}
// Fallback for unknown types
return (
<div
className={cn(
"flex items-center gap-2 rounded-md border bg-gray-100 px-3 py-2 text-gray-500",
className,
)}
>
<File className="h-5 w-5 flex-shrink-0" />
<span className="truncate text-xs">Unsupported file type</span>
{removable && (
<button
type="button"
className="ml-2 rounded-full bg-gray-200 p-1 text-gray-500 hover:bg-gray-300"
onClick={onRemove}
aria-label="Remove file"
>
<XIcon className="h-4 w-4" />
</button>
)}
</div>
);
};

View file

@ -0,0 +1,532 @@
import { HumanResponseWithEdits, SubmitType } from "../types";
import { Textarea } from "@/components/ui/textarea";
import React from "react";
import { haveArgsChanged, prettifyText } from "../utils";
import { Button } from "@/components/ui/button";
import { Undo2 } from "lucide-react";
import { MarkdownText } from "../../markdown-text";
import { ActionRequest, HumanInterrupt } from "@langchain/langgraph/prebuilt";
import { toast } from "sonner";
import { Separator } from "@/components/ui/separator";
function ResetButton({ handleReset }: { handleReset: () => void }) {
return (
<Button
onClick={handleReset}
variant="ghost"
className="flex items-center justify-center gap-2 text-gray-500 hover:text-red-500"
>
<Undo2 className="h-4 w-4" />
<span>Reset</span>
</Button>
);
}
function ArgsRenderer({ args }: { args: Record<string, any> }) {
return (
<div className="flex w-full flex-col items-start gap-6">
{Object.entries(args).map(([k, v]) => {
let value = "";
if (["string", "number"].includes(typeof v)) {
value = v.toString();
} else {
value = JSON.stringify(v, null);
}
return (
<div
key={`args-${k}`}
className="flex flex-col items-start gap-1"
>
<p className="text-sm leading-[18px] text-wrap text-gray-600">
{prettifyText(k)}:
</p>
<span className="w-full max-w-full rounded-xl bg-zinc-100 p-3 text-[13px] leading-[18px] text-black">
<MarkdownText>{value}</MarkdownText>
</span>
</div>
);
})}
</div>
);
}
interface InboxItemInputProps {
interruptValue: HumanInterrupt;
humanResponse: HumanResponseWithEdits[];
supportsMultipleMethods: boolean;
acceptAllowed: boolean;
hasEdited: boolean;
hasAddedResponse: boolean;
initialValues: Record<string, string>;
streaming: boolean;
streamFinished: boolean;
setHumanResponse: React.Dispatch<
React.SetStateAction<HumanResponseWithEdits[]>
>;
setSelectedSubmitType: React.Dispatch<
React.SetStateAction<SubmitType | undefined>
>;
setHasAddedResponse: React.Dispatch<React.SetStateAction<boolean>>;
setHasEdited: React.Dispatch<React.SetStateAction<boolean>>;
handleSubmit: (
e: React.MouseEvent<HTMLButtonElement, MouseEvent> | React.KeyboardEvent,
) => Promise<void>;
}
function ResponseComponent({
humanResponse,
streaming,
showArgsInResponse,
interruptValue,
onResponseChange,
handleSubmit,
}: {
humanResponse: HumanResponseWithEdits[];
streaming: boolean;
showArgsInResponse: boolean;
interruptValue: HumanInterrupt;
onResponseChange: (change: string, response: HumanResponseWithEdits) => void;
handleSubmit: (
e: React.MouseEvent<HTMLButtonElement, MouseEvent> | React.KeyboardEvent,
) => Promise<void>;
}) {
const res = humanResponse.find((r) => r.type === "response");
if (!res || typeof res.args !== "string") {
return null;
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault();
handleSubmit(e);
}
};
return (
<div className="flex w-full flex-col items-start gap-4 rounded-xl border-[1px] border-gray-300 p-6">
<div className="flex w-full items-center justify-between">
<p className="text-base font-semibold text-black">
Respond to assistant
</p>
<ResetButton
handleReset={() => {
onResponseChange("", res);
}}
/>
</div>
{showArgsInResponse && (
<ArgsRenderer args={interruptValue.action_request.args} />
)}
<div className="flex w-full flex-col items-start gap-[6px]">
<p className="min-w-fit text-sm font-medium">Response</p>
<Textarea
disabled={streaming}
value={res.args}
onChange={(e) => onResponseChange(e.target.value, res)}
onKeyDown={handleKeyDown}
rows={4}
placeholder="Your response here..."
/>
</div>
<div className="flex w-full items-center justify-end gap-2">
<Button
variant="brand"
disabled={streaming}
onClick={handleSubmit}
>
Send Response
</Button>
</div>
</div>
);
}
const Response = React.memo(ResponseComponent);
function AcceptComponent({
streaming,
actionRequestArgs,
handleSubmit,
}: {
streaming: boolean;
actionRequestArgs: Record<string, any>;
handleSubmit: (
e: React.MouseEvent<HTMLButtonElement, MouseEvent> | React.KeyboardEvent,
) => Promise<void>;
}) {
return (
<div className="flex w-full flex-col items-start gap-4 rounded-lg border-[1px] border-gray-300 p-6">
{actionRequestArgs && Object.keys(actionRequestArgs).length > 0 && (
<ArgsRenderer args={actionRequestArgs} />
)}
<Button
variant="brand"
disabled={streaming}
onClick={handleSubmit}
className="w-full"
>
Accept
</Button>
</div>
);
}
function EditAndOrAcceptComponent({
humanResponse,
streaming,
initialValues,
onEditChange,
handleSubmit,
interruptValue,
}: {
humanResponse: HumanResponseWithEdits[];
streaming: boolean;
initialValues: Record<string, string>;
interruptValue: HumanInterrupt;
onEditChange: (
text: string | string[],
response: HumanResponseWithEdits,
key: string | string[],
) => void;
handleSubmit: (
e: React.MouseEvent<HTMLButtonElement, MouseEvent> | React.KeyboardEvent,
) => Promise<void>;
}) {
const defaultRows = React.useRef<Record<string, number>>({});
const editResponse = humanResponse.find((r) => r.type === "edit");
const acceptResponse = humanResponse.find((r) => r.type === "accept");
if (
!editResponse ||
typeof editResponse.args !== "object" ||
!editResponse.args
) {
if (acceptResponse) {
return (
<AcceptComponent
actionRequestArgs={interruptValue.action_request.args}
streaming={streaming}
handleSubmit={handleSubmit}
/>
);
}
return null;
}
const header = editResponse.acceptAllowed ? "Edit/Accept" : "Edit";
let buttonText = "Submit";
if (editResponse.acceptAllowed && !editResponse.editsMade) {
buttonText = "Accept";
}
const handleReset = () => {
if (
!editResponse ||
typeof editResponse.args !== "object" ||
!editResponse.args ||
!editResponse.args.args
) {
return;
}
// use initialValues to reset the text areas
const keysToReset: string[] = [];
const valuesToReset: string[] = [];
Object.entries(initialValues).forEach(([k, v]) => {
if (k in (editResponse.args as Record<string, any>).args) {
const value = ["string", "number"].includes(typeof v)
? v
: JSON.stringify(v, null);
keysToReset.push(k);
valuesToReset.push(value);
}
});
if (keysToReset.length > 0 && valuesToReset.length > 0) {
onEditChange(valuesToReset, editResponse, keysToReset);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault();
handleSubmit(e);
}
};
return (
<div className="flex w-full flex-col items-start gap-4 rounded-lg border-[1px] border-gray-300 p-6">
<div className="flex w-full items-center justify-between">
<p className="text-base font-semibold text-black">{header}</p>
<ResetButton handleReset={handleReset} />
</div>
{Object.entries(editResponse.args.args).map(([k, v], idx) => {
const value = ["string", "number"].includes(typeof v)
? v
: JSON.stringify(v, null);
// Calculate the default number of rows by the total length of the initial value divided by 30
// or 8, whichever is greater. Stored in a ref to prevent re-rendering.
if (
defaultRows.current[k as keyof typeof defaultRows.current] ===
undefined
) {
defaultRows.current[k as keyof typeof defaultRows.current] = !v.length
? 3
: Math.max(v.length / 30, 7);
}
const numRows =
defaultRows.current[k as keyof typeof defaultRows.current] || 8;
return (
<div
className="flex h-full w-full flex-col items-start gap-1 px-[1px]"
key={`allow-edit-args--${k}-${idx}`}
>
<div className="flex w-full flex-col items-start gap-[6px]">
<p className="min-w-fit text-sm font-medium">{prettifyText(k)}</p>
<Textarea
disabled={streaming}
className="h-full"
value={value}
onChange={(e) => onEditChange(e.target.value, editResponse, k)}
onKeyDown={handleKeyDown}
rows={numRows}
/>
</div>
</div>
);
})}
<div className="flex w-full items-center justify-end gap-2">
<Button
variant="brand"
disabled={streaming}
onClick={handleSubmit}
>
{buttonText}
</Button>
</div>
</div>
);
}
const EditAndOrAccept = React.memo(EditAndOrAcceptComponent);
export function InboxItemInput({
interruptValue,
humanResponse,
streaming,
streamFinished,
supportsMultipleMethods,
acceptAllowed,
hasEdited,
hasAddedResponse,
initialValues,
setHumanResponse,
setSelectedSubmitType,
setHasEdited,
setHasAddedResponse,
handleSubmit,
}: InboxItemInputProps) {
const isEditAllowed = interruptValue.config.allow_edit;
const isResponseAllowed = interruptValue.config.allow_respond;
const hasArgs = Object.entries(interruptValue.action_request.args).length > 0;
const showArgsInResponse =
hasArgs && !isEditAllowed && !acceptAllowed && isResponseAllowed;
const showArgsOutsideActionCards =
hasArgs && !showArgsInResponse && !isEditAllowed && !acceptAllowed;
const onEditChange = (
change: string | string[],
response: HumanResponseWithEdits,
key: string | string[],
) => {
if (
(Array.isArray(change) && !Array.isArray(key)) ||
(!Array.isArray(change) && Array.isArray(key))
) {
toast.error("Error", {
description: "Something went wrong",
richColors: true,
closeButton: true,
});
return;
}
let valuesChanged = true;
if (typeof response.args === "object") {
const updatedArgs = { ...(response.args?.args || {}) };
if (Array.isArray(change) && Array.isArray(key)) {
// Handle array inputs by mapping corresponding values
change.forEach((value, index) => {
if (index < key.length) {
updatedArgs[key[index]] = value;
}
});
} else {
// Handle single value case
updatedArgs[key as string] = change as string;
}
const haveValuesChanged = haveArgsChanged(updatedArgs, initialValues);
valuesChanged = haveValuesChanged;
}
if (!valuesChanged) {
setHasEdited(false);
if (acceptAllowed) {
setSelectedSubmitType("accept");
} else if (hasAddedResponse) {
setSelectedSubmitType("response");
}
} else {
setSelectedSubmitType("edit");
setHasEdited(true);
}
setHumanResponse((prev) => {
if (typeof response.args !== "object" || !response.args) {
console.error(
"Mismatched response type",
!!response.args,
typeof response.args,
);
return prev;
}
const newEdit: HumanResponseWithEdits = {
type: response.type,
args: {
action: response.args.action,
args:
Array.isArray(change) && Array.isArray(key)
? {
...response.args.args,
...Object.fromEntries(key.map((k, i) => [k, change[i]])),
}
: {
...response.args.args,
[key as string]: change as string,
},
},
};
if (
prev.find(
(p) =>
p.type === response.type &&
typeof p.args === "object" &&
p.args?.action === (response.args as ActionRequest).action,
)
) {
return prev.map((p) => {
if (
p.type === response.type &&
typeof p.args === "object" &&
p.args?.action === (response.args as ActionRequest).action
) {
if (p.acceptAllowed) {
return {
...newEdit,
acceptAllowed: true,
editsMade: valuesChanged,
};
}
return newEdit;
}
return p;
});
} else {
throw new Error("No matching response found");
}
});
};
const onResponseChange = (
change: string,
response: HumanResponseWithEdits,
) => {
if (!change) {
setHasAddedResponse(false);
if (hasEdited) {
// The user has deleted their response, so we should set the submit type to
// `edit` if they've edited, or `accept` if it's allowed and they have not edited.
setSelectedSubmitType("edit");
} else if (acceptAllowed) {
setSelectedSubmitType("accept");
}
} else {
setSelectedSubmitType("response");
setHasAddedResponse(true);
}
setHumanResponse((prev) => {
const newResponse: HumanResponseWithEdits = {
type: response.type,
args: change,
};
if (prev.find((p) => p.type === response.type)) {
return prev.map((p) => {
if (p.type === response.type) {
if (p.acceptAllowed) {
return {
...newResponse,
acceptAllowed: true,
editsMade: !!change,
};
}
return newResponse;
}
return p;
});
} else {
throw new Error("No human response found for string response");
}
});
};
return (
<div className="flex w-full flex-col items-start justify-start gap-2">
{showArgsOutsideActionCards && (
<ArgsRenderer args={interruptValue.action_request.args} />
)}
<div className="flex w-full flex-col items-start gap-2">
<EditAndOrAccept
humanResponse={humanResponse}
streaming={streaming}
initialValues={initialValues}
interruptValue={interruptValue}
onEditChange={onEditChange}
handleSubmit={handleSubmit}
/>
{supportsMultipleMethods ? (
<div className="mx-auto mt-3 flex items-center gap-3">
<Separator className="w-[full]" />
<p className="text-sm text-gray-500">Or</p>
<Separator className="w-full" />
</div>
) : null}
<Response
humanResponse={humanResponse}
streaming={streaming}
showArgsInResponse={showArgsInResponse}
interruptValue={interruptValue}
onResponseChange={onResponseChange}
handleSubmit={handleSubmit}
/>
{streaming && <p className="text-sm text-gray-600">Running...</p>}
{streamFinished && (
<p className="text-base font-medium text-green-600">
Successfully finished Graph invocation.
</p>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,303 @@
import { ChevronRight, X, ChevronsDownUp, ChevronsUpDown } from "lucide-react";
import { useEffect, useState } from "react";
import {
baseMessageObject,
isArrayOfMessages,
prettifyText,
unknownToPrettyDate,
} from "../utils";
import { motion } from "framer-motion";
import { cn } from "@/lib/utils";
import { BaseMessage } from "@langchain/core/messages";
import { ToolCall } from "@langchain/core/messages/tool";
import { ToolCallTable } from "./tool-call-table";
import { Button } from "@/components/ui/button";
import { MarkdownText } from "../../markdown-text";
interface StateViewRecursiveProps {
value: unknown;
expanded?: boolean;
}
const messageTypeToLabel = (message: BaseMessage) => {
let type = "";
if ("type" in message) {
type = message.type as string;
} else {
type = message._getType();
}
switch (type) {
case "human":
return "User";
case "ai":
return "Assistant";
case "tool":
return "Tool";
case "System":
return "System";
default:
return "";
}
};
function MessagesRenderer({ messages }: { messages: BaseMessage[] }) {
return (
<div className="flex w-full flex-col gap-1">
{messages.map((msg, idx) => {
const messageTypeLabel = messageTypeToLabel(msg);
const content =
typeof msg.content === "string"
? msg.content
: JSON.stringify(msg.content, null);
return (
<div
key={msg.id ?? `message-${idx}`}
className="ml-2 flex w-full flex-col gap-[2px]"
>
<p className="font-medium text-gray-700">{messageTypeLabel}:</p>
{content && <MarkdownText>{content}</MarkdownText>}
{"tool_calls" in msg && msg.tool_calls ? (
<div className="flex w-full flex-col items-start gap-1">
{(msg.tool_calls as ToolCall[]).map((tc, idx) => (
<ToolCallTable
key={tc.id ?? `tool-call-${idx}`}
toolCall={tc}
/>
))}
</div>
) : null}
</div>
);
})}
</div>
);
}
function StateViewRecursive(props: StateViewRecursiveProps) {
const date = unknownToPrettyDate(props.value);
if (date) {
return <p className="font-light text-gray-600">{date}</p>;
}
if (["string", "number"].includes(typeof props.value)) {
return <MarkdownText>{props.value as string}</MarkdownText>;
}
if (typeof props.value === "boolean") {
return <MarkdownText>{JSON.stringify(props.value)}</MarkdownText>;
}
if (props.value == null) {
return <p className="font-light whitespace-pre-wrap text-gray-600">null</p>;
}
if (Array.isArray(props.value)) {
if (props.value.length > 0 && isArrayOfMessages(props.value)) {
return <MessagesRenderer messages={props.value} />;
}
const valueArray = props.value as unknown[];
return (
<div className="flex w-full flex-row items-start justify-start gap-1">
<span className="font-normal text-black">[</span>
{valueArray.map((item, idx) => {
const itemRenderValue = baseMessageObject(item);
return (
<div
key={`state-view-${idx}`}
className="flex w-full flex-row items-start whitespace-pre-wrap"
>
<StateViewRecursive value={itemRenderValue} />
{idx < valueArray?.length - 1 && (
<span className="font-normal text-black">,&nbsp;</span>
)}
</div>
);
})}
<span className="font-normal text-black">]</span>
</div>
);
}
if (typeof props.value === "object") {
if (Object.keys(props.value).length === 0) {
return <p className="font-light text-gray-600">{"{}"}</p>;
}
return (
<div className="relative ml-6 flex w-full flex-col items-start justify-start gap-1">
{/* Vertical line */}
<div className="absolute top-0 left-[-24px] h-full w-[1px] bg-gray-200" />
{Object.entries(props.value).map(([key, value], idx) => (
<div
key={`state-view-object-${key}-${idx}`}
className="relative w-full"
>
{/* Horizontal connector line */}
<div className="absolute top-[10px] left-[-20px] h-[1px] w-[18px] bg-gray-200" />
<StateViewObject
expanded={props.expanded}
keyName={key}
value={value}
/>
</div>
))}
</div>
);
}
}
function HasContentsEllipsis({ onClick }: { onClick?: () => void }) {
return (
<span
onClick={onClick}
className={cn(
"rounded-md p-[2px] font-mono text-[10px] leading-3",
"bg-gray-50 text-gray-600 hover:bg-gray-100 hover:text-gray-800",
"cursor-pointer transition-colors ease-in-out",
"inline-block -translate-y-[2px]",
)}
>
{"{...}"}
</span>
);
}
interface StateViewProps {
keyName: string;
value: unknown;
/**
* Whether or not to expand or collapse the view
* @default true
*/
expanded?: boolean;
}
export function StateViewObject(props: StateViewProps) {
const [expanded, setExpanded] = useState(false);
useEffect(() => {
if (props.expanded != null) {
setExpanded(props.expanded);
}
}, [props.expanded]);
return (
<div className="relative flex flex-row items-start justify-start gap-2 text-sm">
<motion.div
initial={false}
animate={{ rotate: expanded ? 90 : 0 }}
transition={{ duration: 0.2 }}
>
<div
onClick={() => setExpanded((prev) => !prev)}
className="flex h-5 w-5 cursor-pointer items-center justify-center rounded-md text-gray-500 transition-colors ease-in-out hover:bg-gray-100 hover:text-black"
>
<ChevronRight className="h-4 w-4" />
</div>
</motion.div>
<div className="flex w-full flex-col items-start justify-start gap-1">
<p className="font-normal text-black">
{prettifyText(props.keyName)}{" "}
{!expanded && (
<HasContentsEllipsis onClick={() => setExpanded((prev) => !prev)} />
)}
</p>
<motion.div
initial={false}
animate={{
height: expanded ? "auto" : 0,
opacity: expanded ? 1 : 0,
}}
transition={{
duration: 0.2,
ease: "easeInOut",
}}
style={{ overflow: "hidden" }}
className="relative w-full"
>
<StateViewRecursive
expanded={props.expanded}
value={props.value}
/>
</motion.div>
</div>
</div>
);
}
interface StateViewComponentProps {
values: Record<string, any>;
description: string | undefined;
handleShowSidePanel: (showState: boolean, showDescription: boolean) => void;
view: "description" | "state";
}
export function StateView({
handleShowSidePanel,
view,
values,
description,
}: StateViewComponentProps) {
const [expanded, setExpanded] = useState(false);
if (!values) {
return <div>No state found</div>;
}
return (
<div
className={cn(
"flex w-full flex-row gap-0",
view === "state" &&
"border-t-[1px] border-gray-100 lg:border-t-[0px] lg:border-l-[1px]",
)}
>
{view === "description" && (
<div className="pt-6 pb-2">
<MarkdownText>
{description ?? "No description provided"}
</MarkdownText>
</div>
)}
{view === "state" && (
<div className="flex flex-col items-start justify-start gap-1">
{Object.entries(values).map(([k, v], idx) => (
<StateViewObject
expanded={expanded}
key={`state-view-${k}-${idx}`}
keyName={k}
value={v}
/>
))}
</div>
)}
<div className="flex items-start justify-end gap-2">
{view === "state" && (
<Button
onClick={() => setExpanded((prev) => !prev)}
variant="ghost"
className="text-gray-600"
size="sm"
>
{expanded ? (
<ChevronsUpDown className="h-4 w-4" />
) : (
<ChevronsDownUp className="h-4 w-4" />
)}
</Button>
)}
<Button
onClick={() => handleShowSidePanel(false, false)}
variant="ghost"
className="text-gray-600"
size="sm"
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
);
}

View file

@ -0,0 +1,173 @@
import { Button } from "@/components/ui/button";
import { ThreadIdCopyable } from "./thread-id";
import { InboxItemInput } from "./inbox-item-input";
import useInterruptedActions from "../hooks/use-interrupted-actions";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import { useQueryState } from "nuqs";
import { constructOpenInStudioURL } from "../utils";
import { HumanInterrupt } from "@langchain/langgraph/prebuilt";
interface ThreadActionsViewProps {
interrupt: HumanInterrupt;
handleShowSidePanel: (showState: boolean, showDescription: boolean) => void;
showState: boolean;
showDescription: boolean;
}
function ButtonGroup({
handleShowState,
handleShowDescription,
showingState,
showingDescription,
}: {
handleShowState: () => void;
handleShowDescription: () => void;
showingState: boolean;
showingDescription: boolean;
}) {
return (
<div className="flex flex-row items-center justify-center gap-0">
<Button
variant="outline"
className={cn(
"rounded-l-md rounded-r-none border-r-[0px]",
showingState ? "text-black" : "bg-white",
)}
size="sm"
onClick={handleShowState}
>
State
</Button>
<Button
variant="outline"
className={cn(
"rounded-l-none rounded-r-md border-l-[0px]",
showingDescription ? "text-black" : "bg-white",
)}
size="sm"
onClick={handleShowDescription}
>
Description
</Button>
</div>
);
}
export function ThreadActionsView({
interrupt,
handleShowSidePanel,
showDescription,
showState,
}: ThreadActionsViewProps) {
const [threadId] = useQueryState("threadId");
const {
acceptAllowed,
hasEdited,
hasAddedResponse,
streaming,
supportsMultipleMethods,
streamFinished,
loading,
handleSubmit,
handleIgnore,
handleResolve,
setSelectedSubmitType,
setHasAddedResponse,
setHasEdited,
humanResponse,
setHumanResponse,
initialHumanInterruptEditValue,
} = useInterruptedActions({
interrupt,
});
const [apiUrl] = useQueryState("apiUrl");
const handleOpenInStudio = () => {
if (!apiUrl) {
toast.error("Error", {
description: "Please set the LangGraph deployment URL in settings.",
duration: 5000,
richColors: true,
closeButton: true,
});
return;
}
const studioUrl = constructOpenInStudioURL(apiUrl, threadId ?? undefined);
window.open(studioUrl, "_blank");
};
const threadTitle = interrupt.action_request.action || "Unknown";
const actionsDisabled = loading || streaming;
const ignoreAllowed = interrupt.config.allow_ignore;
return (
<div className="flex min-h-full w-full flex-col gap-9">
{/* Header */}
<div className="flex w-full flex-wrap items-center justify-between gap-3">
<div className="flex items-center justify-start gap-3">
<p className="text-2xl tracking-tighter text-pretty">{threadTitle}</p>
{threadId && <ThreadIdCopyable threadId={threadId} />}
</div>
<div className="flex flex-row items-center justify-start gap-2">
{apiUrl && (
<Button
size="sm"
variant="outline"
className="flex items-center gap-1 bg-white"
onClick={handleOpenInStudio}
>
Studio
</Button>
)}
<ButtonGroup
handleShowState={() => handleShowSidePanel(true, false)}
handleShowDescription={() => handleShowSidePanel(false, true)}
showingState={showState}
showingDescription={showDescription}
/>
</div>
</div>
<div className="flex w-full flex-row items-center justify-start gap-2">
<Button
variant="outline"
className="border-gray-500 bg-white font-normal text-gray-800"
onClick={handleResolve}
disabled={actionsDisabled}
>
Mark as Resolved
</Button>
{ignoreAllowed && (
<Button
variant="outline"
className="border-gray-500 bg-white font-normal text-gray-800"
onClick={handleIgnore}
disabled={actionsDisabled}
>
Ignore
</Button>
)}
</div>
{/* Actions */}
<InboxItemInput
acceptAllowed={acceptAllowed}
hasEdited={hasEdited}
hasAddedResponse={hasAddedResponse}
interruptValue={interrupt}
humanResponse={humanResponse}
initialValues={initialHumanInterruptEditValue.current}
setHumanResponse={setHumanResponse}
streaming={streaming}
streamFinished={streamFinished}
supportsMultipleMethods={supportsMultipleMethods}
setSelectedSubmitType={setSelectedSubmitType}
setHasAddedResponse={setHasAddedResponse}
setHasEdited={setHasEdited}
handleSubmit={handleSubmit}
/>
</div>
);
}

View file

@ -0,0 +1,84 @@
import { Copy, CopyCheck } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import React from "react";
import { motion, AnimatePresence } from "framer-motion";
import { TooltipIconButton } from "../../tooltip-icon-button";
export function ThreadIdTooltip({ threadId }: { threadId: string }) {
const firstThreeChars = threadId.slice(0, 3);
const lastThreeChars = threadId.slice(-3);
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<p className="rounded-md bg-gray-100 px-1 py-[2px] font-mono text-[10px] leading-[12px] tracking-tighter">
{firstThreeChars}...{lastThreeChars}
</p>
</TooltipTrigger>
<TooltipContent>
<ThreadIdCopyable threadId={threadId} />
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
export function ThreadIdCopyable({
threadId,
showUUID = false,
}: {
threadId: string;
showUUID?: boolean;
}) {
const [copied, setCopied] = React.useState(false);
const handleCopy = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.stopPropagation();
navigator.clipboard.writeText(threadId);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<TooltipIconButton
onClick={(e) => handleCopy(e)}
variant="ghost"
tooltip="Copy thread ID"
className="flex w-fit flex-grow-0 cursor-pointer items-center gap-1 rounded-md border-[1px] border-gray-200 p-1 hover:bg-gray-50/90"
>
<p className="font-mono text-xs">{showUUID ? threadId : "ID"}</p>
<AnimatePresence
mode="wait"
initial={false}
>
{copied ? (
<motion.div
key="check"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.15 }}
>
<CopyCheck className="h-3 max-h-3 w-3 max-w-3 text-green-500" />
</motion.div>
) : (
<motion.div
key="copy"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.15 }}
>
<Copy className="h-3 max-h-3 w-3 max-w-3 text-gray-500" />
</motion.div>
)}
</AnimatePresence>
</TooltipIconButton>
);
}

View file

@ -0,0 +1,51 @@
import { ToolCall } from "@langchain/core/messages/tool";
import { unknownToPrettyDate } from "../utils";
export function ToolCallTable({ toolCall }: { toolCall: ToolCall }) {
return (
<div className="max-w-full min-w-[300px] overflow-hidden rounded-lg border">
<table className="w-full border-collapse">
<thead>
<tr>
<th
className="bg-gray-100 px-2 py-0 text-left text-sm"
colSpan={2}
>
{toolCall.name}
</th>
</tr>
</thead>
<tbody>
{Object.entries(toolCall.args).map(([key, value]) => {
let valueStr = "";
if (["string", "number"].includes(typeof value)) {
valueStr = value.toString();
}
const date = unknownToPrettyDate(value);
if (date) {
valueStr = date;
}
try {
valueStr = valueStr || JSON.stringify(value, null);
} catch (_) {
// failed to stringify, just assign an empty string
valueStr = "";
}
return (
<tr
key={key}
className="border-t"
>
<td className="w-1/3 px-2 py-1 text-xs font-medium">{key}</td>
<td className="px-2 py-1 font-mono text-xs">{valueStr}</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}

View file

@ -0,0 +1,305 @@
import { HumanResponseWithEdits, SubmitType } from "../types";
import {
KeyboardEvent,
Dispatch,
SetStateAction,
MutableRefObject,
useState,
useRef,
useEffect,
} from "react";
import { createDefaultHumanResponse } from "../utils";
import { toast } from "sonner";
import { HumanInterrupt, HumanResponse } from "@langchain/langgraph/prebuilt";
import { END } from "@langchain/langgraph/web";
import { useStreamContext } from "@/providers/Stream";
interface UseInterruptedActionsInput {
interrupt: HumanInterrupt;
}
interface UseInterruptedActionsValue {
// Actions
handleSubmit: (
e: React.MouseEvent<HTMLButtonElement, MouseEvent> | KeyboardEvent,
) => Promise<void>;
handleIgnore: (
e: React.MouseEvent<HTMLButtonElement, MouseEvent>,
) => Promise<void>;
handleResolve: (
e: React.MouseEvent<HTMLButtonElement, MouseEvent>,
) => Promise<void>;
// State values
streaming: boolean;
streamFinished: boolean;
loading: boolean;
supportsMultipleMethods: boolean;
hasEdited: boolean;
hasAddedResponse: boolean;
acceptAllowed: boolean;
humanResponse: HumanResponseWithEdits[];
// State setters
setSelectedSubmitType: Dispatch<SetStateAction<SubmitType | undefined>>;
setHumanResponse: Dispatch<SetStateAction<HumanResponseWithEdits[]>>;
setHasAddedResponse: Dispatch<SetStateAction<boolean>>;
setHasEdited: Dispatch<SetStateAction<boolean>>;
// Refs
initialHumanInterruptEditValue: MutableRefObject<Record<string, string>>;
}
export default function useInterruptedActions({
interrupt,
}: UseInterruptedActionsInput): UseInterruptedActionsValue {
const thread = useStreamContext();
const [humanResponse, setHumanResponse] = useState<HumanResponseWithEdits[]>(
[],
);
const [loading, setLoading] = useState(false);
const [streaming, setStreaming] = useState(false);
const [streamFinished, setStreamFinished] = useState(false);
const initialHumanInterruptEditValue = useRef<Record<string, string>>({});
const [selectedSubmitType, setSelectedSubmitType] = useState<SubmitType>();
// Whether or not the user has edited any fields which allow editing.
const [hasEdited, setHasEdited] = useState(false);
// Whether or not the user has added a response.
const [hasAddedResponse, setHasAddedResponse] = useState(false);
const [acceptAllowed, setAcceptAllowed] = useState(false);
useEffect(() => {
try {
const { responses, defaultSubmitType, hasAccept } =
createDefaultHumanResponse(interrupt, initialHumanInterruptEditValue);
setSelectedSubmitType(defaultSubmitType);
setHumanResponse(responses);
setAcceptAllowed(hasAccept);
} catch (e) {
console.error("Error formatting and setting human response state", e);
}
}, [interrupt]);
const resumeRun = (response: HumanResponse[]): boolean => {
try {
thread.submit(
{},
{
command: {
resume: response,
},
},
);
return true;
} catch (e: any) {
console.error("Error sending human response", e);
return false;
}
};
const handleSubmit = async (
e: React.MouseEvent<HTMLButtonElement, MouseEvent> | KeyboardEvent,
) => {
e.preventDefault();
if (!humanResponse) {
toast.error("Error", {
description: "Please enter a response.",
duration: 5000,
richColors: true,
closeButton: true,
});
return;
}
let errorOccurred = false;
initialHumanInterruptEditValue.current = {};
if (
humanResponse.some((r) => ["response", "edit", "accept"].includes(r.type))
) {
setStreamFinished(false);
try {
const humanResponseInput: HumanResponse[] = humanResponse.flatMap(
(r) => {
if (r.type === "edit") {
if (r.acceptAllowed && !r.editsMade) {
return {
type: "accept",
args: r.args,
};
} else {
return {
type: "edit",
args: r.args,
};
}
}
if (r.type === "response" && !r.args) {
// If response was allowed but no response was given, do not include in the response
return [];
}
return {
type: r.type,
args: r.args,
};
},
);
const input = humanResponseInput.find(
(r) => r.type === selectedSubmitType,
);
if (!input) {
toast.error("Error", {
description: "No response found.",
richColors: true,
closeButton: true,
duration: 5000,
});
return;
}
setLoading(true);
setStreaming(true);
const resumedSuccessfully = resumeRun([input]);
if (!resumedSuccessfully) {
// This will only be undefined if the graph ID is not found
// in this case, the method will trigger a toast for us.
return;
}
toast("Success", {
description: "Response submitted successfully.",
duration: 5000,
});
if (!errorOccurred) {
setStreamFinished(true);
}
} catch (e: any) {
console.error("Error sending human response", e);
if ("message" in e && e.message.includes("Invalid assistant ID")) {
toast("Error: Invalid assistant ID", {
description:
"The provided assistant ID was not found in this graph. Please update the assistant ID in the settings and try again.",
richColors: true,
closeButton: true,
duration: 5000,
});
} else {
toast.error("Error", {
description: "Failed to submit response.",
richColors: true,
closeButton: true,
duration: 5000,
});
}
errorOccurred = true;
setStreaming(false);
setStreamFinished(false);
}
if (!errorOccurred) {
setStreaming(false);
setStreamFinished(false);
}
} else {
setLoading(true);
resumeRun(humanResponse);
toast("Success", {
description: "Response submitted successfully.",
duration: 5000,
});
}
setLoading(false);
};
const handleIgnore = async (
e: React.MouseEvent<HTMLButtonElement, MouseEvent>,
) => {
e.preventDefault();
const ignoreResponse = humanResponse.find((r) => r.type === "ignore");
if (!ignoreResponse) {
toast.error("Error", {
description: "The selected thread does not support ignoring.",
duration: 5000,
});
return;
}
setLoading(true);
initialHumanInterruptEditValue.current = {};
resumeRun([ignoreResponse]);
setLoading(false);
toast("Successfully ignored thread", {
duration: 5000,
});
};
const handleResolve = async (
e: React.MouseEvent<HTMLButtonElement, MouseEvent>,
) => {
e.preventDefault();
setLoading(true);
initialHumanInterruptEditValue.current = {};
try {
thread.submit(
{},
{
command: {
goto: END,
},
},
);
toast("Success", {
description: "Marked thread as resolved.",
duration: 3000,
});
} catch (e) {
console.error("Error marking thread as resolved", e);
toast.error("Error", {
description: "Failed to mark thread as resolved.",
richColors: true,
closeButton: true,
duration: 3000,
});
}
setLoading(false);
};
const supportsMultipleMethods =
humanResponse.filter(
(r) => r.type === "edit" || r.type === "accept" || r.type === "response",
).length > 1;
return {
handleSubmit,
handleIgnore,
handleResolve,
humanResponse,
streaming,
streamFinished,
loading,
supportsMultipleMethods,
hasEdited,
hasAddedResponse,
acceptAllowed,
setSelectedSubmitType,
setHumanResponse,
setHasAddedResponse,
setHasEdited,
initialHumanInterruptEditValue,
};
}

View file

@ -0,0 +1,57 @@
import { StateView } from "./components/state-view";
import { ThreadActionsView } from "./components/thread-actions-view";
import { useState } from "react";
import { HumanInterrupt } from "@langchain/langgraph/prebuilt";
import { useStreamContext } from "@/providers/Stream";
interface ThreadViewProps {
interrupt: HumanInterrupt | HumanInterrupt[];
}
export function ThreadView({ interrupt }: ThreadViewProps) {
const interruptObj = Array.isArray(interrupt) ? interrupt[0] : interrupt;
const thread = useStreamContext();
const [showDescription, setShowDescription] = useState(false);
const [showState, setShowState] = useState(false);
const showSidePanel = showDescription || showState;
const handleShowSidePanel = (
showState: boolean,
showDescription: boolean,
) => {
if (showState && showDescription) {
console.error("Cannot show both state and description");
return;
}
if (showState) {
setShowDescription(false);
setShowState(true);
} else if (showDescription) {
setShowState(false);
setShowDescription(true);
} else {
setShowState(false);
setShowDescription(false);
}
};
return (
<div className="flex h-[80vh] w-full flex-col overflow-y-scroll rounded-2xl bg-gray-50/50 p-8 lg:flex-row [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-track]:bg-transparent">
{showSidePanel ? (
<StateView
handleShowSidePanel={handleShowSidePanel}
description={interruptObj.description}
values={thread.values}
view={showState ? "state" : "description"}
/>
) : (
<ThreadActionsView
interrupt={interruptObj}
handleShowSidePanel={handleShowSidePanel}
showState={showState}
showDescription={showDescription}
/>
)}
</div>
);
}

View file

@ -0,0 +1,72 @@
import { BaseMessage } from "@langchain/core/messages";
import { Thread, ThreadStatus } from "@langchain/langgraph-sdk";
import { HumanInterrupt, HumanResponse } from "@langchain/langgraph/prebuilt";
export type HumanResponseWithEdits = HumanResponse &
(
| { acceptAllowed?: false; editsMade?: never }
| { acceptAllowed?: true; editsMade?: boolean }
);
export type Email = {
id: string;
thread_id: string;
from_email: string;
to_email: string;
subject: string;
page_content: string;
send_time: string | undefined;
read?: boolean;
status?: "in-queue" | "processing" | "hitl" | "done";
};
export interface ThreadValues {
email: Email;
messages: BaseMessage[];
triage: {
logic: string;
response: string;
};
}
export type ThreadData<
ThreadValues extends Record<string, any> = Record<string, any>,
> = {
thread: Thread<ThreadValues>;
} & (
| {
status: "interrupted";
interrupts: HumanInterrupt[] | undefined;
}
| {
status: "idle" | "busy" | "error";
interrupts?: never;
}
);
export type ThreadStatusWithAll = ThreadStatus | "all";
export type SubmitType = "accept" | "response" | "edit";
export interface AgentInbox {
/**
* A unique identifier for the inbox.
*/
id: string;
/**
* The ID of the graph.
*/
graphId: string;
/**
* The URL of the deployment. Either a localhost URL, or a deployment URL.
*/
deploymentUrl: string;
/**
* Optional name for the inbox, used in the UI to label the inbox.
*/
name?: string;
/**
* Whether or not the inbox is selected.
*/
selected: boolean;
}

View file

@ -0,0 +1,223 @@
import { BaseMessage, isBaseMessage } from "@langchain/core/messages";
import { format } from "date-fns";
import { startCase } from "lodash";
import { HumanResponseWithEdits, SubmitType } from "./types";
import { HumanInterrupt } from "@langchain/langgraph/prebuilt";
export function prettifyText(action: string) {
return startCase(action.replace(/_/g, " "));
}
export function isArrayOfMessages(
value: Record<string, any>[],
): value is BaseMessage[] {
if (
value.every(isBaseMessage) ||
(Array.isArray(value) &&
value.every(
(v) =>
typeof v === "object" &&
"id" in v &&
"type" in v &&
"content" in v &&
"additional_kwargs" in v,
))
) {
return true;
}
return false;
}
export function baseMessageObject(item: unknown): string {
if (isBaseMessage(item)) {
const contentText =
typeof item.content === "string"
? item.content
: JSON.stringify(item.content, null);
let toolCallText = "";
if ("tool_calls" in item) {
toolCallText = JSON.stringify(item.tool_calls, null);
}
if ("type" in item) {
return `${item.type}:${contentText ? ` ${contentText}` : ""}${toolCallText ? ` - Tool calls: ${toolCallText}` : ""}`;
} else if ("_getType" in item) {
return `${item._getType()}:${contentText ? ` ${contentText}` : ""}${toolCallText ? ` - Tool calls: ${toolCallText}` : ""}`;
}
} else if (
typeof item === "object" &&
item &&
"type" in item &&
"content" in item
) {
const contentText =
typeof item.content === "string"
? item.content
: JSON.stringify(item.content, null);
let toolCallText = "";
if ("tool_calls" in item) {
toolCallText = JSON.stringify(item.tool_calls, null);
}
return `${item.type}:${contentText ? ` ${contentText}` : ""}${toolCallText ? ` - Tool calls: ${toolCallText}` : ""}`;
}
if (typeof item === "object") {
return JSON.stringify(item, null);
} else {
return item as string;
}
}
export function unknownToPrettyDate(input: unknown): string | undefined {
try {
if (
Object.prototype.toString.call(input) === "[object Date]" ||
new Date(input as string)
) {
return format(new Date(input as string), "MM/dd/yyyy hh:mm a");
}
} catch (_) {
// failed to parse date. no-op
}
return undefined;
}
export function createDefaultHumanResponse(
interrupt: HumanInterrupt,
initialHumanInterruptEditValue: React.MutableRefObject<
Record<string, string>
>,
): {
responses: HumanResponseWithEdits[];
defaultSubmitType: SubmitType | undefined;
hasAccept: boolean;
} {
const responses: HumanResponseWithEdits[] = [];
if (interrupt.config.allow_edit) {
if (interrupt.config.allow_accept) {
Object.entries(interrupt.action_request.args).forEach(([k, v]) => {
let stringValue = "";
if (typeof v === "string") {
stringValue = v;
} else {
stringValue = JSON.stringify(v, null);
}
if (
!initialHumanInterruptEditValue.current ||
!(k in initialHumanInterruptEditValue.current)
) {
initialHumanInterruptEditValue.current = {
...initialHumanInterruptEditValue.current,
[k]: stringValue,
};
} else if (
k in initialHumanInterruptEditValue.current &&
initialHumanInterruptEditValue.current[k] !== stringValue
) {
console.error(
"KEY AND VALUE FOUND IN initialHumanInterruptEditValue.current THAT DOES NOT MATCH THE ACTION REQUEST",
{
key: k,
value: stringValue,
expectedValue: initialHumanInterruptEditValue.current[k],
},
);
}
});
responses.push({
type: "edit",
args: interrupt.action_request,
acceptAllowed: true,
editsMade: false,
});
} else {
responses.push({
type: "edit",
args: interrupt.action_request,
acceptAllowed: false,
});
}
}
if (interrupt.config.allow_respond) {
responses.push({
type: "response",
args: "",
});
}
if (interrupt.config.allow_ignore) {
responses.push({
type: "ignore",
args: null,
});
}
// Set the submit type.
// Priority: accept > response > edit
const acceptAllowedConfig = interrupt.config.allow_accept;
const ignoreAllowedConfig = interrupt.config.allow_ignore;
const hasResponse = responses.find((r) => r.type === "response");
const hasAccept =
responses.find((r) => r.acceptAllowed) || acceptAllowedConfig;
const hasEdit = responses.find((r) => r.type === "edit");
let defaultSubmitType: SubmitType | undefined;
if (hasAccept) {
defaultSubmitType = "accept";
} else if (hasResponse) {
defaultSubmitType = "response";
} else if (hasEdit) {
defaultSubmitType = "edit";
}
if (acceptAllowedConfig && !responses.find((r) => r.type === "accept")) {
responses.push({
type: "accept",
args: null,
});
}
if (ignoreAllowedConfig && !responses.find((r) => r.type === "ignore")) {
responses.push({
type: "ignore",
args: null,
});
}
return { responses, defaultSubmitType, hasAccept: !!hasAccept };
}
export function constructOpenInStudioURL(
deploymentUrl: string,
threadId?: string,
) {
const smithStudioURL = new URL("https://smith.langchain.com/studio/thread");
// trim the trailing slash from deploymentUrl
const trimmedDeploymentUrl = deploymentUrl.replace(/\/$/, "");
if (threadId) {
smithStudioURL.pathname += `/${threadId}`;
}
smithStudioURL.searchParams.append("baseUrl", trimmedDeploymentUrl);
return smithStudioURL.toString();
}
export function haveArgsChanged(
args: unknown,
initialValues: Record<string, string>,
): boolean {
if (typeof args !== "object" || !args) {
return false;
}
const currentValues = args as Record<string, string>;
return Object.entries(currentValues).some(([key, value]) => {
const valueString = ["string", "number"].includes(typeof value)
? value.toString()
: JSON.stringify(value, null);
return initialValues[key] !== valueString;
});
}

View file

@ -0,0 +1,189 @@
import {
HTMLAttributes,
ReactNode,
createContext,
useCallback,
useContext,
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
type Setter<T> = (value: T | ((value: T) => T)) => void;
const ArtifactSlotContext = createContext<{
open: [string | null, Setter<string | null>];
mounted: [string | null, Setter<string | null>];
title: [HTMLElement | null, Setter<HTMLElement | null>];
content: [HTMLElement | null, Setter<HTMLElement | null>];
context: [Record<string, unknown>, Setter<Record<string, unknown>>];
}>(null!);
/**
* Headless component that will obtain the title and content of the artifact
* and render them in place of the `ArtifactContent` and `ArtifactTitle` components via
* React Portals.
*/
const ArtifactSlot = (props: {
id: string;
children?: ReactNode;
title?: ReactNode;
}) => {
const context = useContext(ArtifactSlotContext);
const [ctxMounted, ctxSetMounted] = context.mounted;
const [content] = context.content;
const [title] = context.title;
const isMounted = ctxMounted === props.id;
const isEmpty = props.children == null && props.title == null;
useEffect(() => {
if (isEmpty) {
ctxSetMounted((open) => (open === props.id ? null : open));
}
}, [isEmpty, ctxSetMounted, props.id]);
if (!isMounted) return null;
return (
<>
{title != null ? createPortal(<>{props.title}</>, title) : null}
{content != null ? createPortal(<>{props.children}</>, content) : null}
</>
);
};
export function ArtifactContent(props: HTMLAttributes<HTMLDivElement>) {
const context = useContext(ArtifactSlotContext);
const [mounted] = context.mounted;
const ref = useRef<HTMLDivElement>(null);
const [, setStateRef] = context.content;
useLayoutEffect(
() => setStateRef?.(mounted ? ref.current : null),
[setStateRef, mounted],
);
if (!mounted) return null;
return (
<div
{...props}
ref={ref}
/>
);
}
export function ArtifactTitle(props: HTMLAttributes<HTMLDivElement>) {
const context = useContext(ArtifactSlotContext);
const ref = useRef<HTMLDivElement>(null);
const [, setStateRef] = context.title;
useLayoutEffect(() => setStateRef?.(ref.current), [setStateRef]);
return (
<div
{...props}
ref={ref}
/>
);
}
export function ArtifactProvider(props: { children?: ReactNode }) {
const content = useState<HTMLElement | null>(null);
const title = useState<HTMLElement | null>(null);
const open = useState<string | null>(null);
const mounted = useState<string | null>(null);
const context = useState<Record<string, unknown>>({});
return (
<ArtifactSlotContext.Provider
value={{ open, mounted, title, content, context }}
>
{props.children}
</ArtifactSlotContext.Provider>
);
}
/**
* Provides a value to be passed into `meta.artifact` field
* of the `LoadExternalComponent` component, to be consumed by the `useArtifact` hook
* on the generative UI side.
*/
export function useArtifact() {
const id = useId();
const context = useContext(ArtifactSlotContext);
const [ctxOpen, ctxSetOpen] = context.open;
const [ctxContext, ctxSetContext] = context.context;
const [, ctxSetMounted] = context.mounted;
const open = ctxOpen === id;
const setOpen = useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
if (typeof value === "boolean") {
ctxSetOpen(value ? id : null);
} else {
ctxSetOpen((open) => (open === id ? null : id));
}
ctxSetMounted(id);
},
[ctxSetOpen, ctxSetMounted, id],
);
const ArtifactContent = useCallback(
(props: { title?: React.ReactNode; children: React.ReactNode }) => {
return (
<ArtifactSlot
id={id}
title={props.title}
>
{props.children}
</ArtifactSlot>
);
},
[id],
);
return [
ArtifactContent,
{ open, setOpen, context: ctxContext, setContext: ctxSetContext },
] as [
typeof ArtifactContent,
{
open: typeof open;
setOpen: typeof setOpen;
context: typeof ctxContext;
setContext: typeof ctxSetContext;
},
];
}
/**
* General hook for detecting if any artifact is open.
*/
export function useArtifactOpen() {
const context = useContext(ArtifactSlotContext);
const [ctxOpen, setCtxOpen] = context.open;
const open = ctxOpen !== null;
const onClose = useCallback(() => setCtxOpen(null), [setCtxOpen]);
return [open, onClose] as const;
}
/**
* Artifacts may at their discretion provide additional context
* that will be used when creating a new run.
*/
export function useArtifactContext() {
const context = useContext(ArtifactSlotContext);
return context.context;
}

View file

@ -0,0 +1,146 @@
import { Button } from "@/components/ui/button";
import { useThreads } from "@/providers/Thread";
import { Thread } from "@langchain/langgraph-sdk";
import { useEffect } from "react";
import { getContentString } from "../utils";
import { useQueryState, parseAsBoolean } from "nuqs";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { PanelRightOpen, PanelRightClose } from "lucide-react";
import { useMediaQuery } from "@/hooks/useMediaQuery";
function ThreadList({
threads,
onThreadClick,
}: {
threads: Thread[];
onThreadClick?: (threadId: string) => void;
}) {
const [threadId, setThreadId] = useQueryState("threadId");
return (
<div className="flex h-full w-full flex-col items-start justify-start gap-2 overflow-y-scroll [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-track]:bg-transparent">
{threads.map((t) => {
let itemText = t.thread_id;
if (
typeof t.values === "object" &&
t.values &&
"messages" in t.values &&
Array.isArray(t.values.messages) &&
t.values.messages?.length > 0
) {
const firstMessage = t.values.messages[0];
itemText = getContentString(firstMessage.content);
}
return (
<div
key={t.thread_id}
className="w-full px-1"
>
<Button
variant="ghost"
className="w-[280px] items-start justify-start text-left font-normal"
onClick={(e) => {
e.preventDefault();
onThreadClick?.(t.thread_id);
if (t.thread_id === threadId) return;
setThreadId(t.thread_id);
}}
>
<p className="truncate text-ellipsis">{itemText}</p>
</Button>
</div>
);
})}
</div>
);
}
function ThreadHistoryLoading() {
return (
<div className="flex h-full w-full flex-col items-start justify-start gap-2 overflow-y-scroll [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-track]:bg-transparent">
{Array.from({ length: 30 }).map((_, i) => (
<Skeleton
key={`skeleton-${i}`}
className="h-10 w-[280px]"
/>
))}
</div>
);
}
export default function ThreadHistory() {
const isLargeScreen = useMediaQuery("(min-width: 1024px)");
const [chatHistoryOpen, setChatHistoryOpen] = useQueryState(
"chatHistoryOpen",
parseAsBoolean.withDefault(false),
);
const { getThreads, threads, setThreads, threadsLoading, setThreadsLoading } =
useThreads();
useEffect(() => {
if (typeof window === "undefined") return;
setThreadsLoading(true);
getThreads()
.then(setThreads)
.catch(console.error)
.finally(() => setThreadsLoading(false));
}, []);
return (
<>
<div className="shadow-inner-right hidden h-screen w-[300px] shrink-0 flex-col items-start justify-start gap-6 border-r-[1px] border-slate-300 lg:flex">
<div className="flex w-full items-center justify-between px-4 pt-1.5">
<Button
className="hover:bg-gray-100"
variant="ghost"
onClick={() => setChatHistoryOpen((p) => !p)}
>
{chatHistoryOpen ? (
<PanelRightOpen className="size-5" />
) : (
<PanelRightClose className="size-5" />
)}
</Button>
<h1 className="text-xl font-semibold tracking-tight">
Thread History
</h1>
</div>
{threadsLoading ? (
<ThreadHistoryLoading />
) : (
<ThreadList threads={threads} />
)}
</div>
<div className="lg:hidden">
<Sheet
open={!!chatHistoryOpen && !isLargeScreen}
onOpenChange={(open) => {
if (isLargeScreen) return;
setChatHistoryOpen(open);
}}
>
<SheetContent
side="left"
className="flex lg:hidden"
>
<SheetHeader>
<SheetTitle>Thread History</SheetTitle>
</SheetHeader>
<ThreadList
threads={threads}
onThreadClick={() => setChatHistoryOpen((o) => !o)}
/>
</SheetContent>
</Sheet>
</div>
</>
);
}

View file

@ -0,0 +1,562 @@
import { v4 as uuidv4 } from "uuid";
import { ReactNode, useEffect, useRef } from "react";
import { motion } from "framer-motion";
import { cn } from "@/lib/utils";
import { useStreamContext } from "@/providers/Stream";
import { useState, FormEvent } from "react";
import { Button } from "../ui/button";
import { Checkpoint, Message } from "@langchain/langgraph-sdk";
import { AssistantMessage, AssistantMessageLoading } from "./messages/ai";
import { HumanMessage } from "./messages/human";
import {
DO_NOT_RENDER_ID_PREFIX,
ensureToolCallsHaveResponses,
} from "@/lib/ensure-tool-responses";
import { LangGraphLogoSVG } from "../icons/langgraph";
import { TooltipIconButton } from "./tooltip-icon-button";
import {
ArrowDown,
LoaderCircle,
PanelRightOpen,
PanelRightClose,
SquarePen,
XIcon,
Plus,
CircleX,
} from "lucide-react";
import { useQueryState, parseAsBoolean } from "nuqs";
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
import ThreadHistory from "./history";
import { toast } from "sonner";
import { useMediaQuery } from "@/hooks/useMediaQuery";
import { Label } from "../ui/label";
import { Switch } from "../ui/switch";
import { GitHubSVG } from "../icons/github";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "../ui/tooltip";
import { useFileUpload } from "@/hooks/use-file-upload";
import { ContentBlocksPreview } from "./ContentBlocksPreview";
import {
useArtifactOpen,
ArtifactContent,
ArtifactTitle,
useArtifactContext,
} from "./artifact";
function StickyToBottomContent(props: {
content: ReactNode;
footer?: ReactNode;
className?: string;
contentClassName?: string;
}) {
const context = useStickToBottomContext();
return (
<div
ref={context.scrollRef}
style={{ width: "100%", height: "100%" }}
className={props.className}
>
<div
ref={context.contentRef}
className={props.contentClassName}
>
{props.content}
</div>
{props.footer}
</div>
);
}
function ScrollToBottom(props: { className?: string }) {
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
if (isAtBottom) return null;
return (
<Button
variant="outline"
className={props.className}
onClick={() => scrollToBottom()}
>
<ArrowDown className="h-4 w-4" />
<span>Scroll to bottom</span>
</Button>
);
}
function OpenGitHubRepo() {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<a
href="https://github.com/langchain-ai/agent-chat-ui"
target="_blank"
className="flex items-center justify-center"
>
<GitHubSVG
width="24"
height="24"
/>
</a>
</TooltipTrigger>
<TooltipContent side="left">
<p>Open GitHub repo</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
export function Thread() {
const [artifactContext, setArtifactContext] = useArtifactContext();
const [artifactOpen, closeArtifact] = useArtifactOpen();
const [threadId, _setThreadId] = useQueryState("threadId");
const [chatHistoryOpen, setChatHistoryOpen] = useQueryState(
"chatHistoryOpen",
parseAsBoolean.withDefault(false),
);
const [hideToolCalls, setHideToolCalls] = useQueryState(
"hideToolCalls",
parseAsBoolean.withDefault(false),
);
const [input, setInput] = useState("");
const {
contentBlocks,
setContentBlocks,
handleFileUpload,
dropRef,
removeBlock,
resetBlocks,
dragOver,
handlePaste,
} = useFileUpload();
const [firstTokenReceived, setFirstTokenReceived] = useState(false);
const isLargeScreen = useMediaQuery("(min-width: 1024px)");
const stream = useStreamContext();
const messages = stream.messages;
const isLoading = stream.isLoading;
const lastError = useRef<string | undefined>(undefined);
const setThreadId = (id: string | null) => {
_setThreadId(id);
// close artifact and reset artifact context
closeArtifact();
setArtifactContext({});
};
useEffect(() => {
if (!stream.error) {
lastError.current = undefined;
return;
}
try {
const message = (stream.error as any).message;
if (!message || lastError.current === message) {
// Message has already been logged. do not modify ref, return early.
return;
}
// Message is defined, and it has not been logged yet. Save it, and send the error
lastError.current = message;
toast.error("An error occurred. Please try again.", {
description: (
<p>
<strong>Error:</strong> <code>{message}</code>
</p>
),
richColors: true,
closeButton: true,
});
} catch {
// no-op
}
}, [stream.error]);
// TODO: this should be part of the useStream hook
const prevMessageLength = useRef(0);
useEffect(() => {
if (
messages.length !== prevMessageLength.current &&
messages?.length &&
messages[messages.length - 1].type === "ai"
) {
setFirstTokenReceived(true);
}
prevMessageLength.current = messages.length;
}, [messages]);
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
if ((input.trim().length === 0 && contentBlocks.length === 0) || isLoading)
return;
setFirstTokenReceived(false);
const newHumanMessage: Message = {
id: uuidv4(),
type: "human",
content: [
...(input.trim().length > 0 ? [{ type: "text", text: input }] : []),
...contentBlocks,
] as Message["content"],
};
const toolMessages = ensureToolCallsHaveResponses(stream.messages);
const context =
Object.keys(artifactContext).length > 0 ? artifactContext : undefined;
stream.submit(
{ messages: [...toolMessages, newHumanMessage], context },
{
streamMode: ["values"],
optimisticValues: (prev) => ({
...prev,
context,
messages: [
...(prev.messages ?? []),
...toolMessages,
newHumanMessage,
],
}),
},
);
setInput("");
setContentBlocks([]);
};
const handleRegenerate = (
parentCheckpoint: Checkpoint | null | undefined,
) => {
// Do this so the loading state is correct
prevMessageLength.current = prevMessageLength.current - 1;
setFirstTokenReceived(false);
stream.submit(undefined, {
checkpoint: parentCheckpoint,
streamMode: ["values"],
});
};
const chatStarted = !!threadId || !!messages.length;
const hasNoAIOrToolMessages = !messages.find(
(m) => m.type === "ai" || m.type === "tool",
);
return (
<div className="flex h-screen w-full overflow-hidden">
<div className="relative hidden lg:flex">
<motion.div
className="absolute z-20 h-full overflow-hidden border-r bg-white"
style={{ width: 300 }}
animate={
isLargeScreen
? { x: chatHistoryOpen ? 0 : -300 }
: { x: chatHistoryOpen ? 0 : -300 }
}
initial={{ x: -300 }}
transition={
isLargeScreen
? { type: "spring", stiffness: 300, damping: 30 }
: { duration: 0 }
}
>
<div
className="relative h-full"
style={{ width: 300 }}
>
<ThreadHistory />
</div>
</motion.div>
</div>
<div
className={cn(
"grid w-full grid-cols-[1fr_0fr] transition-all duration-500",
artifactOpen && "grid-cols-[3fr_2fr]",
)}
>
<motion.div
className={cn(
"relative flex min-w-0 flex-1 flex-col overflow-hidden",
!chatStarted && "grid-rows-[1fr]",
)}
layout={isLargeScreen}
animate={{
marginLeft: chatHistoryOpen ? (isLargeScreen ? 300 : 0) : 0,
width: chatHistoryOpen
? isLargeScreen
? "calc(100% - 300px)"
: "100%"
: "100%",
}}
transition={
isLargeScreen
? { type: "spring", stiffness: 300, damping: 30 }
: { duration: 0 }
}
>
{!chatStarted && (
<div className="absolute top-0 left-0 z-10 flex w-full items-center justify-between gap-3 p-2 pl-4">
<div>
{(!chatHistoryOpen || !isLargeScreen) && (
<Button
className="hover:bg-gray-100"
variant="ghost"
onClick={() => setChatHistoryOpen((p) => !p)}
>
{chatHistoryOpen ? (
<PanelRightOpen className="size-5" />
) : (
<PanelRightClose className="size-5" />
)}
</Button>
)}
</div>
<div className="absolute top-2 right-4 flex items-center">
<OpenGitHubRepo />
</div>
</div>
)}
{chatStarted && (
<div className="relative z-10 flex items-center justify-between gap-3 p-2">
<div className="relative flex items-center justify-start gap-2">
<div className="absolute left-0 z-10">
{(!chatHistoryOpen || !isLargeScreen) && (
<Button
className="hover:bg-gray-100"
variant="ghost"
onClick={() => setChatHistoryOpen((p) => !p)}
>
{chatHistoryOpen ? (
<PanelRightOpen className="size-5" />
) : (
<PanelRightClose className="size-5" />
)}
</Button>
)}
</div>
<motion.button
className="flex cursor-pointer items-center gap-2"
onClick={() => setThreadId(null)}
animate={{
marginLeft: !chatHistoryOpen ? 48 : 0,
}}
transition={{
type: "spring",
stiffness: 300,
damping: 30,
}}
>
<LangGraphLogoSVG
width={32}
height={32}
/>
<span className="text-xl font-semibold tracking-tight">
Agent Chat
</span>
</motion.button>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center">
<OpenGitHubRepo />
</div>
<TooltipIconButton
size="lg"
className="p-4"
tooltip="New thread"
variant="ghost"
onClick={() => setThreadId(null)}
>
<SquarePen className="size-5" />
</TooltipIconButton>
</div>
<div className="from-background to-background/0 absolute inset-x-0 top-full h-5 bg-gradient-to-b" />
</div>
)}
<StickToBottom className="relative flex-1 overflow-hidden">
<StickyToBottomContent
className={cn(
"absolute inset-0 overflow-y-scroll px-4 [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-track]:bg-transparent",
!chatStarted && "mt-[25vh] flex flex-col items-stretch",
chatStarted && "grid grid-rows-[1fr_auto]",
)}
contentClassName="pt-8 pb-16 max-w-3xl mx-auto flex flex-col gap-4 w-full"
content={
<>
{messages
.filter((m) => !m.id?.startsWith(DO_NOT_RENDER_ID_PREFIX))
.map((message, index) =>
message.type === "human" ? (
<HumanMessage
key={message.id || `${message.type}-${index}`}
message={message}
isLoading={isLoading}
/>
) : (
<AssistantMessage
key={message.id || `${message.type}-${index}`}
message={message}
isLoading={isLoading}
handleRegenerate={handleRegenerate}
/>
),
)}
{/* Special rendering case where there are no AI/tool messages, but there is an interrupt.
We need to render it outside of the messages list, since there are no messages to render */}
{hasNoAIOrToolMessages && !!stream.interrupt && (
<AssistantMessage
key="interrupt-msg"
message={undefined}
isLoading={isLoading}
handleRegenerate={handleRegenerate}
/>
)}
{isLoading && !firstTokenReceived && (
<AssistantMessageLoading />
)}
</>
}
footer={
<div className="sticky bottom-0 flex flex-col items-center gap-8 bg-white">
{!chatStarted && (
<div className="flex items-center gap-3">
<LangGraphLogoSVG className="h-8 flex-shrink-0" />
<h1 className="text-2xl font-semibold tracking-tight">
Agent Chat
</h1>
</div>
)}
<ScrollToBottom className="animate-in fade-in-0 zoom-in-95 absolute bottom-full left-1/2 mb-4 -translate-x-1/2" />
<div
ref={dropRef}
className={cn(
"bg-muted relative z-10 mx-auto mb-8 w-full max-w-3xl rounded-2xl shadow-xs transition-all",
dragOver
? "border-primary border-2 border-dotted"
: "border border-solid",
)}
>
<form
onSubmit={handleSubmit}
className="mx-auto grid max-w-3xl grid-rows-[1fr_auto] gap-2"
>
<ContentBlocksPreview
blocks={contentBlocks}
onRemove={removeBlock}
/>
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onPaste={handlePaste}
onKeyDown={(e) => {
if (
e.key === "Enter" &&
!e.shiftKey &&
!e.metaKey &&
!e.nativeEvent.isComposing
) {
e.preventDefault();
const el = e.target as HTMLElement | undefined;
const form = el?.closest("form");
form?.requestSubmit();
}
}}
placeholder="Type your message..."
className="field-sizing-content resize-none border-none bg-transparent p-3.5 pb-0 shadow-none ring-0 outline-none focus:ring-0 focus:outline-none"
/>
<div className="flex items-center gap-6 p-2 pt-4">
<div>
<div className="flex items-center space-x-2">
<Switch
id="render-tool-calls"
checked={hideToolCalls ?? false}
onCheckedChange={setHideToolCalls}
/>
<Label
htmlFor="render-tool-calls"
className="text-sm text-gray-600"
>
Hide Tool Calls
</Label>
</div>
</div>
<Label
htmlFor="file-input"
className="flex cursor-pointer items-center gap-2"
>
<Plus className="size-5 text-gray-600" />
<span className="text-sm text-gray-600">
Upload PDF or Image
</span>
</Label>
<input
id="file-input"
type="file"
onChange={handleFileUpload}
multiple
accept="image/jpeg,image/png,image/gif,image/webp,application/pdf"
className="hidden"
/>
{stream.isLoading ? (
<Button
key="stop"
onClick={() => stream.stop()}
className="ml-auto"
>
<LoaderCircle className="h-4 w-4 animate-spin" />
Cancel
</Button>
) : (
<Button
type="submit"
className="ml-auto shadow-md transition-all"
disabled={
isLoading ||
(!input.trim() && contentBlocks.length === 0)
}
>
Send
</Button>
)}
</div>
</form>
</div>
</div>
}
/>
</StickToBottom>
</motion.div>
<div className="relative flex flex-col border-l">
<div className="absolute inset-0 flex min-w-[30vw] flex-col">
<div className="grid grid-cols-[1fr_auto] border-b p-4">
<ArtifactTitle className="truncate overflow-hidden" />
<button
onClick={closeArtifact}
className="cursor-pointer"
>
<XIcon className="size-5" />
</button>
</div>
<ArtifactContent className="relative flex-grow" />
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,45 @@
/* Base markdown styles */
.markdown-content code:not(pre code) {
background-color: rgba(0, 0, 0, 0.05);
padding: 0.2em 0.4em;
border-radius: 3px;
font-size: 0.9em;
}
.markdown-content a {
color: #0070f3;
text-decoration: none;
}
.markdown-content a:hover {
text-decoration: underline;
}
.markdown-content blockquote {
border-left: 4px solid #ddd;
padding-left: 1rem;
color: #666;
}
.markdown-content pre {
overflow-x: auto;
}
.markdown-content table {
border-collapse: collapse;
width: 100%;
}
.markdown-content th,
.markdown-content td {
border: 1px solid #ddd;
padding: 8px;
}
.markdown-content th {
background-color: #f2f2f2;
}
.markdown-content tr:nth-child(even) {
background-color: #f9f9f9;
}

View file

@ -0,0 +1,260 @@
"use client";
import "./markdown-styles.css";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import rehypeKatex from "rehype-katex";
import remarkMath from "remark-math";
import { FC, memo, useState } from "react";
import { CheckIcon, CopyIcon } from "lucide-react";
import { SyntaxHighlighter } from "@/components/thread/syntax-highlighter";
import { TooltipIconButton } from "@/components/thread/tooltip-icon-button";
import { cn } from "@/lib/utils";
import "katex/dist/katex.min.css";
interface CodeHeaderProps {
language?: string;
code: string;
}
const useCopyToClipboard = ({
copiedDuration = 3000,
}: {
copiedDuration?: number;
} = {}) => {
const [isCopied, setIsCopied] = useState<boolean>(false);
const copyToClipboard = (value: string) => {
if (!value) return;
navigator.clipboard.writeText(value).then(() => {
setIsCopied(true);
setTimeout(() => setIsCopied(false), copiedDuration);
});
};
return { isCopied, copyToClipboard };
};
const CodeHeader: FC<CodeHeaderProps> = ({ language, code }) => {
const { isCopied, copyToClipboard } = useCopyToClipboard();
const onCopy = () => {
if (!code || isCopied) return;
copyToClipboard(code);
};
return (
<div className="flex items-center justify-between gap-4 rounded-t-lg bg-zinc-900 px-4 py-2 text-sm font-semibold text-white">
<span className="lowercase [&>span]:text-xs">{language}</span>
<TooltipIconButton
tooltip="Copy"
onClick={onCopy}
>
{!isCopied && <CopyIcon />}
{isCopied && <CheckIcon />}
</TooltipIconButton>
</div>
);
};
const defaultComponents: any = {
h1: ({ className, ...props }: { className?: string }) => (
<h1
className={cn(
"mb-8 scroll-m-20 text-4xl font-extrabold tracking-tight last:mb-0",
className,
)}
{...props}
/>
),
h2: ({ className, ...props }: { className?: string }) => (
<h2
className={cn(
"mt-8 mb-4 scroll-m-20 text-3xl font-semibold tracking-tight first:mt-0 last:mb-0",
className,
)}
{...props}
/>
),
h3: ({ className, ...props }: { className?: string }) => (
<h3
className={cn(
"mt-6 mb-4 scroll-m-20 text-2xl font-semibold tracking-tight first:mt-0 last:mb-0",
className,
)}
{...props}
/>
),
h4: ({ className, ...props }: { className?: string }) => (
<h4
className={cn(
"mt-6 mb-4 scroll-m-20 text-xl font-semibold tracking-tight first:mt-0 last:mb-0",
className,
)}
{...props}
/>
),
h5: ({ className, ...props }: { className?: string }) => (
<h5
className={cn(
"my-4 text-lg font-semibold first:mt-0 last:mb-0",
className,
)}
{...props}
/>
),
h6: ({ className, ...props }: { className?: string }) => (
<h6
className={cn("my-4 font-semibold first:mt-0 last:mb-0", className)}
{...props}
/>
),
p: ({ className, ...props }: { className?: string }) => (
<p
className={cn("mt-5 mb-5 leading-7 first:mt-0 last:mb-0", className)}
{...props}
/>
),
a: ({ className, ...props }: { className?: string }) => (
<a
className={cn(
"text-primary font-medium underline underline-offset-4",
className,
)}
{...props}
/>
),
blockquote: ({ className, ...props }: { className?: string }) => (
<blockquote
className={cn("border-l-2 pl-6 italic", className)}
{...props}
/>
),
ul: ({ className, ...props }: { className?: string }) => (
<ul
className={cn("my-5 ml-6 list-disc [&>li]:mt-2", className)}
{...props}
/>
),
ol: ({ className, ...props }: { className?: string }) => (
<ol
className={cn("my-5 ml-6 list-decimal [&>li]:mt-2", className)}
{...props}
/>
),
hr: ({ className, ...props }: { className?: string }) => (
<hr
className={cn("my-5 border-b", className)}
{...props}
/>
),
table: ({ className, ...props }: { className?: string }) => (
<table
className={cn(
"my-5 w-full border-separate border-spacing-0 overflow-y-auto",
className,
)}
{...props}
/>
),
th: ({ className, ...props }: { className?: string }) => (
<th
className={cn(
"bg-muted px-4 py-2 text-left font-bold first:rounded-tl-lg last:rounded-tr-lg [&[align=center]]:text-center [&[align=right]]:text-right",
className,
)}
{...props}
/>
),
td: ({ className, ...props }: { className?: string }) => (
<td
className={cn(
"border-b border-l px-4 py-2 text-left last:border-r [&[align=center]]:text-center [&[align=right]]:text-right",
className,
)}
{...props}
/>
),
tr: ({ className, ...props }: { className?: string }) => (
<tr
className={cn(
"m-0 border-b p-0 first:border-t [&:last-child>td:first-child]:rounded-bl-lg [&:last-child>td:last-child]:rounded-br-lg",
className,
)}
{...props}
/>
),
sup: ({ className, ...props }: { className?: string }) => (
<sup
className={cn("[&>a]:text-xs [&>a]:no-underline", className)}
{...props}
/>
),
pre: ({ className, ...props }: { className?: string }) => (
<pre
className={cn(
"max-w-4xl overflow-x-auto rounded-lg bg-black text-white",
className,
)}
{...props}
/>
),
code: ({
className,
children,
...props
}: {
className?: string;
children: React.ReactNode;
}) => {
const match = /language-(\w+)/.exec(className || "");
if (match) {
const language = match[1];
const code = String(children).replace(/\n$/, "");
return (
<>
<CodeHeader
language={language}
code={code}
/>
<SyntaxHighlighter
language={language}
className={className}
>
{code}
</SyntaxHighlighter>
</>
);
}
return (
<code
className={cn("rounded font-semibold", className)}
{...props}
>
{children}
</code>
);
},
};
const MarkdownTextImpl: FC<{ children: string }> = ({ children }) => {
return (
<div className="markdown-content">
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeKatex]}
components={defaultComponents}
>
{children}
</ReactMarkdown>
</div>
);
};
export const MarkdownText = memo(MarkdownTextImpl);

View file

@ -0,0 +1,224 @@
import { parsePartialJson } from "@langchain/core/output_parsers";
import { useStreamContext } from "@/providers/Stream";
import { AIMessage, Checkpoint, Message } from "@langchain/langgraph-sdk";
import { getContentString } from "../utils";
import { BranchSwitcher, CommandBar } from "./shared";
import { MarkdownText } from "../markdown-text";
import { LoadExternalComponent } from "@langchain/langgraph-sdk/react-ui";
import { cn } from "@/lib/utils";
import { ToolCalls, ToolResult } from "./tool-calls";
import { MessageContentComplex } from "@langchain/core/messages";
import { Fragment } from "react/jsx-runtime";
import { isAgentInboxInterruptSchema } from "@/lib/agent-inbox-interrupt";
import { ThreadView } from "../agent-inbox";
import { useQueryState, parseAsBoolean } from "nuqs";
import { GenericInterruptView } from "./generic-interrupt";
import { useArtifact } from "../artifact";
function CustomComponent({
message,
thread,
}: {
message: Message;
thread: ReturnType<typeof useStreamContext>;
}) {
const artifact = useArtifact();
const { values } = useStreamContext();
const customComponents = values.ui?.filter(
(ui) => ui.metadata?.message_id === message.id,
);
if (!customComponents?.length) return null;
return (
<Fragment key={message.id}>
{customComponents.map((customComponent) => (
<LoadExternalComponent
key={customComponent.id}
stream={thread}
message={customComponent}
meta={{ ui: customComponent, artifact }}
/>
))}
</Fragment>
);
}
function parseAnthropicStreamedToolCalls(
content: MessageContentComplex[],
): AIMessage["tool_calls"] {
const toolCallContents = content.filter((c) => c.type === "tool_use" && c.id);
return toolCallContents.map((tc) => {
const toolCall = tc as Record<string, any>;
let json: Record<string, any> = {};
if (toolCall?.input) {
try {
json = parsePartialJson(toolCall.input) ?? {};
} catch {
// Pass
}
}
return {
name: toolCall.name ?? "",
id: toolCall.id ?? "",
args: json,
type: "tool_call",
};
});
}
interface InterruptProps {
interruptValue?: unknown;
isLastMessage: boolean;
hasNoAIOrToolMessages: boolean;
}
function Interrupt({
interruptValue,
isLastMessage,
hasNoAIOrToolMessages,
}: InterruptProps) {
return (
<>
{isAgentInboxInterruptSchema(interruptValue) &&
(isLastMessage || hasNoAIOrToolMessages) && (
<ThreadView interrupt={interruptValue} />
)}
{interruptValue &&
!isAgentInboxInterruptSchema(interruptValue) &&
isLastMessage ? (
<GenericInterruptView interrupt={interruptValue} />
) : null}
</>
);
}
export function AssistantMessage({
message,
isLoading,
handleRegenerate,
}: {
message: Message | undefined;
isLoading: boolean;
handleRegenerate: (parentCheckpoint: Checkpoint | null | undefined) => void;
}) {
const content = message?.content ?? [];
const contentString = getContentString(content);
const [hideToolCalls] = useQueryState(
"hideToolCalls",
parseAsBoolean.withDefault(false),
);
const thread = useStreamContext();
const isLastMessage =
thread.messages[thread.messages.length - 1].id === message?.id;
const hasNoAIOrToolMessages = !thread.messages.find(
(m) => m.type === "ai" || m.type === "tool",
);
const meta = message ? thread.getMessagesMetadata(message) : undefined;
const threadInterrupt = thread.interrupt;
const parentCheckpoint = meta?.firstSeenState?.parent_checkpoint;
const anthropicStreamedToolCalls = Array.isArray(content)
? parseAnthropicStreamedToolCalls(content)
: undefined;
const hasToolCalls =
message &&
"tool_calls" in message &&
message.tool_calls &&
message.tool_calls.length > 0;
const toolCallsHaveContents =
hasToolCalls &&
message.tool_calls?.some(
(tc) => tc.args && Object.keys(tc.args).length > 0,
);
const hasAnthropicToolCalls = !!anthropicStreamedToolCalls?.length;
const isToolResult = message?.type === "tool";
if (isToolResult && hideToolCalls) {
return null;
}
return (
<div className="group mr-auto flex items-start gap-2">
<div className="flex flex-col gap-2">
{isToolResult ? (
<>
<ToolResult message={message} />
<Interrupt
interruptValue={threadInterrupt?.value}
isLastMessage={isLastMessage}
hasNoAIOrToolMessages={hasNoAIOrToolMessages}
/>
</>
) : (
<>
{contentString.length > 0 && (
<div className="py-1">
<MarkdownText>{contentString}</MarkdownText>
</div>
)}
{!hideToolCalls && (
<>
{(hasToolCalls && toolCallsHaveContents && (
<ToolCalls toolCalls={message.tool_calls} />
)) ||
(hasAnthropicToolCalls && (
<ToolCalls toolCalls={anthropicStreamedToolCalls} />
)) ||
(hasToolCalls && (
<ToolCalls toolCalls={message.tool_calls} />
))}
</>
)}
{message && (
<CustomComponent
message={message}
thread={thread}
/>
)}
<Interrupt
interruptValue={threadInterrupt?.value}
isLastMessage={isLastMessage}
hasNoAIOrToolMessages={hasNoAIOrToolMessages}
/>
<div
className={cn(
"mr-auto flex items-center gap-2 transition-opacity",
"opacity-0 group-focus-within:opacity-100 group-hover:opacity-100",
)}
>
<BranchSwitcher
branch={meta?.branch}
branchOptions={meta?.branchOptions}
onSelect={(branch) => thread.setBranch(branch)}
isLoading={isLoading}
/>
<CommandBar
content={contentString}
isLoading={isLoading}
isAiMessage={true}
handleRegenerate={() => handleRegenerate(parentCheckpoint)}
/>
</div>
</>
)}
</div>
</div>
);
}
export function AssistantMessageLoading() {
return (
<div className="mr-auto flex items-start gap-2">
<div className="bg-muted flex h-8 items-center gap-1 rounded-2xl px-4 py-2">
<div className="bg-foreground/50 h-1.5 w-1.5 animate-[pulse_1.5s_ease-in-out_infinite] rounded-full"></div>
<div className="bg-foreground/50 h-1.5 w-1.5 animate-[pulse_1.5s_ease-in-out_0.5s_infinite] rounded-full"></div>
<div className="bg-foreground/50 h-1.5 w-1.5 animate-[pulse_1.5s_ease-in-out_1s_infinite] rounded-full"></div>
</div>
</div>
);
}

View file

@ -0,0 +1,129 @@
import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { ChevronDown, ChevronUp } from "lucide-react";
function isComplexValue(value: any): boolean {
return Array.isArray(value) || (typeof value === "object" && value !== null);
}
export function GenericInterruptView({
interrupt,
}: {
interrupt: Record<string, any> | Record<string, any>[];
}) {
const [isExpanded, setIsExpanded] = useState(false);
const contentStr = JSON.stringify(interrupt, null, 2);
const contentLines = contentStr.split("\n");
const shouldTruncate = contentLines.length > 4 || contentStr.length > 500;
// Function to truncate long string values
const truncateValue = (value: any): any => {
if (typeof value === "string" && value.length > 100) {
return value.substring(0, 100) + "...";
}
if (Array.isArray(value) && !isExpanded) {
return value.slice(0, 2).map(truncateValue);
}
if (isComplexValue(value) && !isExpanded) {
const strValue = JSON.stringify(value, null, 2);
if (strValue.length > 100) {
// Return plain text for truncated content instead of a JSON object
return `Truncated ${strValue.length} characters...`;
}
}
return value;
};
// Process entries based on expanded state
const processEntries = () => {
if (Array.isArray(interrupt)) {
return isExpanded ? interrupt : interrupt.slice(0, 5);
} else {
const entries = Object.entries(interrupt);
if (!isExpanded && shouldTruncate) {
// When collapsed, process each value to potentially truncate it
return entries.map(([key, value]) => [key, truncateValue(value)]);
}
return entries;
}
};
const displayEntries = processEntries();
return (
<div className="overflow-hidden rounded-lg border border-gray-200">
<div className="border-b border-gray-200 bg-gray-50 px-4 py-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="font-medium text-gray-900">Human Interrupt</h3>
</div>
</div>
<motion.div
className="min-w-full bg-gray-100"
initial={false}
animate={{ height: "auto" }}
transition={{ duration: 0.3 }}
>
<div className="p-3">
<AnimatePresence
mode="wait"
initial={false}
>
<motion.div
key={isExpanded ? "expanded" : "collapsed"}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.2 }}
style={{
maxHeight: isExpanded ? "none" : "500px",
overflow: "auto",
}}
>
<table className="min-w-full divide-y divide-gray-200">
<tbody className="divide-y divide-gray-200">
{displayEntries.map((item, argIdx) => {
const [key, value] = Array.isArray(interrupt)
? [argIdx.toString(), item]
: (item as [string, any]);
return (
<tr key={argIdx}>
<td className="px-4 py-2 text-sm font-medium whitespace-nowrap text-gray-900">
{key}
</td>
<td className="px-4 py-2 text-sm text-gray-500">
{isComplexValue(value) ? (
<code className="rounded bg-gray-50 px-2 py-1 font-mono text-sm">
{JSON.stringify(value, null, 2)}
</code>
) : (
String(value)
)}
</td>
</tr>
);
})}
</tbody>
</table>
</motion.div>
</AnimatePresence>
</div>
{(shouldTruncate ||
(Array.isArray(interrupt) && interrupt.length > 5)) && (
<motion.button
onClick={() => setIsExpanded(!isExpanded)}
className="flex w-full cursor-pointer items-center justify-center border-t-[1px] border-gray-200 py-2 text-gray-500 transition-all duration-200 ease-in-out hover:bg-gray-50 hover:text-gray-600"
initial={{ scale: 1 }}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
{isExpanded ? <ChevronUp /> : <ChevronDown />}
</motion.button>
)}
</motion.div>
</div>
);
}

View file

@ -0,0 +1,149 @@
import { useStreamContext } from "@/providers/Stream";
import { Message } from "@langchain/langgraph-sdk";
import { useState } from "react";
import { getContentString } from "../utils";
import { cn } from "@/lib/utils";
import { Textarea } from "@/components/ui/textarea";
import { BranchSwitcher, CommandBar } from "./shared";
import { MultimodalPreview } from "@/components/thread/MultimodalPreview";
import { isBase64ContentBlock } from "@/lib/multimodal-utils";
function EditableContent({
value,
setValue,
onSubmit,
}: {
value: string;
setValue: React.Dispatch<React.SetStateAction<string>>;
onSubmit: () => void;
}) {
const handleKeyDown = (e: React.KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault();
onSubmit();
}
};
return (
<Textarea
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
className="focus-visible:ring-0"
/>
);
}
export function HumanMessage({
message,
isLoading,
}: {
message: Message;
isLoading: boolean;
}) {
const thread = useStreamContext();
const meta = thread.getMessagesMetadata(message);
const parentCheckpoint = meta?.firstSeenState?.parent_checkpoint;
const [isEditing, setIsEditing] = useState(false);
const [value, setValue] = useState("");
const contentString = getContentString(message.content);
const handleSubmitEdit = () => {
setIsEditing(false);
const newMessage: Message = { type: "human", content: value };
thread.submit(
{ messages: [newMessage] },
{
checkpoint: parentCheckpoint,
streamMode: ["values"],
optimisticValues: (prev) => {
const values = meta?.firstSeenState?.values;
if (!values) return prev;
return {
...values,
messages: [...(values.messages ?? []), newMessage],
};
},
},
);
};
return (
<div
className={cn(
"group ml-auto flex items-center gap-2",
isEditing && "w-full max-w-xl",
)}
>
<div className={cn("flex flex-col gap-2", isEditing && "w-full")}>
{isEditing ? (
<EditableContent
value={value}
setValue={setValue}
onSubmit={handleSubmitEdit}
/>
) : (
<div className="flex flex-col gap-2">
{/* Render images and files if no text */}
{Array.isArray(message.content) && message.content.length > 0 && (
<div className="flex flex-wrap items-end justify-end gap-2">
{message.content.reduce<React.ReactNode[]>(
(acc, block, idx) => {
if (isBase64ContentBlock(block)) {
acc.push(
<MultimodalPreview
key={idx}
block={block}
size="md"
/>,
);
}
return acc;
},
[],
)}
</div>
)}
{/* Render text if present, otherwise fallback to file/image name */}
{contentString ? (
<p className="bg-muted ml-auto w-fit rounded-3xl px-4 py-2 text-right whitespace-pre-wrap">
{contentString}
</p>
) : null}
</div>
)}
<div
className={cn(
"ml-auto flex items-center gap-2 transition-opacity",
"opacity-0 group-focus-within:opacity-100 group-hover:opacity-100",
isEditing && "opacity-100",
)}
>
<BranchSwitcher
branch={meta?.branch}
branchOptions={meta?.branchOptions}
onSelect={(branch) => thread.setBranch(branch)}
isLoading={isLoading}
/>
<CommandBar
isLoading={isLoading}
content={contentString}
isEditing={isEditing}
setIsEditing={(c) => {
if (c) {
setValue(contentString);
}
setIsEditing(c);
}}
handleSubmitEdit={handleSubmitEdit}
isHumanMessage={true}
/>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,221 @@
import {
XIcon,
SendHorizontal,
RefreshCcw,
Pencil,
Copy,
CopyCheck,
ChevronLeft,
ChevronRight,
} from "lucide-react";
import { TooltipIconButton } from "../tooltip-icon-button";
import { AnimatePresence, motion } from "framer-motion";
import { useState } from "react";
import { Button } from "@/components/ui/button";
function ContentCopyable({
content,
disabled,
}: {
content: string;
disabled: boolean;
}) {
const [copied, setCopied] = useState(false);
const handleCopy = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.stopPropagation();
navigator.clipboard.writeText(content);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<TooltipIconButton
onClick={(e) => handleCopy(e)}
variant="ghost"
tooltip="Copy content"
disabled={disabled}
>
<AnimatePresence
mode="wait"
initial={false}
>
{copied ? (
<motion.div
key="check"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.15 }}
>
<CopyCheck className="text-green-500" />
</motion.div>
) : (
<motion.div
key="copy"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.15 }}
>
<Copy />
</motion.div>
)}
</AnimatePresence>
</TooltipIconButton>
);
}
export function BranchSwitcher({
branch,
branchOptions,
onSelect,
isLoading,
}: {
branch: string | undefined;
branchOptions: string[] | undefined;
onSelect: (branch: string) => void;
isLoading: boolean;
}) {
if (!branchOptions || !branch) return null;
const index = branchOptions.indexOf(branch);
return (
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
className="size-6 p-1"
onClick={() => {
const prevBranch = branchOptions[index - 1];
if (!prevBranch) return;
onSelect(prevBranch);
}}
disabled={isLoading}
>
<ChevronLeft />
</Button>
<span className="text-sm">
{index + 1} / {branchOptions.length}
</span>
<Button
variant="ghost"
size="icon"
className="size-6 p-1"
onClick={() => {
const nextBranch = branchOptions[index + 1];
if (!nextBranch) return;
onSelect(nextBranch);
}}
disabled={isLoading}
>
<ChevronRight />
</Button>
</div>
);
}
export function CommandBar({
content,
isHumanMessage,
isAiMessage,
isEditing,
setIsEditing,
handleSubmitEdit,
handleRegenerate,
isLoading,
}: {
content: string;
isHumanMessage?: boolean;
isAiMessage?: boolean;
isEditing?: boolean;
setIsEditing?: React.Dispatch<React.SetStateAction<boolean>>;
handleSubmitEdit?: () => void;
handleRegenerate?: () => void;
isLoading: boolean;
}) {
if (isHumanMessage && isAiMessage) {
throw new Error(
"Can only set one of isHumanMessage or isAiMessage to true, not both.",
);
}
if (!isHumanMessage && !isAiMessage) {
throw new Error(
"One of isHumanMessage or isAiMessage must be set to true.",
);
}
if (
isHumanMessage &&
(isEditing === undefined ||
setIsEditing === undefined ||
handleSubmitEdit === undefined)
) {
throw new Error(
"If isHumanMessage is true, all of isEditing, setIsEditing, and handleSubmitEdit must be set.",
);
}
const showEdit =
isHumanMessage &&
isEditing !== undefined &&
!!setIsEditing &&
!!handleSubmitEdit;
if (isHumanMessage && isEditing && !!setIsEditing && !!handleSubmitEdit) {
return (
<div className="flex items-center gap-2">
<TooltipIconButton
disabled={isLoading}
tooltip="Cancel edit"
variant="ghost"
onClick={() => {
setIsEditing(false);
}}
>
<XIcon />
</TooltipIconButton>
<TooltipIconButton
disabled={isLoading}
tooltip="Submit"
variant="secondary"
onClick={handleSubmitEdit}
>
<SendHorizontal />
</TooltipIconButton>
</div>
);
}
return (
<div className="flex items-center gap-2">
<ContentCopyable
content={content}
disabled={isLoading}
/>
{isAiMessage && !!handleRegenerate && (
<TooltipIconButton
disabled={isLoading}
tooltip="Refresh"
variant="ghost"
onClick={handleRegenerate}
>
<RefreshCcw />
</TooltipIconButton>
)}
{showEdit && (
<TooltipIconButton
disabled={isLoading}
tooltip="Edit"
variant="ghost"
onClick={() => {
setIsEditing?.(true);
}}
>
<Pencil />
</TooltipIconButton>
)}
</div>
);
}

View file

@ -0,0 +1,191 @@
import { AIMessage, ToolMessage } from "@langchain/langgraph-sdk";
import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { ChevronDown, ChevronUp } from "lucide-react";
function isComplexValue(value: any): boolean {
return Array.isArray(value) || (typeof value === "object" && value !== null);
}
export function ToolCalls({
toolCalls,
}: {
toolCalls: AIMessage["tool_calls"];
}) {
if (!toolCalls || toolCalls.length === 0) return null;
return (
<div className="mx-auto grid max-w-3xl grid-rows-[1fr_auto] gap-2">
{toolCalls.map((tc, idx) => {
const args = tc.args as Record<string, any>;
const hasArgs = Object.keys(args).length > 0;
return (
<div
key={idx}
className="overflow-hidden rounded-lg border border-gray-200"
>
<div className="border-b border-gray-200 bg-gray-50 px-4 py-2">
<h3 className="font-medium text-gray-900">
{tc.name}
{tc.id && (
<code className="ml-2 rounded bg-gray-100 px-2 py-1 text-sm">
{tc.id}
</code>
)}
</h3>
</div>
{hasArgs ? (
<table className="min-w-full divide-y divide-gray-200">
<tbody className="divide-y divide-gray-200">
{Object.entries(args).map(([key, value], argIdx) => (
<tr key={argIdx}>
<td className="px-4 py-2 text-sm font-medium whitespace-nowrap text-gray-900">
{key}
</td>
<td className="px-4 py-2 text-sm text-gray-500">
{isComplexValue(value) ? (
<code className="rounded bg-gray-50 px-2 py-1 font-mono text-sm break-all">
{JSON.stringify(value, null, 2)}
</code>
) : (
String(value)
)}
</td>
</tr>
))}
</tbody>
</table>
) : (
<code className="block p-3 text-sm">{"{}"}</code>
)}
</div>
);
})}
</div>
);
}
export function ToolResult({ message }: { message: ToolMessage }) {
const [isExpanded, setIsExpanded] = useState(false);
let parsedContent: any;
let isJsonContent = false;
try {
if (typeof message.content === "string") {
parsedContent = JSON.parse(message.content);
isJsonContent = isComplexValue(parsedContent);
}
} catch {
// Content is not JSON, use as is
parsedContent = message.content;
}
const contentStr = isJsonContent
? JSON.stringify(parsedContent, null, 2)
: String(message.content);
const contentLines = contentStr.split("\n");
const shouldTruncate = contentLines.length > 4 || contentStr.length > 500;
const displayedContent =
shouldTruncate && !isExpanded
? contentStr.length > 500
? contentStr.slice(0, 500) + "..."
: contentLines.slice(0, 4).join("\n") + "\n..."
: contentStr;
return (
<div className="mx-auto grid max-w-3xl grid-rows-[1fr_auto] gap-2">
<div className="overflow-hidden rounded-lg border border-gray-200">
<div className="border-b border-gray-200 bg-gray-50 px-4 py-2">
<div className="flex flex-wrap items-center justify-between gap-2">
{message.name ? (
<h3 className="font-medium text-gray-900">
Tool Result:{" "}
<code className="rounded bg-gray-100 px-2 py-1">
{message.name}
</code>
</h3>
) : (
<h3 className="font-medium text-gray-900">Tool Result</h3>
)}
{message.tool_call_id && (
<code className="ml-2 rounded bg-gray-100 px-2 py-1 text-sm">
{message.tool_call_id}
</code>
)}
</div>
</div>
<motion.div
className="min-w-full bg-gray-100"
initial={false}
animate={{ height: "auto" }}
transition={{ duration: 0.3 }}
>
<div className="p-3">
<AnimatePresence
mode="wait"
initial={false}
>
<motion.div
key={isExpanded ? "expanded" : "collapsed"}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.2 }}
>
{isJsonContent ? (
<table className="min-w-full divide-y divide-gray-200">
<tbody className="divide-y divide-gray-200">
{(Array.isArray(parsedContent)
? isExpanded
? parsedContent
: parsedContent.slice(0, 5)
: Object.entries(parsedContent)
).map((item, argIdx) => {
const [key, value] = Array.isArray(parsedContent)
? [argIdx, item]
: [item[0], item[1]];
return (
<tr key={argIdx}>
<td className="px-4 py-2 text-sm font-medium whitespace-nowrap text-gray-900">
{key}
</td>
<td className="px-4 py-2 text-sm text-gray-500">
{isComplexValue(value) ? (
<code className="rounded bg-gray-50 px-2 py-1 font-mono text-sm break-all">
{JSON.stringify(value, null, 2)}
</code>
) : (
String(value)
)}
</td>
</tr>
);
})}
</tbody>
</table>
) : (
<code className="block text-sm">{displayedContent}</code>
)}
</motion.div>
</AnimatePresence>
</div>
{((shouldTruncate && !isJsonContent) ||
(isJsonContent &&
Array.isArray(parsedContent) &&
parsedContent.length > 5)) && (
<motion.button
onClick={() => setIsExpanded(!isExpanded)}
className="flex w-full cursor-pointer items-center justify-center border-t-[1px] border-gray-200 py-2 text-gray-500 transition-all duration-200 ease-in-out hover:bg-gray-50 hover:text-gray-600"
initial={{ scale: 1 }}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
{isExpanded ? <ChevronUp /> : <ChevronDown />}
</motion.button>
)}
</motion.div>
</div>
</div>
);
}

View file

@ -0,0 +1,40 @@
import { PrismAsyncLight as SyntaxHighlighterPrism } from "react-syntax-highlighter";
import tsx from "react-syntax-highlighter/dist/esm/languages/prism/tsx";
import python from "react-syntax-highlighter/dist/esm/languages/prism/python";
import { coldarkDark } from "react-syntax-highlighter/dist/cjs/styles/prism";
import { FC } from "react";
// Register languages you want to support
SyntaxHighlighterPrism.registerLanguage("js", tsx);
SyntaxHighlighterPrism.registerLanguage("jsx", tsx);
SyntaxHighlighterPrism.registerLanguage("ts", tsx);
SyntaxHighlighterPrism.registerLanguage("tsx", tsx);
SyntaxHighlighterPrism.registerLanguage("python", python);
interface SyntaxHighlighterProps {
children: string;
language: string;
className?: string;
}
export const SyntaxHighlighter: FC<SyntaxHighlighterProps> = ({
children,
language,
className,
}) => {
return (
<SyntaxHighlighterPrism
language={language}
style={coldarkDark}
customStyle={{
margin: 0,
width: "100%",
background: "transparent",
padding: "1.5rem 1rem",
}}
className={className}
>
{children}
</SyntaxHighlighterPrism>
);
};

View file

@ -0,0 +1,44 @@
"use client";
import { forwardRef } from "react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Button, ButtonProps } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export type TooltipIconButtonProps = ButtonProps & {
tooltip: string;
side?: "top" | "bottom" | "left" | "right";
};
export const TooltipIconButton = forwardRef<
HTMLButtonElement,
TooltipIconButtonProps
>(({ children, tooltip, side = "bottom", className, ...rest }, ref) => {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
{...rest}
className={cn("size-6 p-1", className)}
ref={ref}
>
{children}
<span className="sr-only">{tooltip}</span>
</Button>
</TooltipTrigger>
<TooltipContent side={side}>{tooltip}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
});
TooltipIconButton.displayName = "TooltipIconButton";

View file

@ -0,0 +1,15 @@
import type { Message } from "@langchain/langgraph-sdk";
/**
* Extracts a string summary from a message's content, supporting multimodal (text, image, file, etc.).
* - If text is present, returns the joined text.
* - If not, returns a label for the first non-text modality (e.g., 'Image', 'Other').
* - If unknown, returns 'Multimodal message'.
*/
export function getContentString(content: Message["content"]): string {
if (typeof content === "string") return content;
const texts = content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.text);
return texts.join(" ");
}

View file

@ -0,0 +1,51 @@
import * as React from "react";
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import { cn } from "@/lib/utils";
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
className,
)}
{...props}
/>
);
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
);
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted flex size-full items-center justify-center rounded-full",
className,
)}
{...props}
/>
);
}
export { Avatar, AvatarImage, AvatarFallback };

View file

@ -0,0 +1,61 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,box-shadow] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40",
outline:
"border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
brand: "bg-[#2F6868] hover:bg-[#2F6868]/90 border-[#2F6868] text-white",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
type ButtonProps = React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
};
function Button({
className,
variant,
size,
asChild = false,
...props
}: ButtonProps) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants, type ButtonProps };

View file

@ -0,0 +1,75 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className,
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn("flex flex-col gap-1.5 px-6", className)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
);
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6", className)}
{...props}
/>
);
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardDescription,
CardContent,
};

View file

@ -0,0 +1,20 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"border-input file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className,
)}
{...props}
/>
);
}
export { Input };

View file

@ -0,0 +1,22 @@
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cn } from "@/lib/utils";
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className,
)}
{...props}
/>
);
}
export { Label };

View file

@ -0,0 +1,60 @@
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { Input } from "./input";
import { Button } from "./button";
import { EyeIcon, EyeOffIcon } from "lucide-react";
export const PasswordInput = React.forwardRef<
HTMLInputElement,
React.ComponentProps<"input">
>(({ className, ...props }, ref) => {
const [showPassword, setShowPassword] = React.useState(false);
return (
<div className="relative w-full">
<Input
type={showPassword ? "text" : "password"}
className={cn("hide-password-toggle pr-10", className)}
ref={ref}
{...props}
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute top-0 right-0 h-full px-3 py-2 hover:bg-transparent"
onClick={() => setShowPassword((prev) => !prev)}
>
{showPassword ? (
<EyeIcon
className="h-4 w-4"
aria-hidden="true"
/>
) : (
<EyeOffIcon
className="h-4 w-4"
aria-hidden="true"
/>
)}
<span className="sr-only">
{showPassword ? "Hide password" : "Show password"}
</span>
</Button>
{/* hides browsers password toggles */}
<style>{`
.hide-password-toggle::-ms-reveal,
.hide-password-toggle::-ms-clear {
visibility: hidden;
pointer-events: none;
display: none;
}
`}</style>
</div>
);
});
PasswordInput.displayName = "PasswordInput";

View file

@ -0,0 +1,26 @@
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "@/lib/utils";
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator-root"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className,
)}
{...props}
/>
);
}
export { Separator };

View file

@ -0,0 +1,157 @@
import * as React from "react";
import * as SheetPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return (
<SheetPrimitive.Root
data-slot="sheet"
{...props}
/>
);
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return (
<SheetPrimitive.Trigger
data-slot="sheet-trigger"
{...props}
/>
);
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return (
<SheetPrimitive.Close
data-slot="sheet-close"
{...props}
/>
);
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return (
<SheetPrimitive.Portal
data-slot="sheet-portal"
{...props}
/>
);
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80",
className,
)}
{...props}
/>
);
}
function SheetContent({
className,
children,
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left";
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className,
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
);
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
);
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
);
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
);
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
};

View file

@ -0,0 +1,13 @@
import { cn } from "@/lib/utils";
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-primary/10 animate-pulse rounded-md", className)}
{...props}
/>
);
}
export { Skeleton };

View file

@ -0,0 +1,27 @@
import { useTheme } from "next-themes";
import { Toaster as Sonner, ToasterProps } from "sonner";
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme();
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground font-medium",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground font-medium",
},
}}
{...props}
/>
);
};
export { Toaster };

View file

@ -0,0 +1,29 @@
import * as React from "react";
import * as SwitchPrimitive from "@radix-ui/react-switch";
import { cn } from "@/lib/utils";
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 inline-flex h-5 w-9 shrink-0 items-center rounded-full border-2 border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"bg-background pointer-events-none block size-4 rounded-full shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
)}
/>
</SwitchPrimitive.Root>
);
}
export { Switch };

View file

@ -0,0 +1,18 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
{...props}
/>
);
}
export { Textarea };

View file

@ -0,0 +1,67 @@
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils";
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
);
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root
data-slot="tooltip"
{...props}
/>
</TooltipProvider>
);
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return (
<TooltipPrimitive.Trigger
data-slot="tooltip-trigger"
{...props}
/>
);
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit rounded-md px-3 py-1.5 text-xs text-balance",
className,
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

View file

@ -0,0 +1,268 @@
import { useState, useRef, useEffect, ChangeEvent } from "react";
import { toast } from "sonner";
import type { Base64ContentBlock } from "@langchain/core/messages";
import { fileToContentBlock } from "@/lib/multimodal-utils";
export const SUPPORTED_FILE_TYPES = [
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"application/pdf",
];
interface UseFileUploadOptions {
initialBlocks?: Base64ContentBlock[];
}
export function useFileUpload({
initialBlocks = [],
}: UseFileUploadOptions = {}) {
const [contentBlocks, setContentBlocks] =
useState<Base64ContentBlock[]>(initialBlocks);
const dropRef = useRef<HTMLDivElement>(null);
const [dragOver, setDragOver] = useState(false);
const dragCounter = useRef(0);
const isDuplicate = (file: File, blocks: Base64ContentBlock[]) => {
if (file.type === "application/pdf") {
return blocks.some(
(b) =>
b.type === "file" &&
b.mime_type === "application/pdf" &&
b.metadata?.filename === file.name,
);
}
if (SUPPORTED_FILE_TYPES.includes(file.type)) {
return blocks.some(
(b) =>
b.type === "image" &&
b.metadata?.name === file.name &&
b.mime_type === file.type,
);
}
return false;
};
const handleFileUpload = async (e: ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files) return;
const fileArray = Array.from(files);
const validFiles = fileArray.filter((file) =>
SUPPORTED_FILE_TYPES.includes(file.type),
);
const invalidFiles = fileArray.filter(
(file) => !SUPPORTED_FILE_TYPES.includes(file.type),
);
const duplicateFiles = validFiles.filter((file) =>
isDuplicate(file, contentBlocks),
);
const uniqueFiles = validFiles.filter(
(file) => !isDuplicate(file, contentBlocks),
);
if (invalidFiles.length > 0) {
toast.error(
"You have uploaded invalid file type. Please upload a JPEG, PNG, GIF, WEBP image or a PDF.",
);
}
if (duplicateFiles.length > 0) {
toast.error(
`Duplicate file(s) detected: ${duplicateFiles.map((f) => f.name).join(", ")}. Each file can only be uploaded once per message.`,
);
}
const newBlocks = uniqueFiles.length
? await Promise.all(uniqueFiles.map(fileToContentBlock))
: [];
setContentBlocks((prev) => [...prev, ...newBlocks]);
e.target.value = "";
};
// Drag and drop handlers
useEffect(() => {
if (!dropRef.current) return;
// Global drag events with counter for robust dragOver state
const handleWindowDragEnter = (e: DragEvent) => {
if (e.dataTransfer?.types?.includes("Files")) {
dragCounter.current++;
setDragOver(true);
}
};
const handleWindowDragLeave = (e: DragEvent) => {
if (e.dataTransfer?.types?.includes("Files")) {
dragCounter.current--;
if (dragCounter.current <= 0) {
setDragOver(false);
dragCounter.current = 0;
}
}
};
const handleWindowDrop = async (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounter.current = 0;
setDragOver(false);
if (!e.dataTransfer) return;
const files = Array.from(e.dataTransfer.files);
const validFiles = files.filter((file) =>
SUPPORTED_FILE_TYPES.includes(file.type),
);
const invalidFiles = files.filter(
(file) => !SUPPORTED_FILE_TYPES.includes(file.type),
);
const duplicateFiles = validFiles.filter((file) =>
isDuplicate(file, contentBlocks),
);
const uniqueFiles = validFiles.filter(
(file) => !isDuplicate(file, contentBlocks),
);
if (invalidFiles.length > 0) {
toast.error(
"You have uploaded invalid file type. Please upload a JPEG, PNG, GIF, WEBP image or a PDF.",
);
}
if (duplicateFiles.length > 0) {
toast.error(
`Duplicate file(s) detected: ${duplicateFiles.map((f) => f.name).join(", ")}. Each file can only be uploaded once per message.`,
);
}
const newBlocks = uniqueFiles.length
? await Promise.all(uniqueFiles.map(fileToContentBlock))
: [];
setContentBlocks((prev) => [...prev, ...newBlocks]);
};
const handleWindowDragEnd = (e: DragEvent) => {
dragCounter.current = 0;
setDragOver(false);
};
window.addEventListener("dragenter", handleWindowDragEnter);
window.addEventListener("dragleave", handleWindowDragLeave);
window.addEventListener("drop", handleWindowDrop);
window.addEventListener("dragend", handleWindowDragEnd);
// Prevent default browser behavior for dragover globally
const handleWindowDragOver = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
};
window.addEventListener("dragover", handleWindowDragOver);
// Remove element-specific drop event (handled globally)
const handleDragOver = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragOver(true);
};
const handleDragEnter = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragOver(true);
};
const handleDragLeave = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragOver(false);
};
const element = dropRef.current;
element.addEventListener("dragover", handleDragOver);
element.addEventListener("dragenter", handleDragEnter);
element.addEventListener("dragleave", handleDragLeave);
return () => {
element.removeEventListener("dragover", handleDragOver);
element.removeEventListener("dragenter", handleDragEnter);
element.removeEventListener("dragleave", handleDragLeave);
window.removeEventListener("dragenter", handleWindowDragEnter);
window.removeEventListener("dragleave", handleWindowDragLeave);
window.removeEventListener("drop", handleWindowDrop);
window.removeEventListener("dragend", handleWindowDragEnd);
window.removeEventListener("dragover", handleWindowDragOver);
dragCounter.current = 0;
};
}, [contentBlocks]);
const removeBlock = (idx: number) => {
setContentBlocks((prev) => prev.filter((_, i) => i !== idx));
};
const resetBlocks = () => setContentBlocks([]);
/**
* Handle paste event for files (images, PDFs)
* Can be used as onPaste={handlePaste} on a textarea or input
*/
const handlePaste = async (
e: React.ClipboardEvent<HTMLTextAreaElement | HTMLInputElement>,
) => {
e.preventDefault();
const items = e.clipboardData.items;
if (!items) return;
const files: File[] = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.kind === "file") {
const file = item.getAsFile();
if (file) files.push(file);
}
}
if (files.length === 0) return;
const validFiles = files.filter((file) =>
SUPPORTED_FILE_TYPES.includes(file.type),
);
const invalidFiles = files.filter(
(file) => !SUPPORTED_FILE_TYPES.includes(file.type),
);
const isDuplicate = (file: File) => {
if (file.type === "application/pdf") {
return contentBlocks.some(
(b) =>
b.type === "file" &&
b.mime_type === "application/pdf" &&
b.metadata?.filename === file.name,
);
}
if (SUPPORTED_FILE_TYPES.includes(file.type)) {
return contentBlocks.some(
(b) =>
b.type === "image" &&
b.metadata?.name === file.name &&
b.mime_type === file.type,
);
}
return false;
};
const duplicateFiles = validFiles.filter(isDuplicate);
const uniqueFiles = validFiles.filter((file) => !isDuplicate(file));
if (invalidFiles.length > 0) {
toast.error(
"You have pasted an invalid file type. Please paste a JPEG, PNG, GIF, WEBP image or a PDF.",
);
}
if (duplicateFiles.length > 0) {
toast.error(
`Duplicate file(s) detected: ${duplicateFiles.map((f) => f.name).join(", ")}. Each file can only be uploaded once per message.`,
);
}
if (uniqueFiles.length > 0) {
const newBlocks = await Promise.all(uniqueFiles.map(fileToContentBlock));
setContentBlocks((prev) => [...prev, ...newBlocks]);
}
};
return {
contentBlocks,
setContentBlocks,
handleFileUpload,
dropRef,
removeBlock,
resetBlocks,
dragOver,
handlePaste,
};
}

View file

@ -0,0 +1,16 @@
import { useEffect, useState } from "react";
export function useMediaQuery(query: string) {
const [matches, setMatches] = useState(false);
useEffect(() => {
const media = window.matchMedia(query);
setMatches(media.matches);
const listener = (e: MediaQueryListEvent) => setMatches(e.matches);
media.addEventListener("change", listener);
return () => media.removeEventListener("change", listener);
}, [query]);
return matches;
}

View file

@ -0,0 +1,19 @@
import { HumanInterrupt } from "@langchain/langgraph/prebuilt";
export function isAgentInboxInterruptSchema(
value: unknown,
): value is HumanInterrupt | HumanInterrupt[] {
const valueAsObject = Array.isArray(value) ? value[0] : value;
return (
valueAsObject &&
typeof valueAsObject === "object" &&
"action_request" in valueAsObject &&
typeof valueAsObject.action_request === "object" &&
"config" in valueAsObject &&
typeof valueAsObject.config === "object" &&
"allow_respond" in valueAsObject.config &&
"allow_accept" in valueAsObject.config &&
"allow_edit" in valueAsObject.config &&
"allow_ignore" in valueAsObject.config
);
}

View file

@ -0,0 +1,10 @@
export function getApiKey(): string | null {
try {
if (typeof window === "undefined") return null;
return window.localStorage.getItem("lg:chat:apiKey") ?? null;
} catch {
// no-op
}
return null;
}

View file

@ -0,0 +1,34 @@
import { v4 as uuidv4 } from "uuid";
import { Message, ToolMessage } from "@langchain/langgraph-sdk";
export const DO_NOT_RENDER_ID_PREFIX = "do-not-render-";
export function ensureToolCallsHaveResponses(messages: Message[]): Message[] {
const newMessages: ToolMessage[] = [];
messages.forEach((message, index) => {
if (message.type !== "ai" || message.tool_calls?.length === 0) {
// If it's not an AI message, or it doesn't have tool calls, we can ignore.
return;
}
// If it has tool calls, ensure the message which follows this is a tool message
const followingMessage = messages[index + 1];
if (followingMessage && followingMessage.type === "tool") {
// Following message is a tool message, so we can ignore.
return;
}
// Since the following message is not a tool message, we must create a new tool message
newMessages.push(
...(message.tool_calls?.map((tc) => ({
type: "tool" as const,
tool_call_id: tc.id ?? "",
id: `${DO_NOT_RENDER_ID_PREFIX}${uuidv4()}`,
name: tc.name,
content: "Successfully handled tool call.",
})) ?? []),
);
});
return newMessages;
}

View file

@ -0,0 +1,89 @@
import type { Base64ContentBlock } from "@langchain/core/messages";
import { toast } from "sonner";
// Returns a Promise of a typed multimodal block for images or PDFs
export async function fileToContentBlock(
file: File,
): Promise<Base64ContentBlock> {
const supportedImageTypes = [
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
];
const supportedFileTypes = [...supportedImageTypes, "application/pdf"];
if (!supportedFileTypes.includes(file.type)) {
toast.error(
`Unsupported file type: ${file.type}. Supported types are: ${supportedFileTypes.join(", ")}`,
);
return Promise.reject(new Error(`Unsupported file type: ${file.type}`));
}
const data = await fileToBase64(file);
if (supportedImageTypes.includes(file.type)) {
return {
type: "image",
source_type: "base64",
mime_type: file.type,
data,
metadata: { name: file.name },
};
}
// PDF
return {
type: "file",
source_type: "base64",
mime_type: "application/pdf",
data,
metadata: { filename: file.name },
};
}
// Helper to convert File to base64 string
export async function fileToBase64(file: File): Promise<string> {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => {
const result = reader.result as string;
// Remove the data:...;base64, prefix
resolve(result.split(",")[1]);
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
// Type guard for Base64ContentBlock
export function isBase64ContentBlock(
block: unknown,
): block is Base64ContentBlock {
if (typeof block !== "object" || block === null || !("type" in block))
return false;
// file type (legacy)
if (
(block as { type: unknown }).type === "file" &&
"source_type" in block &&
(block as { source_type: unknown }).source_type === "base64" &&
"mime_type" in block &&
typeof (block as { mime_type?: unknown }).mime_type === "string" &&
((block as { mime_type: string }).mime_type.startsWith("image/") ||
(block as { mime_type: string }).mime_type === "application/pdf")
) {
return true;
}
// image type (new)
if (
(block as { type: unknown }).type === "image" &&
"source_type" in block &&
(block as { source_type: unknown }).source_type === "base64" &&
"mime_type" in block &&
typeof (block as { mime_type?: unknown }).mime_type === "string" &&
(block as { mime_type: string }).mime_type.startsWith("image/")
) {
return true;
}
return false;
}

View file

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View file

@ -0,0 +1,285 @@
import React, {
createContext,
useContext,
ReactNode,
useState,
useEffect,
} from "react";
import { useStream } from "@langchain/langgraph-sdk/react";
import { type Message } from "@langchain/langgraph-sdk";
import {
uiMessageReducer,
isUIMessage,
isRemoveUIMessage,
type UIMessage,
type RemoveUIMessage,
} from "@langchain/langgraph-sdk/react-ui";
import { useQueryState } from "nuqs";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { LangGraphLogoSVG } from "@/components/icons/langgraph";
import { Label } from "@/components/ui/label";
import { ArrowRight } from "lucide-react";
import { PasswordInput } from "@/components/ui/password-input";
import { getApiKey } from "@/lib/api-key";
import { useThreads } from "./Thread";
import { toast } from "sonner";
export type StateType = { messages: Message[]; ui?: UIMessage[] };
const useTypedStream = useStream<
StateType,
{
UpdateType: {
messages?: Message[] | Message | string;
ui?: (UIMessage | RemoveUIMessage)[] | UIMessage | RemoveUIMessage;
context?: Record<string, unknown>;
};
CustomEventType: UIMessage | RemoveUIMessage;
}
>;
type StreamContextType = ReturnType<typeof useTypedStream>;
const StreamContext = createContext<StreamContextType | undefined>(undefined);
async function sleep(ms = 4000) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function checkGraphStatus(
apiUrl: string,
apiKey: string | null,
): Promise<boolean> {
try {
const res = await fetch(`${apiUrl}/info`, {
...(apiKey && {
headers: {
"X-Api-Key": apiKey,
},
}),
});
return res.ok;
} catch (e) {
console.error(e);
return false;
}
}
const StreamSession = ({
children,
apiKey,
apiUrl,
assistantId,
}: {
children: ReactNode;
apiKey: string | null;
apiUrl: string;
assistantId: string;
}) => {
const [threadId, setThreadId] = useQueryState("threadId");
const { getThreads, setThreads } = useThreads();
const streamValue = useTypedStream({
apiUrl,
apiKey: apiKey ?? undefined,
assistantId,
threadId: threadId ?? null,
onCustomEvent: (event, options) => {
if (isUIMessage(event) || isRemoveUIMessage(event)) {
options.mutate((prev) => {
const ui = uiMessageReducer(prev.ui ?? [], event);
return { ...prev, ui };
});
}
},
onThreadId: (id) => {
setThreadId(id);
// Refetch threads list when thread ID changes.
// Wait for some seconds before fetching so we're able to get the new thread that was created.
sleep().then(() => getThreads().then(setThreads).catch(console.error));
},
});
useEffect(() => {
checkGraphStatus(apiUrl, apiKey).then((ok) => {
if (!ok) {
toast.error("Failed to connect to LangGraph server", {
description: () => (
<p>
Please ensure your graph is running at <code>{apiUrl}</code> and
your API key is correctly set (if connecting to a deployed graph).
</p>
),
duration: 10000,
richColors: true,
closeButton: true,
});
}
});
}, [apiKey, apiUrl]);
return (
<StreamContext.Provider value={streamValue}>
{children}
</StreamContext.Provider>
);
};
// Default values for the form
const DEFAULT_API_URL = "http://localhost:2024";
const DEFAULT_ASSISTANT_ID = "agent";
export const StreamProvider: React.FC<{ children: ReactNode }> = ({
children,
}) => {
// Get environment variables
const envApiUrl: string | undefined = process.env.NEXT_PUBLIC_API_URL;
const envAssistantId: string | undefined =
process.env.NEXT_PUBLIC_ASSISTANT_ID;
// Use URL params with env var fallbacks
const [apiUrl, setApiUrl] = useQueryState("apiUrl", {
defaultValue: envApiUrl || "",
});
const [assistantId, setAssistantId] = useQueryState("assistantId", {
defaultValue: envAssistantId || "",
});
// For API key, use localStorage with env var fallback
const [apiKey, _setApiKey] = useState(() => {
const storedKey = getApiKey();
return storedKey || "";
});
const setApiKey = (key: string) => {
window.localStorage.setItem("lg:chat:apiKey", key);
_setApiKey(key);
};
// Determine final values to use, prioritizing URL params then env vars
const finalApiUrl = apiUrl || envApiUrl;
const finalAssistantId = assistantId || envAssistantId;
// Show the form if we: don't have an API URL, or don't have an assistant ID
if (!finalApiUrl || !finalAssistantId) {
return (
<div className="flex min-h-screen w-full items-center justify-center p-4">
<div className="animate-in fade-in-0 zoom-in-95 bg-background flex max-w-3xl flex-col rounded-lg border shadow-lg">
<div className="mt-14 flex flex-col gap-2 border-b p-6">
<div className="flex flex-col items-start gap-2">
<LangGraphLogoSVG className="h-7" />
<h1 className="text-xl font-semibold tracking-tight">
Agent Chat
</h1>
</div>
<p className="text-muted-foreground">
Welcome to Agent Chat! Before you get started, you need to enter
the URL of the deployment and the assistant / graph ID.
</p>
</div>
<form
onSubmit={(e) => {
e.preventDefault();
const form = e.target as HTMLFormElement;
const formData = new FormData(form);
const apiUrl = formData.get("apiUrl") as string;
const assistantId = formData.get("assistantId") as string;
const apiKey = formData.get("apiKey") as string;
setApiUrl(apiUrl);
setApiKey(apiKey);
setAssistantId(assistantId);
form.reset();
}}
className="bg-muted/50 flex flex-col gap-6 p-6"
>
<div className="flex flex-col gap-2">
<Label htmlFor="apiUrl">
Deployment URL<span className="text-rose-500">*</span>
</Label>
<p className="text-muted-foreground text-sm">
This is the URL of your LangGraph deployment. Can be a local, or
production deployment.
</p>
<Input
id="apiUrl"
name="apiUrl"
className="bg-background"
defaultValue={apiUrl || DEFAULT_API_URL}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="assistantId">
Assistant / Graph ID<span className="text-rose-500">*</span>
</Label>
<p className="text-muted-foreground text-sm">
This is the ID of the graph (can be the graph name), or
assistant to fetch threads from, and invoke when actions are
taken.
</p>
<Input
id="assistantId"
name="assistantId"
className="bg-background"
defaultValue={assistantId || DEFAULT_ASSISTANT_ID}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="apiKey">LangSmith API Key</Label>
<p className="text-muted-foreground text-sm">
This is <strong>NOT</strong> required if using a local LangGraph
server. This value is stored in your browser's local storage and
is only used to authenticate requests sent to your LangGraph
server.
</p>
<PasswordInput
id="apiKey"
name="apiKey"
defaultValue={apiKey ?? ""}
className="bg-background"
placeholder="lsv2_pt_..."
/>
</div>
<div className="mt-2 flex justify-end">
<Button
type="submit"
size="lg"
>
Continue
<ArrowRight className="size-5" />
</Button>
</div>
</form>
</div>
</div>
);
}
return (
<StreamSession
apiKey={apiKey}
apiUrl={apiUrl}
assistantId={assistantId}
>
{children}
</StreamSession>
);
};
// Create a custom hook to use the context
export const useStreamContext = (): StreamContextType => {
const context = useContext(StreamContext);
if (context === undefined) {
throw new Error("useStreamContext must be used within a StreamProvider");
}
return context;
};
export default StreamContext;

View file

@ -0,0 +1,75 @@
import { validate } from "uuid";
import { getApiKey } from "@/lib/api-key";
import { Thread } from "@langchain/langgraph-sdk";
import { useQueryState } from "nuqs";
import {
createContext,
useContext,
ReactNode,
useCallback,
useState,
Dispatch,
SetStateAction,
} from "react";
import { createClient } from "./client";
interface ThreadContextType {
getThreads: () => Promise<Thread[]>;
threads: Thread[];
setThreads: Dispatch<SetStateAction<Thread[]>>;
threadsLoading: boolean;
setThreadsLoading: Dispatch<SetStateAction<boolean>>;
}
const ThreadContext = createContext<ThreadContextType | undefined>(undefined);
function getThreadSearchMetadata(
assistantId: string,
): { graph_id: string } | { assistant_id: string } {
if (validate(assistantId)) {
return { assistant_id: assistantId };
} else {
return { graph_id: assistantId };
}
}
export function ThreadProvider({ children }: { children: ReactNode }) {
const [apiUrl] = useQueryState("apiUrl");
const [assistantId] = useQueryState("assistantId");
const [threads, setThreads] = useState<Thread[]>([]);
const [threadsLoading, setThreadsLoading] = useState(false);
const getThreads = useCallback(async (): Promise<Thread[]> => {
if (!apiUrl || !assistantId) return [];
const client = createClient(apiUrl, getApiKey() ?? undefined);
const threads = await client.threads.search({
metadata: {
...getThreadSearchMetadata(assistantId),
},
limit: 100,
});
return threads;
}, [apiUrl, assistantId]);
const value = {
getThreads,
threads,
setThreads,
threadsLoading,
setThreadsLoading,
};
return (
<ThreadContext.Provider value={value}>{children}</ThreadContext.Provider>
);
}
export function useThreads() {
const context = useContext(ThreadContext);
if (context === undefined) {
throw new Error("useThreads must be used within a ThreadProvider");
}
return context;
}

View file

@ -0,0 +1,8 @@
import { Client } from "@langchain/langgraph-sdk";
export function createClient(apiUrl: string, apiKey: string | undefined) {
return new Client({
apiKey,
apiUrl,
});
}

View file

@ -0,0 +1,65 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ["class"],
content: [
"./index.html",
"./src/**/*.{ts,tsx,js,jsx}",
"./agent/**/*.{ts,tsx,js,jsx}",
],
theme: {
extend: {
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
components: {
".scrollbar-pretty":
"overflow-y-scroll [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-track]:bg-transparent",
},
colors: {
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
chart: {
1: "hsl(var(--chart-1))",
2: "hsl(var(--chart-2))",
3: "hsl(var(--chart-3))",
4: "hsl(var(--chart-4))",
5: "hsl(var(--chart-5))",
},
},
},
},
plugins: [require("tailwindcss-animate"), require("tailwind-scrollbar")],
};

34
apps/web/tsconfig.json Normal file
View file

@ -0,0 +1,34 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
},
"target": "ES2017"
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
"src/hooks/use-threads",
"src/app/thread/[threadId]"
],
"exclude": ["node_modules"]
}

3100
yarn.lock

File diff suppressed because it is too large Load diff