Add agent chat frontend base

This commit is contained in:
武阳
2026-05-06 12:10:22 +08:00
parent 69c335714c
commit d6a2359dc1
69 changed files with 14436 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
productionize
+4
View File
@@ -0,0 +1,4 @@
node_modules
.next
.git
.env
+30
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
View File
@@ -0,0 +1,6 @@
# Ignore artifacts:
build
coverage
#
pnpm-lock.yaml
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Brace Sproul
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+21
View File
@@ -0,0 +1,21 @@
# Claw Code Agent Frontend
This frontend is based on the MIT-licensed `langchain-ai/agent-chat-ui` project and will be adapted into the Claw Code Agent data workbench.
Current scope:
- Keep the upstream chat UI foundation.
- Preserve the artifact side-panel pattern for review and dataset outputs.
- Replace the LangGraph client integration with Claw Code Agent FastAPI endpoints in later steps.
Upstream license attribution is kept in `LICENSE.agent-chat-ui`.
## Development
```bash
corepack enable
pnpm install
pnpm dev
```
The default Next.js dev server runs on `http://localhost:3000`.
+21
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
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
View File
@@ -0,0 +1,10 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
serverActions: {
bodySizeLimit: "10mb",
},
},
};
export default nextConfig;
+106
View File
@@ -0,0 +1,106 @@
{
"name": "claw-code-agent-frontend",
"readme": "./README.md",
"homepage": "https://github.com/HarnessLab/claw-code-agent",
"repository": {
"type": "git",
"url": "git+https://github.com/HarnessLab/claw-code-agent.git"
},
"private": true,
"version": "0.1.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": "^1.1.44",
"@langchain/langgraph": "^1.2.9",
"@langchain/langgraph-sdk": "^1.8.10",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tooltip": "^1.2.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"esbuild": "^0.28.0",
"esbuild-plugin-tailwindcss": "^2.2.0",
"framer-motion": "^12.38.0",
"katex": "^0.16.45",
"langgraph-nextjs-api-passthrough": "^0.1.4",
"lodash": "^4.18.1",
"lucide-react": "^0.476.0",
"next-themes": "^0.4.6",
"nuqs": "^2.8.9",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-markdown": "^10.1.0",
"react-syntax-highlighter": "^15.6.1",
"recharts": "^2.15.3",
"rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss-animate": "^1.0.7",
"use-stick-to-bottom": "^1.1.4",
"uuid": "^14.0.0",
"zod": "^4.4.2"
},
"devDependencies": {
"@eslint/js": "^9.27.0",
"@playwright/test": "^1.59.1",
"@tailwindcss/postcss": "^4.2.4",
"@types/lodash": "^4.17.24",
"@types/node": "^22.15.18",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/uuid": "^10.0.0",
"autoprefixer": "^10.5.0",
"dotenv": "^16.5.0",
"eslint": "^9.39.3",
"eslint-config-next": "16.2.4",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"next": "^15.5.15",
"postcss": "^8.5.13",
"prettier": "^3.8.3",
"prettier-plugin-tailwindcss": "^0.8.0",
"tailwind-scrollbar": "^4.0.2",
"tailwindcss": "^4.2.4",
"typescript": "~5.8.3",
"typescript-eslint": "^8.59.1"
},
"overrides": {
"react-is": "^19.0.0-rc-69d4b800-20241021"
},
"pnpm": {
"overrides": {
"@eslint/config-array>minimatch": "^3.1.3",
"eslint>minimatch": "^3.1.3",
"eslint-plugin-import>minimatch": "^3.1.3",
"eslint-plugin-jsx-a11y>minimatch": "^3.1.3",
"eslint-plugin-react>minimatch": "^3.1.3",
"@typescript-eslint/typescript-estree>minimatch": "^9.0.6",
"micromatch>picomatch": "^2.3.2",
"tinyglobby>picomatch": "^4.0.4",
"flat-cache>flatted": "^3.4.2",
"minimatch@9>brace-expansion": "^5.0.5",
"eslint>ajv": "^6.14.0",
"mdast-util-to-hast": "^13.2.1",
"refractor>prismjs": "^1.30.0"
}
},
"packageManager": "pnpm@10.5.1"
}
+7728
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};
+11
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;
@@ -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: 15 KiB

+151
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);
}
}
+30
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
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>
);
}
@@ -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>
);
@@ -0,0 +1,102 @@
export function LangGraphLogoSVG({
className,
width,
height,
}: {
width?: number;
height?: number;
className?: string;
}) {
return (
<svg
width={width}
height={height}
className={className}
viewBox="0 0 3000 554"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clip-path="url(#clip0_7880_26096)">
<path
d="M1599.44 227.244H1598.94C1589.71 205.732 1563.05 182.656 1518.96 182.656C1447.21 182.656 1395.95 232.889 1395.95 316.953C1395.95 401.018 1447.71 452.28 1519.45 452.28C1565.07 452.28 1588.64 429.738 1598.9 404.641H1599.4V436.413C1599.4 481.535 1576.32 503.047 1534.82 503.047C1499.96 503.047 1476.92 490.727 1470.78 462.044H1406.21C1413.38 514.337 1461.59 553.28 1535.89 553.28C1606.11 553.28 1668.62 517.884 1668.62 427.183V189.827H1599.47V227.244H1599.44ZM1533.79 400.522C1486.12 400.522 1462.01 361.045 1462.01 317.487C1462.01 273.929 1486.61 234.987 1533.79 234.987C1580.97 234.987 1605.54 279.574 1605.54 317.487C1605.54 355.4 1586.05 400.522 1533.79 400.522Z"
fill="#030710"
/>
<path
d="M1088.87 294.449C1088.87 229.341 1058.13 181.664 975.056 181.664C905.333 181.664 869.022 227.358 861.317 270.878H923.336C930.01 246.276 949.501 230.905 975.628 230.905C1005.84 230.905 1024.3 243.187 1024.3 264.203C1024.3 286.745 1007.9 291.894 973.568 297.005C936.647 302.612 849.531 309.782 849.531 382.595C849.531 427.182 884.393 462.044 942.292 462.044C992.525 462.044 1014.57 435.383 1025.33 414.405H1025.86V427.716C1025.86 436.413 1026.39 446.177 1027.92 454.873H1094.02C1090.4 437.938 1088.91 410.781 1088.91 390.299V294.449H1088.87ZM1025.33 331.904C1025.33 393.427 994.088 413.375 960.257 413.375C930.544 413.375 915.135 399.53 915.135 379.543C915.135 359.557 931.04 348.801 966.398 341.592C1005.34 333.926 1019.19 322.14 1025.33 309.324V331.866V331.904Z"
fill="#030710"
/>
<path
d="M1274.43 182.695C1233.43 182.695 1209.89 204.703 1194.98 235.483V189.866H1130.41V454.875H1196.51V335.949C1196.51 271.375 1220.61 240.099 1255.97 240.099C1293.92 240.099 1304.14 270.345 1304.14 308.792V454.875H1369.75V292.887C1369.75 227.817 1343.08 182.695 1274.39 182.695H1274.43Z"
fill="#030710"
/>
<path
d="M1890.27 140.664C1939.97 140.664 1973.8 160.65 1993.29 203.712H2060.95C2038.41 128.878 1982 82.2305 1890.27 82.2305C1779.05 82.2305 1703.68 161.222 1703.68 272.405C1703.68 383.588 1778.51 461.55 1889.73 461.512C1980.47 461.512 2042.49 412.843 2060.95 340.03H1992.79C1977.92 378.973 1946.14 403.079 1890.27 403.079C1819.51 403.079 1772.37 347.735 1772.37 272.405C1772.37 197.075 1819.02 140.702 1890.27 140.702V140.664Z"
fill="#030710"
/>
<path
d="M2904.65 182.695C2863.64 182.695 2840.11 204.703 2825.2 235.483V189.866H2760.62V454.875H2826.72V335.949C2826.72 271.375 2850.83 240.099 2886.19 240.099C2924.1 240.099 2934.36 270.345 2934.36 308.792V454.875H2999.96V292.887C2999.96 227.817 2973.3 182.695 2904.61 182.695H2904.65Z"
fill="#030710"
/>
<path
d="M2716.03 111.676H2647.88V173.198H2716.03V111.676Z"
fill="#030710"
/>
<path
d="M2648.91 235.751V454.837H2715.01V189.828H2694.79C2669.43 189.828 2648.91 210.387 2648.91 235.751Z"
fill="#030710"
/>
<path
d="M2242.39 182.62C2201.39 182.62 2177.85 204.665 2162.94 234.912V88.8672H2098.37V454.761H2164.47V335.835C2164.47 271.261 2188.57 239.985 2223.93 239.985C2261.88 239.985 2272.1 270.231 2272.1 308.678V454.761H2337.71V292.773C2337.71 227.703 2311.05 182.581 2242.35 182.581L2242.39 182.62Z"
fill="#030710"
/>
<path
d="M2605.84 294.449C2605.84 229.341 2575.1 181.664 2492.03 181.664C2422.31 181.664 2386 227.358 2378.33 270.878H2440.35C2447.02 246.276 2466.51 230.905 2492.64 230.905C2522.85 230.905 2541.31 243.187 2541.31 264.203C2541.31 286.745 2524.91 291.894 2490.58 297.005C2453.66 302.612 2366.54 309.782 2366.54 382.595C2366.54 427.182 2401.4 462.044 2459.3 462.044C2509.54 462.044 2531.58 435.383 2542.34 414.405H2542.83V427.716C2542.83 436.413 2543.33 446.177 2544.89 454.873H2610.99C2607.41 437.938 2605.88 410.781 2605.88 390.299V294.449H2605.84ZM2542.26 331.904C2542.26 393.427 2511.02 413.375 2477.19 413.375C2447.48 413.375 2432.07 399.53 2432.07 379.543C2432.07 359.557 2447.98 348.801 2483.33 341.592C2522.28 333.926 2536.12 322.14 2542.26 309.324V331.866V331.904Z"
fill="#030710"
/>
<path
d="M657.602 333.318V88.8672H591.197V379.431H611.489C636.967 379.431 657.602 358.796 657.602 333.318Z"
fill="#030710"
/>
<path
d="M828.858 396.176H591.197V454.876H828.858V396.176Z"
fill="#030710"
/>
</g>
<g clip-path="url(#clip1_7880_26096)">
<path
d="M153.197 324.988C181.918 296.266 198.063 257.269 198.063 216.654C198.063 176.039 181.904 137.042 153.197 108.32L44.866 0C16.159 28.7218 0 67.7192 0 108.334C0 148.949 16.159 187.946 44.866 216.668L153.183 324.988H153.197Z"
fill="#030710"
/>
<path
d="M379.871 335.012C351.164 306.304 312.153 290.145 271.554 290.145C230.954 290.145 191.944 306.304 163.223 335.012L271.554 443.346C300.261 472.054 339.271 488.213 379.885 488.213C420.498 488.213 459.495 472.054 488.215 443.346L379.885 335.012H379.871Z"
fill="#030710"
/>
<path
d="M45.13 443.096C73.8509 471.804 112.847 487.963 153.461 487.963V334.762H0.25C0.263942 375.377 16.409 414.374 45.13 443.096Z"
fill="#030710"
/>
<path
d="M421.695 174.84C392.974 146.132 353.978 129.959 313.35 129.973C272.737 129.973 233.74 146.132 205.02 174.854L313.35 283.188L421.695 174.84Z"
fill="#030710"
/>
</g>
<defs>
<clipPath id="clip0_7880_26096">
<rect
width="2408.8"
height="471.05"
fill="white"
transform="translate(591.197 82.2305)"
/>
</clipPath>
<clipPath id="clip1_7880_26096">
<rect
width="488.214"
height="488.214"
fill="white"
/>
</clipPath>
</defs>
</svg>
);
}
@@ -0,0 +1,37 @@
import React from "react";
import { MultimodalPreview } from "./MultimodalPreview";
import { cn } from "@/lib/utils";
import { ContentBlock } from "@langchain/core/messages";
interface ContentBlocksPreviewProps {
blocks: ContentBlock.Multimodal.Data[];
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>
);
};
@@ -0,0 +1,115 @@
import React from "react";
import { File, X as XIcon } from "lucide-react";
import { ContentBlock } from "@langchain/core/messages";
import { cn } from "@/lib/utils";
import Image from "next/image";
export interface MultimodalPreviewProps {
block: ContentBlock.Multimodal.Data;
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" &&
typeof block.mimeType === "string" &&
block.mimeType.startsWith("image/")
) {
const url = `data:${block.mimeType};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.mimeType === "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>
);
};
@@ -0,0 +1,499 @@
import React from "react";
import { DecisionWithEdits, SubmitType, HITLRequest } from "../types";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { Undo2 } from "lucide-react";
import { MarkdownText } from "../../markdown-text";
import { haveArgsChanged, prettifyText } from "../utils";
import { toast } from "sonner";
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, unknown> }) {
return (
<div className="flex w-full flex-col items-start gap-6">
{Object.entries(args).map(([key, value]) => {
const stringValue =
typeof value === "string" || typeof value === "number"
? value.toString()
: JSON.stringify(value, null);
return (
<div
key={`args-${key}`}
className="flex flex-col items-start gap-1"
>
<p className="text-sm leading-[18px] text-wrap text-gray-600">
{prettifyText(key)}
</p>
<span className="w-full max-w-full rounded-xl bg-zinc-100 p-3 text-[13px] leading-[18px] text-black">
<MarkdownText>{stringValue}</MarkdownText>
</span>
</div>
);
})}
</div>
);
}
interface InboxItemInputProps {
interruptValue: HITLRequest;
humanResponse: DecisionWithEdits[];
supportsMultipleMethods: boolean;
approveAllowed: boolean;
hasEdited: boolean;
hasAddedResponse: boolean;
initialValues: Record<string, string>;
isLoading: boolean;
selectedSubmitType: SubmitType | undefined;
setHumanResponse: React.Dispatch<React.SetStateAction<DecisionWithEdits[]>>;
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> | void;
}
function ApproveOnly({
isLoading,
actionRequestArgs,
handleSubmit,
}: {
isLoading: boolean;
actionRequestArgs: Record<string, unknown>;
handleSubmit: (
e: React.MouseEvent<HTMLButtonElement, MouseEvent> | React.KeyboardEvent,
) => Promise<void> | void;
}) {
return (
<div className="flex w-full flex-col items-start gap-4 rounded-lg border border-gray-300 p-6">
{Object.keys(actionRequestArgs).length > 0 && (
<ArgsRenderer args={actionRequestArgs} />
)}
<Button
variant="brand"
disabled={isLoading}
onClick={handleSubmit}
className="w-full"
>
Approve
</Button>
</div>
);
}
function EditActionCard({
humanResponse,
isLoading,
initialValues,
onEditChange,
handleSubmit,
actionArgs,
}: {
humanResponse: DecisionWithEdits[];
isLoading: boolean;
initialValues: Record<string, string>;
actionArgs: Record<string, unknown>;
onEditChange: (
text: string | string[],
response: DecisionWithEdits,
key: string | string[],
) => void;
handleSubmit: (
e: React.MouseEvent<HTMLButtonElement, MouseEvent> | React.KeyboardEvent,
) => Promise<void> | void;
}) {
const defaultRows = React.useRef<Record<string, number>>({});
const editResponse = humanResponse.find(
(response) => response.type === "edit",
);
const approveResponse = humanResponse.find(
(response) => response.type === "approve",
);
if (
!editResponse ||
editResponse.type !== "edit" ||
typeof editResponse.edited_action !== "object" ||
!editResponse.edited_action
) {
if (approveResponse) {
return (
<ApproveOnly
actionRequestArgs={actionArgs}
isLoading={isLoading}
handleSubmit={handleSubmit}
/>
);
}
return null;
}
const header = editResponse.acceptAllowed ? "Edit/Approve" : "Edit";
const buttonText =
editResponse.acceptAllowed && !editResponse.editsMade
? "Approve"
: "Submit";
const handleReset = () => {
if (!editResponse.edited_action?.args) {
return;
}
const keysToReset: string[] = [];
const valuesToReset: string[] = [];
Object.entries(initialValues).forEach(([key, value]) => {
if (key in editResponse.edited_action.args) {
const stringValue =
typeof value === "string" || typeof value === "number"
? value.toString()
: JSON.stringify(value, null);
keysToReset.push(key);
valuesToReset.push(stringValue);
}
});
if (keysToReset.length > 0 && valuesToReset.length > 0) {
onEditChange(valuesToReset, editResponse, keysToReset);
}
};
const handleKeyDown = (event: React.KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
event.preventDefault();
handleSubmit(event);
}
};
return (
<div className="flex w-full min-w-full flex-col items-start gap-4 rounded-lg border 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.edited_action.args).map(
([key, value], idx) => {
const stringValue =
typeof value === "string" || typeof value === "number"
? value.toString()
: JSON.stringify(value, null);
if (defaultRows.current[key] === undefined) {
defaultRows.current[key] = !stringValue.length
? 3
: Math.max(stringValue.length / 30, 7);
}
return (
<div
className="flex h-full w-full flex-col items-start gap-1 px-[1px]"
key={`allow-edit-args--${key}-${idx}`}
>
<div className="flex w-full flex-col items-start gap-[6px]">
<p className="min-w-fit text-sm font-medium">
{prettifyText(key)}
</p>
<Textarea
disabled={isLoading}
className="h-full w-full max-w-full"
value={stringValue}
onChange={(event) =>
onEditChange(event.target.value, editResponse, key)
}
onKeyDown={handleKeyDown}
rows={defaultRows.current[key] || 8}
/>
</div>
</div>
);
},
)}
<div className="flex w-full items-center justify-end gap-2">
<Button
variant="brand"
disabled={isLoading}
onClick={handleSubmit}
>
{buttonText}
</Button>
</div>
</div>
);
}
const EditAndApprove = React.memo(EditActionCard);
function RejectActionCard({
humanResponse,
isLoading,
onChange,
handleSubmit,
showArgs,
actionArgs,
}: {
humanResponse: DecisionWithEdits[];
isLoading: boolean;
onChange: (value: string, response: DecisionWithEdits) => void;
handleSubmit: (
e: React.MouseEvent<HTMLButtonElement, MouseEvent> | React.KeyboardEvent,
) => Promise<void> | void;
showArgs: boolean;
actionArgs: Record<string, unknown>;
}) {
const rejectResponse = humanResponse.find(
(response) => response.type === "reject",
);
if (!rejectResponse) {
return null;
}
const handleKeyDown = (event: React.KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
event.preventDefault();
handleSubmit(event);
}
};
return (
<div className="flex w-full max-w-full flex-col items-start gap-4 rounded-xl border border-gray-300 p-6">
<div className="flex w-full items-center justify-between">
<p className="text-base font-semibold text-black">Reject</p>
<ResetButton handleReset={() => onChange("", rejectResponse)} />
</div>
{showArgs && <ArgsRenderer args={actionArgs} />}
<div className="flex w-full flex-col items-start gap-[6px]">
<p className="min-w-fit text-sm font-medium">Reason</p>
<Textarea
disabled={isLoading}
className="w-full max-w-full"
value={rejectResponse.message ?? ""}
onChange={(event) => onChange(event.target.value, rejectResponse)}
onKeyDown={handleKeyDown}
rows={4}
placeholder="Share feedback with the agent..."
/>
</div>
<div className="flex w-full items-center justify-end gap-2">
<Button
variant="brand"
disabled={isLoading}
onClick={handleSubmit}
>
Submit rejection
</Button>
</div>
</div>
);
}
const RejectCard = React.memo(RejectActionCard);
export function InboxItemInput({
interruptValue,
humanResponse,
approveAllowed,
hasEdited,
hasAddedResponse,
initialValues,
isLoading,
supportsMultipleMethods,
selectedSubmitType,
setHumanResponse,
setSelectedSubmitType,
setHasAddedResponse,
setHasEdited,
handleSubmit,
}: InboxItemInputProps) {
const allowedDecisions =
interruptValue.review_configs?.[0]?.allowed_decisions ?? [];
const actionRequest = interruptValue.action_requests?.[0];
const actionArgs = actionRequest?.args ?? {};
const isEditAllowed = allowedDecisions.includes("edit");
const isRejectAllowed = allowedDecisions.includes("reject");
const hasArgs = Object.keys(actionArgs).length > 0;
const showArgsInReject =
hasArgs && !isEditAllowed && !approveAllowed && isRejectAllowed;
const showArgsOutsideCards =
hasArgs && !showArgsInReject && !isEditAllowed && !approveAllowed;
const onEditChange = (
change: string | string[],
response: DecisionWithEdits,
key: string | string[],
) => {
if (
(Array.isArray(change) && !Array.isArray(key)) ||
(!Array.isArray(change) && Array.isArray(key))
) {
toast.error("Error", {
description: "Unable to update edited values.",
richColors: true,
closeButton: true,
});
return;
}
let valuesChanged = true;
if (response.type === "edit" && response.edited_action) {
const updatedArgs = { ...(response.edited_action.args || {}) };
if (Array.isArray(change) && Array.isArray(key)) {
change.forEach((value, index) => {
if (index < key.length) {
updatedArgs[key[index]] = value;
}
});
} else {
updatedArgs[key as string] = change as string;
}
valuesChanged = haveArgsChanged(updatedArgs, initialValues);
}
if (!valuesChanged) {
setHasEdited(false);
if (approveAllowed) {
setSelectedSubmitType("approve");
} else if (hasAddedResponse) {
setSelectedSubmitType("reject");
}
} else {
setSelectedSubmitType("edit");
setHasEdited(true);
}
setHumanResponse((prev) => {
if (response.type !== "edit" || !response.edited_action) {
console.error("Mismatched response type for edit", response.type);
return prev;
}
const newArgs =
Array.isArray(change) && Array.isArray(key)
? {
...response.edited_action.args,
...Object.fromEntries(key.map((k, index) => [k, change[index]])),
}
: {
...response.edited_action.args,
[key as string]: change as string,
};
const newEdit: DecisionWithEdits = {
type: "edit",
edited_action: {
name: response.edited_action.name,
args: newArgs,
},
};
return prev.map((existing) => {
if (existing.type !== "edit") {
return existing;
}
if (existing.acceptAllowed) {
return {
...newEdit,
acceptAllowed: true,
editsMade: valuesChanged,
};
}
return newEdit;
});
});
};
const onRejectChange = (change: string, response: DecisionWithEdits) => {
if (response.type !== "reject") {
console.error("Mismatched response type for rejection");
return;
}
const trimmed = change.trim();
setHasAddedResponse(!!trimmed);
if (!trimmed) {
if (hasEdited) {
setSelectedSubmitType("edit");
} else if (approveAllowed) {
setSelectedSubmitType("approve");
}
} else {
setSelectedSubmitType("reject");
}
setHumanResponse((prev) =>
prev.map((existing) =>
existing.type === "reject"
? { type: "reject", message: change }
: existing,
),
);
};
return (
<div className="flex w-full max-w-full flex-col items-start justify-start gap-2">
{showArgsOutsideCards && <ArgsRenderer args={actionArgs} />}
<div className="flex w-full flex-col items-stretch gap-2">
<EditAndApprove
humanResponse={humanResponse}
isLoading={isLoading}
initialValues={initialValues}
actionArgs={actionArgs}
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}
<RejectCard
humanResponse={humanResponse}
isLoading={isLoading}
showArgs={showArgsInReject}
actionArgs={actionArgs}
onChange={onRejectChange}
handleSubmit={handleSubmit}
/>
{isLoading && (
<p className="text-sm text-gray-600">Submitting decision...</p>
)}
{selectedSubmitType && supportsMultipleMethods && (
<p className="text-xs text-gray-500">
Currently selected: {prettifyText(selectedSubmitType)}
</p>
)}
</div>
</div>
);
}
@@ -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 if ("getType" in message) {
type = (message as BaseMessage).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 min-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>
);
}
@@ -0,0 +1,449 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Interrupt } from "@langchain/langgraph-sdk";
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, buildDecisionFromState } from "../utils";
import { Decision, HITLRequest, DecisionType, ActionRequest } from "../types";
import { useStreamContext } from "@/providers/Stream";
interface ThreadActionsViewProps {
interrupt: Interrupt<HITLRequest>;
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>
);
}
function isValidHitlRequest(
interrupt: Interrupt<HITLRequest>,
): interrupt is Interrupt<HITLRequest> & { value: HITLRequest } {
return (
!!interrupt.value &&
Array.isArray(interrupt.value.action_requests) &&
interrupt.value.action_requests.length > 0 &&
Array.isArray(interrupt.value.review_configs) &&
interrupt.value.review_configs.length > 0
);
}
function getDecisionStatus(
decision: Decision | undefined,
): DecisionType | null {
if (!decision) return null;
return decision.type;
}
function getActionTitle(action?: ActionRequest) {
return action?.name ?? "Unknown interrupt";
}
export function ThreadActionsView({
interrupt,
handleShowSidePanel,
showDescription,
showState,
}: ThreadActionsViewProps) {
const stream = useStreamContext();
const [threadId] = useQueryState("threadId");
const [apiUrl] = useQueryState("apiUrl");
const [currentIndex, setCurrentIndex] = useState(0);
const [addressedActions, setAddressedActions] = useState<
Map<number, Decision>
>(new Map());
const [submittingAll, setSubmittingAll] = useState(false);
const hitlValue = interrupt.value;
const actionRequests = useMemo(
() => hitlValue?.action_requests ?? [],
[hitlValue?.action_requests],
);
const reviewConfigs = useMemo(
() => hitlValue?.review_configs ?? [],
[hitlValue?.review_configs],
);
const hasMultipleActions = actionRequests.length > 1;
const currentAction = actionRequests[currentIndex];
const matchingConfig =
reviewConfigs.find(
(config) => config.action_name === currentAction?.name,
) ?? reviewConfigs[currentIndex];
const singleActionInterrupt = useMemo(() => {
if (!currentAction || !matchingConfig) {
return interrupt;
}
return {
...interrupt,
value: {
action_requests: [currentAction],
review_configs: [matchingConfig],
},
};
}, [interrupt, currentAction, matchingConfig]);
const {
approveAllowed,
hasEdited,
hasAddedResponse,
streaming,
supportsMultipleMethods,
streamFinished,
loading,
handleSubmit,
handleResolve,
setSelectedSubmitType,
setHasAddedResponse,
setHasEdited,
humanResponse,
setHumanResponse,
selectedSubmitType,
initialHumanInterruptEditValue,
} = useInterruptedActions({
interrupt: singleActionInterrupt,
});
useEffect(() => {
setCurrentIndex(0);
setAddressedActions(new Map());
}, [interrupt]);
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 handleApproveAll = useCallback(() => {
if (!hasMultipleActions) return;
try {
const allDecisions: Decision[] = actionRequests.map(() => ({
type: "approve",
}));
stream.submit(
{},
{
command: {
resume: { decisions: allDecisions },
},
},
);
toast("Success", {
description: "All actions approved successfully.",
duration: 5000,
});
} catch (error) {
console.error("Error approving all actions", error);
toast.error("Error", {
description: "Failed to approve all actions.",
richColors: true,
closeButton: true,
duration: 5000,
});
}
}, [actionRequests, hasMultipleActions, stream]);
const handleSubmitAll = useCallback(() => {
if (!hasMultipleActions) return;
if (addressedActions.size !== actionRequests.length) {
toast.error("Error", {
description: `Please address all ${actionRequests.length} actions before submitting.`,
richColors: true,
closeButton: true,
duration: 5000,
});
return;
}
try {
setSubmittingAll(true);
const allDecisions = actionRequests.map((_, index) => {
const decision = addressedActions.get(index);
if (!decision) {
throw new Error(`Missing decision for action ${index + 1}`);
}
return decision;
});
stream.submit(
{},
{
command: {
resume: { decisions: allDecisions },
},
},
);
toast("Success", {
description: "All actions submitted successfully.",
duration: 5000,
});
setAddressedActions(new Map());
} catch (error) {
console.error("Error submitting all actions", error);
toast.error("Error", {
description: "Failed to submit actions.",
richColors: true,
closeButton: true,
duration: 5000,
});
} finally {
setSubmittingAll(false);
}
}, [actionRequests, addressedActions, hasMultipleActions, stream]);
const allAllowApprove = useMemo(() => {
if (!hasMultipleActions) return false;
return actionRequests.every((actionRequest) => {
const matching = reviewConfigs.find(
(config) => config.action_name === actionRequest.name,
);
return matching?.allowed_decisions.includes("approve");
});
}, [actionRequests, reviewConfigs, hasMultipleActions]);
const handleSaveDecision = () => {
const { decision, error } = buildDecisionFromState(
humanResponse,
selectedSubmitType,
);
if (!decision || error) {
toast.error("Error", {
description: error ?? "Unable to determine decision.",
richColors: true,
closeButton: true,
duration: 5000,
});
return;
}
setAddressedActions((prev) => {
const next = new Map(prev);
next.set(currentIndex, decision);
return next;
});
toast("Success", {
description: `Action ${currentIndex + 1} captured.`,
duration: 3000,
});
if (currentIndex < actionRequests.length - 1) {
setCurrentIndex((prev) => Math.min(actionRequests.length - 1, prev + 1));
}
};
const currentTitle = getActionTitle(currentAction);
const actionsDisabled = loading || streaming || submittingAll;
const hasAllDecisions =
hasMultipleActions && addressedActions.size === actionRequests.length;
if (!isValidHitlRequest(interrupt)) {
return (
<div className="flex min-h-full w-full flex-col items-center justify-center rounded-2xl bg-gray-50/50 p-8">
<p className="text-sm text-gray-600">
Unable to render interrupt. The data provided is not in the expected
HITL format.
</p>
</div>
);
}
const interruptValue = singleActionInterrupt.value as HITLRequest;
return (
<div className="flex min-h-full w-full max-w-full flex-col gap-9">
<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">
{hasMultipleActions
? `${currentTitle} (${currentIndex + 1}/${actionRequests.length})`
: currentTitle}
</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 flex-wrap 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>
{hasMultipleActions && allAllowApprove && (
<Button
variant="outline"
className="border-gray-500 bg-white font-normal text-gray-800"
onClick={handleApproveAll}
disabled={actionsDisabled}
>
Approve All
</Button>
)}
</div>
{hasMultipleActions && (
<div className="flex w-full items-center gap-2">
{actionRequests.map((_, index) => {
const status = getDecisionStatus(addressedActions.get(index));
return (
<button
type="button"
key={index}
onClick={() => setCurrentIndex(index)}
className={cn(
"h-2 flex-1 rounded-full border transition-colors",
"border-gray-300 bg-gray-200",
status === "approve" && "border-emerald-500 bg-emerald-200",
status === "reject" && "border-red-500 bg-red-200",
status === "edit" && "border-amber-500 bg-amber-200",
index === currentIndex &&
"outline-primary outline-2 outline-offset-2",
)}
>
<span className="sr-only">Action {index + 1}</span>
</button>
);
})}
</div>
)}
<InboxItemInput
approveAllowed={approveAllowed}
hasEdited={hasEdited}
hasAddedResponse={hasAddedResponse}
interruptValue={interruptValue}
humanResponse={humanResponse}
initialValues={initialHumanInterruptEditValue.current}
setHumanResponse={setHumanResponse}
supportsMultipleMethods={supportsMultipleMethods}
setSelectedSubmitType={setSelectedSubmitType}
setHasAddedResponse={setHasAddedResponse}
setHasEdited={setHasEdited}
handleSubmit={hasMultipleActions ? handleSaveDecision : handleSubmit}
isLoading={hasMultipleActions ? submittingAll : loading}
selectedSubmitType={selectedSubmitType}
/>
{hasMultipleActions && (
<div className="flex w-full items-center justify-between">
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={currentIndex === 0}
onClick={() => setCurrentIndex((prev) => Math.max(0, prev - 1))}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
disabled={currentIndex === actionRequests.length - 1}
onClick={() =>
setCurrentIndex((prev) =>
Math.min(actionRequests.length - 1, prev + 1),
)
}
>
Next
</Button>
</div>
<Button
variant="brand"
disabled={!hasAllDecisions || submittingAll}
onClick={handleSubmitAll}
>
{submittingAll
? "Submitting..."
: `Submit all ${actionRequests.length} decisions`}
</Button>
</div>
)}
{!hasMultipleActions && streamFinished && (
<p className="text-base font-medium text-green-600">
Successfully finished Graph invocation.
</p>
)}
</div>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -0,0 +1,240 @@
import { useStreamContext } from "@/providers/Stream";
import { END } from "@langchain/langgraph/web";
import { Interrupt } from "@langchain/langgraph-sdk";
import { toast } from "sonner";
import {
Dispatch,
KeyboardEvent,
MutableRefObject,
SetStateAction,
useEffect,
useRef,
useState,
} from "react";
import { Decision, DecisionWithEdits, HITLRequest, SubmitType } from "../types";
import { buildDecisionFromState, createDefaultHumanResponse } from "../utils";
interface UseInterruptedActionsInput {
interrupt: Interrupt<HITLRequest>;
}
interface UseInterruptedActionsValue {
handleSubmit: (
e: React.MouseEvent<HTMLButtonElement, MouseEvent> | KeyboardEvent,
) => Promise<void>;
handleResolve: (
e: React.MouseEvent<HTMLButtonElement, MouseEvent>,
) => Promise<void>;
streaming: boolean;
streamFinished: boolean;
loading: boolean;
supportsMultipleMethods: boolean;
hasEdited: boolean;
hasAddedResponse: boolean;
approveAllowed: boolean;
humanResponse: DecisionWithEdits[];
selectedSubmitType: SubmitType | undefined;
setSelectedSubmitType: Dispatch<SetStateAction<SubmitType | undefined>>;
setHumanResponse: Dispatch<SetStateAction<DecisionWithEdits[]>>;
setHasAddedResponse: Dispatch<SetStateAction<boolean>>;
setHasEdited: Dispatch<SetStateAction<boolean>>;
initialHumanInterruptEditValue: MutableRefObject<Record<string, string>>;
}
export default function useInterruptedActions({
interrupt,
}: UseInterruptedActionsInput): UseInterruptedActionsValue {
const thread = useStreamContext();
const [humanResponse, setHumanResponse] = useState<DecisionWithEdits[]>([]);
const [loading, setLoading] = useState(false);
const [streaming, setStreaming] = useState(false);
const [streamFinished, setStreamFinished] = useState(false);
const [selectedSubmitType, setSelectedSubmitType] = useState<SubmitType>();
const [hasEdited, setHasEdited] = useState(false);
const [hasAddedResponse, setHasAddedResponse] = useState(false);
const [approveAllowed, setApproveAllowed] = useState(false);
const initialHumanInterruptEditValue = useRef<Record<string, string>>({});
useEffect(() => {
const hitlValue = interrupt.value as HITLRequest | undefined;
initialHumanInterruptEditValue.current = {};
if (!hitlValue) {
setHumanResponse([]);
setSelectedSubmitType(undefined);
setApproveAllowed(false);
setHasEdited(false);
setHasAddedResponse(false);
return;
}
try {
const { responses, defaultSubmitType, hasApprove } =
createDefaultHumanResponse(hitlValue, initialHumanInterruptEditValue);
setHumanResponse(responses);
setSelectedSubmitType(defaultSubmitType);
setApproveAllowed(hasApprove);
setHasEdited(false);
setHasAddedResponse(false);
} catch (error) {
console.error("Error formatting and setting human response state", error);
setHumanResponse([]);
setSelectedSubmitType(undefined);
setApproveAllowed(false);
}
}, [interrupt]);
const resumeRun = (decisions: Decision[]): boolean => {
try {
thread.submit(
{},
{
command: {
resume: {
decisions,
},
},
},
);
return true;
} catch (error) {
console.error("Error sending human response", error);
return false;
}
};
const handleSubmit = async (
e: React.MouseEvent<HTMLButtonElement, MouseEvent> | KeyboardEvent,
) => {
e.preventDefault();
const { decision, error } = buildDecisionFromState(
humanResponse,
selectedSubmitType,
);
if (!decision) {
toast.error("Error", {
description: error ?? "Unsupported response type.",
duration: 5000,
richColors: true,
closeButton: true,
});
return;
}
if (error) {
toast.error("Error", {
description: error,
duration: 5000,
richColors: true,
closeButton: true,
});
return;
}
let errorOccurred = false;
initialHumanInterruptEditValue.current = {};
try {
setLoading(true);
setStreaming(true);
const resumedSuccessfully = resumeRun([decision]);
if (!resumedSuccessfully) {
errorOccurred = true;
return;
}
toast("Success", {
description: "Response submitted successfully.",
duration: 5000,
});
setStreamFinished(true);
} catch (error: any) {
console.error("Error sending human response", error);
errorOccurred = true;
if ("message" in error && error.message.includes("Invalid assistant")) {
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,
});
}
} finally {
setStreaming(false);
setLoading(false);
if (errorOccurred) {
setStreamFinished(false);
}
}
};
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 (error) {
console.error("Error marking thread as resolved", error);
toast.error("Error", {
description: "Failed to mark thread as resolved.",
richColors: true,
closeButton: true,
duration: 3000,
});
} finally {
setLoading(false);
}
};
const supportsMultipleMethods =
humanResponse.filter((response) =>
["edit", "approve", "reject"].includes(response.type),
).length > 1;
return {
handleSubmit,
handleResolve,
humanResponse,
selectedSubmitType,
streaming,
streamFinished,
loading,
supportsMultipleMethods,
hasEdited,
hasAddedResponse,
approveAllowed,
setSelectedSubmitType,
setHumanResponse,
setHasAddedResponse,
setHasEdited,
initialHumanInterruptEditValue,
};
}
@@ -0,0 +1,104 @@
import { useEffect, useMemo, useState } from "react";
import { Interrupt } from "@langchain/langgraph-sdk";
import { cn } from "@/lib/utils";
import { useStreamContext } from "@/providers/Stream";
import { HITLRequest } from "./types";
import { StateView } from "./components/state-view";
import { ThreadActionsView } from "./components/thread-actions-view";
interface ThreadViewProps {
interrupt: Interrupt<HITLRequest> | Interrupt<HITLRequest>[];
}
export function ThreadView({ interrupt }: ThreadViewProps) {
const thread = useStreamContext();
const interrupts = useMemo(
() =>
(Array.isArray(interrupt) ? interrupt : [interrupt]).filter(
(item): item is Interrupt<HITLRequest> => !!item,
),
[interrupt],
);
const [activeInterruptIndex, setActiveInterruptIndex] = useState(0);
const [showDescription, setShowDescription] = useState(false);
const [showState, setShowState] = useState(false);
const showSidePanel = showDescription || showState;
useEffect(() => {
setActiveInterruptIndex(0);
}, [interrupts.length]);
const activeInterrupt = interrupts[activeInterruptIndex];
const activeDescription =
activeInterrupt?.value?.action_requests?.[0]?.description ?? "";
const handleShowSidePanel = (
showStateFlag: boolean,
showDescriptionFlag: boolean,
) => {
if (showStateFlag && showDescriptionFlag) {
console.error("Cannot show both state and description");
return;
}
if (showStateFlag) {
setShowDescription(false);
setShowState(true);
} else if (showDescriptionFlag) {
setShowState(false);
setShowDescription(true);
} else {
setShowState(false);
setShowDescription(false);
}
};
if (!activeInterrupt) {
return null;
}
return (
<div className="flex h-full w-full flex-col rounded-2xl bg-gray-50 p-8 lg:flex-row">
{showSidePanel ? (
<StateView
handleShowSidePanel={handleShowSidePanel}
description={activeDescription}
values={thread.values}
view={showState ? "state" : "description"}
/>
) : (
<div className="flex w-full flex-col gap-6">
{interrupts.length > 1 && (
<div className="flex flex-wrap items-center gap-2">
{interrupts.map((it, idx) => {
const title =
it.value?.action_requests?.[0]?.name ??
`Interrupt ${idx + 1}`;
return (
<button
key={it.id ?? idx}
type="button"
onClick={() => setActiveInterruptIndex(idx)}
className={cn(
"rounded-full border px-3 py-1 text-sm transition-colors",
idx === activeInterruptIndex
? "border-primary bg-primary/10 text-primary"
: "hover:border-primary hover:text-primary border-gray-300 bg-white text-gray-600",
)}
>
{title}
</button>
);
})}
</div>
)}
<ThreadActionsView
interrupt={activeInterrupt}
handleShowSidePanel={handleShowSidePanel}
showState={showState}
showDescription={showDescription}
/>
</div>
)}
</div>
);
}
@@ -0,0 +1,104 @@
import { BaseMessage } from "@langchain/core/messages";
import { Interrupt, Thread, ThreadStatus } from "@langchain/langgraph-sdk";
export type DecisionType = "approve" | "edit" | "reject";
export interface Action {
name: string;
args: Record<string, unknown>;
}
export interface ActionRequest {
name: string;
args: Record<string, unknown>;
description?: string;
}
export interface ReviewConfig {
action_name: string;
allowed_decisions: DecisionType[];
args_schema?: Record<string, unknown>;
}
export interface HITLRequest {
action_requests: ActionRequest[];
review_configs: ReviewConfig[];
}
export type Decision =
| { type: "approve" }
| { type: "reject"; message?: string }
| { type: "edit"; edited_action: Action };
export type DecisionWithEdits =
| { type: "approve" }
| { type: "reject"; message?: string }
| {
type: "edit";
edited_action: Action;
acceptAllowed?: boolean;
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: Interrupt<HITLRequest>[] | undefined;
}
| {
status: "idle" | "busy" | "error";
interrupts?: never;
}
);
export type ThreadStatusWithAll = ThreadStatus | "all";
export type SubmitType = DecisionType;
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;
}
@@ -0,0 +1,238 @@
import { BaseMessage, isBaseMessage } from "@langchain/core/messages";
import { format } from "date-fns";
import { startCase } from "lodash";
import {
Action,
Decision,
DecisionWithEdits,
HITLRequest,
SubmitType,
} from "./types";
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 as BaseMessage).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(
hitlRequest: HITLRequest,
initialHumanInterruptEditValue: React.MutableRefObject<
Record<string, string>
>,
): {
responses: DecisionWithEdits[];
defaultSubmitType: SubmitType | undefined;
hasApprove: boolean;
} {
const responses: DecisionWithEdits[] = [];
const actionRequest = hitlRequest.action_requests?.[0];
const reviewConfig =
hitlRequest.review_configs?.find(
(config) => config.action_name === actionRequest?.name,
) ?? hitlRequest.review_configs?.[0];
if (!actionRequest || !reviewConfig) {
return { responses: [], defaultSubmitType: undefined, hasApprove: false };
}
const allowedDecisions = reviewConfig.allowed_decisions ?? [];
if (allowedDecisions.includes("edit")) {
Object.entries(actionRequest.args).forEach(([key, value]) => {
const stringValue =
typeof value === "string" || typeof value === "number"
? value.toString()
: JSON.stringify(value, null);
initialHumanInterruptEditValue.current = {
...initialHumanInterruptEditValue.current,
[key]: stringValue,
};
});
const editedAction: Action = {
name: actionRequest.name,
args: { ...actionRequest.args },
};
responses.push({
type: "edit",
edited_action: editedAction,
acceptAllowed: allowedDecisions.includes("approve"),
editsMade: false,
});
}
if (allowedDecisions.includes("approve")) {
responses.push({ type: "approve" });
}
if (allowedDecisions.includes("reject")) {
responses.push({ type: "reject", message: "" });
}
// Determine default submit type. Priority: approve > reject > edit
let defaultSubmitType: SubmitType | undefined;
if (allowedDecisions.includes("approve")) {
defaultSubmitType = "approve";
} else if (allowedDecisions.includes("reject")) {
defaultSubmitType = "reject";
} else if (allowedDecisions.includes("edit")) {
defaultSubmitType = "edit";
}
const hasApprove = allowedDecisions.includes("approve");
return { responses, defaultSubmitType, hasApprove };
}
export function buildDecisionFromState(
responses: DecisionWithEdits[],
selectedSubmitType: SubmitType | undefined,
): { decision?: Decision; error?: string } {
if (!responses.length) {
return { error: "Please enter a response." };
}
const selectedDecision = responses.find(
(response) => response.type === selectedSubmitType,
);
if (!selectedDecision) {
return { error: "No response selected." };
}
if (selectedDecision.type === "approve") {
return { decision: { type: "approve" } };
}
if (selectedDecision.type === "reject") {
const message = selectedDecision.message?.trim();
if (!message) {
return { error: "Please provide a rejection reason." };
}
return { decision: { type: "reject", message } };
}
if (selectedDecision.type === "edit") {
if (selectedDecision.acceptAllowed && !selectedDecision.editsMade) {
return { decision: { type: "approve" } };
}
return {
decision: {
type: "edit",
edited_action: selectedDecision.edited_action,
},
};
}
return { error: "Unsupported response type." };
}
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;
});
}
@@ -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;
}
@@ -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>
</>
);
}
@@ -0,0 +1,565 @@
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,
} 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: _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"],
streamSubgraphs: true,
streamResumable: true,
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"],
streamSubgraphs: true,
streamResumable: true,
});
};
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>
);
}
@@ -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;
}
@@ -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);
@@ -0,0 +1,230 @@
import { parsePartialJson } from "@langchain/core/output_parsers";
import { useStreamContext } from "@/providers/Stream";
import { AIMessage, Checkpoint, Message } from "@langchain/langgraph-sdk";
import { useStream } from "@langchain/langgraph-sdk/react";
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 as unknown as ReturnType<typeof useStream>}
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 {
interrupt?: unknown;
isLastMessage: boolean;
hasNoAIOrToolMessages: boolean;
}
function Interrupt({
interrupt,
isLastMessage,
hasNoAIOrToolMessages,
}: InterruptProps) {
const fallbackValue = Array.isArray(interrupt)
? (interrupt as Record<string, any>[])
: (((interrupt as { value?: unknown } | undefined)?.value ??
interrupt) as Record<string, any>);
return (
<>
{isAgentInboxInterruptSchema(interrupt) &&
(isLastMessage || hasNoAIOrToolMessages) && (
<ThreadView interrupt={interrupt} />
)}
{interrupt &&
!isAgentInboxInterruptSchema(interrupt) &&
(isLastMessage || hasNoAIOrToolMessages) ? (
<GenericInterruptView interrupt={fallbackValue} />
) : 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 w-full items-start gap-2">
<div className="flex w-full flex-col gap-2">
{isToolResult ? (
<>
<ToolResult message={message} />
<Interrupt
interrupt={threadInterrupt}
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
interrupt={threadInterrupt}
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>
);
}
@@ -0,0 +1,160 @@
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);
}
function isUrl(value: any): boolean {
if (typeof value !== "string") return false;
try {
new URL(value);
return value.startsWith("http://") || value.startsWith("https://");
} catch {
return false;
}
}
function renderInterruptStateItem(value: any): React.ReactNode {
if (isComplexValue(value)) {
return (
<code className="rounded bg-gray-50 px-2 py-1 font-mono text-sm">
{JSON.stringify(value, null, 2)}
</code>
);
} else if (isUrl(value)) {
return (
<a
href={value}
target="_blank"
rel="noopener noreferrer"
className="break-all text-blue-600 underline hover:text-blue-800"
>
{value}
</a>
);
} else {
return String(value);
}
}
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 (but preserve URLs)
const truncateValue = (value: any): any => {
if (typeof value === "string" && value.length > 100) {
// Don't truncate URLs so they remain clickable
if (isUrl(value)) {
return value;
}
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">
{renderInterruptStateItem(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>
);
}
@@ -0,0 +1,151 @@
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"],
streamSubgraphs: true,
streamResumable: true,
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>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
);
};
@@ -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";
@@ -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(" ");
}
+51
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 };
+61
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 };
+75
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,
};
+20
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 };
+22
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 };
@@ -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";
@@ -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 };
+157
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,
};
@@ -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 };
+27
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 };
+29
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 };
@@ -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 };
@@ -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 };
+270
View File
@@ -0,0 +1,270 @@
import { useState, useRef, useEffect, ChangeEvent } from "react";
import { toast } from "sonner";
import { ContentBlock } 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?: ContentBlock.Multimodal.Data[];
}
export function useFileUpload({
initialBlocks = [],
}: UseFileUploadOptions = {}) {
const [contentBlocks, setContentBlocks] =
useState<ContentBlock.Multimodal.Data[]>(initialBlocks);
const dropRef = useRef<HTMLDivElement>(null);
const [dragOver, setDragOver] = useState(false);
const dragCounter = useRef(0);
const isDuplicate = (file: File, blocks: ContentBlock.Multimodal.Data[]) => {
if (file.type === "application/pdf") {
return blocks.some(
(b) =>
b.type === "file" &&
b.mimeType === "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.mimeType === 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 += 1;
setDragOver(true);
}
};
const handleWindowDragLeave = (e: DragEvent) => {
if (e.dataTransfer?.types?.includes("Files")) {
dragCounter.current -= 1;
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>,
) => {
const items = e.clipboardData.items;
if (!items) return;
const files: File[] = [];
for (let i = 0; i < items.length; i += 1) {
const item = items[i];
if (item.kind === "file") {
const file = item.getAsFile();
if (file) files.push(file);
}
}
if (files.length === 0) {
return;
}
e.preventDefault();
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.mimeType === "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.mimeType === 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,
};
}
+16
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;
}
@@ -0,0 +1,52 @@
import { Interrupt } from "@langchain/langgraph-sdk";
import { HITLRequest } from "@/components/thread/agent-inbox/types";
export function isAgentInboxInterruptSchema(
value: unknown,
): value is Interrupt<HITLRequest> | Interrupt<HITLRequest>[] {
const valueAsObject = Array.isArray(value) ? value[0] : value;
if (!valueAsObject || typeof valueAsObject !== "object") {
return false;
}
const interrupt = valueAsObject as Interrupt<HITLRequest>;
if (!interrupt.value || typeof interrupt.value !== "object") {
return false;
}
const hitlValue = interrupt.value as Partial<HITLRequest>;
const { action_requests: actionRequests, review_configs: reviewConfigs } =
hitlValue;
if (!Array.isArray(actionRequests) || actionRequests.length === 0) {
return false;
}
if (!Array.isArray(reviewConfigs) || reviewConfigs.length === 0) {
return false;
}
const hasValidActionRequests = actionRequests.every((request) => {
return (
request &&
typeof request === "object" &&
"name" in request &&
typeof request.name === "string" &&
"args" in request &&
request.args !== null &&
typeof request.args === "object"
);
});
const hasValidConfigs = reviewConfigs.every((config) => {
return (
config &&
typeof config === "object" &&
"action_name" in config &&
typeof config.action_name === "string" &&
"allowed_decisions" in config &&
Array.isArray(config.allowed_decisions)
);
});
return hasValidActionRequests && hasValidConfigs;
}
+10
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;
}
@@ -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;
}
+83
View File
@@ -0,0 +1,83 @@
import { ContentBlock } 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<ContentBlock.Multimodal.Data> {
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",
mimeType: file.type,
data,
metadata: { name: file.name },
};
}
// PDF
return {
type: "file",
mimeType: "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 ContentBlock.Multimodal.Data {
if (typeof block !== "object" || block === null || !("type" in block))
return false;
// file type (legacy)
if (
(block as { type: unknown }).type === "file" &&
"mimeType" in block &&
typeof (block as { mimeType?: unknown }).mimeType === "string" &&
((block as { mimeType: string }).mimeType.startsWith("image/") ||
(block as { mimeType: string }).mimeType === "application/pdf")
) {
return true;
}
// image type (new)
if (
(block as { type: unknown }).type === "image" &&
"mimeType" in block &&
typeof (block as { mimeType?: unknown }).mimeType === "string" &&
(block as { mimeType: string }).mimeType.startsWith("image/")
) {
return true;
}
return false;
}
+6
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));
}
+326
View File
@@ -0,0 +1,326 @@
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 { Switch } from "@/components/ui/switch";
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,
authScheme?: string,
): Promise<boolean> {
try {
const headers = new Headers();
if (apiKey) headers.set("X-Api-Key", apiKey);
if (authScheme) headers.set("X-Auth-Scheme", authScheme);
const res = await fetch(`${apiUrl}/info`, {
headers,
});
return res.ok;
} catch (e) {
console.error(e);
return false;
}
}
const StreamSession = ({
children,
apiKey,
apiUrl,
assistantId,
authScheme,
}: {
children: ReactNode;
apiKey: string | null;
apiUrl: string;
assistantId: string;
authScheme?: string;
}) => {
const [threadId, setThreadId] = useQueryState("threadId");
const { getThreads, setThreads } = useThreads();
const streamValue = useTypedStream({
apiUrl,
apiKey: apiKey ?? undefined,
assistantId,
...(authScheme && {
defaultHeaders: {
"X-Auth-Scheme": authScheme,
},
}),
threadId: threadId ?? null,
fetchStateHistory: true,
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, authScheme).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, authScheme]);
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";
const AGENT_BUILDER_AUTH_SCHEME = "langsmith-api-key";
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;
const envAuthScheme: string | undefined = process.env.NEXT_PUBLIC_AUTH_SCHEME;
// Use URL params with env var fallbacks
const [apiUrl, setApiUrl] = useQueryState("apiUrl", {
defaultValue: envApiUrl || "",
});
const [assistantId, setAssistantId] = useQueryState("assistantId", {
defaultValue: envAssistantId || "",
});
const [authScheme, setAuthScheme] = useQueryState("authScheme", {
defaultValue: envAuthScheme || "",
});
const [isAgentBuilder, setIsAgentBuilder] = useState(
() =>
(authScheme || envAuthScheme || "").toLowerCase() ===
AGENT_BUILDER_AUTH_SCHEME,
);
// 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;
const finalAuthScheme = authScheme || envAuthScheme || "";
// 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);
setAuthScheme(isAgentBuilder ? AGENT_BUILDER_AUTH_SCHEME : "");
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="flex flex-col gap-3">
<div className="flex items-center justify-between gap-4">
<div className="flex flex-col gap-1">
<Label htmlFor="agentBuilderEnabled">
Built with Agent Builder
</Label>
<p className="text-muted-foreground text-sm">
Enable this for Agent Builder deployments.
</p>
</div>
<Switch
id="agentBuilderEnabled"
checked={isAgentBuilder}
onCheckedChange={setIsAgentBuilder}
/>
</div>
</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={finalApiUrl}
assistantId={finalAssistantId}
authScheme={finalAuthScheme || undefined}
>
{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;
+90
View File
@@ -0,0 +1,90 @@
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 envApiUrl: string | undefined = process.env.NEXT_PUBLIC_API_URL;
const envAssistantId: string | undefined =
process.env.NEXT_PUBLIC_ASSISTANT_ID;
const envAuthScheme: string | undefined = process.env.NEXT_PUBLIC_AUTH_SCHEME;
const [apiUrl] = useQueryState("apiUrl", {
defaultValue: envApiUrl || "",
});
const [assistantId] = useQueryState("assistantId");
const [authScheme] = useQueryState("authScheme", {
defaultValue: envAuthScheme || "",
});
const [threads, setThreads] = useState<Thread[]>([]);
const [threadsLoading, setThreadsLoading] = useState(false);
const getThreads = useCallback(async (): Promise<Thread[]> => {
const resolvedAssistantId = assistantId || envAssistantId;
if (!apiUrl || !resolvedAssistantId) return [];
const client = createClient(
apiUrl,
getApiKey() ?? undefined,
authScheme || undefined,
);
const threads = await client.threads.search({
metadata: {
...getThreadSearchMetadata(resolvedAssistantId),
},
limit: 100,
});
return threads;
}, [apiUrl, assistantId, authScheme, envAssistantId]);
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;
}
+17
View File
@@ -0,0 +1,17 @@
import { Client } from "@langchain/langgraph-sdk";
export function createClient(
apiUrl: string,
apiKey: string | undefined,
authScheme: string | undefined,
) {
return new Client({
apiKey,
apiUrl,
...(authScheme && {
defaultHeaders: {
"X-Auth-Scheme": authScheme,
},
}),
});
}
+65
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
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"]
}