Add agent chat frontend base
This commit is contained in:
@@ -0,0 +1 @@
|
||||
productionize
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.env
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
# Ignore artifacts:
|
||||
build
|
||||
coverage
|
||||
|
||||
#
|
||||
pnpm-lock.yaml
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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 },
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,10 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
experimental: {
|
||||
serverActions: {
|
||||
bodySizeLimit: "10mb",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -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"
|
||||
}
|
||||
Generated
+7728
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
@@ -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 |
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">, </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(" ");
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -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")],
|
||||
};
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user