mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 11:33:14 +00:00
feat: Add web package (#34)
This commit is contained in:
parent
ea793b9912
commit
76dadeeb72
70 changed files with 9493 additions and 40 deletions
1
apps/web/.codespellignore
Normal file
1
apps/web/.codespellignore
Normal file
|
|
@ -0,0 +1 @@
|
|||
productionize
|
||||
4
apps/web/.dockerignore
Normal file
4
apps/web/.dockerignore
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
node_modules
|
||||
.next
|
||||
.git
|
||||
.env
|
||||
13
apps/web/.env.example
Normal file
13
apps/web/.env.example
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# LangGraph Configuration
|
||||
NEXT_PUBLIC_API_URL=http://localhost:2024
|
||||
NEXT_PUBLIC_ASSISTANT_ID=agent
|
||||
# Do NOT prefix this with "NEXT_PUBLIC_" as we do not want this exposed in the client.
|
||||
LANGSMITH_API_KEY=
|
||||
|
||||
# Production LangGraph Configuration (quickstart) - Uncomment to use
|
||||
# NEXT_PUBLIC_ASSISTANT_ID="agent"
|
||||
# This should be the deployment URL of your LangGraph server
|
||||
# LANGGRAPH_API_URL="https://my-agent.default.us.langgraph.app"
|
||||
# This should be the URL of your website + "/api". This is how you connect to the API proxy
|
||||
# NEXT_PUBLIC_API_URL="https://my-website.com/api"
|
||||
# LANGSMITH_API_KEY="lsv2_..."
|
||||
30
apps/web/.gitignore
vendored
Normal file
30
apps/web/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# LangGraph API
|
||||
.langgraph_api
|
||||
.env
|
||||
.next/
|
||||
next-env.d.ts
|
||||
6
apps/web/.prettierignore
Normal file
6
apps/web/.prettierignore
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
# Ignore artifacts:
|
||||
build
|
||||
coverage
|
||||
|
||||
#
|
||||
pnpm-lock.yaml
|
||||
242
apps/web/README.md
Normal file
242
apps/web/README.md
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
# Agent Chat UI
|
||||
|
||||
Agent Chat UI is a Next.js application which enables chatting with any LangGraph server with a `messages` key through a chat interface.
|
||||
|
||||
> [!NOTE]
|
||||
> 🎥 Watch the video setup guide [here](https://youtu.be/lInrwVnZ83o).
|
||||
|
||||
## Setup
|
||||
|
||||
> [!TIP]
|
||||
> Don't want to run the app locally? Use the deployed site here: [agentchat.vercel.app](https://agentchat.vercel.app)!
|
||||
|
||||
First, clone the repository, or run the [`npx` command](https://www.npmjs.com/package/create-agent-chat-app):
|
||||
|
||||
```bash
|
||||
npx create-agent-chat-app
|
||||
```
|
||||
|
||||
or
|
||||
|
||||
```bash
|
||||
git clone https://github.com/langchain-ai/agent-chat-ui.git
|
||||
|
||||
cd agent-chat-ui
|
||||
```
|
||||
|
||||
Install dependencies:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
Run the app:
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
The app will be available at `http://localhost:3000`.
|
||||
|
||||
## Usage
|
||||
|
||||
Once the app is running (or if using the deployed site), you'll be prompted to enter:
|
||||
|
||||
- **Deployment URL**: The URL of the LangGraph server you want to chat with. This can be a production or development URL.
|
||||
- **Assistant/Graph ID**: The name of the graph, or ID of the assistant to use when fetching, and submitting runs via the chat interface.
|
||||
- **LangSmith API Key**: (only required for connecting to deployed LangGraph servers) Your LangSmith API key to use when authenticating requests sent to LangGraph servers.
|
||||
|
||||
After entering these values, click `Continue`. You'll then be redirected to a chat interface where you can start chatting with your LangGraph server.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
You can bypass the initial setup form by setting the following environment variables:
|
||||
|
||||
```bash
|
||||
NEXT_PUBLIC_API_URL=http://localhost:2024
|
||||
NEXT_PUBLIC_ASSISTANT_ID=agent
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> If you want to connect to a production LangGraph server, read the [Going to Production](#going-to-production) section.
|
||||
|
||||
To use these variables:
|
||||
|
||||
1. Copy the `.env.example` file to a new file named `.env`
|
||||
2. Fill in the values in the `.env` file
|
||||
3. Restart the application
|
||||
|
||||
When these environment variables are set, the application will use them instead of showing the setup form.
|
||||
|
||||
## Hiding Messages in the Chat
|
||||
|
||||
You can control the visibility of messages within the Agent Chat UI in two main ways:
|
||||
|
||||
**1. Prevent Live Streaming:**
|
||||
|
||||
To stop messages from being displayed _as they stream_ from an LLM call, add the `langsmith:nostream` tag to the chat model's configuration. The UI normally uses `on_chat_model_stream` events to render streaming messages; this tag prevents those events from being emitted for the tagged model.
|
||||
|
||||
_Python Example:_
|
||||
|
||||
```python
|
||||
from langchain_anthropic import ChatAnthropic
|
||||
|
||||
# Add tags via the .with_config method
|
||||
model = ChatAnthropic().with_config(
|
||||
config={"tags": ["langsmith:nostream"]}
|
||||
)
|
||||
```
|
||||
|
||||
_TypeScript Example:_
|
||||
|
||||
```typescript
|
||||
import { ChatAnthropic } from "@langchain/anthropic";
|
||||
|
||||
const model = new ChatAnthropic()
|
||||
// Add tags via the .withConfig method
|
||||
.withConfig({ tags: ["langsmith:nostream"] });
|
||||
```
|
||||
|
||||
**Note:** Even if streaming is hidden this way, the message will still appear after the LLM call completes if it's saved to the graph's state without further modification.
|
||||
|
||||
**2. Hide Messages Permanently:**
|
||||
|
||||
To ensure a message is _never_ displayed in the chat UI (neither during streaming nor after being saved to state), prefix its `id` field with `do-not-render-` _before_ adding it to the graph's state, along with adding the `langsmith:do-not-render` tag to the chat model's configuration. The UI explicitly filters out any message whose `id` starts with this prefix.
|
||||
|
||||
_Python Example:_
|
||||
|
||||
```python
|
||||
result = model.invoke([messages])
|
||||
# Prefix the ID before saving to state
|
||||
result.id = f"do-not-render-{result.id}"
|
||||
return {"messages": [result]}
|
||||
```
|
||||
|
||||
_TypeScript Example:_
|
||||
|
||||
```typescript
|
||||
const result = await model.invoke([messages]);
|
||||
// Prefix the ID before saving to state
|
||||
result.id = `do-not-render-${result.id}`;
|
||||
return { messages: [result] };
|
||||
```
|
||||
|
||||
This approach guarantees the message remains completely hidden from the user interface.
|
||||
|
||||
## Rendering Artifacts
|
||||
|
||||
The Agent Chat UI supports rendering artifacts in the chat. Artifacts are rendered in a side panel to the right of the chat. To render an artifact, you can obtain the artifact context from the `thread.meta.artifact` field. Here's a sample utility hook for obtaining the artifact context:
|
||||
|
||||
```tsx
|
||||
export function useArtifact<TContext = Record<string, unknown>>() {
|
||||
type Component = (props: {
|
||||
children: React.ReactNode;
|
||||
title?: React.ReactNode;
|
||||
}) => React.ReactNode;
|
||||
|
||||
type Context = TContext | undefined;
|
||||
|
||||
type Bag = {
|
||||
open: boolean;
|
||||
setOpen: (value: boolean | ((prev: boolean) => boolean)) => void;
|
||||
|
||||
context: Context;
|
||||
setContext: (value: Context | ((prev: Context) => Context)) => void;
|
||||
};
|
||||
|
||||
const thread = useStreamContext<
|
||||
{ messages: Message[]; ui: UIMessage[] },
|
||||
{ MetaType: { artifact: [Component, Bag] } }
|
||||
>();
|
||||
|
||||
return thread.meta?.artifact;
|
||||
}
|
||||
```
|
||||
|
||||
After which you can render additional content using the `Artifact` component from the `useArtifact` hook:
|
||||
|
||||
```tsx
|
||||
import { useArtifact } from "../utils/use-artifact";
|
||||
import { LoaderIcon } from "lucide-react";
|
||||
|
||||
export function Writer(props: {
|
||||
title?: string;
|
||||
content?: string;
|
||||
description?: string;
|
||||
}) {
|
||||
const [Artifact, { open, setOpen }] = useArtifact();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
onClick={() => setOpen(!open)}
|
||||
className="cursor-pointer rounded-lg border p-4"
|
||||
>
|
||||
<p className="font-medium">{props.title}</p>
|
||||
<p className="text-sm text-gray-500">{props.description}</p>
|
||||
</div>
|
||||
|
||||
<Artifact title={props.title}>
|
||||
<p className="p-4 whitespace-pre-wrap">{props.content}</p>
|
||||
</Artifact>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Going to Production
|
||||
|
||||
Once you're ready to go to production, you'll need to update how you connect, and authenticate requests to your deployment. By default, the Agent Chat UI is setup for local development, and connects to your LangGraph server directly from the client. This is not possible if you want to go to production, because it requires every user to have their own LangSmith API key, and set the LangGraph configuration themselves.
|
||||
|
||||
### Production Setup
|
||||
|
||||
To productionize the Agent Chat UI, you'll need to pick one of two ways to authenticate requests to your LangGraph server. Below, I'll outline the two options:
|
||||
|
||||
### Quickstart - API Passthrough
|
||||
|
||||
The quickest way to productionize the Agent Chat UI is to use the [API Passthrough](https://github.com/langchain-ai/langgraph-nextjs-api-passthrough) package. This package provides a simple way to proxy requests to your LangGraph server, and handle authentication for you.
|
||||
|
||||
This repository already contains all of the code you need to start using this method. The only configuration you need to do is set the proper environment variables.
|
||||
|
||||
```bash
|
||||
NEXT_PUBLIC_ASSISTANT_ID="agent"
|
||||
# This should be the deployment URL of your LangGraph server
|
||||
LANGGRAPH_API_URL="https://my-agent.default.us.langgraph.app"
|
||||
# This should be the URL of your website + "/api". This is how you connect to the API proxy
|
||||
NEXT_PUBLIC_API_URL="https://my-website.com/api"
|
||||
# Your LangSmith API key which is injected into requests inside the API proxy
|
||||
LANGSMITH_API_KEY="lsv2_..."
|
||||
```
|
||||
|
||||
Let's cover what each of these environment variables does:
|
||||
|
||||
- `NEXT_PUBLIC_ASSISTANT_ID`: The ID of the assistant you want to use when fetching, and submitting runs via the chat interface. This still needs the `NEXT_PUBLIC_` prefix, since it's not a secret, and we use it on the client when submitting requests.
|
||||
- `LANGGRAPH_API_URL`: The URL of your LangGraph server. This should be the production deployment URL.
|
||||
- `NEXT_PUBLIC_API_URL`: The URL of your website + `/api`. This is how you connect to the API proxy. For the [Agent Chat demo](https://agentchat.vercel.app), this would be set as `https://agentchat.vercel.app/api`. You should set this to whatever your production URL is.
|
||||
- `LANGSMITH_API_KEY`: Your LangSmith API key to use when authenticating requests sent to LangGraph servers. Once again, do _not_ prefix this with `NEXT_PUBLIC_` since it's a secret, and is only used on the server when the API proxy injects it into the request to your deployed LangGraph server.
|
||||
|
||||
For in depth documentation, consult the [LangGraph Next.js API Passthrough](https://www.npmjs.com/package/langgraph-nextjs-api-passthrough) docs.
|
||||
|
||||
### Advanced Setup - Custom Authentication
|
||||
|
||||
Custom authentication in your LangGraph deployment is an advanced, and more robust way of authenticating requests to your LangGraph server. Using custom authentication, you can allow requests to be made from the client, without the need for a LangSmith API key. Additionally, you can specify custom access controls on requests.
|
||||
|
||||
To set this up in your LangGraph deployment, please read the LangGraph custom authentication docs for [Python](https://langchain-ai.github.io/langgraph/tutorials/auth/getting_started/), and [TypeScript here](https://langchain-ai.github.io/langgraphjs/how-tos/auth/custom_auth/).
|
||||
|
||||
Once you've set it up on your deployment, you should make the following changes to the Agent Chat UI:
|
||||
|
||||
1. Configure any additional API requests to fetch the authentication token from your LangGraph deployment which will be used to authenticate requests from the client.
|
||||
2. Set the `NEXT_PUBLIC_API_URL` environment variable to your production LangGraph deployment URL.
|
||||
3. Set the `NEXT_PUBLIC_ASSISTANT_ID` environment variable to the ID of the assistant you want to use when fetching, and submitting runs via the chat interface.
|
||||
4. Modify the [`useTypedStream`](src/providers/Stream.tsx) (extension of `useStream`) hook to pass your authentication token through headers to the LangGraph server:
|
||||
|
||||
```tsx
|
||||
const streamValue = useTypedStream({
|
||||
apiUrl: process.env.NEXT_PUBLIC_API_URL,
|
||||
assistantId: process.env.NEXT_PUBLIC_ASSISTANT_ID,
|
||||
// ... other fields
|
||||
defaultHeaders: {
|
||||
Authentication: `Bearer ${addYourTokenHere}`, // this is where you would pass your authentication token
|
||||
},
|
||||
});
|
||||
```
|
||||
21
apps/web/components.json
Normal file
21
apps/web/components.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
33
apps/web/eslint.config.js
Normal file
33
apps/web/eslint.config.js
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ["dist"] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
"react-hooks": reactHooks,
|
||||
"react-refresh": reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
"@typescript-eslint/no-explicit-any": 0,
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"warn",
|
||||
{ args: "none", argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
|
||||
],
|
||||
"react-refresh/only-export-components": [
|
||||
"warn",
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
10
apps/web/next.config.mjs
Normal file
10
apps/web/next.config.mjs
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
experimental: {
|
||||
serverActions: {
|
||||
bodySizeLimit: "10mb",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
88
apps/web/package.json
Normal file
88
apps/web/package.json
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
{
|
||||
"name": "@open-swe/web",
|
||||
"readme": "https://github.com/langchain-ai/open-swe/blob/main/apps/web/README.md",
|
||||
"homepage": "https://github.com/langchain-ai/open-swe/blob/main/README.md",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/langchain-ai/open-swe.git"
|
||||
},
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"lint:fix": "next lint --fix",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@langchain/core": "^0.3.57",
|
||||
"@langchain/langgraph": "^0.2.73",
|
||||
"@langchain/langgraph-sdk": "^0.0.78",
|
||||
"@radix-ui/react-avatar": "^1.1.3",
|
||||
"@radix-ui/react-dialog": "^1.1.6",
|
||||
"@radix-ui/react-label": "^2.1.2",
|
||||
"@radix-ui/react-separator": "^1.1.2",
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"@radix-ui/react-switch": "^1.1.3",
|
||||
"@radix-ui/react-tooltip": "^1.1.8",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"esbuild": "^0.25.0",
|
||||
"esbuild-plugin-tailwindcss": "^2.0.1",
|
||||
"framer-motion": "^12.4.9",
|
||||
"katex": "^0.16.21",
|
||||
"langgraph-nextjs-api-passthrough": "^0.0.4",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.476.0",
|
||||
"next-themes": "^0.4.4",
|
||||
"nuqs": "^2.4.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-markdown": "^10.0.1",
|
||||
"react-syntax-highlighter": "^15.5.0",
|
||||
"recharts": "^2.15.1",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-math": "^6.0.0",
|
||||
"sonner": "^2.0.1",
|
||||
"tailwind-merge": "^3.0.2",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"use-stick-to-bottom": "^1.0.46",
|
||||
"uuid": "^11.1.0",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.19.0",
|
||||
"@tailwindcss/postcss": "^4.0.13",
|
||||
"@types/lodash": "^4.17.16",
|
||||
"@types/node": "^22.13.5",
|
||||
"@types/react": "^19.0.8",
|
||||
"@types/react-dom": "^19.0.3",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"dotenv": "^16.4.7",
|
||||
"eslint": "^9.19.0",
|
||||
"eslint-config-next": "15.2.2",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.18",
|
||||
"globals": "^15.14.0",
|
||||
"next": "^15.2.3",
|
||||
"postcss": "^8.5.3",
|
||||
"prettier": "^3.5.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||
"tailwind-scrollbar": "^4.0.1",
|
||||
"tailwindcss": "^4.0.13",
|
||||
"typescript": "~5.7.2",
|
||||
"typescript-eslint": "^8.22.0"
|
||||
},
|
||||
"overrides": {
|
||||
"react-is": "^19.0.0-rc-69d4b800-20241021"
|
||||
},
|
||||
"packageManager": "yarn@3.5.1"
|
||||
}
|
||||
5
apps/web/postcss.config.mjs
Normal file
5
apps/web/postcss.config.mjs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
11
apps/web/prettier.config.js
Normal file
11
apps/web/prettier.config.js
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
/**
|
||||
* @see https://prettier.io/docs/configuration
|
||||
* @type {import("prettier").Config}
|
||||
*/
|
||||
const config = {
|
||||
endOfLine: "auto",
|
||||
singleAttributePerLine: true,
|
||||
plugins: ["prettier-plugin-tailwindcss"],
|
||||
};
|
||||
|
||||
export default config;
|
||||
8
apps/web/public/logo.svg
Normal file
8
apps/web/public/logo.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 5.8 KiB |
11
apps/web/src/app/api/[..._path]/route.ts
Normal file
11
apps/web/src/app/api/[..._path]/route.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { initApiPassthrough } from "langgraph-nextjs-api-passthrough";
|
||||
|
||||
// This file acts as a proxy for requests to your LangGraph server.
|
||||
// Read the [Going to Production](https://github.com/langchain-ai/agent-chat-ui?tab=readme-ov-file#going-to-production) section for more information.
|
||||
|
||||
export const { GET, POST, PUT, PATCH, DELETE, OPTIONS, runtime } =
|
||||
initApiPassthrough({
|
||||
apiUrl: process.env.LANGGRAPH_API_URL ?? "remove-me", // default, if not defined it will attempt to read process.env.LANGGRAPH_API_URL
|
||||
apiKey: process.env.LANGSMITH_API_KEY ?? "remove-me", // default, if not defined it will attempt to read process.env.LANGSMITH_API_KEY
|
||||
runtime: "edge", // default
|
||||
});
|
||||
BIN
apps/web/src/app/favicon.ico
Normal file
BIN
apps/web/src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
151
apps/web/src/app/globals.css
Normal file
151
apps/web/src/app/globals.css
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
@import "tailwindcss";
|
||||
|
||||
@plugin "tailwindcss-animate";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.87 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.87 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.145 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.145 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.985 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.396 0.141 25.723);
|
||||
--destructive-foreground: oklch(0.637 0.237 25.331);
|
||||
--border: oklch(0.269 0 0);
|
||||
--input: oklch(0.269 0 0);
|
||||
--ring: oklch(0.439 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(0.269 0 0);
|
||||
--sidebar-ring: oklch(0.439 0 0);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
|
||||
:root {
|
||||
--chart-1: 12 76% 61%;
|
||||
--chart-2: 173 58% 39%;
|
||||
--chart-3: 197 37% 24%;
|
||||
--chart-4: 43 74% 66%;
|
||||
--chart-5: 27 87% 67%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.shadow-inner-right {
|
||||
box-shadow: inset -9px 0 6px -1px rgb(0 0 0 / 0.02);
|
||||
}
|
||||
|
||||
.shadow-inner-left {
|
||||
box-shadow: inset 9px 0 6px -1px rgb(0 0 0 / 0.02);
|
||||
}
|
||||
}
|
||||
30
apps/web/src/app/layout.tsx
Normal file
30
apps/web/src/app/layout.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
import { Inter } from "next/font/google";
|
||||
import React from "react";
|
||||
import { NuqsAdapter } from "nuqs/adapters/next/app";
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
preload: true,
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Agent Chat",
|
||||
description: "Agent Chat UX by LangChain",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={inter.className}>
|
||||
<NuqsAdapter>{children}</NuqsAdapter>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
23
apps/web/src/app/page.tsx
Normal file
23
apps/web/src/app/page.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
"use client";
|
||||
|
||||
import { Thread } from "@/components/thread";
|
||||
import { StreamProvider } from "@/providers/Stream";
|
||||
import { ThreadProvider } from "@/providers/Thread";
|
||||
import { ArtifactProvider } from "@/components/thread/artifact";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import React from "react";
|
||||
|
||||
export default function DemoPage(): React.ReactNode {
|
||||
return (
|
||||
<React.Suspense fallback={<div>Loading (layout)...</div>}>
|
||||
<Toaster />
|
||||
<ThreadProvider>
|
||||
<StreamProvider>
|
||||
<ArtifactProvider>
|
||||
<Thread />
|
||||
</ArtifactProvider>
|
||||
</StreamProvider>
|
||||
</ThreadProvider>
|
||||
</React.Suspense>
|
||||
);
|
||||
}
|
||||
12
apps/web/src/components/icons/github.tsx
Normal file
12
apps/web/src/components/icons/github.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
export const GitHubSVG = ({ width = "100%", height = "100%" }) => (
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
width={width}
|
||||
height={height}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>GitHub</title>
|
||||
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
|
||||
</svg>
|
||||
);
|
||||
27
apps/web/src/components/icons/langgraph.tsx
Normal file
27
apps/web/src/components/icons/langgraph.tsx
Normal file
File diff suppressed because one or more lines are too long
37
apps/web/src/components/thread/ContentBlocksPreview.tsx
Normal file
37
apps/web/src/components/thread/ContentBlocksPreview.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import React from "react";
|
||||
import type { Base64ContentBlock } from "@langchain/core/messages";
|
||||
import { MultimodalPreview } from "./MultimodalPreview";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ContentBlocksPreviewProps {
|
||||
blocks: Base64ContentBlock[];
|
||||
onRemove: (idx: number) => void;
|
||||
size?: "sm" | "md" | "lg";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a preview of content blocks with optional remove functionality.
|
||||
* Uses cn utility for robust class merging.
|
||||
*/
|
||||
export const ContentBlocksPreview: React.FC<ContentBlocksPreviewProps> = ({
|
||||
blocks,
|
||||
onRemove,
|
||||
size = "md",
|
||||
className,
|
||||
}) => {
|
||||
if (!blocks.length) return null;
|
||||
return (
|
||||
<div className={cn("flex flex-wrap gap-2 p-3.5 pb-0", className)}>
|
||||
{blocks.map((block, idx) => (
|
||||
<MultimodalPreview
|
||||
key={idx}
|
||||
block={block}
|
||||
removable
|
||||
onRemove={() => onRemove(idx)}
|
||||
size={size}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
120
apps/web/src/components/thread/MultimodalPreview.tsx
Normal file
120
apps/web/src/components/thread/MultimodalPreview.tsx
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import React from "react";
|
||||
import { File, Image as ImageIcon, X as XIcon } from "lucide-react";
|
||||
import type { Base64ContentBlock } from "@langchain/core/messages";
|
||||
import { cn } from "@/lib/utils";
|
||||
import Image from "next/image";
|
||||
export interface MultimodalPreviewProps {
|
||||
block: Base64ContentBlock;
|
||||
removable?: boolean;
|
||||
onRemove?: () => void;
|
||||
className?: string;
|
||||
size?: "sm" | "md" | "lg";
|
||||
}
|
||||
|
||||
export const MultimodalPreview: React.FC<MultimodalPreviewProps> = ({
|
||||
block,
|
||||
removable = false,
|
||||
onRemove,
|
||||
className,
|
||||
size = "md",
|
||||
}) => {
|
||||
// Image block
|
||||
if (
|
||||
block.type === "image" &&
|
||||
block.source_type === "base64" &&
|
||||
typeof block.mime_type === "string" &&
|
||||
block.mime_type.startsWith("image/")
|
||||
) {
|
||||
const url = `data:${block.mime_type};base64,${block.data}`;
|
||||
let imgClass: string = "rounded-md object-cover h-16 w-16 text-lg";
|
||||
if (size === "sm") imgClass = "rounded-md object-cover h-10 w-10 text-base";
|
||||
if (size === "lg") imgClass = "rounded-md object-cover h-24 w-24 text-xl";
|
||||
return (
|
||||
<div className={cn("relative inline-block", className)}>
|
||||
<Image
|
||||
src={url}
|
||||
alt={String(block.metadata?.name || "uploaded image")}
|
||||
className={imgClass}
|
||||
width={size === "sm" ? 16 : size === "md" ? 32 : 48}
|
||||
height={size === "sm" ? 16 : size === "md" ? 32 : 48}
|
||||
/>
|
||||
{removable && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-1 right-1 z-10 rounded-full bg-gray-500 text-white hover:bg-gray-700"
|
||||
onClick={onRemove}
|
||||
aria-label="Remove image"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// PDF block
|
||||
if (
|
||||
block.type === "file" &&
|
||||
block.source_type === "base64" &&
|
||||
block.mime_type === "application/pdf"
|
||||
) {
|
||||
const filename =
|
||||
block.metadata?.filename || block.metadata?.name || "PDF file";
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex items-start gap-2 rounded-md border bg-gray-100 px-3 py-2",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-shrink-0 flex-col items-start justify-start">
|
||||
<File
|
||||
className={cn(
|
||||
"text-teal-700",
|
||||
size === "sm" ? "h-5 w-5" : "h-7 w-7",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
className={cn("min-w-0 flex-1 text-sm break-all text-gray-800")}
|
||||
style={{ wordBreak: "break-all", whiteSpace: "pre-wrap" }}
|
||||
>
|
||||
{String(filename)}
|
||||
</span>
|
||||
{removable && (
|
||||
<button
|
||||
type="button"
|
||||
className="ml-2 self-start rounded-full bg-gray-200 p-1 text-teal-700 hover:bg-gray-300"
|
||||
onClick={onRemove}
|
||||
aria-label="Remove PDF"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback for unknown types
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md border bg-gray-100 px-3 py-2 text-gray-500",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<File className="h-5 w-5 flex-shrink-0" />
|
||||
<span className="truncate text-xs">Unsupported file type</span>
|
||||
{removable && (
|
||||
<button
|
||||
type="button"
|
||||
className="ml-2 rounded-full bg-gray-200 p-1 text-gray-500 hover:bg-gray-300"
|
||||
onClick={onRemove}
|
||||
aria-label="Remove file"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,532 @@
|
|||
import { HumanResponseWithEdits, SubmitType } from "../types";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import React from "react";
|
||||
import { haveArgsChanged, prettifyText } from "../utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Undo2 } from "lucide-react";
|
||||
import { MarkdownText } from "../../markdown-text";
|
||||
import { ActionRequest, HumanInterrupt } from "@langchain/langgraph/prebuilt";
|
||||
import { toast } from "sonner";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
function ResetButton({ handleReset }: { handleReset: () => void }) {
|
||||
return (
|
||||
<Button
|
||||
onClick={handleReset}
|
||||
variant="ghost"
|
||||
className="flex items-center justify-center gap-2 text-gray-500 hover:text-red-500"
|
||||
>
|
||||
<Undo2 className="h-4 w-4" />
|
||||
<span>Reset</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function ArgsRenderer({ args }: { args: Record<string, any> }) {
|
||||
return (
|
||||
<div className="flex w-full flex-col items-start gap-6">
|
||||
{Object.entries(args).map(([k, v]) => {
|
||||
let value = "";
|
||||
if (["string", "number"].includes(typeof v)) {
|
||||
value = v.toString();
|
||||
} else {
|
||||
value = JSON.stringify(v, null);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`args-${k}`}
|
||||
className="flex flex-col items-start gap-1"
|
||||
>
|
||||
<p className="text-sm leading-[18px] text-wrap text-gray-600">
|
||||
{prettifyText(k)}:
|
||||
</p>
|
||||
<span className="w-full max-w-full rounded-xl bg-zinc-100 p-3 text-[13px] leading-[18px] text-black">
|
||||
<MarkdownText>{value}</MarkdownText>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface InboxItemInputProps {
|
||||
interruptValue: HumanInterrupt;
|
||||
humanResponse: HumanResponseWithEdits[];
|
||||
supportsMultipleMethods: boolean;
|
||||
acceptAllowed: boolean;
|
||||
hasEdited: boolean;
|
||||
hasAddedResponse: boolean;
|
||||
initialValues: Record<string, string>;
|
||||
|
||||
streaming: boolean;
|
||||
streamFinished: boolean;
|
||||
|
||||
setHumanResponse: React.Dispatch<
|
||||
React.SetStateAction<HumanResponseWithEdits[]>
|
||||
>;
|
||||
setSelectedSubmitType: React.Dispatch<
|
||||
React.SetStateAction<SubmitType | undefined>
|
||||
>;
|
||||
setHasAddedResponse: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setHasEdited: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
|
||||
handleSubmit: (
|
||||
e: React.MouseEvent<HTMLButtonElement, MouseEvent> | React.KeyboardEvent,
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
function ResponseComponent({
|
||||
humanResponse,
|
||||
streaming,
|
||||
showArgsInResponse,
|
||||
interruptValue,
|
||||
onResponseChange,
|
||||
handleSubmit,
|
||||
}: {
|
||||
humanResponse: HumanResponseWithEdits[];
|
||||
streaming: boolean;
|
||||
showArgsInResponse: boolean;
|
||||
interruptValue: HumanInterrupt;
|
||||
onResponseChange: (change: string, response: HumanResponseWithEdits) => void;
|
||||
handleSubmit: (
|
||||
e: React.MouseEvent<HTMLButtonElement, MouseEvent> | React.KeyboardEvent,
|
||||
) => Promise<void>;
|
||||
}) {
|
||||
const res = humanResponse.find((r) => r.type === "response");
|
||||
if (!res || typeof res.args !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleSubmit(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col items-start gap-4 rounded-xl border-[1px] border-gray-300 p-6">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<p className="text-base font-semibold text-black">
|
||||
Respond to assistant
|
||||
</p>
|
||||
<ResetButton
|
||||
handleReset={() => {
|
||||
onResponseChange("", res);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showArgsInResponse && (
|
||||
<ArgsRenderer args={interruptValue.action_request.args} />
|
||||
)}
|
||||
|
||||
<div className="flex w-full flex-col items-start gap-[6px]">
|
||||
<p className="min-w-fit text-sm font-medium">Response</p>
|
||||
<Textarea
|
||||
disabled={streaming}
|
||||
value={res.args}
|
||||
onChange={(e) => onResponseChange(e.target.value, res)}
|
||||
onKeyDown={handleKeyDown}
|
||||
rows={4}
|
||||
placeholder="Your response here..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="brand"
|
||||
disabled={streaming}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
Send Response
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const Response = React.memo(ResponseComponent);
|
||||
|
||||
function AcceptComponent({
|
||||
streaming,
|
||||
actionRequestArgs,
|
||||
handleSubmit,
|
||||
}: {
|
||||
streaming: boolean;
|
||||
actionRequestArgs: Record<string, any>;
|
||||
handleSubmit: (
|
||||
e: React.MouseEvent<HTMLButtonElement, MouseEvent> | React.KeyboardEvent,
|
||||
) => Promise<void>;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex w-full flex-col items-start gap-4 rounded-lg border-[1px] border-gray-300 p-6">
|
||||
{actionRequestArgs && Object.keys(actionRequestArgs).length > 0 && (
|
||||
<ArgsRenderer args={actionRequestArgs} />
|
||||
)}
|
||||
<Button
|
||||
variant="brand"
|
||||
disabled={streaming}
|
||||
onClick={handleSubmit}
|
||||
className="w-full"
|
||||
>
|
||||
Accept
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EditAndOrAcceptComponent({
|
||||
humanResponse,
|
||||
streaming,
|
||||
initialValues,
|
||||
onEditChange,
|
||||
handleSubmit,
|
||||
interruptValue,
|
||||
}: {
|
||||
humanResponse: HumanResponseWithEdits[];
|
||||
streaming: boolean;
|
||||
initialValues: Record<string, string>;
|
||||
interruptValue: HumanInterrupt;
|
||||
onEditChange: (
|
||||
text: string | string[],
|
||||
response: HumanResponseWithEdits,
|
||||
key: string | string[],
|
||||
) => void;
|
||||
handleSubmit: (
|
||||
e: React.MouseEvent<HTMLButtonElement, MouseEvent> | React.KeyboardEvent,
|
||||
) => Promise<void>;
|
||||
}) {
|
||||
const defaultRows = React.useRef<Record<string, number>>({});
|
||||
const editResponse = humanResponse.find((r) => r.type === "edit");
|
||||
const acceptResponse = humanResponse.find((r) => r.type === "accept");
|
||||
if (
|
||||
!editResponse ||
|
||||
typeof editResponse.args !== "object" ||
|
||||
!editResponse.args
|
||||
) {
|
||||
if (acceptResponse) {
|
||||
return (
|
||||
<AcceptComponent
|
||||
actionRequestArgs={interruptValue.action_request.args}
|
||||
streaming={streaming}
|
||||
handleSubmit={handleSubmit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const header = editResponse.acceptAllowed ? "Edit/Accept" : "Edit";
|
||||
let buttonText = "Submit";
|
||||
if (editResponse.acceptAllowed && !editResponse.editsMade) {
|
||||
buttonText = "Accept";
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
if (
|
||||
!editResponse ||
|
||||
typeof editResponse.args !== "object" ||
|
||||
!editResponse.args ||
|
||||
!editResponse.args.args
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// use initialValues to reset the text areas
|
||||
const keysToReset: string[] = [];
|
||||
const valuesToReset: string[] = [];
|
||||
Object.entries(initialValues).forEach(([k, v]) => {
|
||||
if (k in (editResponse.args as Record<string, any>).args) {
|
||||
const value = ["string", "number"].includes(typeof v)
|
||||
? v
|
||||
: JSON.stringify(v, null);
|
||||
keysToReset.push(k);
|
||||
valuesToReset.push(value);
|
||||
}
|
||||
});
|
||||
|
||||
if (keysToReset.length > 0 && valuesToReset.length > 0) {
|
||||
onEditChange(valuesToReset, editResponse, keysToReset);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleSubmit(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col items-start gap-4 rounded-lg border-[1px] border-gray-300 p-6">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<p className="text-base font-semibold text-black">{header}</p>
|
||||
<ResetButton handleReset={handleReset} />
|
||||
</div>
|
||||
|
||||
{Object.entries(editResponse.args.args).map(([k, v], idx) => {
|
||||
const value = ["string", "number"].includes(typeof v)
|
||||
? v
|
||||
: JSON.stringify(v, null);
|
||||
// Calculate the default number of rows by the total length of the initial value divided by 30
|
||||
// or 8, whichever is greater. Stored in a ref to prevent re-rendering.
|
||||
if (
|
||||
defaultRows.current[k as keyof typeof defaultRows.current] ===
|
||||
undefined
|
||||
) {
|
||||
defaultRows.current[k as keyof typeof defaultRows.current] = !v.length
|
||||
? 3
|
||||
: Math.max(v.length / 30, 7);
|
||||
}
|
||||
const numRows =
|
||||
defaultRows.current[k as keyof typeof defaultRows.current] || 8;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-full w-full flex-col items-start gap-1 px-[1px]"
|
||||
key={`allow-edit-args--${k}-${idx}`}
|
||||
>
|
||||
<div className="flex w-full flex-col items-start gap-[6px]">
|
||||
<p className="min-w-fit text-sm font-medium">{prettifyText(k)}</p>
|
||||
<Textarea
|
||||
disabled={streaming}
|
||||
className="h-full"
|
||||
value={value}
|
||||
onChange={(e) => onEditChange(e.target.value, editResponse, k)}
|
||||
onKeyDown={handleKeyDown}
|
||||
rows={numRows}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="flex w-full items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="brand"
|
||||
disabled={streaming}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{buttonText}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const EditAndOrAccept = React.memo(EditAndOrAcceptComponent);
|
||||
|
||||
export function InboxItemInput({
|
||||
interruptValue,
|
||||
humanResponse,
|
||||
streaming,
|
||||
streamFinished,
|
||||
supportsMultipleMethods,
|
||||
acceptAllowed,
|
||||
hasEdited,
|
||||
hasAddedResponse,
|
||||
initialValues,
|
||||
setHumanResponse,
|
||||
setSelectedSubmitType,
|
||||
setHasEdited,
|
||||
setHasAddedResponse,
|
||||
handleSubmit,
|
||||
}: InboxItemInputProps) {
|
||||
const isEditAllowed = interruptValue.config.allow_edit;
|
||||
const isResponseAllowed = interruptValue.config.allow_respond;
|
||||
const hasArgs = Object.entries(interruptValue.action_request.args).length > 0;
|
||||
const showArgsInResponse =
|
||||
hasArgs && !isEditAllowed && !acceptAllowed && isResponseAllowed;
|
||||
const showArgsOutsideActionCards =
|
||||
hasArgs && !showArgsInResponse && !isEditAllowed && !acceptAllowed;
|
||||
|
||||
const onEditChange = (
|
||||
change: string | string[],
|
||||
response: HumanResponseWithEdits,
|
||||
key: string | string[],
|
||||
) => {
|
||||
if (
|
||||
(Array.isArray(change) && !Array.isArray(key)) ||
|
||||
(!Array.isArray(change) && Array.isArray(key))
|
||||
) {
|
||||
toast.error("Error", {
|
||||
description: "Something went wrong",
|
||||
richColors: true,
|
||||
closeButton: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let valuesChanged = true;
|
||||
if (typeof response.args === "object") {
|
||||
const updatedArgs = { ...(response.args?.args || {}) };
|
||||
|
||||
if (Array.isArray(change) && Array.isArray(key)) {
|
||||
// Handle array inputs by mapping corresponding values
|
||||
change.forEach((value, index) => {
|
||||
if (index < key.length) {
|
||||
updatedArgs[key[index]] = value;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Handle single value case
|
||||
updatedArgs[key as string] = change as string;
|
||||
}
|
||||
|
||||
const haveValuesChanged = haveArgsChanged(updatedArgs, initialValues);
|
||||
valuesChanged = haveValuesChanged;
|
||||
}
|
||||
|
||||
if (!valuesChanged) {
|
||||
setHasEdited(false);
|
||||
if (acceptAllowed) {
|
||||
setSelectedSubmitType("accept");
|
||||
} else if (hasAddedResponse) {
|
||||
setSelectedSubmitType("response");
|
||||
}
|
||||
} else {
|
||||
setSelectedSubmitType("edit");
|
||||
setHasEdited(true);
|
||||
}
|
||||
|
||||
setHumanResponse((prev) => {
|
||||
if (typeof response.args !== "object" || !response.args) {
|
||||
console.error(
|
||||
"Mismatched response type",
|
||||
!!response.args,
|
||||
typeof response.args,
|
||||
);
|
||||
return prev;
|
||||
}
|
||||
|
||||
const newEdit: HumanResponseWithEdits = {
|
||||
type: response.type,
|
||||
args: {
|
||||
action: response.args.action,
|
||||
args:
|
||||
Array.isArray(change) && Array.isArray(key)
|
||||
? {
|
||||
...response.args.args,
|
||||
...Object.fromEntries(key.map((k, i) => [k, change[i]])),
|
||||
}
|
||||
: {
|
||||
...response.args.args,
|
||||
[key as string]: change as string,
|
||||
},
|
||||
},
|
||||
};
|
||||
if (
|
||||
prev.find(
|
||||
(p) =>
|
||||
p.type === response.type &&
|
||||
typeof p.args === "object" &&
|
||||
p.args?.action === (response.args as ActionRequest).action,
|
||||
)
|
||||
) {
|
||||
return prev.map((p) => {
|
||||
if (
|
||||
p.type === response.type &&
|
||||
typeof p.args === "object" &&
|
||||
p.args?.action === (response.args as ActionRequest).action
|
||||
) {
|
||||
if (p.acceptAllowed) {
|
||||
return {
|
||||
...newEdit,
|
||||
acceptAllowed: true,
|
||||
editsMade: valuesChanged,
|
||||
};
|
||||
}
|
||||
|
||||
return newEdit;
|
||||
}
|
||||
return p;
|
||||
});
|
||||
} else {
|
||||
throw new Error("No matching response found");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const onResponseChange = (
|
||||
change: string,
|
||||
response: HumanResponseWithEdits,
|
||||
) => {
|
||||
if (!change) {
|
||||
setHasAddedResponse(false);
|
||||
if (hasEdited) {
|
||||
// The user has deleted their response, so we should set the submit type to
|
||||
// `edit` if they've edited, or `accept` if it's allowed and they have not edited.
|
||||
setSelectedSubmitType("edit");
|
||||
} else if (acceptAllowed) {
|
||||
setSelectedSubmitType("accept");
|
||||
}
|
||||
} else {
|
||||
setSelectedSubmitType("response");
|
||||
setHasAddedResponse(true);
|
||||
}
|
||||
|
||||
setHumanResponse((prev) => {
|
||||
const newResponse: HumanResponseWithEdits = {
|
||||
type: response.type,
|
||||
args: change,
|
||||
};
|
||||
|
||||
if (prev.find((p) => p.type === response.type)) {
|
||||
return prev.map((p) => {
|
||||
if (p.type === response.type) {
|
||||
if (p.acceptAllowed) {
|
||||
return {
|
||||
...newResponse,
|
||||
acceptAllowed: true,
|
||||
editsMade: !!change,
|
||||
};
|
||||
}
|
||||
return newResponse;
|
||||
}
|
||||
return p;
|
||||
});
|
||||
} else {
|
||||
throw new Error("No human response found for string response");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col items-start justify-start gap-2">
|
||||
{showArgsOutsideActionCards && (
|
||||
<ArgsRenderer args={interruptValue.action_request.args} />
|
||||
)}
|
||||
|
||||
<div className="flex w-full flex-col items-start gap-2">
|
||||
<EditAndOrAccept
|
||||
humanResponse={humanResponse}
|
||||
streaming={streaming}
|
||||
initialValues={initialValues}
|
||||
interruptValue={interruptValue}
|
||||
onEditChange={onEditChange}
|
||||
handleSubmit={handleSubmit}
|
||||
/>
|
||||
{supportsMultipleMethods ? (
|
||||
<div className="mx-auto mt-3 flex items-center gap-3">
|
||||
<Separator className="w-[full]" />
|
||||
<p className="text-sm text-gray-500">Or</p>
|
||||
<Separator className="w-full" />
|
||||
</div>
|
||||
) : null}
|
||||
<Response
|
||||
humanResponse={humanResponse}
|
||||
streaming={streaming}
|
||||
showArgsInResponse={showArgsInResponse}
|
||||
interruptValue={interruptValue}
|
||||
onResponseChange={onResponseChange}
|
||||
handleSubmit={handleSubmit}
|
||||
/>
|
||||
{streaming && <p className="text-sm text-gray-600">Running...</p>}
|
||||
{streamFinished && (
|
||||
<p className="text-base font-medium text-green-600">
|
||||
Successfully finished Graph invocation.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,303 @@
|
|||
import { ChevronRight, X, ChevronsDownUp, ChevronsUpDown } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
baseMessageObject,
|
||||
isArrayOfMessages,
|
||||
prettifyText,
|
||||
unknownToPrettyDate,
|
||||
} from "../utils";
|
||||
import { motion } from "framer-motion";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { BaseMessage } from "@langchain/core/messages";
|
||||
import { ToolCall } from "@langchain/core/messages/tool";
|
||||
import { ToolCallTable } from "./tool-call-table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { MarkdownText } from "../../markdown-text";
|
||||
|
||||
interface StateViewRecursiveProps {
|
||||
value: unknown;
|
||||
expanded?: boolean;
|
||||
}
|
||||
|
||||
const messageTypeToLabel = (message: BaseMessage) => {
|
||||
let type = "";
|
||||
if ("type" in message) {
|
||||
type = message.type as string;
|
||||
} else {
|
||||
type = message._getType();
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case "human":
|
||||
return "User";
|
||||
case "ai":
|
||||
return "Assistant";
|
||||
case "tool":
|
||||
return "Tool";
|
||||
case "System":
|
||||
return "System";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
function MessagesRenderer({ messages }: { messages: BaseMessage[] }) {
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
{messages.map((msg, idx) => {
|
||||
const messageTypeLabel = messageTypeToLabel(msg);
|
||||
const content =
|
||||
typeof msg.content === "string"
|
||||
? msg.content
|
||||
: JSON.stringify(msg.content, null);
|
||||
return (
|
||||
<div
|
||||
key={msg.id ?? `message-${idx}`}
|
||||
className="ml-2 flex w-full flex-col gap-[2px]"
|
||||
>
|
||||
<p className="font-medium text-gray-700">{messageTypeLabel}:</p>
|
||||
{content && <MarkdownText>{content}</MarkdownText>}
|
||||
{"tool_calls" in msg && msg.tool_calls ? (
|
||||
<div className="flex w-full flex-col items-start gap-1">
|
||||
{(msg.tool_calls as ToolCall[]).map((tc, idx) => (
|
||||
<ToolCallTable
|
||||
key={tc.id ?? `tool-call-${idx}`}
|
||||
toolCall={tc}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StateViewRecursive(props: StateViewRecursiveProps) {
|
||||
const date = unknownToPrettyDate(props.value);
|
||||
if (date) {
|
||||
return <p className="font-light text-gray-600">{date}</p>;
|
||||
}
|
||||
|
||||
if (["string", "number"].includes(typeof props.value)) {
|
||||
return <MarkdownText>{props.value as string}</MarkdownText>;
|
||||
}
|
||||
|
||||
if (typeof props.value === "boolean") {
|
||||
return <MarkdownText>{JSON.stringify(props.value)}</MarkdownText>;
|
||||
}
|
||||
|
||||
if (props.value == null) {
|
||||
return <p className="font-light whitespace-pre-wrap text-gray-600">null</p>;
|
||||
}
|
||||
|
||||
if (Array.isArray(props.value)) {
|
||||
if (props.value.length > 0 && isArrayOfMessages(props.value)) {
|
||||
return <MessagesRenderer messages={props.value} />;
|
||||
}
|
||||
|
||||
const valueArray = props.value as unknown[];
|
||||
return (
|
||||
<div className="flex w-full flex-row items-start justify-start gap-1">
|
||||
<span className="font-normal text-black">[</span>
|
||||
{valueArray.map((item, idx) => {
|
||||
const itemRenderValue = baseMessageObject(item);
|
||||
return (
|
||||
<div
|
||||
key={`state-view-${idx}`}
|
||||
className="flex w-full flex-row items-start whitespace-pre-wrap"
|
||||
>
|
||||
<StateViewRecursive value={itemRenderValue} />
|
||||
{idx < valueArray?.length - 1 && (
|
||||
<span className="font-normal text-black">, </span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<span className="font-normal text-black">]</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof props.value === "object") {
|
||||
if (Object.keys(props.value).length === 0) {
|
||||
return <p className="font-light text-gray-600">{"{}"}</p>;
|
||||
}
|
||||
return (
|
||||
<div className="relative ml-6 flex w-full flex-col items-start justify-start gap-1">
|
||||
{/* Vertical line */}
|
||||
<div className="absolute top-0 left-[-24px] h-full w-[1px] bg-gray-200" />
|
||||
|
||||
{Object.entries(props.value).map(([key, value], idx) => (
|
||||
<div
|
||||
key={`state-view-object-${key}-${idx}`}
|
||||
className="relative w-full"
|
||||
>
|
||||
{/* Horizontal connector line */}
|
||||
<div className="absolute top-[10px] left-[-20px] h-[1px] w-[18px] bg-gray-200" />
|
||||
<StateViewObject
|
||||
expanded={props.expanded}
|
||||
keyName={key}
|
||||
value={value}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function HasContentsEllipsis({ onClick }: { onClick?: () => void }) {
|
||||
return (
|
||||
<span
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"rounded-md p-[2px] font-mono text-[10px] leading-3",
|
||||
"bg-gray-50 text-gray-600 hover:bg-gray-100 hover:text-gray-800",
|
||||
"cursor-pointer transition-colors ease-in-out",
|
||||
"inline-block -translate-y-[2px]",
|
||||
)}
|
||||
>
|
||||
{"{...}"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface StateViewProps {
|
||||
keyName: string;
|
||||
value: unknown;
|
||||
/**
|
||||
* Whether or not to expand or collapse the view
|
||||
* @default true
|
||||
*/
|
||||
expanded?: boolean;
|
||||
}
|
||||
|
||||
export function StateViewObject(props: StateViewProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.expanded != null) {
|
||||
setExpanded(props.expanded);
|
||||
}
|
||||
}, [props.expanded]);
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-row items-start justify-start gap-2 text-sm">
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={{ rotate: expanded ? 90 : 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<div
|
||||
onClick={() => setExpanded((prev) => !prev)}
|
||||
className="flex h-5 w-5 cursor-pointer items-center justify-center rounded-md text-gray-500 transition-colors ease-in-out hover:bg-gray-100 hover:text-black"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</div>
|
||||
</motion.div>
|
||||
<div className="flex w-full flex-col items-start justify-start gap-1">
|
||||
<p className="font-normal text-black">
|
||||
{prettifyText(props.keyName)}{" "}
|
||||
{!expanded && (
|
||||
<HasContentsEllipsis onClick={() => setExpanded((prev) => !prev)} />
|
||||
)}
|
||||
</p>
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={{
|
||||
height: expanded ? "auto" : 0,
|
||||
opacity: expanded ? 1 : 0,
|
||||
}}
|
||||
transition={{
|
||||
duration: 0.2,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
style={{ overflow: "hidden" }}
|
||||
className="relative w-full"
|
||||
>
|
||||
<StateViewRecursive
|
||||
expanded={props.expanded}
|
||||
value={props.value}
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface StateViewComponentProps {
|
||||
values: Record<string, any>;
|
||||
description: string | undefined;
|
||||
handleShowSidePanel: (showState: boolean, showDescription: boolean) => void;
|
||||
view: "description" | "state";
|
||||
}
|
||||
|
||||
export function StateView({
|
||||
handleShowSidePanel,
|
||||
view,
|
||||
values,
|
||||
description,
|
||||
}: StateViewComponentProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
if (!values) {
|
||||
return <div>No state found</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full flex-row gap-0",
|
||||
view === "state" &&
|
||||
"border-t-[1px] border-gray-100 lg:border-t-[0px] lg:border-l-[1px]",
|
||||
)}
|
||||
>
|
||||
{view === "description" && (
|
||||
<div className="pt-6 pb-2">
|
||||
<MarkdownText>
|
||||
{description ?? "No description provided"}
|
||||
</MarkdownText>
|
||||
</div>
|
||||
)}
|
||||
{view === "state" && (
|
||||
<div className="flex flex-col items-start justify-start gap-1">
|
||||
{Object.entries(values).map(([k, v], idx) => (
|
||||
<StateViewObject
|
||||
expanded={expanded}
|
||||
key={`state-view-${k}-${idx}`}
|
||||
keyName={k}
|
||||
value={v}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-start justify-end gap-2">
|
||||
{view === "state" && (
|
||||
<Button
|
||||
onClick={() => setExpanded((prev) => !prev)}
|
||||
variant="ghost"
|
||||
className="text-gray-600"
|
||||
size="sm"
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronsUpDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronsDownUp className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={() => handleShowSidePanel(false, false)}
|
||||
variant="ghost"
|
||||
className="text-gray-600"
|
||||
size="sm"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,173 @@
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { ThreadIdCopyable } from "./thread-id";
|
||||
import { InboxItemInput } from "./inbox-item-input";
|
||||
import useInterruptedActions from "../hooks/use-interrupted-actions";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
import { useQueryState } from "nuqs";
|
||||
import { constructOpenInStudioURL } from "../utils";
|
||||
import { HumanInterrupt } from "@langchain/langgraph/prebuilt";
|
||||
|
||||
interface ThreadActionsViewProps {
|
||||
interrupt: HumanInterrupt;
|
||||
handleShowSidePanel: (showState: boolean, showDescription: boolean) => void;
|
||||
showState: boolean;
|
||||
showDescription: boolean;
|
||||
}
|
||||
|
||||
function ButtonGroup({
|
||||
handleShowState,
|
||||
handleShowDescription,
|
||||
showingState,
|
||||
showingDescription,
|
||||
}: {
|
||||
handleShowState: () => void;
|
||||
handleShowDescription: () => void;
|
||||
showingState: boolean;
|
||||
showingDescription: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-row items-center justify-center gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"rounded-l-md rounded-r-none border-r-[0px]",
|
||||
showingState ? "text-black" : "bg-white",
|
||||
)}
|
||||
size="sm"
|
||||
onClick={handleShowState}
|
||||
>
|
||||
State
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"rounded-l-none rounded-r-md border-l-[0px]",
|
||||
showingDescription ? "text-black" : "bg-white",
|
||||
)}
|
||||
size="sm"
|
||||
onClick={handleShowDescription}
|
||||
>
|
||||
Description
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ThreadActionsView({
|
||||
interrupt,
|
||||
handleShowSidePanel,
|
||||
showDescription,
|
||||
showState,
|
||||
}: ThreadActionsViewProps) {
|
||||
const [threadId] = useQueryState("threadId");
|
||||
const {
|
||||
acceptAllowed,
|
||||
hasEdited,
|
||||
hasAddedResponse,
|
||||
streaming,
|
||||
supportsMultipleMethods,
|
||||
streamFinished,
|
||||
loading,
|
||||
handleSubmit,
|
||||
handleIgnore,
|
||||
handleResolve,
|
||||
setSelectedSubmitType,
|
||||
setHasAddedResponse,
|
||||
setHasEdited,
|
||||
humanResponse,
|
||||
setHumanResponse,
|
||||
initialHumanInterruptEditValue,
|
||||
} = useInterruptedActions({
|
||||
interrupt,
|
||||
});
|
||||
const [apiUrl] = useQueryState("apiUrl");
|
||||
|
||||
const handleOpenInStudio = () => {
|
||||
if (!apiUrl) {
|
||||
toast.error("Error", {
|
||||
description: "Please set the LangGraph deployment URL in settings.",
|
||||
duration: 5000,
|
||||
richColors: true,
|
||||
closeButton: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const studioUrl = constructOpenInStudioURL(apiUrl, threadId ?? undefined);
|
||||
window.open(studioUrl, "_blank");
|
||||
};
|
||||
|
||||
const threadTitle = interrupt.action_request.action || "Unknown";
|
||||
const actionsDisabled = loading || streaming;
|
||||
const ignoreAllowed = interrupt.config.allow_ignore;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full w-full flex-col gap-9">
|
||||
{/* Header */}
|
||||
<div className="flex w-full flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center justify-start gap-3">
|
||||
<p className="text-2xl tracking-tighter text-pretty">{threadTitle}</p>
|
||||
{threadId && <ThreadIdCopyable threadId={threadId} />}
|
||||
</div>
|
||||
<div className="flex flex-row items-center justify-start gap-2">
|
||||
{apiUrl && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="flex items-center gap-1 bg-white"
|
||||
onClick={handleOpenInStudio}
|
||||
>
|
||||
Studio
|
||||
</Button>
|
||||
)}
|
||||
<ButtonGroup
|
||||
handleShowState={() => handleShowSidePanel(true, false)}
|
||||
handleShowDescription={() => handleShowSidePanel(false, true)}
|
||||
showingState={showState}
|
||||
showingDescription={showDescription}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-row items-center justify-start gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-gray-500 bg-white font-normal text-gray-800"
|
||||
onClick={handleResolve}
|
||||
disabled={actionsDisabled}
|
||||
>
|
||||
Mark as Resolved
|
||||
</Button>
|
||||
{ignoreAllowed && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-gray-500 bg-white font-normal text-gray-800"
|
||||
onClick={handleIgnore}
|
||||
disabled={actionsDisabled}
|
||||
>
|
||||
Ignore
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<InboxItemInput
|
||||
acceptAllowed={acceptAllowed}
|
||||
hasEdited={hasEdited}
|
||||
hasAddedResponse={hasAddedResponse}
|
||||
interruptValue={interrupt}
|
||||
humanResponse={humanResponse}
|
||||
initialValues={initialHumanInterruptEditValue.current}
|
||||
setHumanResponse={setHumanResponse}
|
||||
streaming={streaming}
|
||||
streamFinished={streamFinished}
|
||||
supportsMultipleMethods={supportsMultipleMethods}
|
||||
setSelectedSubmitType={setSelectedSubmitType}
|
||||
setHasAddedResponse={setHasAddedResponse}
|
||||
setHasEdited={setHasEdited}
|
||||
handleSubmit={handleSubmit}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,305 @@
|
|||
import { HumanResponseWithEdits, SubmitType } from "../types";
|
||||
import {
|
||||
KeyboardEvent,
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
MutableRefObject,
|
||||
useState,
|
||||
useRef,
|
||||
useEffect,
|
||||
} from "react";
|
||||
import { createDefaultHumanResponse } from "../utils";
|
||||
import { toast } from "sonner";
|
||||
import { HumanInterrupt, HumanResponse } from "@langchain/langgraph/prebuilt";
|
||||
import { END } from "@langchain/langgraph/web";
|
||||
import { useStreamContext } from "@/providers/Stream";
|
||||
|
||||
interface UseInterruptedActionsInput {
|
||||
interrupt: HumanInterrupt;
|
||||
}
|
||||
|
||||
interface UseInterruptedActionsValue {
|
||||
// Actions
|
||||
handleSubmit: (
|
||||
e: React.MouseEvent<HTMLButtonElement, MouseEvent> | KeyboardEvent,
|
||||
) => Promise<void>;
|
||||
handleIgnore: (
|
||||
e: React.MouseEvent<HTMLButtonElement, MouseEvent>,
|
||||
) => Promise<void>;
|
||||
handleResolve: (
|
||||
e: React.MouseEvent<HTMLButtonElement, MouseEvent>,
|
||||
) => Promise<void>;
|
||||
|
||||
// State values
|
||||
streaming: boolean;
|
||||
streamFinished: boolean;
|
||||
loading: boolean;
|
||||
supportsMultipleMethods: boolean;
|
||||
hasEdited: boolean;
|
||||
hasAddedResponse: boolean;
|
||||
acceptAllowed: boolean;
|
||||
humanResponse: HumanResponseWithEdits[];
|
||||
|
||||
// State setters
|
||||
setSelectedSubmitType: Dispatch<SetStateAction<SubmitType | undefined>>;
|
||||
setHumanResponse: Dispatch<SetStateAction<HumanResponseWithEdits[]>>;
|
||||
setHasAddedResponse: Dispatch<SetStateAction<boolean>>;
|
||||
setHasEdited: Dispatch<SetStateAction<boolean>>;
|
||||
|
||||
// Refs
|
||||
initialHumanInterruptEditValue: MutableRefObject<Record<string, string>>;
|
||||
}
|
||||
|
||||
export default function useInterruptedActions({
|
||||
interrupt,
|
||||
}: UseInterruptedActionsInput): UseInterruptedActionsValue {
|
||||
const thread = useStreamContext();
|
||||
const [humanResponse, setHumanResponse] = useState<HumanResponseWithEdits[]>(
|
||||
[],
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [streaming, setStreaming] = useState(false);
|
||||
const [streamFinished, setStreamFinished] = useState(false);
|
||||
const initialHumanInterruptEditValue = useRef<Record<string, string>>({});
|
||||
const [selectedSubmitType, setSelectedSubmitType] = useState<SubmitType>();
|
||||
// Whether or not the user has edited any fields which allow editing.
|
||||
const [hasEdited, setHasEdited] = useState(false);
|
||||
// Whether or not the user has added a response.
|
||||
const [hasAddedResponse, setHasAddedResponse] = useState(false);
|
||||
const [acceptAllowed, setAcceptAllowed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const { responses, defaultSubmitType, hasAccept } =
|
||||
createDefaultHumanResponse(interrupt, initialHumanInterruptEditValue);
|
||||
setSelectedSubmitType(defaultSubmitType);
|
||||
setHumanResponse(responses);
|
||||
setAcceptAllowed(hasAccept);
|
||||
} catch (e) {
|
||||
console.error("Error formatting and setting human response state", e);
|
||||
}
|
||||
}, [interrupt]);
|
||||
|
||||
const resumeRun = (response: HumanResponse[]): boolean => {
|
||||
try {
|
||||
thread.submit(
|
||||
{},
|
||||
{
|
||||
command: {
|
||||
resume: response,
|
||||
},
|
||||
},
|
||||
);
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
console.error("Error sending human response", e);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (
|
||||
e: React.MouseEvent<HTMLButtonElement, MouseEvent> | KeyboardEvent,
|
||||
) => {
|
||||
e.preventDefault();
|
||||
if (!humanResponse) {
|
||||
toast.error("Error", {
|
||||
description: "Please enter a response.",
|
||||
duration: 5000,
|
||||
richColors: true,
|
||||
closeButton: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let errorOccurred = false;
|
||||
initialHumanInterruptEditValue.current = {};
|
||||
|
||||
if (
|
||||
humanResponse.some((r) => ["response", "edit", "accept"].includes(r.type))
|
||||
) {
|
||||
setStreamFinished(false);
|
||||
|
||||
try {
|
||||
const humanResponseInput: HumanResponse[] = humanResponse.flatMap(
|
||||
(r) => {
|
||||
if (r.type === "edit") {
|
||||
if (r.acceptAllowed && !r.editsMade) {
|
||||
return {
|
||||
type: "accept",
|
||||
args: r.args,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
type: "edit",
|
||||
args: r.args,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (r.type === "response" && !r.args) {
|
||||
// If response was allowed but no response was given, do not include in the response
|
||||
return [];
|
||||
}
|
||||
return {
|
||||
type: r.type,
|
||||
args: r.args,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const input = humanResponseInput.find(
|
||||
(r) => r.type === selectedSubmitType,
|
||||
);
|
||||
if (!input) {
|
||||
toast.error("Error", {
|
||||
description: "No response found.",
|
||||
richColors: true,
|
||||
closeButton: true,
|
||||
duration: 5000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setStreaming(true);
|
||||
const resumedSuccessfully = resumeRun([input]);
|
||||
if (!resumedSuccessfully) {
|
||||
// This will only be undefined if the graph ID is not found
|
||||
// in this case, the method will trigger a toast for us.
|
||||
return;
|
||||
}
|
||||
|
||||
toast("Success", {
|
||||
description: "Response submitted successfully.",
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
if (!errorOccurred) {
|
||||
setStreamFinished(true);
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error("Error sending human response", e);
|
||||
|
||||
if ("message" in e && e.message.includes("Invalid assistant ID")) {
|
||||
toast("Error: Invalid assistant ID", {
|
||||
description:
|
||||
"The provided assistant ID was not found in this graph. Please update the assistant ID in the settings and try again.",
|
||||
richColors: true,
|
||||
closeButton: true,
|
||||
duration: 5000,
|
||||
});
|
||||
} else {
|
||||
toast.error("Error", {
|
||||
description: "Failed to submit response.",
|
||||
richColors: true,
|
||||
closeButton: true,
|
||||
duration: 5000,
|
||||
});
|
||||
}
|
||||
|
||||
errorOccurred = true;
|
||||
setStreaming(false);
|
||||
setStreamFinished(false);
|
||||
}
|
||||
|
||||
if (!errorOccurred) {
|
||||
setStreaming(false);
|
||||
setStreamFinished(false);
|
||||
}
|
||||
} else {
|
||||
setLoading(true);
|
||||
resumeRun(humanResponse);
|
||||
|
||||
toast("Success", {
|
||||
description: "Response submitted successfully.",
|
||||
duration: 5000,
|
||||
});
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleIgnore = async (
|
||||
e: React.MouseEvent<HTMLButtonElement, MouseEvent>,
|
||||
) => {
|
||||
e.preventDefault();
|
||||
|
||||
const ignoreResponse = humanResponse.find((r) => r.type === "ignore");
|
||||
if (!ignoreResponse) {
|
||||
toast.error("Error", {
|
||||
description: "The selected thread does not support ignoring.",
|
||||
duration: 5000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
initialHumanInterruptEditValue.current = {};
|
||||
|
||||
resumeRun([ignoreResponse]);
|
||||
|
||||
setLoading(false);
|
||||
toast("Successfully ignored thread", {
|
||||
duration: 5000,
|
||||
});
|
||||
};
|
||||
|
||||
const handleResolve = async (
|
||||
e: React.MouseEvent<HTMLButtonElement, MouseEvent>,
|
||||
) => {
|
||||
e.preventDefault();
|
||||
|
||||
setLoading(true);
|
||||
initialHumanInterruptEditValue.current = {};
|
||||
|
||||
try {
|
||||
thread.submit(
|
||||
{},
|
||||
{
|
||||
command: {
|
||||
goto: END,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
toast("Success", {
|
||||
description: "Marked thread as resolved.",
|
||||
duration: 3000,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Error marking thread as resolved", e);
|
||||
toast.error("Error", {
|
||||
description: "Failed to mark thread as resolved.",
|
||||
richColors: true,
|
||||
closeButton: true,
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const supportsMultipleMethods =
|
||||
humanResponse.filter(
|
||||
(r) => r.type === "edit" || r.type === "accept" || r.type === "response",
|
||||
).length > 1;
|
||||
|
||||
return {
|
||||
handleSubmit,
|
||||
handleIgnore,
|
||||
handleResolve,
|
||||
humanResponse,
|
||||
streaming,
|
||||
streamFinished,
|
||||
loading,
|
||||
supportsMultipleMethods,
|
||||
hasEdited,
|
||||
hasAddedResponse,
|
||||
acceptAllowed,
|
||||
setSelectedSubmitType,
|
||||
setHumanResponse,
|
||||
setHasAddedResponse,
|
||||
setHasEdited,
|
||||
initialHumanInterruptEditValue,
|
||||
};
|
||||
}
|
||||
57
apps/web/src/components/thread/agent-inbox/index.tsx
Normal file
57
apps/web/src/components/thread/agent-inbox/index.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { StateView } from "./components/state-view";
|
||||
import { ThreadActionsView } from "./components/thread-actions-view";
|
||||
import { useState } from "react";
|
||||
import { HumanInterrupt } from "@langchain/langgraph/prebuilt";
|
||||
import { useStreamContext } from "@/providers/Stream";
|
||||
|
||||
interface ThreadViewProps {
|
||||
interrupt: HumanInterrupt | HumanInterrupt[];
|
||||
}
|
||||
|
||||
export function ThreadView({ interrupt }: ThreadViewProps) {
|
||||
const interruptObj = Array.isArray(interrupt) ? interrupt[0] : interrupt;
|
||||
const thread = useStreamContext();
|
||||
const [showDescription, setShowDescription] = useState(false);
|
||||
const [showState, setShowState] = useState(false);
|
||||
const showSidePanel = showDescription || showState;
|
||||
|
||||
const handleShowSidePanel = (
|
||||
showState: boolean,
|
||||
showDescription: boolean,
|
||||
) => {
|
||||
if (showState && showDescription) {
|
||||
console.error("Cannot show both state and description");
|
||||
return;
|
||||
}
|
||||
if (showState) {
|
||||
setShowDescription(false);
|
||||
setShowState(true);
|
||||
} else if (showDescription) {
|
||||
setShowState(false);
|
||||
setShowDescription(true);
|
||||
} else {
|
||||
setShowState(false);
|
||||
setShowDescription(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-[80vh] w-full flex-col overflow-y-scroll rounded-2xl bg-gray-50/50 p-8 lg:flex-row [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-track]:bg-transparent">
|
||||
{showSidePanel ? (
|
||||
<StateView
|
||||
handleShowSidePanel={handleShowSidePanel}
|
||||
description={interruptObj.description}
|
||||
values={thread.values}
|
||||
view={showState ? "state" : "description"}
|
||||
/>
|
||||
) : (
|
||||
<ThreadActionsView
|
||||
interrupt={interruptObj}
|
||||
handleShowSidePanel={handleShowSidePanel}
|
||||
showState={showState}
|
||||
showDescription={showDescription}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
72
apps/web/src/components/thread/agent-inbox/types.ts
Normal file
72
apps/web/src/components/thread/agent-inbox/types.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { BaseMessage } from "@langchain/core/messages";
|
||||
import { Thread, ThreadStatus } from "@langchain/langgraph-sdk";
|
||||
import { HumanInterrupt, HumanResponse } from "@langchain/langgraph/prebuilt";
|
||||
|
||||
export type HumanResponseWithEdits = HumanResponse &
|
||||
(
|
||||
| { acceptAllowed?: false; editsMade?: never }
|
||||
| { acceptAllowed?: true; editsMade?: boolean }
|
||||
);
|
||||
|
||||
export type Email = {
|
||||
id: string;
|
||||
thread_id: string;
|
||||
from_email: string;
|
||||
to_email: string;
|
||||
subject: string;
|
||||
page_content: string;
|
||||
send_time: string | undefined;
|
||||
read?: boolean;
|
||||
status?: "in-queue" | "processing" | "hitl" | "done";
|
||||
};
|
||||
|
||||
export interface ThreadValues {
|
||||
email: Email;
|
||||
messages: BaseMessage[];
|
||||
triage: {
|
||||
logic: string;
|
||||
response: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type ThreadData<
|
||||
ThreadValues extends Record<string, any> = Record<string, any>,
|
||||
> = {
|
||||
thread: Thread<ThreadValues>;
|
||||
} & (
|
||||
| {
|
||||
status: "interrupted";
|
||||
interrupts: HumanInterrupt[] | undefined;
|
||||
}
|
||||
| {
|
||||
status: "idle" | "busy" | "error";
|
||||
interrupts?: never;
|
||||
}
|
||||
);
|
||||
|
||||
export type ThreadStatusWithAll = ThreadStatus | "all";
|
||||
|
||||
export type SubmitType = "accept" | "response" | "edit";
|
||||
|
||||
export interface AgentInbox {
|
||||
/**
|
||||
* A unique identifier for the inbox.
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* The ID of the graph.
|
||||
*/
|
||||
graphId: string;
|
||||
/**
|
||||
* The URL of the deployment. Either a localhost URL, or a deployment URL.
|
||||
*/
|
||||
deploymentUrl: string;
|
||||
/**
|
||||
* Optional name for the inbox, used in the UI to label the inbox.
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* Whether or not the inbox is selected.
|
||||
*/
|
||||
selected: boolean;
|
||||
}
|
||||
223
apps/web/src/components/thread/agent-inbox/utils.ts
Normal file
223
apps/web/src/components/thread/agent-inbox/utils.ts
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
import { BaseMessage, isBaseMessage } from "@langchain/core/messages";
|
||||
import { format } from "date-fns";
|
||||
import { startCase } from "lodash";
|
||||
import { HumanResponseWithEdits, SubmitType } from "./types";
|
||||
import { HumanInterrupt } from "@langchain/langgraph/prebuilt";
|
||||
|
||||
export function prettifyText(action: string) {
|
||||
return startCase(action.replace(/_/g, " "));
|
||||
}
|
||||
|
||||
export function isArrayOfMessages(
|
||||
value: Record<string, any>[],
|
||||
): value is BaseMessage[] {
|
||||
if (
|
||||
value.every(isBaseMessage) ||
|
||||
(Array.isArray(value) &&
|
||||
value.every(
|
||||
(v) =>
|
||||
typeof v === "object" &&
|
||||
"id" in v &&
|
||||
"type" in v &&
|
||||
"content" in v &&
|
||||
"additional_kwargs" in v,
|
||||
))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function baseMessageObject(item: unknown): string {
|
||||
if (isBaseMessage(item)) {
|
||||
const contentText =
|
||||
typeof item.content === "string"
|
||||
? item.content
|
||||
: JSON.stringify(item.content, null);
|
||||
let toolCallText = "";
|
||||
if ("tool_calls" in item) {
|
||||
toolCallText = JSON.stringify(item.tool_calls, null);
|
||||
}
|
||||
if ("type" in item) {
|
||||
return `${item.type}:${contentText ? ` ${contentText}` : ""}${toolCallText ? ` - Tool calls: ${toolCallText}` : ""}`;
|
||||
} else if ("_getType" in item) {
|
||||
return `${item._getType()}:${contentText ? ` ${contentText}` : ""}${toolCallText ? ` - Tool calls: ${toolCallText}` : ""}`;
|
||||
}
|
||||
} else if (
|
||||
typeof item === "object" &&
|
||||
item &&
|
||||
"type" in item &&
|
||||
"content" in item
|
||||
) {
|
||||
const contentText =
|
||||
typeof item.content === "string"
|
||||
? item.content
|
||||
: JSON.stringify(item.content, null);
|
||||
let toolCallText = "";
|
||||
if ("tool_calls" in item) {
|
||||
toolCallText = JSON.stringify(item.tool_calls, null);
|
||||
}
|
||||
return `${item.type}:${contentText ? ` ${contentText}` : ""}${toolCallText ? ` - Tool calls: ${toolCallText}` : ""}`;
|
||||
}
|
||||
|
||||
if (typeof item === "object") {
|
||||
return JSON.stringify(item, null);
|
||||
} else {
|
||||
return item as string;
|
||||
}
|
||||
}
|
||||
|
||||
export function unknownToPrettyDate(input: unknown): string | undefined {
|
||||
try {
|
||||
if (
|
||||
Object.prototype.toString.call(input) === "[object Date]" ||
|
||||
new Date(input as string)
|
||||
) {
|
||||
return format(new Date(input as string), "MM/dd/yyyy hh:mm a");
|
||||
}
|
||||
} catch (_) {
|
||||
// failed to parse date. no-op
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function createDefaultHumanResponse(
|
||||
interrupt: HumanInterrupt,
|
||||
initialHumanInterruptEditValue: React.MutableRefObject<
|
||||
Record<string, string>
|
||||
>,
|
||||
): {
|
||||
responses: HumanResponseWithEdits[];
|
||||
defaultSubmitType: SubmitType | undefined;
|
||||
hasAccept: boolean;
|
||||
} {
|
||||
const responses: HumanResponseWithEdits[] = [];
|
||||
if (interrupt.config.allow_edit) {
|
||||
if (interrupt.config.allow_accept) {
|
||||
Object.entries(interrupt.action_request.args).forEach(([k, v]) => {
|
||||
let stringValue = "";
|
||||
if (typeof v === "string") {
|
||||
stringValue = v;
|
||||
} else {
|
||||
stringValue = JSON.stringify(v, null);
|
||||
}
|
||||
|
||||
if (
|
||||
!initialHumanInterruptEditValue.current ||
|
||||
!(k in initialHumanInterruptEditValue.current)
|
||||
) {
|
||||
initialHumanInterruptEditValue.current = {
|
||||
...initialHumanInterruptEditValue.current,
|
||||
[k]: stringValue,
|
||||
};
|
||||
} else if (
|
||||
k in initialHumanInterruptEditValue.current &&
|
||||
initialHumanInterruptEditValue.current[k] !== stringValue
|
||||
) {
|
||||
console.error(
|
||||
"KEY AND VALUE FOUND IN initialHumanInterruptEditValue.current THAT DOES NOT MATCH THE ACTION REQUEST",
|
||||
{
|
||||
key: k,
|
||||
value: stringValue,
|
||||
expectedValue: initialHumanInterruptEditValue.current[k],
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
responses.push({
|
||||
type: "edit",
|
||||
args: interrupt.action_request,
|
||||
acceptAllowed: true,
|
||||
editsMade: false,
|
||||
});
|
||||
} else {
|
||||
responses.push({
|
||||
type: "edit",
|
||||
args: interrupt.action_request,
|
||||
acceptAllowed: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (interrupt.config.allow_respond) {
|
||||
responses.push({
|
||||
type: "response",
|
||||
args: "",
|
||||
});
|
||||
}
|
||||
|
||||
if (interrupt.config.allow_ignore) {
|
||||
responses.push({
|
||||
type: "ignore",
|
||||
args: null,
|
||||
});
|
||||
}
|
||||
|
||||
// Set the submit type.
|
||||
// Priority: accept > response > edit
|
||||
const acceptAllowedConfig = interrupt.config.allow_accept;
|
||||
const ignoreAllowedConfig = interrupt.config.allow_ignore;
|
||||
|
||||
const hasResponse = responses.find((r) => r.type === "response");
|
||||
const hasAccept =
|
||||
responses.find((r) => r.acceptAllowed) || acceptAllowedConfig;
|
||||
const hasEdit = responses.find((r) => r.type === "edit");
|
||||
|
||||
let defaultSubmitType: SubmitType | undefined;
|
||||
if (hasAccept) {
|
||||
defaultSubmitType = "accept";
|
||||
} else if (hasResponse) {
|
||||
defaultSubmitType = "response";
|
||||
} else if (hasEdit) {
|
||||
defaultSubmitType = "edit";
|
||||
}
|
||||
|
||||
if (acceptAllowedConfig && !responses.find((r) => r.type === "accept")) {
|
||||
responses.push({
|
||||
type: "accept",
|
||||
args: null,
|
||||
});
|
||||
}
|
||||
if (ignoreAllowedConfig && !responses.find((r) => r.type === "ignore")) {
|
||||
responses.push({
|
||||
type: "ignore",
|
||||
args: null,
|
||||
});
|
||||
}
|
||||
|
||||
return { responses, defaultSubmitType, hasAccept: !!hasAccept };
|
||||
}
|
||||
|
||||
export function constructOpenInStudioURL(
|
||||
deploymentUrl: string,
|
||||
threadId?: string,
|
||||
) {
|
||||
const smithStudioURL = new URL("https://smith.langchain.com/studio/thread");
|
||||
// trim the trailing slash from deploymentUrl
|
||||
const trimmedDeploymentUrl = deploymentUrl.replace(/\/$/, "");
|
||||
|
||||
if (threadId) {
|
||||
smithStudioURL.pathname += `/${threadId}`;
|
||||
}
|
||||
|
||||
smithStudioURL.searchParams.append("baseUrl", trimmedDeploymentUrl);
|
||||
|
||||
return smithStudioURL.toString();
|
||||
}
|
||||
|
||||
export function haveArgsChanged(
|
||||
args: unknown,
|
||||
initialValues: Record<string, string>,
|
||||
): boolean {
|
||||
if (typeof args !== "object" || !args) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentValues = args as Record<string, string>;
|
||||
|
||||
return Object.entries(currentValues).some(([key, value]) => {
|
||||
const valueString = ["string", "number"].includes(typeof value)
|
||||
? value.toString()
|
||||
: JSON.stringify(value, null);
|
||||
return initialValues[key] !== valueString;
|
||||
});
|
||||
}
|
||||
189
apps/web/src/components/thread/artifact.tsx
Normal file
189
apps/web/src/components/thread/artifact.tsx
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
import {
|
||||
HTMLAttributes,
|
||||
ReactNode,
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useId,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
type Setter<T> = (value: T | ((value: T) => T)) => void;
|
||||
|
||||
const ArtifactSlotContext = createContext<{
|
||||
open: [string | null, Setter<string | null>];
|
||||
mounted: [string | null, Setter<string | null>];
|
||||
|
||||
title: [HTMLElement | null, Setter<HTMLElement | null>];
|
||||
content: [HTMLElement | null, Setter<HTMLElement | null>];
|
||||
|
||||
context: [Record<string, unknown>, Setter<Record<string, unknown>>];
|
||||
}>(null!);
|
||||
|
||||
/**
|
||||
* Headless component that will obtain the title and content of the artifact
|
||||
* and render them in place of the `ArtifactContent` and `ArtifactTitle` components via
|
||||
* React Portals.
|
||||
*/
|
||||
const ArtifactSlot = (props: {
|
||||
id: string;
|
||||
children?: ReactNode;
|
||||
title?: ReactNode;
|
||||
}) => {
|
||||
const context = useContext(ArtifactSlotContext);
|
||||
|
||||
const [ctxMounted, ctxSetMounted] = context.mounted;
|
||||
const [content] = context.content;
|
||||
const [title] = context.title;
|
||||
|
||||
const isMounted = ctxMounted === props.id;
|
||||
const isEmpty = props.children == null && props.title == null;
|
||||
|
||||
useEffect(() => {
|
||||
if (isEmpty) {
|
||||
ctxSetMounted((open) => (open === props.id ? null : open));
|
||||
}
|
||||
}, [isEmpty, ctxSetMounted, props.id]);
|
||||
|
||||
if (!isMounted) return null;
|
||||
return (
|
||||
<>
|
||||
{title != null ? createPortal(<>{props.title}</>, title) : null}
|
||||
{content != null ? createPortal(<>{props.children}</>, content) : null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export function ArtifactContent(props: HTMLAttributes<HTMLDivElement>) {
|
||||
const context = useContext(ArtifactSlotContext);
|
||||
|
||||
const [mounted] = context.mounted;
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [, setStateRef] = context.content;
|
||||
|
||||
useLayoutEffect(
|
||||
() => setStateRef?.(mounted ? ref.current : null),
|
||||
[setStateRef, mounted],
|
||||
);
|
||||
|
||||
if (!mounted) return null;
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArtifactTitle(props: HTMLAttributes<HTMLDivElement>) {
|
||||
const context = useContext(ArtifactSlotContext);
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [, setStateRef] = context.title;
|
||||
|
||||
useLayoutEffect(() => setStateRef?.(ref.current), [setStateRef]);
|
||||
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArtifactProvider(props: { children?: ReactNode }) {
|
||||
const content = useState<HTMLElement | null>(null);
|
||||
const title = useState<HTMLElement | null>(null);
|
||||
|
||||
const open = useState<string | null>(null);
|
||||
const mounted = useState<string | null>(null);
|
||||
const context = useState<Record<string, unknown>>({});
|
||||
|
||||
return (
|
||||
<ArtifactSlotContext.Provider
|
||||
value={{ open, mounted, title, content, context }}
|
||||
>
|
||||
{props.children}
|
||||
</ArtifactSlotContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides a value to be passed into `meta.artifact` field
|
||||
* of the `LoadExternalComponent` component, to be consumed by the `useArtifact` hook
|
||||
* on the generative UI side.
|
||||
*/
|
||||
export function useArtifact() {
|
||||
const id = useId();
|
||||
const context = useContext(ArtifactSlotContext);
|
||||
const [ctxOpen, ctxSetOpen] = context.open;
|
||||
const [ctxContext, ctxSetContext] = context.context;
|
||||
const [, ctxSetMounted] = context.mounted;
|
||||
|
||||
const open = ctxOpen === id;
|
||||
const setOpen = useCallback(
|
||||
(value: boolean | ((value: boolean) => boolean)) => {
|
||||
if (typeof value === "boolean") {
|
||||
ctxSetOpen(value ? id : null);
|
||||
} else {
|
||||
ctxSetOpen((open) => (open === id ? null : id));
|
||||
}
|
||||
|
||||
ctxSetMounted(id);
|
||||
},
|
||||
[ctxSetOpen, ctxSetMounted, id],
|
||||
);
|
||||
|
||||
const ArtifactContent = useCallback(
|
||||
(props: { title?: React.ReactNode; children: React.ReactNode }) => {
|
||||
return (
|
||||
<ArtifactSlot
|
||||
id={id}
|
||||
title={props.title}
|
||||
>
|
||||
{props.children}
|
||||
</ArtifactSlot>
|
||||
);
|
||||
},
|
||||
[id],
|
||||
);
|
||||
|
||||
return [
|
||||
ArtifactContent,
|
||||
{ open, setOpen, context: ctxContext, setContext: ctxSetContext },
|
||||
] as [
|
||||
typeof ArtifactContent,
|
||||
{
|
||||
open: typeof open;
|
||||
setOpen: typeof setOpen;
|
||||
context: typeof ctxContext;
|
||||
setContext: typeof ctxSetContext;
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* General hook for detecting if any artifact is open.
|
||||
*/
|
||||
export function useArtifactOpen() {
|
||||
const context = useContext(ArtifactSlotContext);
|
||||
const [ctxOpen, setCtxOpen] = context.open;
|
||||
|
||||
const open = ctxOpen !== null;
|
||||
const onClose = useCallback(() => setCtxOpen(null), [setCtxOpen]);
|
||||
|
||||
return [open, onClose] as const;
|
||||
}
|
||||
|
||||
/**
|
||||
* Artifacts may at their discretion provide additional context
|
||||
* that will be used when creating a new run.
|
||||
*/
|
||||
export function useArtifactContext() {
|
||||
const context = useContext(ArtifactSlotContext);
|
||||
return context.context;
|
||||
}
|
||||
146
apps/web/src/components/thread/history/index.tsx
Normal file
146
apps/web/src/components/thread/history/index.tsx
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { useThreads } from "@/providers/Thread";
|
||||
import { Thread } from "@langchain/langgraph-sdk";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { getContentString } from "../utils";
|
||||
import { useQueryState, parseAsBoolean } from "nuqs";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { PanelRightOpen, PanelRightClose } from "lucide-react";
|
||||
import { useMediaQuery } from "@/hooks/useMediaQuery";
|
||||
|
||||
function ThreadList({
|
||||
threads,
|
||||
onThreadClick,
|
||||
}: {
|
||||
threads: Thread[];
|
||||
onThreadClick?: (threadId: string) => void;
|
||||
}) {
|
||||
const [threadId, setThreadId] = useQueryState("threadId");
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-start justify-start gap-2 overflow-y-scroll [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-track]:bg-transparent">
|
||||
{threads.map((t) => {
|
||||
let itemText = t.thread_id;
|
||||
if (
|
||||
typeof t.values === "object" &&
|
||||
t.values &&
|
||||
"messages" in t.values &&
|
||||
Array.isArray(t.values.messages) &&
|
||||
t.values.messages?.length > 0
|
||||
) {
|
||||
const firstMessage = t.values.messages[0];
|
||||
itemText = getContentString(firstMessage.content);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={t.thread_id}
|
||||
className="w-full px-1"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-[280px] items-start justify-start text-left font-normal"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onThreadClick?.(t.thread_id);
|
||||
if (t.thread_id === threadId) return;
|
||||
setThreadId(t.thread_id);
|
||||
}}
|
||||
>
|
||||
<p className="truncate text-ellipsis">{itemText}</p>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ThreadHistoryLoading() {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-start justify-start gap-2 overflow-y-scroll [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-track]:bg-transparent">
|
||||
{Array.from({ length: 30 }).map((_, i) => (
|
||||
<Skeleton
|
||||
key={`skeleton-${i}`}
|
||||
className="h-10 w-[280px]"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ThreadHistory() {
|
||||
const isLargeScreen = useMediaQuery("(min-width: 1024px)");
|
||||
const [chatHistoryOpen, setChatHistoryOpen] = useQueryState(
|
||||
"chatHistoryOpen",
|
||||
parseAsBoolean.withDefault(false),
|
||||
);
|
||||
|
||||
const { getThreads, threads, setThreads, threadsLoading, setThreadsLoading } =
|
||||
useThreads();
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
setThreadsLoading(true);
|
||||
getThreads()
|
||||
.then(setThreads)
|
||||
.catch(console.error)
|
||||
.finally(() => setThreadsLoading(false));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="shadow-inner-right hidden h-screen w-[300px] shrink-0 flex-col items-start justify-start gap-6 border-r-[1px] border-slate-300 lg:flex">
|
||||
<div className="flex w-full items-center justify-between px-4 pt-1.5">
|
||||
<Button
|
||||
className="hover:bg-gray-100"
|
||||
variant="ghost"
|
||||
onClick={() => setChatHistoryOpen((p) => !p)}
|
||||
>
|
||||
{chatHistoryOpen ? (
|
||||
<PanelRightOpen className="size-5" />
|
||||
) : (
|
||||
<PanelRightClose className="size-5" />
|
||||
)}
|
||||
</Button>
|
||||
<h1 className="text-xl font-semibold tracking-tight">
|
||||
Thread History
|
||||
</h1>
|
||||
</div>
|
||||
{threadsLoading ? (
|
||||
<ThreadHistoryLoading />
|
||||
) : (
|
||||
<ThreadList threads={threads} />
|
||||
)}
|
||||
</div>
|
||||
<div className="lg:hidden">
|
||||
<Sheet
|
||||
open={!!chatHistoryOpen && !isLargeScreen}
|
||||
onOpenChange={(open) => {
|
||||
if (isLargeScreen) return;
|
||||
setChatHistoryOpen(open);
|
||||
}}
|
||||
>
|
||||
<SheetContent
|
||||
side="left"
|
||||
className="flex lg:hidden"
|
||||
>
|
||||
<SheetHeader>
|
||||
<SheetTitle>Thread History</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ThreadList
|
||||
threads={threads}
|
||||
onThreadClick={() => setChatHistoryOpen((o) => !o)}
|
||||
/>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
562
apps/web/src/components/thread/index.tsx
Normal file
562
apps/web/src/components/thread/index.tsx
Normal file
|
|
@ -0,0 +1,562 @@
|
|||
import { v4 as uuidv4 } from "uuid";
|
||||
import { ReactNode, useEffect, useRef } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useStreamContext } from "@/providers/Stream";
|
||||
import { useState, FormEvent } from "react";
|
||||
import { Button } from "../ui/button";
|
||||
import { Checkpoint, Message } from "@langchain/langgraph-sdk";
|
||||
import { AssistantMessage, AssistantMessageLoading } from "./messages/ai";
|
||||
import { HumanMessage } from "./messages/human";
|
||||
import {
|
||||
DO_NOT_RENDER_ID_PREFIX,
|
||||
ensureToolCallsHaveResponses,
|
||||
} from "@/lib/ensure-tool-responses";
|
||||
import { LangGraphLogoSVG } from "../icons/langgraph";
|
||||
import { TooltipIconButton } from "./tooltip-icon-button";
|
||||
import {
|
||||
ArrowDown,
|
||||
LoaderCircle,
|
||||
PanelRightOpen,
|
||||
PanelRightClose,
|
||||
SquarePen,
|
||||
XIcon,
|
||||
Plus,
|
||||
CircleX,
|
||||
} from "lucide-react";
|
||||
import { useQueryState, parseAsBoolean } from "nuqs";
|
||||
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
|
||||
import ThreadHistory from "./history";
|
||||
import { toast } from "sonner";
|
||||
import { useMediaQuery } from "@/hooks/useMediaQuery";
|
||||
import { Label } from "../ui/label";
|
||||
import { Switch } from "../ui/switch";
|
||||
import { GitHubSVG } from "../icons/github";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "../ui/tooltip";
|
||||
import { useFileUpload } from "@/hooks/use-file-upload";
|
||||
import { ContentBlocksPreview } from "./ContentBlocksPreview";
|
||||
import {
|
||||
useArtifactOpen,
|
||||
ArtifactContent,
|
||||
ArtifactTitle,
|
||||
useArtifactContext,
|
||||
} from "./artifact";
|
||||
|
||||
function StickyToBottomContent(props: {
|
||||
content: ReactNode;
|
||||
footer?: ReactNode;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
}) {
|
||||
const context = useStickToBottomContext();
|
||||
return (
|
||||
<div
|
||||
ref={context.scrollRef}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
className={props.className}
|
||||
>
|
||||
<div
|
||||
ref={context.contentRef}
|
||||
className={props.contentClassName}
|
||||
>
|
||||
{props.content}
|
||||
</div>
|
||||
|
||||
{props.footer}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScrollToBottom(props: { className?: string }) {
|
||||
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
|
||||
|
||||
if (isAtBottom) return null;
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
className={props.className}
|
||||
onClick={() => scrollToBottom()}
|
||||
>
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
<span>Scroll to bottom</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function OpenGitHubRepo() {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<a
|
||||
href="https://github.com/langchain-ai/agent-chat-ui"
|
||||
target="_blank"
|
||||
className="flex items-center justify-center"
|
||||
>
|
||||
<GitHubSVG
|
||||
width="24"
|
||||
height="24"
|
||||
/>
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
<p>Open GitHub repo</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function Thread() {
|
||||
const [artifactContext, setArtifactContext] = useArtifactContext();
|
||||
const [artifactOpen, closeArtifact] = useArtifactOpen();
|
||||
|
||||
const [threadId, _setThreadId] = useQueryState("threadId");
|
||||
const [chatHistoryOpen, setChatHistoryOpen] = useQueryState(
|
||||
"chatHistoryOpen",
|
||||
parseAsBoolean.withDefault(false),
|
||||
);
|
||||
const [hideToolCalls, setHideToolCalls] = useQueryState(
|
||||
"hideToolCalls",
|
||||
parseAsBoolean.withDefault(false),
|
||||
);
|
||||
const [input, setInput] = useState("");
|
||||
const {
|
||||
contentBlocks,
|
||||
setContentBlocks,
|
||||
handleFileUpload,
|
||||
dropRef,
|
||||
removeBlock,
|
||||
resetBlocks,
|
||||
dragOver,
|
||||
handlePaste,
|
||||
} = useFileUpload();
|
||||
const [firstTokenReceived, setFirstTokenReceived] = useState(false);
|
||||
const isLargeScreen = useMediaQuery("(min-width: 1024px)");
|
||||
|
||||
const stream = useStreamContext();
|
||||
const messages = stream.messages;
|
||||
const isLoading = stream.isLoading;
|
||||
|
||||
const lastError = useRef<string | undefined>(undefined);
|
||||
|
||||
const setThreadId = (id: string | null) => {
|
||||
_setThreadId(id);
|
||||
|
||||
// close artifact and reset artifact context
|
||||
closeArtifact();
|
||||
setArtifactContext({});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!stream.error) {
|
||||
lastError.current = undefined;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const message = (stream.error as any).message;
|
||||
if (!message || lastError.current === message) {
|
||||
// Message has already been logged. do not modify ref, return early.
|
||||
return;
|
||||
}
|
||||
|
||||
// Message is defined, and it has not been logged yet. Save it, and send the error
|
||||
lastError.current = message;
|
||||
toast.error("An error occurred. Please try again.", {
|
||||
description: (
|
||||
<p>
|
||||
<strong>Error:</strong> <code>{message}</code>
|
||||
</p>
|
||||
),
|
||||
richColors: true,
|
||||
closeButton: true,
|
||||
});
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
}, [stream.error]);
|
||||
|
||||
// TODO: this should be part of the useStream hook
|
||||
const prevMessageLength = useRef(0);
|
||||
useEffect(() => {
|
||||
if (
|
||||
messages.length !== prevMessageLength.current &&
|
||||
messages?.length &&
|
||||
messages[messages.length - 1].type === "ai"
|
||||
) {
|
||||
setFirstTokenReceived(true);
|
||||
}
|
||||
|
||||
prevMessageLength.current = messages.length;
|
||||
}, [messages]);
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if ((input.trim().length === 0 && contentBlocks.length === 0) || isLoading)
|
||||
return;
|
||||
setFirstTokenReceived(false);
|
||||
|
||||
const newHumanMessage: Message = {
|
||||
id: uuidv4(),
|
||||
type: "human",
|
||||
content: [
|
||||
...(input.trim().length > 0 ? [{ type: "text", text: input }] : []),
|
||||
...contentBlocks,
|
||||
] as Message["content"],
|
||||
};
|
||||
|
||||
const toolMessages = ensureToolCallsHaveResponses(stream.messages);
|
||||
|
||||
const context =
|
||||
Object.keys(artifactContext).length > 0 ? artifactContext : undefined;
|
||||
|
||||
stream.submit(
|
||||
{ messages: [...toolMessages, newHumanMessage], context },
|
||||
{
|
||||
streamMode: ["values"],
|
||||
optimisticValues: (prev) => ({
|
||||
...prev,
|
||||
context,
|
||||
messages: [
|
||||
...(prev.messages ?? []),
|
||||
...toolMessages,
|
||||
newHumanMessage,
|
||||
],
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
setInput("");
|
||||
setContentBlocks([]);
|
||||
};
|
||||
|
||||
const handleRegenerate = (
|
||||
parentCheckpoint: Checkpoint | null | undefined,
|
||||
) => {
|
||||
// Do this so the loading state is correct
|
||||
prevMessageLength.current = prevMessageLength.current - 1;
|
||||
setFirstTokenReceived(false);
|
||||
stream.submit(undefined, {
|
||||
checkpoint: parentCheckpoint,
|
||||
streamMode: ["values"],
|
||||
});
|
||||
};
|
||||
|
||||
const chatStarted = !!threadId || !!messages.length;
|
||||
const hasNoAIOrToolMessages = !messages.find(
|
||||
(m) => m.type === "ai" || m.type === "tool",
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full overflow-hidden">
|
||||
<div className="relative hidden lg:flex">
|
||||
<motion.div
|
||||
className="absolute z-20 h-full overflow-hidden border-r bg-white"
|
||||
style={{ width: 300 }}
|
||||
animate={
|
||||
isLargeScreen
|
||||
? { x: chatHistoryOpen ? 0 : -300 }
|
||||
: { x: chatHistoryOpen ? 0 : -300 }
|
||||
}
|
||||
initial={{ x: -300 }}
|
||||
transition={
|
||||
isLargeScreen
|
||||
? { type: "spring", stiffness: 300, damping: 30 }
|
||||
: { duration: 0 }
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="relative h-full"
|
||||
style={{ width: 300 }}
|
||||
>
|
||||
<ThreadHistory />
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"grid w-full grid-cols-[1fr_0fr] transition-all duration-500",
|
||||
artifactOpen && "grid-cols-[3fr_2fr]",
|
||||
)}
|
||||
>
|
||||
<motion.div
|
||||
className={cn(
|
||||
"relative flex min-w-0 flex-1 flex-col overflow-hidden",
|
||||
!chatStarted && "grid-rows-[1fr]",
|
||||
)}
|
||||
layout={isLargeScreen}
|
||||
animate={{
|
||||
marginLeft: chatHistoryOpen ? (isLargeScreen ? 300 : 0) : 0,
|
||||
width: chatHistoryOpen
|
||||
? isLargeScreen
|
||||
? "calc(100% - 300px)"
|
||||
: "100%"
|
||||
: "100%",
|
||||
}}
|
||||
transition={
|
||||
isLargeScreen
|
||||
? { type: "spring", stiffness: 300, damping: 30 }
|
||||
: { duration: 0 }
|
||||
}
|
||||
>
|
||||
{!chatStarted && (
|
||||
<div className="absolute top-0 left-0 z-10 flex w-full items-center justify-between gap-3 p-2 pl-4">
|
||||
<div>
|
||||
{(!chatHistoryOpen || !isLargeScreen) && (
|
||||
<Button
|
||||
className="hover:bg-gray-100"
|
||||
variant="ghost"
|
||||
onClick={() => setChatHistoryOpen((p) => !p)}
|
||||
>
|
||||
{chatHistoryOpen ? (
|
||||
<PanelRightOpen className="size-5" />
|
||||
) : (
|
||||
<PanelRightClose className="size-5" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="absolute top-2 right-4 flex items-center">
|
||||
<OpenGitHubRepo />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{chatStarted && (
|
||||
<div className="relative z-10 flex items-center justify-between gap-3 p-2">
|
||||
<div className="relative flex items-center justify-start gap-2">
|
||||
<div className="absolute left-0 z-10">
|
||||
{(!chatHistoryOpen || !isLargeScreen) && (
|
||||
<Button
|
||||
className="hover:bg-gray-100"
|
||||
variant="ghost"
|
||||
onClick={() => setChatHistoryOpen((p) => !p)}
|
||||
>
|
||||
{chatHistoryOpen ? (
|
||||
<PanelRightOpen className="size-5" />
|
||||
) : (
|
||||
<PanelRightClose className="size-5" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<motion.button
|
||||
className="flex cursor-pointer items-center gap-2"
|
||||
onClick={() => setThreadId(null)}
|
||||
animate={{
|
||||
marginLeft: !chatHistoryOpen ? 48 : 0,
|
||||
}}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 30,
|
||||
}}
|
||||
>
|
||||
<LangGraphLogoSVG
|
||||
width={32}
|
||||
height={32}
|
||||
/>
|
||||
<span className="text-xl font-semibold tracking-tight">
|
||||
Agent Chat
|
||||
</span>
|
||||
</motion.button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center">
|
||||
<OpenGitHubRepo />
|
||||
</div>
|
||||
<TooltipIconButton
|
||||
size="lg"
|
||||
className="p-4"
|
||||
tooltip="New thread"
|
||||
variant="ghost"
|
||||
onClick={() => setThreadId(null)}
|
||||
>
|
||||
<SquarePen className="size-5" />
|
||||
</TooltipIconButton>
|
||||
</div>
|
||||
|
||||
<div className="from-background to-background/0 absolute inset-x-0 top-full h-5 bg-gradient-to-b" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<StickToBottom className="relative flex-1 overflow-hidden">
|
||||
<StickyToBottomContent
|
||||
className={cn(
|
||||
"absolute inset-0 overflow-y-scroll px-4 [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-track]:bg-transparent",
|
||||
!chatStarted && "mt-[25vh] flex flex-col items-stretch",
|
||||
chatStarted && "grid grid-rows-[1fr_auto]",
|
||||
)}
|
||||
contentClassName="pt-8 pb-16 max-w-3xl mx-auto flex flex-col gap-4 w-full"
|
||||
content={
|
||||
<>
|
||||
{messages
|
||||
.filter((m) => !m.id?.startsWith(DO_NOT_RENDER_ID_PREFIX))
|
||||
.map((message, index) =>
|
||||
message.type === "human" ? (
|
||||
<HumanMessage
|
||||
key={message.id || `${message.type}-${index}`}
|
||||
message={message}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
) : (
|
||||
<AssistantMessage
|
||||
key={message.id || `${message.type}-${index}`}
|
||||
message={message}
|
||||
isLoading={isLoading}
|
||||
handleRegenerate={handleRegenerate}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
{/* Special rendering case where there are no AI/tool messages, but there is an interrupt.
|
||||
We need to render it outside of the messages list, since there are no messages to render */}
|
||||
{hasNoAIOrToolMessages && !!stream.interrupt && (
|
||||
<AssistantMessage
|
||||
key="interrupt-msg"
|
||||
message={undefined}
|
||||
isLoading={isLoading}
|
||||
handleRegenerate={handleRegenerate}
|
||||
/>
|
||||
)}
|
||||
{isLoading && !firstTokenReceived && (
|
||||
<AssistantMessageLoading />
|
||||
)}
|
||||
</>
|
||||
}
|
||||
footer={
|
||||
<div className="sticky bottom-0 flex flex-col items-center gap-8 bg-white">
|
||||
{!chatStarted && (
|
||||
<div className="flex items-center gap-3">
|
||||
<LangGraphLogoSVG className="h-8 flex-shrink-0" />
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
Agent Chat
|
||||
</h1>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ScrollToBottom className="animate-in fade-in-0 zoom-in-95 absolute bottom-full left-1/2 mb-4 -translate-x-1/2" />
|
||||
|
||||
<div
|
||||
ref={dropRef}
|
||||
className={cn(
|
||||
"bg-muted relative z-10 mx-auto mb-8 w-full max-w-3xl rounded-2xl shadow-xs transition-all",
|
||||
dragOver
|
||||
? "border-primary border-2 border-dotted"
|
||||
: "border border-solid",
|
||||
)}
|
||||
>
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="mx-auto grid max-w-3xl grid-rows-[1fr_auto] gap-2"
|
||||
>
|
||||
<ContentBlocksPreview
|
||||
blocks={contentBlocks}
|
||||
onRemove={removeBlock}
|
||||
/>
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onPaste={handlePaste}
|
||||
onKeyDown={(e) => {
|
||||
if (
|
||||
e.key === "Enter" &&
|
||||
!e.shiftKey &&
|
||||
!e.metaKey &&
|
||||
!e.nativeEvent.isComposing
|
||||
) {
|
||||
e.preventDefault();
|
||||
const el = e.target as HTMLElement | undefined;
|
||||
const form = el?.closest("form");
|
||||
form?.requestSubmit();
|
||||
}
|
||||
}}
|
||||
placeholder="Type your message..."
|
||||
className="field-sizing-content resize-none border-none bg-transparent p-3.5 pb-0 shadow-none ring-0 outline-none focus:ring-0 focus:outline-none"
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-6 p-2 pt-4">
|
||||
<div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="render-tool-calls"
|
||||
checked={hideToolCalls ?? false}
|
||||
onCheckedChange={setHideToolCalls}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="render-tool-calls"
|
||||
className="text-sm text-gray-600"
|
||||
>
|
||||
Hide Tool Calls
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
<Label
|
||||
htmlFor="file-input"
|
||||
className="flex cursor-pointer items-center gap-2"
|
||||
>
|
||||
<Plus className="size-5 text-gray-600" />
|
||||
<span className="text-sm text-gray-600">
|
||||
Upload PDF or Image
|
||||
</span>
|
||||
</Label>
|
||||
<input
|
||||
id="file-input"
|
||||
type="file"
|
||||
onChange={handleFileUpload}
|
||||
multiple
|
||||
accept="image/jpeg,image/png,image/gif,image/webp,application/pdf"
|
||||
className="hidden"
|
||||
/>
|
||||
{stream.isLoading ? (
|
||||
<Button
|
||||
key="stop"
|
||||
onClick={() => stream.stop()}
|
||||
className="ml-auto"
|
||||
>
|
||||
<LoaderCircle className="h-4 w-4 animate-spin" />
|
||||
Cancel
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="submit"
|
||||
className="ml-auto shadow-md transition-all"
|
||||
disabled={
|
||||
isLoading ||
|
||||
(!input.trim() && contentBlocks.length === 0)
|
||||
}
|
||||
>
|
||||
Send
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</StickToBottom>
|
||||
</motion.div>
|
||||
<div className="relative flex flex-col border-l">
|
||||
<div className="absolute inset-0 flex min-w-[30vw] flex-col">
|
||||
<div className="grid grid-cols-[1fr_auto] border-b p-4">
|
||||
<ArtifactTitle className="truncate overflow-hidden" />
|
||||
<button
|
||||
onClick={closeArtifact}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<XIcon className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
<ArtifactContent className="relative flex-grow" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
45
apps/web/src/components/thread/markdown-styles.css
Normal file
45
apps/web/src/components/thread/markdown-styles.css
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
/* Base markdown styles */
|
||||
.markdown-content code:not(pre code) {
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
padding: 0.2em 0.4em;
|
||||
border-radius: 3px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.markdown-content a {
|
||||
color: #0070f3;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.markdown-content a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.markdown-content blockquote {
|
||||
border-left: 4px solid #ddd;
|
||||
padding-left: 1rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.markdown-content pre {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.markdown-content table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.markdown-content th,
|
||||
.markdown-content td {
|
||||
border: 1px solid #ddd;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.markdown-content th {
|
||||
background-color: #f2f2f2;
|
||||
}
|
||||
|
||||
.markdown-content tr:nth-child(even) {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
260
apps/web/src/components/thread/markdown-text.tsx
Normal file
260
apps/web/src/components/thread/markdown-text.tsx
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
"use client";
|
||||
|
||||
import "./markdown-styles.css";
|
||||
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import rehypeKatex from "rehype-katex";
|
||||
import remarkMath from "remark-math";
|
||||
import { FC, memo, useState } from "react";
|
||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||
import { SyntaxHighlighter } from "@/components/thread/syntax-highlighter";
|
||||
|
||||
import { TooltipIconButton } from "@/components/thread/tooltip-icon-button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import "katex/dist/katex.min.css";
|
||||
|
||||
interface CodeHeaderProps {
|
||||
language?: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
const useCopyToClipboard = ({
|
||||
copiedDuration = 3000,
|
||||
}: {
|
||||
copiedDuration?: number;
|
||||
} = {}) => {
|
||||
const [isCopied, setIsCopied] = useState<boolean>(false);
|
||||
|
||||
const copyToClipboard = (value: string) => {
|
||||
if (!value) return;
|
||||
|
||||
navigator.clipboard.writeText(value).then(() => {
|
||||
setIsCopied(true);
|
||||
setTimeout(() => setIsCopied(false), copiedDuration);
|
||||
});
|
||||
};
|
||||
|
||||
return { isCopied, copyToClipboard };
|
||||
};
|
||||
|
||||
const CodeHeader: FC<CodeHeaderProps> = ({ language, code }) => {
|
||||
const { isCopied, copyToClipboard } = useCopyToClipboard();
|
||||
const onCopy = () => {
|
||||
if (!code || isCopied) return;
|
||||
copyToClipboard(code);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 rounded-t-lg bg-zinc-900 px-4 py-2 text-sm font-semibold text-white">
|
||||
<span className="lowercase [&>span]:text-xs">{language}</span>
|
||||
<TooltipIconButton
|
||||
tooltip="Copy"
|
||||
onClick={onCopy}
|
||||
>
|
||||
{!isCopied && <CopyIcon />}
|
||||
{isCopied && <CheckIcon />}
|
||||
</TooltipIconButton>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const defaultComponents: any = {
|
||||
h1: ({ className, ...props }: { className?: string }) => (
|
||||
<h1
|
||||
className={cn(
|
||||
"mb-8 scroll-m-20 text-4xl font-extrabold tracking-tight last:mb-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
h2: ({ className, ...props }: { className?: string }) => (
|
||||
<h2
|
||||
className={cn(
|
||||
"mt-8 mb-4 scroll-m-20 text-3xl font-semibold tracking-tight first:mt-0 last:mb-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
h3: ({ className, ...props }: { className?: string }) => (
|
||||
<h3
|
||||
className={cn(
|
||||
"mt-6 mb-4 scroll-m-20 text-2xl font-semibold tracking-tight first:mt-0 last:mb-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
h4: ({ className, ...props }: { className?: string }) => (
|
||||
<h4
|
||||
className={cn(
|
||||
"mt-6 mb-4 scroll-m-20 text-xl font-semibold tracking-tight first:mt-0 last:mb-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
h5: ({ className, ...props }: { className?: string }) => (
|
||||
<h5
|
||||
className={cn(
|
||||
"my-4 text-lg font-semibold first:mt-0 last:mb-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
h6: ({ className, ...props }: { className?: string }) => (
|
||||
<h6
|
||||
className={cn("my-4 font-semibold first:mt-0 last:mb-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
p: ({ className, ...props }: { className?: string }) => (
|
||||
<p
|
||||
className={cn("mt-5 mb-5 leading-7 first:mt-0 last:mb-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
a: ({ className, ...props }: { className?: string }) => (
|
||||
<a
|
||||
className={cn(
|
||||
"text-primary font-medium underline underline-offset-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
blockquote: ({ className, ...props }: { className?: string }) => (
|
||||
<blockquote
|
||||
className={cn("border-l-2 pl-6 italic", className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
ul: ({ className, ...props }: { className?: string }) => (
|
||||
<ul
|
||||
className={cn("my-5 ml-6 list-disc [&>li]:mt-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
ol: ({ className, ...props }: { className?: string }) => (
|
||||
<ol
|
||||
className={cn("my-5 ml-6 list-decimal [&>li]:mt-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
hr: ({ className, ...props }: { className?: string }) => (
|
||||
<hr
|
||||
className={cn("my-5 border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
table: ({ className, ...props }: { className?: string }) => (
|
||||
<table
|
||||
className={cn(
|
||||
"my-5 w-full border-separate border-spacing-0 overflow-y-auto",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
th: ({ className, ...props }: { className?: string }) => (
|
||||
<th
|
||||
className={cn(
|
||||
"bg-muted px-4 py-2 text-left font-bold first:rounded-tl-lg last:rounded-tr-lg [&[align=center]]:text-center [&[align=right]]:text-right",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
td: ({ className, ...props }: { className?: string }) => (
|
||||
<td
|
||||
className={cn(
|
||||
"border-b border-l px-4 py-2 text-left last:border-r [&[align=center]]:text-center [&[align=right]]:text-right",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
tr: ({ className, ...props }: { className?: string }) => (
|
||||
<tr
|
||||
className={cn(
|
||||
"m-0 border-b p-0 first:border-t [&:last-child>td:first-child]:rounded-bl-lg [&:last-child>td:last-child]:rounded-br-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
sup: ({ className, ...props }: { className?: string }) => (
|
||||
<sup
|
||||
className={cn("[&>a]:text-xs [&>a]:no-underline", className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
pre: ({ className, ...props }: { className?: string }) => (
|
||||
<pre
|
||||
className={cn(
|
||||
"max-w-4xl overflow-x-auto rounded-lg bg-black text-white",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
code: ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const match = /language-(\w+)/.exec(className || "");
|
||||
|
||||
if (match) {
|
||||
const language = match[1];
|
||||
const code = String(children).replace(/\n$/, "");
|
||||
|
||||
return (
|
||||
<>
|
||||
<CodeHeader
|
||||
language={language}
|
||||
code={code}
|
||||
/>
|
||||
<SyntaxHighlighter
|
||||
language={language}
|
||||
className={className}
|
||||
>
|
||||
{code}
|
||||
</SyntaxHighlighter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<code
|
||||
className={cn("rounded font-semibold", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const MarkdownTextImpl: FC<{ children: string }> = ({ children }) => {
|
||||
return (
|
||||
<div className="markdown-content">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkMath]}
|
||||
rehypePlugins={[rehypeKatex]}
|
||||
components={defaultComponents}
|
||||
>
|
||||
{children}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const MarkdownText = memo(MarkdownTextImpl);
|
||||
224
apps/web/src/components/thread/messages/ai.tsx
Normal file
224
apps/web/src/components/thread/messages/ai.tsx
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
import { parsePartialJson } from "@langchain/core/output_parsers";
|
||||
import { useStreamContext } from "@/providers/Stream";
|
||||
import { AIMessage, Checkpoint, Message } from "@langchain/langgraph-sdk";
|
||||
import { getContentString } from "../utils";
|
||||
import { BranchSwitcher, CommandBar } from "./shared";
|
||||
import { MarkdownText } from "../markdown-text";
|
||||
import { LoadExternalComponent } from "@langchain/langgraph-sdk/react-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ToolCalls, ToolResult } from "./tool-calls";
|
||||
import { MessageContentComplex } from "@langchain/core/messages";
|
||||
import { Fragment } from "react/jsx-runtime";
|
||||
import { isAgentInboxInterruptSchema } from "@/lib/agent-inbox-interrupt";
|
||||
import { ThreadView } from "../agent-inbox";
|
||||
import { useQueryState, parseAsBoolean } from "nuqs";
|
||||
import { GenericInterruptView } from "./generic-interrupt";
|
||||
import { useArtifact } from "../artifact";
|
||||
|
||||
function CustomComponent({
|
||||
message,
|
||||
thread,
|
||||
}: {
|
||||
message: Message;
|
||||
thread: ReturnType<typeof useStreamContext>;
|
||||
}) {
|
||||
const artifact = useArtifact();
|
||||
const { values } = useStreamContext();
|
||||
const customComponents = values.ui?.filter(
|
||||
(ui) => ui.metadata?.message_id === message.id,
|
||||
);
|
||||
|
||||
if (!customComponents?.length) return null;
|
||||
return (
|
||||
<Fragment key={message.id}>
|
||||
{customComponents.map((customComponent) => (
|
||||
<LoadExternalComponent
|
||||
key={customComponent.id}
|
||||
stream={thread}
|
||||
message={customComponent}
|
||||
meta={{ ui: customComponent, artifact }}
|
||||
/>
|
||||
))}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
function parseAnthropicStreamedToolCalls(
|
||||
content: MessageContentComplex[],
|
||||
): AIMessage["tool_calls"] {
|
||||
const toolCallContents = content.filter((c) => c.type === "tool_use" && c.id);
|
||||
|
||||
return toolCallContents.map((tc) => {
|
||||
const toolCall = tc as Record<string, any>;
|
||||
let json: Record<string, any> = {};
|
||||
if (toolCall?.input) {
|
||||
try {
|
||||
json = parsePartialJson(toolCall.input) ?? {};
|
||||
} catch {
|
||||
// Pass
|
||||
}
|
||||
}
|
||||
return {
|
||||
name: toolCall.name ?? "",
|
||||
id: toolCall.id ?? "",
|
||||
args: json,
|
||||
type: "tool_call",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
interface InterruptProps {
|
||||
interruptValue?: unknown;
|
||||
isLastMessage: boolean;
|
||||
hasNoAIOrToolMessages: boolean;
|
||||
}
|
||||
|
||||
function Interrupt({
|
||||
interruptValue,
|
||||
isLastMessage,
|
||||
hasNoAIOrToolMessages,
|
||||
}: InterruptProps) {
|
||||
return (
|
||||
<>
|
||||
{isAgentInboxInterruptSchema(interruptValue) &&
|
||||
(isLastMessage || hasNoAIOrToolMessages) && (
|
||||
<ThreadView interrupt={interruptValue} />
|
||||
)}
|
||||
{interruptValue &&
|
||||
!isAgentInboxInterruptSchema(interruptValue) &&
|
||||
isLastMessage ? (
|
||||
<GenericInterruptView interrupt={interruptValue} />
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function AssistantMessage({
|
||||
message,
|
||||
isLoading,
|
||||
handleRegenerate,
|
||||
}: {
|
||||
message: Message | undefined;
|
||||
isLoading: boolean;
|
||||
handleRegenerate: (parentCheckpoint: Checkpoint | null | undefined) => void;
|
||||
}) {
|
||||
const content = message?.content ?? [];
|
||||
const contentString = getContentString(content);
|
||||
const [hideToolCalls] = useQueryState(
|
||||
"hideToolCalls",
|
||||
parseAsBoolean.withDefault(false),
|
||||
);
|
||||
|
||||
const thread = useStreamContext();
|
||||
const isLastMessage =
|
||||
thread.messages[thread.messages.length - 1].id === message?.id;
|
||||
const hasNoAIOrToolMessages = !thread.messages.find(
|
||||
(m) => m.type === "ai" || m.type === "tool",
|
||||
);
|
||||
const meta = message ? thread.getMessagesMetadata(message) : undefined;
|
||||
const threadInterrupt = thread.interrupt;
|
||||
|
||||
const parentCheckpoint = meta?.firstSeenState?.parent_checkpoint;
|
||||
const anthropicStreamedToolCalls = Array.isArray(content)
|
||||
? parseAnthropicStreamedToolCalls(content)
|
||||
: undefined;
|
||||
|
||||
const hasToolCalls =
|
||||
message &&
|
||||
"tool_calls" in message &&
|
||||
message.tool_calls &&
|
||||
message.tool_calls.length > 0;
|
||||
const toolCallsHaveContents =
|
||||
hasToolCalls &&
|
||||
message.tool_calls?.some(
|
||||
(tc) => tc.args && Object.keys(tc.args).length > 0,
|
||||
);
|
||||
const hasAnthropicToolCalls = !!anthropicStreamedToolCalls?.length;
|
||||
const isToolResult = message?.type === "tool";
|
||||
|
||||
if (isToolResult && hideToolCalls) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group mr-auto flex items-start gap-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
{isToolResult ? (
|
||||
<>
|
||||
<ToolResult message={message} />
|
||||
<Interrupt
|
||||
interruptValue={threadInterrupt?.value}
|
||||
isLastMessage={isLastMessage}
|
||||
hasNoAIOrToolMessages={hasNoAIOrToolMessages}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{contentString.length > 0 && (
|
||||
<div className="py-1">
|
||||
<MarkdownText>{contentString}</MarkdownText>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hideToolCalls && (
|
||||
<>
|
||||
{(hasToolCalls && toolCallsHaveContents && (
|
||||
<ToolCalls toolCalls={message.tool_calls} />
|
||||
)) ||
|
||||
(hasAnthropicToolCalls && (
|
||||
<ToolCalls toolCalls={anthropicStreamedToolCalls} />
|
||||
)) ||
|
||||
(hasToolCalls && (
|
||||
<ToolCalls toolCalls={message.tool_calls} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{message && (
|
||||
<CustomComponent
|
||||
message={message}
|
||||
thread={thread}
|
||||
/>
|
||||
)}
|
||||
<Interrupt
|
||||
interruptValue={threadInterrupt?.value}
|
||||
isLastMessage={isLastMessage}
|
||||
hasNoAIOrToolMessages={hasNoAIOrToolMessages}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"mr-auto flex items-center gap-2 transition-opacity",
|
||||
"opacity-0 group-focus-within:opacity-100 group-hover:opacity-100",
|
||||
)}
|
||||
>
|
||||
<BranchSwitcher
|
||||
branch={meta?.branch}
|
||||
branchOptions={meta?.branchOptions}
|
||||
onSelect={(branch) => thread.setBranch(branch)}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<CommandBar
|
||||
content={contentString}
|
||||
isLoading={isLoading}
|
||||
isAiMessage={true}
|
||||
handleRegenerate={() => handleRegenerate(parentCheckpoint)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AssistantMessageLoading() {
|
||||
return (
|
||||
<div className="mr-auto flex items-start gap-2">
|
||||
<div className="bg-muted flex h-8 items-center gap-1 rounded-2xl px-4 py-2">
|
||||
<div className="bg-foreground/50 h-1.5 w-1.5 animate-[pulse_1.5s_ease-in-out_infinite] rounded-full"></div>
|
||||
<div className="bg-foreground/50 h-1.5 w-1.5 animate-[pulse_1.5s_ease-in-out_0.5s_infinite] rounded-full"></div>
|
||||
<div className="bg-foreground/50 h-1.5 w-1.5 animate-[pulse_1.5s_ease-in-out_1s_infinite] rounded-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
129
apps/web/src/components/thread/messages/generic-interrupt.tsx
Normal file
129
apps/web/src/components/thread/messages/generic-interrupt.tsx
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import { useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
|
||||
function isComplexValue(value: any): boolean {
|
||||
return Array.isArray(value) || (typeof value === "object" && value !== null);
|
||||
}
|
||||
|
||||
export function GenericInterruptView({
|
||||
interrupt,
|
||||
}: {
|
||||
interrupt: Record<string, any> | Record<string, any>[];
|
||||
}) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const contentStr = JSON.stringify(interrupt, null, 2);
|
||||
const contentLines = contentStr.split("\n");
|
||||
const shouldTruncate = contentLines.length > 4 || contentStr.length > 500;
|
||||
|
||||
// Function to truncate long string values
|
||||
const truncateValue = (value: any): any => {
|
||||
if (typeof value === "string" && value.length > 100) {
|
||||
return value.substring(0, 100) + "...";
|
||||
}
|
||||
|
||||
if (Array.isArray(value) && !isExpanded) {
|
||||
return value.slice(0, 2).map(truncateValue);
|
||||
}
|
||||
|
||||
if (isComplexValue(value) && !isExpanded) {
|
||||
const strValue = JSON.stringify(value, null, 2);
|
||||
if (strValue.length > 100) {
|
||||
// Return plain text for truncated content instead of a JSON object
|
||||
return `Truncated ${strValue.length} characters...`;
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
// Process entries based on expanded state
|
||||
const processEntries = () => {
|
||||
if (Array.isArray(interrupt)) {
|
||||
return isExpanded ? interrupt : interrupt.slice(0, 5);
|
||||
} else {
|
||||
const entries = Object.entries(interrupt);
|
||||
if (!isExpanded && shouldTruncate) {
|
||||
// When collapsed, process each value to potentially truncate it
|
||||
return entries.map(([key, value]) => [key, truncateValue(value)]);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
};
|
||||
|
||||
const displayEntries = processEntries();
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border border-gray-200">
|
||||
<div className="border-b border-gray-200 bg-gray-50 px-4 py-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="font-medium text-gray-900">Human Interrupt</h3>
|
||||
</div>
|
||||
</div>
|
||||
<motion.div
|
||||
className="min-w-full bg-gray-100"
|
||||
initial={false}
|
||||
animate={{ height: "auto" }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<div className="p-3">
|
||||
<AnimatePresence
|
||||
mode="wait"
|
||||
initial={false}
|
||||
>
|
||||
<motion.div
|
||||
key={isExpanded ? "expanded" : "collapsed"}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
style={{
|
||||
maxHeight: isExpanded ? "none" : "500px",
|
||||
overflow: "auto",
|
||||
}}
|
||||
>
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{displayEntries.map((item, argIdx) => {
|
||||
const [key, value] = Array.isArray(interrupt)
|
||||
? [argIdx.toString(), item]
|
||||
: (item as [string, any]);
|
||||
return (
|
||||
<tr key={argIdx}>
|
||||
<td className="px-4 py-2 text-sm font-medium whitespace-nowrap text-gray-900">
|
||||
{key}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-500">
|
||||
{isComplexValue(value) ? (
|
||||
<code className="rounded bg-gray-50 px-2 py-1 font-mono text-sm">
|
||||
{JSON.stringify(value, null, 2)}
|
||||
</code>
|
||||
) : (
|
||||
String(value)
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
{(shouldTruncate ||
|
||||
(Array.isArray(interrupt) && interrupt.length > 5)) && (
|
||||
<motion.button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="flex w-full cursor-pointer items-center justify-center border-t-[1px] border-gray-200 py-2 text-gray-500 transition-all duration-200 ease-in-out hover:bg-gray-50 hover:text-gray-600"
|
||||
initial={{ scale: 1 }}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
{isExpanded ? <ChevronUp /> : <ChevronDown />}
|
||||
</motion.button>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
149
apps/web/src/components/thread/messages/human.tsx
Normal file
149
apps/web/src/components/thread/messages/human.tsx
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import { useStreamContext } from "@/providers/Stream";
|
||||
import { Message } from "@langchain/langgraph-sdk";
|
||||
import { useState } from "react";
|
||||
import { getContentString } from "../utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { BranchSwitcher, CommandBar } from "./shared";
|
||||
import { MultimodalPreview } from "@/components/thread/MultimodalPreview";
|
||||
import { isBase64ContentBlock } from "@/lib/multimodal-utils";
|
||||
|
||||
function EditableContent({
|
||||
value,
|
||||
setValue,
|
||||
onSubmit,
|
||||
}: {
|
||||
value: string;
|
||||
setValue: React.Dispatch<React.SetStateAction<string>>;
|
||||
onSubmit: () => void;
|
||||
}) {
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
onSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Textarea
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="focus-visible:ring-0"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function HumanMessage({
|
||||
message,
|
||||
isLoading,
|
||||
}: {
|
||||
message: Message;
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
const thread = useStreamContext();
|
||||
const meta = thread.getMessagesMetadata(message);
|
||||
const parentCheckpoint = meta?.firstSeenState?.parent_checkpoint;
|
||||
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [value, setValue] = useState("");
|
||||
const contentString = getContentString(message.content);
|
||||
|
||||
const handleSubmitEdit = () => {
|
||||
setIsEditing(false);
|
||||
|
||||
const newMessage: Message = { type: "human", content: value };
|
||||
thread.submit(
|
||||
{ messages: [newMessage] },
|
||||
{
|
||||
checkpoint: parentCheckpoint,
|
||||
streamMode: ["values"],
|
||||
optimisticValues: (prev) => {
|
||||
const values = meta?.firstSeenState?.values;
|
||||
if (!values) return prev;
|
||||
|
||||
return {
|
||||
...values,
|
||||
messages: [...(values.messages ?? []), newMessage],
|
||||
};
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group ml-auto flex items-center gap-2",
|
||||
isEditing && "w-full max-w-xl",
|
||||
)}
|
||||
>
|
||||
<div className={cn("flex flex-col gap-2", isEditing && "w-full")}>
|
||||
{isEditing ? (
|
||||
<EditableContent
|
||||
value={value}
|
||||
setValue={setValue}
|
||||
onSubmit={handleSubmitEdit}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Render images and files if no text */}
|
||||
{Array.isArray(message.content) && message.content.length > 0 && (
|
||||
<div className="flex flex-wrap items-end justify-end gap-2">
|
||||
{message.content.reduce<React.ReactNode[]>(
|
||||
(acc, block, idx) => {
|
||||
if (isBase64ContentBlock(block)) {
|
||||
acc.push(
|
||||
<MultimodalPreview
|
||||
key={idx}
|
||||
block={block}
|
||||
size="md"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[],
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Render text if present, otherwise fallback to file/image name */}
|
||||
{contentString ? (
|
||||
<p className="bg-muted ml-auto w-fit rounded-3xl px-4 py-2 text-right whitespace-pre-wrap">
|
||||
{contentString}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"ml-auto flex items-center gap-2 transition-opacity",
|
||||
"opacity-0 group-focus-within:opacity-100 group-hover:opacity-100",
|
||||
isEditing && "opacity-100",
|
||||
)}
|
||||
>
|
||||
<BranchSwitcher
|
||||
branch={meta?.branch}
|
||||
branchOptions={meta?.branchOptions}
|
||||
onSelect={(branch) => thread.setBranch(branch)}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<CommandBar
|
||||
isLoading={isLoading}
|
||||
content={contentString}
|
||||
isEditing={isEditing}
|
||||
setIsEditing={(c) => {
|
||||
if (c) {
|
||||
setValue(contentString);
|
||||
}
|
||||
setIsEditing(c);
|
||||
}}
|
||||
handleSubmitEdit={handleSubmitEdit}
|
||||
isHumanMessage={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
221
apps/web/src/components/thread/messages/shared.tsx
Normal file
221
apps/web/src/components/thread/messages/shared.tsx
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
import {
|
||||
XIcon,
|
||||
SendHorizontal,
|
||||
RefreshCcw,
|
||||
Pencil,
|
||||
Copy,
|
||||
CopyCheck,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import { TooltipIconButton } from "../tooltip-icon-button";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
function ContentCopyable({
|
||||
content,
|
||||
disabled,
|
||||
}: {
|
||||
content: string;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(content);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipIconButton
|
||||
onClick={(e) => handleCopy(e)}
|
||||
variant="ghost"
|
||||
tooltip="Copy content"
|
||||
disabled={disabled}
|
||||
>
|
||||
<AnimatePresence
|
||||
mode="wait"
|
||||
initial={false}
|
||||
>
|
||||
{copied ? (
|
||||
<motion.div
|
||||
key="check"
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<CopyCheck className="text-green-500" />
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="copy"
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<Copy />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</TooltipIconButton>
|
||||
);
|
||||
}
|
||||
|
||||
export function BranchSwitcher({
|
||||
branch,
|
||||
branchOptions,
|
||||
onSelect,
|
||||
isLoading,
|
||||
}: {
|
||||
branch: string | undefined;
|
||||
branchOptions: string[] | undefined;
|
||||
onSelect: (branch: string) => void;
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
if (!branchOptions || !branch) return null;
|
||||
const index = branchOptions.indexOf(branch);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6 p-1"
|
||||
onClick={() => {
|
||||
const prevBranch = branchOptions[index - 1];
|
||||
if (!prevBranch) return;
|
||||
onSelect(prevBranch);
|
||||
}}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<ChevronLeft />
|
||||
</Button>
|
||||
<span className="text-sm">
|
||||
{index + 1} / {branchOptions.length}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6 p-1"
|
||||
onClick={() => {
|
||||
const nextBranch = branchOptions[index + 1];
|
||||
if (!nextBranch) return;
|
||||
onSelect(nextBranch);
|
||||
}}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<ChevronRight />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CommandBar({
|
||||
content,
|
||||
isHumanMessage,
|
||||
isAiMessage,
|
||||
isEditing,
|
||||
setIsEditing,
|
||||
handleSubmitEdit,
|
||||
handleRegenerate,
|
||||
isLoading,
|
||||
}: {
|
||||
content: string;
|
||||
isHumanMessage?: boolean;
|
||||
isAiMessage?: boolean;
|
||||
isEditing?: boolean;
|
||||
setIsEditing?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
handleSubmitEdit?: () => void;
|
||||
handleRegenerate?: () => void;
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
if (isHumanMessage && isAiMessage) {
|
||||
throw new Error(
|
||||
"Can only set one of isHumanMessage or isAiMessage to true, not both.",
|
||||
);
|
||||
}
|
||||
|
||||
if (!isHumanMessage && !isAiMessage) {
|
||||
throw new Error(
|
||||
"One of isHumanMessage or isAiMessage must be set to true.",
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
isHumanMessage &&
|
||||
(isEditing === undefined ||
|
||||
setIsEditing === undefined ||
|
||||
handleSubmitEdit === undefined)
|
||||
) {
|
||||
throw new Error(
|
||||
"If isHumanMessage is true, all of isEditing, setIsEditing, and handleSubmitEdit must be set.",
|
||||
);
|
||||
}
|
||||
|
||||
const showEdit =
|
||||
isHumanMessage &&
|
||||
isEditing !== undefined &&
|
||||
!!setIsEditing &&
|
||||
!!handleSubmitEdit;
|
||||
|
||||
if (isHumanMessage && isEditing && !!setIsEditing && !!handleSubmitEdit) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<TooltipIconButton
|
||||
disabled={isLoading}
|
||||
tooltip="Cancel edit"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setIsEditing(false);
|
||||
}}
|
||||
>
|
||||
<XIcon />
|
||||
</TooltipIconButton>
|
||||
<TooltipIconButton
|
||||
disabled={isLoading}
|
||||
tooltip="Submit"
|
||||
variant="secondary"
|
||||
onClick={handleSubmitEdit}
|
||||
>
|
||||
<SendHorizontal />
|
||||
</TooltipIconButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<ContentCopyable
|
||||
content={content}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{isAiMessage && !!handleRegenerate && (
|
||||
<TooltipIconButton
|
||||
disabled={isLoading}
|
||||
tooltip="Refresh"
|
||||
variant="ghost"
|
||||
onClick={handleRegenerate}
|
||||
>
|
||||
<RefreshCcw />
|
||||
</TooltipIconButton>
|
||||
)}
|
||||
{showEdit && (
|
||||
<TooltipIconButton
|
||||
disabled={isLoading}
|
||||
tooltip="Edit"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setIsEditing?.(true);
|
||||
}}
|
||||
>
|
||||
<Pencil />
|
||||
</TooltipIconButton>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
191
apps/web/src/components/thread/messages/tool-calls.tsx
Normal file
191
apps/web/src/components/thread/messages/tool-calls.tsx
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
import { AIMessage, ToolMessage } from "@langchain/langgraph-sdk";
|
||||
import { useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
|
||||
function isComplexValue(value: any): boolean {
|
||||
return Array.isArray(value) || (typeof value === "object" && value !== null);
|
||||
}
|
||||
|
||||
export function ToolCalls({
|
||||
toolCalls,
|
||||
}: {
|
||||
toolCalls: AIMessage["tool_calls"];
|
||||
}) {
|
||||
if (!toolCalls || toolCalls.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mx-auto grid max-w-3xl grid-rows-[1fr_auto] gap-2">
|
||||
{toolCalls.map((tc, idx) => {
|
||||
const args = tc.args as Record<string, any>;
|
||||
const hasArgs = Object.keys(args).length > 0;
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className="overflow-hidden rounded-lg border border-gray-200"
|
||||
>
|
||||
<div className="border-b border-gray-200 bg-gray-50 px-4 py-2">
|
||||
<h3 className="font-medium text-gray-900">
|
||||
{tc.name}
|
||||
{tc.id && (
|
||||
<code className="ml-2 rounded bg-gray-100 px-2 py-1 text-sm">
|
||||
{tc.id}
|
||||
</code>
|
||||
)}
|
||||
</h3>
|
||||
</div>
|
||||
{hasArgs ? (
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{Object.entries(args).map(([key, value], argIdx) => (
|
||||
<tr key={argIdx}>
|
||||
<td className="px-4 py-2 text-sm font-medium whitespace-nowrap text-gray-900">
|
||||
{key}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-500">
|
||||
{isComplexValue(value) ? (
|
||||
<code className="rounded bg-gray-50 px-2 py-1 font-mono text-sm break-all">
|
||||
{JSON.stringify(value, null, 2)}
|
||||
</code>
|
||||
) : (
|
||||
String(value)
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<code className="block p-3 text-sm">{"{}"}</code>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToolResult({ message }: { message: ToolMessage }) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
let parsedContent: any;
|
||||
let isJsonContent = false;
|
||||
|
||||
try {
|
||||
if (typeof message.content === "string") {
|
||||
parsedContent = JSON.parse(message.content);
|
||||
isJsonContent = isComplexValue(parsedContent);
|
||||
}
|
||||
} catch {
|
||||
// Content is not JSON, use as is
|
||||
parsedContent = message.content;
|
||||
}
|
||||
|
||||
const contentStr = isJsonContent
|
||||
? JSON.stringify(parsedContent, null, 2)
|
||||
: String(message.content);
|
||||
const contentLines = contentStr.split("\n");
|
||||
const shouldTruncate = contentLines.length > 4 || contentStr.length > 500;
|
||||
const displayedContent =
|
||||
shouldTruncate && !isExpanded
|
||||
? contentStr.length > 500
|
||||
? contentStr.slice(0, 500) + "..."
|
||||
: contentLines.slice(0, 4).join("\n") + "\n..."
|
||||
: contentStr;
|
||||
|
||||
return (
|
||||
<div className="mx-auto grid max-w-3xl grid-rows-[1fr_auto] gap-2">
|
||||
<div className="overflow-hidden rounded-lg border border-gray-200">
|
||||
<div className="border-b border-gray-200 bg-gray-50 px-4 py-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
{message.name ? (
|
||||
<h3 className="font-medium text-gray-900">
|
||||
Tool Result:{" "}
|
||||
<code className="rounded bg-gray-100 px-2 py-1">
|
||||
{message.name}
|
||||
</code>
|
||||
</h3>
|
||||
) : (
|
||||
<h3 className="font-medium text-gray-900">Tool Result</h3>
|
||||
)}
|
||||
{message.tool_call_id && (
|
||||
<code className="ml-2 rounded bg-gray-100 px-2 py-1 text-sm">
|
||||
{message.tool_call_id}
|
||||
</code>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<motion.div
|
||||
className="min-w-full bg-gray-100"
|
||||
initial={false}
|
||||
animate={{ height: "auto" }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<div className="p-3">
|
||||
<AnimatePresence
|
||||
mode="wait"
|
||||
initial={false}
|
||||
>
|
||||
<motion.div
|
||||
key={isExpanded ? "expanded" : "collapsed"}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
{isJsonContent ? (
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{(Array.isArray(parsedContent)
|
||||
? isExpanded
|
||||
? parsedContent
|
||||
: parsedContent.slice(0, 5)
|
||||
: Object.entries(parsedContent)
|
||||
).map((item, argIdx) => {
|
||||
const [key, value] = Array.isArray(parsedContent)
|
||||
? [argIdx, item]
|
||||
: [item[0], item[1]];
|
||||
return (
|
||||
<tr key={argIdx}>
|
||||
<td className="px-4 py-2 text-sm font-medium whitespace-nowrap text-gray-900">
|
||||
{key}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-500">
|
||||
{isComplexValue(value) ? (
|
||||
<code className="rounded bg-gray-50 px-2 py-1 font-mono text-sm break-all">
|
||||
{JSON.stringify(value, null, 2)}
|
||||
</code>
|
||||
) : (
|
||||
String(value)
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<code className="block text-sm">{displayedContent}</code>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
{((shouldTruncate && !isJsonContent) ||
|
||||
(isJsonContent &&
|
||||
Array.isArray(parsedContent) &&
|
||||
parsedContent.length > 5)) && (
|
||||
<motion.button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="flex w-full cursor-pointer items-center justify-center border-t-[1px] border-gray-200 py-2 text-gray-500 transition-all duration-200 ease-in-out hover:bg-gray-50 hover:text-gray-600"
|
||||
initial={{ scale: 1 }}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
{isExpanded ? <ChevronUp /> : <ChevronDown />}
|
||||
</motion.button>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
apps/web/src/components/thread/syntax-highlighter.tsx
Normal file
40
apps/web/src/components/thread/syntax-highlighter.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { PrismAsyncLight as SyntaxHighlighterPrism } from "react-syntax-highlighter";
|
||||
import tsx from "react-syntax-highlighter/dist/esm/languages/prism/tsx";
|
||||
import python from "react-syntax-highlighter/dist/esm/languages/prism/python";
|
||||
import { coldarkDark } from "react-syntax-highlighter/dist/cjs/styles/prism";
|
||||
import { FC } from "react";
|
||||
|
||||
// Register languages you want to support
|
||||
SyntaxHighlighterPrism.registerLanguage("js", tsx);
|
||||
SyntaxHighlighterPrism.registerLanguage("jsx", tsx);
|
||||
SyntaxHighlighterPrism.registerLanguage("ts", tsx);
|
||||
SyntaxHighlighterPrism.registerLanguage("tsx", tsx);
|
||||
SyntaxHighlighterPrism.registerLanguage("python", python);
|
||||
|
||||
interface SyntaxHighlighterProps {
|
||||
children: string;
|
||||
language: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const SyntaxHighlighter: FC<SyntaxHighlighterProps> = ({
|
||||
children,
|
||||
language,
|
||||
className,
|
||||
}) => {
|
||||
return (
|
||||
<SyntaxHighlighterPrism
|
||||
language={language}
|
||||
style={coldarkDark}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
width: "100%",
|
||||
background: "transparent",
|
||||
padding: "1.5rem 1rem",
|
||||
}}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</SyntaxHighlighterPrism>
|
||||
);
|
||||
};
|
||||
44
apps/web/src/components/thread/tooltip-icon-button.tsx
Normal file
44
apps/web/src/components/thread/tooltip-icon-button.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
"use client";
|
||||
|
||||
import { forwardRef } from "react";
|
||||
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Button, ButtonProps } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type TooltipIconButtonProps = ButtonProps & {
|
||||
tooltip: string;
|
||||
side?: "top" | "bottom" | "left" | "right";
|
||||
};
|
||||
|
||||
export const TooltipIconButton = forwardRef<
|
||||
HTMLButtonElement,
|
||||
TooltipIconButtonProps
|
||||
>(({ children, tooltip, side = "bottom", className, ...rest }, ref) => {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
{...rest}
|
||||
className={cn("size-6 p-1", className)}
|
||||
ref={ref}
|
||||
>
|
||||
{children}
|
||||
<span className="sr-only">{tooltip}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side={side}>{tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
});
|
||||
|
||||
TooltipIconButton.displayName = "TooltipIconButton";
|
||||
15
apps/web/src/components/thread/utils.ts
Normal file
15
apps/web/src/components/thread/utils.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
|
||||
/**
|
||||
* Extracts a string summary from a message's content, supporting multimodal (text, image, file, etc.).
|
||||
* - If text is present, returns the joined text.
|
||||
* - If not, returns a label for the first non-text modality (e.g., 'Image', 'Other').
|
||||
* - If unknown, returns 'Multimodal message'.
|
||||
*/
|
||||
export function getContentString(content: Message["content"]): string {
|
||||
if (typeof content === "string") return content;
|
||||
const texts = content
|
||||
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
||||
.map((c) => c.text);
|
||||
return texts.join(" ");
|
||||
}
|
||||
51
apps/web/src/components/ui/avatar.tsx
Normal file
51
apps/web/src/components/ui/avatar.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import * as React from "react";
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
className={cn(
|
||||
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarImage({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn("aspect-square size-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
"bg-muted flex size-full items-center justify-center rounded-full",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback };
|
||||
61
apps/web/src/components/ui/button.tsx
Normal file
61
apps/web/src/components/ui/button.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,box-shadow] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40",
|
||||
outline:
|
||||
"border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
brand: "bg-[#2F6868] hover:bg-[#2F6868]/90 border-[#2F6868] text-white",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
type ButtonProps = React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
};
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Button, buttonVariants, type ButtonProps };
|
||||
75
apps/web/src/components/ui/card.tsx
Normal file
75
apps/web/src/components/ui/card.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn("flex flex-col gap-1.5 px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
};
|
||||
20
apps/web/src/components/ui/input.tsx
Normal file
20
apps/web/src/components/ui/input.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"border-input file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Input };
|
||||
22
apps/web/src/components/ui/label.tsx
Normal file
22
apps/web/src/components/ui/label.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Label };
|
||||
60
apps/web/src/components/ui/password-input.tsx
Normal file
60
apps/web/src/components/ui/password-input.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Input } from "./input";
|
||||
import { Button } from "./button";
|
||||
import { EyeIcon, EyeOffIcon } from "lucide-react";
|
||||
|
||||
export const PasswordInput = React.forwardRef<
|
||||
HTMLInputElement,
|
||||
React.ComponentProps<"input">
|
||||
>(({ className, ...props }, ref) => {
|
||||
const [showPassword, setShowPassword] = React.useState(false);
|
||||
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
type={showPassword ? "text" : "password"}
|
||||
className={cn("hide-password-toggle pr-10", className)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute top-0 right-0 h-full px-3 py-2 hover:bg-transparent"
|
||||
onClick={() => setShowPassword((prev) => !prev)}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeIcon
|
||||
className="h-4 w-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<EyeOffIcon
|
||||
className="h-4 w-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<span className="sr-only">
|
||||
{showPassword ? "Hide password" : "Show password"}
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
{/* hides browsers password toggles */}
|
||||
<style>{`
|
||||
.hide-password-toggle::-ms-reveal,
|
||||
.hide-password-toggle::-ms-clear {
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
display: none;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
PasswordInput.displayName = "PasswordInput";
|
||||
26
apps/web/src/components/ui/separator.tsx
Normal file
26
apps/web/src/components/ui/separator.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import * as React from "react";
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator-root"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Separator };
|
||||
157
apps/web/src/components/ui/sheet.tsx
Normal file
157
apps/web/src/components/ui/sheet.tsx
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import * as React from "react";
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog";
|
||||
import { XIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return (
|
||||
<SheetPrimitive.Root
|
||||
data-slot="sheet"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
return (
|
||||
<SheetPrimitive.Trigger
|
||||
data-slot="sheet-trigger"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
return (
|
||||
<SheetPrimitive.Close
|
||||
data-slot="sheet-close"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
return (
|
||||
<SheetPrimitive.Portal
|
||||
data-slot="sheet-portal"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
side === "right" &&
|
||||
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
||||
side === "left" &&
|
||||
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
||||
side === "top" &&
|
||||
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||
side === "bottom" &&
|
||||
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn("text-foreground font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
};
|
||||
13
apps/web/src/components/ui/skeleton.tsx
Normal file
13
apps/web/src/components/ui/skeleton.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("bg-primary/10 animate-pulse rounded-md", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton };
|
||||
27
apps/web/src/components/ui/sonner.tsx
Normal file
27
apps/web/src/components/ui/sonner.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { useTheme } from "next-themes";
|
||||
import { Toaster as Sonner, ToasterProps } from "sonner";
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme();
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast:
|
||||
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||
description: "group-[.toast]:text-muted-foreground",
|
||||
actionButton:
|
||||
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground font-medium",
|
||||
cancelButton:
|
||||
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground font-medium",
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { Toaster };
|
||||
29
apps/web/src/components/ui/switch.tsx
Normal file
29
apps/web/src/components/ui/switch.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import * as React from "react";
|
||||
import * as SwitchPrimitive from "@radix-ui/react-switch";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
className={cn(
|
||||
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 inline-flex h-5 w-9 shrink-0 items-center rounded-full border-2 border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className={cn(
|
||||
"bg-background pointer-events-none block size-4 rounded-full shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Switch };
|
||||
18
apps/web/src/components/ui/textarea.tsx
Normal file
18
apps/web/src/components/ui/textarea.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Textarea };
|
||||
67
apps/web/src/components/ui/tooltip.tsx
Normal file
67
apps/web/src/components/ui/tooltip.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import * as React from "react";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipPrimitive.Root
|
||||
data-slot="tooltip"
|
||||
{...props}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return (
|
||||
<TooltipPrimitive.Trigger
|
||||
data-slot="tooltip-trigger"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit rounded-md px-3 py-1.5 text-xs text-balance",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
268
apps/web/src/hooks/use-file-upload.tsx
Normal file
268
apps/web/src/hooks/use-file-upload.tsx
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
import { useState, useRef, useEffect, ChangeEvent } from "react";
|
||||
import { toast } from "sonner";
|
||||
import type { Base64ContentBlock } from "@langchain/core/messages";
|
||||
import { fileToContentBlock } from "@/lib/multimodal-utils";
|
||||
|
||||
export const SUPPORTED_FILE_TYPES = [
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"application/pdf",
|
||||
];
|
||||
|
||||
interface UseFileUploadOptions {
|
||||
initialBlocks?: Base64ContentBlock[];
|
||||
}
|
||||
|
||||
export function useFileUpload({
|
||||
initialBlocks = [],
|
||||
}: UseFileUploadOptions = {}) {
|
||||
const [contentBlocks, setContentBlocks] =
|
||||
useState<Base64ContentBlock[]>(initialBlocks);
|
||||
const dropRef = useRef<HTMLDivElement>(null);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const dragCounter = useRef(0);
|
||||
|
||||
const isDuplicate = (file: File, blocks: Base64ContentBlock[]) => {
|
||||
if (file.type === "application/pdf") {
|
||||
return blocks.some(
|
||||
(b) =>
|
||||
b.type === "file" &&
|
||||
b.mime_type === "application/pdf" &&
|
||||
b.metadata?.filename === file.name,
|
||||
);
|
||||
}
|
||||
if (SUPPORTED_FILE_TYPES.includes(file.type)) {
|
||||
return blocks.some(
|
||||
(b) =>
|
||||
b.type === "image" &&
|
||||
b.metadata?.name === file.name &&
|
||||
b.mime_type === file.type,
|
||||
);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleFileUpload = async (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (!files) return;
|
||||
const fileArray = Array.from(files);
|
||||
const validFiles = fileArray.filter((file) =>
|
||||
SUPPORTED_FILE_TYPES.includes(file.type),
|
||||
);
|
||||
const invalidFiles = fileArray.filter(
|
||||
(file) => !SUPPORTED_FILE_TYPES.includes(file.type),
|
||||
);
|
||||
const duplicateFiles = validFiles.filter((file) =>
|
||||
isDuplicate(file, contentBlocks),
|
||||
);
|
||||
const uniqueFiles = validFiles.filter(
|
||||
(file) => !isDuplicate(file, contentBlocks),
|
||||
);
|
||||
|
||||
if (invalidFiles.length > 0) {
|
||||
toast.error(
|
||||
"You have uploaded invalid file type. Please upload a JPEG, PNG, GIF, WEBP image or a PDF.",
|
||||
);
|
||||
}
|
||||
if (duplicateFiles.length > 0) {
|
||||
toast.error(
|
||||
`Duplicate file(s) detected: ${duplicateFiles.map((f) => f.name).join(", ")}. Each file can only be uploaded once per message.`,
|
||||
);
|
||||
}
|
||||
|
||||
const newBlocks = uniqueFiles.length
|
||||
? await Promise.all(uniqueFiles.map(fileToContentBlock))
|
||||
: [];
|
||||
setContentBlocks((prev) => [...prev, ...newBlocks]);
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
// Drag and drop handlers
|
||||
useEffect(() => {
|
||||
if (!dropRef.current) return;
|
||||
|
||||
// Global drag events with counter for robust dragOver state
|
||||
const handleWindowDragEnter = (e: DragEvent) => {
|
||||
if (e.dataTransfer?.types?.includes("Files")) {
|
||||
dragCounter.current++;
|
||||
setDragOver(true);
|
||||
}
|
||||
};
|
||||
const handleWindowDragLeave = (e: DragEvent) => {
|
||||
if (e.dataTransfer?.types?.includes("Files")) {
|
||||
dragCounter.current--;
|
||||
if (dragCounter.current <= 0) {
|
||||
setDragOver(false);
|
||||
dragCounter.current = 0;
|
||||
}
|
||||
}
|
||||
};
|
||||
const handleWindowDrop = async (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragCounter.current = 0;
|
||||
setDragOver(false);
|
||||
|
||||
if (!e.dataTransfer) return;
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
const validFiles = files.filter((file) =>
|
||||
SUPPORTED_FILE_TYPES.includes(file.type),
|
||||
);
|
||||
const invalidFiles = files.filter(
|
||||
(file) => !SUPPORTED_FILE_TYPES.includes(file.type),
|
||||
);
|
||||
const duplicateFiles = validFiles.filter((file) =>
|
||||
isDuplicate(file, contentBlocks),
|
||||
);
|
||||
const uniqueFiles = validFiles.filter(
|
||||
(file) => !isDuplicate(file, contentBlocks),
|
||||
);
|
||||
|
||||
if (invalidFiles.length > 0) {
|
||||
toast.error(
|
||||
"You have uploaded invalid file type. Please upload a JPEG, PNG, GIF, WEBP image or a PDF.",
|
||||
);
|
||||
}
|
||||
if (duplicateFiles.length > 0) {
|
||||
toast.error(
|
||||
`Duplicate file(s) detected: ${duplicateFiles.map((f) => f.name).join(", ")}. Each file can only be uploaded once per message.`,
|
||||
);
|
||||
}
|
||||
|
||||
const newBlocks = uniqueFiles.length
|
||||
? await Promise.all(uniqueFiles.map(fileToContentBlock))
|
||||
: [];
|
||||
setContentBlocks((prev) => [...prev, ...newBlocks]);
|
||||
};
|
||||
const handleWindowDragEnd = (e: DragEvent) => {
|
||||
dragCounter.current = 0;
|
||||
setDragOver(false);
|
||||
};
|
||||
window.addEventListener("dragenter", handleWindowDragEnter);
|
||||
window.addEventListener("dragleave", handleWindowDragLeave);
|
||||
window.addEventListener("drop", handleWindowDrop);
|
||||
window.addEventListener("dragend", handleWindowDragEnd);
|
||||
|
||||
// Prevent default browser behavior for dragover globally
|
||||
const handleWindowDragOver = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
window.addEventListener("dragover", handleWindowDragOver);
|
||||
|
||||
// Remove element-specific drop event (handled globally)
|
||||
const handleDragOver = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragOver(true);
|
||||
};
|
||||
const handleDragEnter = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragOver(true);
|
||||
};
|
||||
const handleDragLeave = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragOver(false);
|
||||
};
|
||||
const element = dropRef.current;
|
||||
element.addEventListener("dragover", handleDragOver);
|
||||
element.addEventListener("dragenter", handleDragEnter);
|
||||
element.addEventListener("dragleave", handleDragLeave);
|
||||
|
||||
return () => {
|
||||
element.removeEventListener("dragover", handleDragOver);
|
||||
element.removeEventListener("dragenter", handleDragEnter);
|
||||
element.removeEventListener("dragleave", handleDragLeave);
|
||||
window.removeEventListener("dragenter", handleWindowDragEnter);
|
||||
window.removeEventListener("dragleave", handleWindowDragLeave);
|
||||
window.removeEventListener("drop", handleWindowDrop);
|
||||
window.removeEventListener("dragend", handleWindowDragEnd);
|
||||
window.removeEventListener("dragover", handleWindowDragOver);
|
||||
dragCounter.current = 0;
|
||||
};
|
||||
}, [contentBlocks]);
|
||||
|
||||
const removeBlock = (idx: number) => {
|
||||
setContentBlocks((prev) => prev.filter((_, i) => i !== idx));
|
||||
};
|
||||
|
||||
const resetBlocks = () => setContentBlocks([]);
|
||||
|
||||
/**
|
||||
* Handle paste event for files (images, PDFs)
|
||||
* Can be used as onPaste={handlePaste} on a textarea or input
|
||||
*/
|
||||
const handlePaste = async (
|
||||
e: React.ClipboardEvent<HTMLTextAreaElement | HTMLInputElement>,
|
||||
) => {
|
||||
e.preventDefault();
|
||||
const items = e.clipboardData.items;
|
||||
if (!items) return;
|
||||
const files: File[] = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (item.kind === "file") {
|
||||
const file = item.getAsFile();
|
||||
if (file) files.push(file);
|
||||
}
|
||||
}
|
||||
if (files.length === 0) return;
|
||||
const validFiles = files.filter((file) =>
|
||||
SUPPORTED_FILE_TYPES.includes(file.type),
|
||||
);
|
||||
const invalidFiles = files.filter(
|
||||
(file) => !SUPPORTED_FILE_TYPES.includes(file.type),
|
||||
);
|
||||
const isDuplicate = (file: File) => {
|
||||
if (file.type === "application/pdf") {
|
||||
return contentBlocks.some(
|
||||
(b) =>
|
||||
b.type === "file" &&
|
||||
b.mime_type === "application/pdf" &&
|
||||
b.metadata?.filename === file.name,
|
||||
);
|
||||
}
|
||||
if (SUPPORTED_FILE_TYPES.includes(file.type)) {
|
||||
return contentBlocks.some(
|
||||
(b) =>
|
||||
b.type === "image" &&
|
||||
b.metadata?.name === file.name &&
|
||||
b.mime_type === file.type,
|
||||
);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const duplicateFiles = validFiles.filter(isDuplicate);
|
||||
const uniqueFiles = validFiles.filter((file) => !isDuplicate(file));
|
||||
if (invalidFiles.length > 0) {
|
||||
toast.error(
|
||||
"You have pasted an invalid file type. Please paste a JPEG, PNG, GIF, WEBP image or a PDF.",
|
||||
);
|
||||
}
|
||||
if (duplicateFiles.length > 0) {
|
||||
toast.error(
|
||||
`Duplicate file(s) detected: ${duplicateFiles.map((f) => f.name).join(", ")}. Each file can only be uploaded once per message.`,
|
||||
);
|
||||
}
|
||||
if (uniqueFiles.length > 0) {
|
||||
const newBlocks = await Promise.all(uniqueFiles.map(fileToContentBlock));
|
||||
setContentBlocks((prev) => [...prev, ...newBlocks]);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
contentBlocks,
|
||||
setContentBlocks,
|
||||
handleFileUpload,
|
||||
dropRef,
|
||||
removeBlock,
|
||||
resetBlocks,
|
||||
dragOver,
|
||||
handlePaste,
|
||||
};
|
||||
}
|
||||
16
apps/web/src/hooks/useMediaQuery.tsx
Normal file
16
apps/web/src/hooks/useMediaQuery.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { useEffect, useState } from "react";
|
||||
|
||||
export function useMediaQuery(query: string) {
|
||||
const [matches, setMatches] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia(query);
|
||||
setMatches(media.matches);
|
||||
|
||||
const listener = (e: MediaQueryListEvent) => setMatches(e.matches);
|
||||
media.addEventListener("change", listener);
|
||||
return () => media.removeEventListener("change", listener);
|
||||
}, [query]);
|
||||
|
||||
return matches;
|
||||
}
|
||||
19
apps/web/src/lib/agent-inbox-interrupt.ts
Normal file
19
apps/web/src/lib/agent-inbox-interrupt.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { HumanInterrupt } from "@langchain/langgraph/prebuilt";
|
||||
|
||||
export function isAgentInboxInterruptSchema(
|
||||
value: unknown,
|
||||
): value is HumanInterrupt | HumanInterrupt[] {
|
||||
const valueAsObject = Array.isArray(value) ? value[0] : value;
|
||||
return (
|
||||
valueAsObject &&
|
||||
typeof valueAsObject === "object" &&
|
||||
"action_request" in valueAsObject &&
|
||||
typeof valueAsObject.action_request === "object" &&
|
||||
"config" in valueAsObject &&
|
||||
typeof valueAsObject.config === "object" &&
|
||||
"allow_respond" in valueAsObject.config &&
|
||||
"allow_accept" in valueAsObject.config &&
|
||||
"allow_edit" in valueAsObject.config &&
|
||||
"allow_ignore" in valueAsObject.config
|
||||
);
|
||||
}
|
||||
10
apps/web/src/lib/api-key.tsx
Normal file
10
apps/web/src/lib/api-key.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
export function getApiKey(): string | null {
|
||||
try {
|
||||
if (typeof window === "undefined") return null;
|
||||
return window.localStorage.getItem("lg:chat:apiKey") ?? null;
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
34
apps/web/src/lib/ensure-tool-responses.ts
Normal file
34
apps/web/src/lib/ensure-tool-responses.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { v4 as uuidv4 } from "uuid";
|
||||
import { Message, ToolMessage } from "@langchain/langgraph-sdk";
|
||||
|
||||
export const DO_NOT_RENDER_ID_PREFIX = "do-not-render-";
|
||||
|
||||
export function ensureToolCallsHaveResponses(messages: Message[]): Message[] {
|
||||
const newMessages: ToolMessage[] = [];
|
||||
|
||||
messages.forEach((message, index) => {
|
||||
if (message.type !== "ai" || message.tool_calls?.length === 0) {
|
||||
// If it's not an AI message, or it doesn't have tool calls, we can ignore.
|
||||
return;
|
||||
}
|
||||
// If it has tool calls, ensure the message which follows this is a tool message
|
||||
const followingMessage = messages[index + 1];
|
||||
if (followingMessage && followingMessage.type === "tool") {
|
||||
// Following message is a tool message, so we can ignore.
|
||||
return;
|
||||
}
|
||||
|
||||
// Since the following message is not a tool message, we must create a new tool message
|
||||
newMessages.push(
|
||||
...(message.tool_calls?.map((tc) => ({
|
||||
type: "tool" as const,
|
||||
tool_call_id: tc.id ?? "",
|
||||
id: `${DO_NOT_RENDER_ID_PREFIX}${uuidv4()}`,
|
||||
name: tc.name,
|
||||
content: "Successfully handled tool call.",
|
||||
})) ?? []),
|
||||
);
|
||||
});
|
||||
|
||||
return newMessages;
|
||||
}
|
||||
89
apps/web/src/lib/multimodal-utils.ts
Normal file
89
apps/web/src/lib/multimodal-utils.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import type { Base64ContentBlock } from "@langchain/core/messages";
|
||||
import { toast } from "sonner";
|
||||
|
||||
// Returns a Promise of a typed multimodal block for images or PDFs
|
||||
export async function fileToContentBlock(
|
||||
file: File,
|
||||
): Promise<Base64ContentBlock> {
|
||||
const supportedImageTypes = [
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
];
|
||||
const supportedFileTypes = [...supportedImageTypes, "application/pdf"];
|
||||
|
||||
if (!supportedFileTypes.includes(file.type)) {
|
||||
toast.error(
|
||||
`Unsupported file type: ${file.type}. Supported types are: ${supportedFileTypes.join(", ")}`,
|
||||
);
|
||||
return Promise.reject(new Error(`Unsupported file type: ${file.type}`));
|
||||
}
|
||||
|
||||
const data = await fileToBase64(file);
|
||||
|
||||
if (supportedImageTypes.includes(file.type)) {
|
||||
return {
|
||||
type: "image",
|
||||
source_type: "base64",
|
||||
mime_type: file.type,
|
||||
data,
|
||||
metadata: { name: file.name },
|
||||
};
|
||||
}
|
||||
|
||||
// PDF
|
||||
return {
|
||||
type: "file",
|
||||
source_type: "base64",
|
||||
mime_type: "application/pdf",
|
||||
data,
|
||||
metadata: { filename: file.name },
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to convert File to base64 string
|
||||
export async function fileToBase64(file: File): Promise<string> {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
const result = reader.result as string;
|
||||
// Remove the data:...;base64, prefix
|
||||
resolve(result.split(",")[1]);
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
// Type guard for Base64ContentBlock
|
||||
export function isBase64ContentBlock(
|
||||
block: unknown,
|
||||
): block is Base64ContentBlock {
|
||||
if (typeof block !== "object" || block === null || !("type" in block))
|
||||
return false;
|
||||
// file type (legacy)
|
||||
if (
|
||||
(block as { type: unknown }).type === "file" &&
|
||||
"source_type" in block &&
|
||||
(block as { source_type: unknown }).source_type === "base64" &&
|
||||
"mime_type" in block &&
|
||||
typeof (block as { mime_type?: unknown }).mime_type === "string" &&
|
||||
((block as { mime_type: string }).mime_type.startsWith("image/") ||
|
||||
(block as { mime_type: string }).mime_type === "application/pdf")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
// image type (new)
|
||||
if (
|
||||
(block as { type: unknown }).type === "image" &&
|
||||
"source_type" in block &&
|
||||
(block as { source_type: unknown }).source_type === "base64" &&
|
||||
"mime_type" in block &&
|
||||
typeof (block as { mime_type?: unknown }).mime_type === "string" &&
|
||||
(block as { mime_type: string }).mime_type.startsWith("image/")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
6
apps/web/src/lib/utils.ts
Normal file
6
apps/web/src/lib/utils.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
285
apps/web/src/providers/Stream.tsx
Normal file
285
apps/web/src/providers/Stream.tsx
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
ReactNode,
|
||||
useState,
|
||||
useEffect,
|
||||
} from "react";
|
||||
import { useStream } from "@langchain/langgraph-sdk/react";
|
||||
import { type Message } from "@langchain/langgraph-sdk";
|
||||
import {
|
||||
uiMessageReducer,
|
||||
isUIMessage,
|
||||
isRemoveUIMessage,
|
||||
type UIMessage,
|
||||
type RemoveUIMessage,
|
||||
} from "@langchain/langgraph-sdk/react-ui";
|
||||
import { useQueryState } from "nuqs";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LangGraphLogoSVG } from "@/components/icons/langgraph";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
import { getApiKey } from "@/lib/api-key";
|
||||
import { useThreads } from "./Thread";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export type StateType = { messages: Message[]; ui?: UIMessage[] };
|
||||
|
||||
const useTypedStream = useStream<
|
||||
StateType,
|
||||
{
|
||||
UpdateType: {
|
||||
messages?: Message[] | Message | string;
|
||||
ui?: (UIMessage | RemoveUIMessage)[] | UIMessage | RemoveUIMessage;
|
||||
context?: Record<string, unknown>;
|
||||
};
|
||||
CustomEventType: UIMessage | RemoveUIMessage;
|
||||
}
|
||||
>;
|
||||
|
||||
type StreamContextType = ReturnType<typeof useTypedStream>;
|
||||
const StreamContext = createContext<StreamContextType | undefined>(undefined);
|
||||
|
||||
async function sleep(ms = 4000) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function checkGraphStatus(
|
||||
apiUrl: string,
|
||||
apiKey: string | null,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`${apiUrl}/info`, {
|
||||
...(apiKey && {
|
||||
headers: {
|
||||
"X-Api-Key": apiKey,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return res.ok;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const StreamSession = ({
|
||||
children,
|
||||
apiKey,
|
||||
apiUrl,
|
||||
assistantId,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
apiKey: string | null;
|
||||
apiUrl: string;
|
||||
assistantId: string;
|
||||
}) => {
|
||||
const [threadId, setThreadId] = useQueryState("threadId");
|
||||
const { getThreads, setThreads } = useThreads();
|
||||
const streamValue = useTypedStream({
|
||||
apiUrl,
|
||||
apiKey: apiKey ?? undefined,
|
||||
assistantId,
|
||||
threadId: threadId ?? null,
|
||||
onCustomEvent: (event, options) => {
|
||||
if (isUIMessage(event) || isRemoveUIMessage(event)) {
|
||||
options.mutate((prev) => {
|
||||
const ui = uiMessageReducer(prev.ui ?? [], event);
|
||||
return { ...prev, ui };
|
||||
});
|
||||
}
|
||||
},
|
||||
onThreadId: (id) => {
|
||||
setThreadId(id);
|
||||
// Refetch threads list when thread ID changes.
|
||||
// Wait for some seconds before fetching so we're able to get the new thread that was created.
|
||||
sleep().then(() => getThreads().then(setThreads).catch(console.error));
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
checkGraphStatus(apiUrl, apiKey).then((ok) => {
|
||||
if (!ok) {
|
||||
toast.error("Failed to connect to LangGraph server", {
|
||||
description: () => (
|
||||
<p>
|
||||
Please ensure your graph is running at <code>{apiUrl}</code> and
|
||||
your API key is correctly set (if connecting to a deployed graph).
|
||||
</p>
|
||||
),
|
||||
duration: 10000,
|
||||
richColors: true,
|
||||
closeButton: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [apiKey, apiUrl]);
|
||||
|
||||
return (
|
||||
<StreamContext.Provider value={streamValue}>
|
||||
{children}
|
||||
</StreamContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// Default values for the form
|
||||
const DEFAULT_API_URL = "http://localhost:2024";
|
||||
const DEFAULT_ASSISTANT_ID = "agent";
|
||||
|
||||
export const StreamProvider: React.FC<{ children: ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
// Get environment variables
|
||||
const envApiUrl: string | undefined = process.env.NEXT_PUBLIC_API_URL;
|
||||
const envAssistantId: string | undefined =
|
||||
process.env.NEXT_PUBLIC_ASSISTANT_ID;
|
||||
|
||||
// Use URL params with env var fallbacks
|
||||
const [apiUrl, setApiUrl] = useQueryState("apiUrl", {
|
||||
defaultValue: envApiUrl || "",
|
||||
});
|
||||
const [assistantId, setAssistantId] = useQueryState("assistantId", {
|
||||
defaultValue: envAssistantId || "",
|
||||
});
|
||||
|
||||
// For API key, use localStorage with env var fallback
|
||||
const [apiKey, _setApiKey] = useState(() => {
|
||||
const storedKey = getApiKey();
|
||||
return storedKey || "";
|
||||
});
|
||||
|
||||
const setApiKey = (key: string) => {
|
||||
window.localStorage.setItem("lg:chat:apiKey", key);
|
||||
_setApiKey(key);
|
||||
};
|
||||
|
||||
// Determine final values to use, prioritizing URL params then env vars
|
||||
const finalApiUrl = apiUrl || envApiUrl;
|
||||
const finalAssistantId = assistantId || envAssistantId;
|
||||
|
||||
// Show the form if we: don't have an API URL, or don't have an assistant ID
|
||||
if (!finalApiUrl || !finalAssistantId) {
|
||||
return (
|
||||
<div className="flex min-h-screen w-full items-center justify-center p-4">
|
||||
<div className="animate-in fade-in-0 zoom-in-95 bg-background flex max-w-3xl flex-col rounded-lg border shadow-lg">
|
||||
<div className="mt-14 flex flex-col gap-2 border-b p-6">
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<LangGraphLogoSVG className="h-7" />
|
||||
<h1 className="text-xl font-semibold tracking-tight">
|
||||
Agent Chat
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
Welcome to Agent Chat! Before you get started, you need to enter
|
||||
the URL of the deployment and the assistant / graph ID.
|
||||
</p>
|
||||
</div>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const form = e.target as HTMLFormElement;
|
||||
const formData = new FormData(form);
|
||||
const apiUrl = formData.get("apiUrl") as string;
|
||||
const assistantId = formData.get("assistantId") as string;
|
||||
const apiKey = formData.get("apiKey") as string;
|
||||
|
||||
setApiUrl(apiUrl);
|
||||
setApiKey(apiKey);
|
||||
setAssistantId(assistantId);
|
||||
|
||||
form.reset();
|
||||
}}
|
||||
className="bg-muted/50 flex flex-col gap-6 p-6"
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="apiUrl">
|
||||
Deployment URL<span className="text-rose-500">*</span>
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
This is the URL of your LangGraph deployment. Can be a local, or
|
||||
production deployment.
|
||||
</p>
|
||||
<Input
|
||||
id="apiUrl"
|
||||
name="apiUrl"
|
||||
className="bg-background"
|
||||
defaultValue={apiUrl || DEFAULT_API_URL}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="assistantId">
|
||||
Assistant / Graph ID<span className="text-rose-500">*</span>
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
This is the ID of the graph (can be the graph name), or
|
||||
assistant to fetch threads from, and invoke when actions are
|
||||
taken.
|
||||
</p>
|
||||
<Input
|
||||
id="assistantId"
|
||||
name="assistantId"
|
||||
className="bg-background"
|
||||
defaultValue={assistantId || DEFAULT_ASSISTANT_ID}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="apiKey">LangSmith API Key</Label>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
This is <strong>NOT</strong> required if using a local LangGraph
|
||||
server. This value is stored in your browser's local storage and
|
||||
is only used to authenticate requests sent to your LangGraph
|
||||
server.
|
||||
</p>
|
||||
<PasswordInput
|
||||
id="apiKey"
|
||||
name="apiKey"
|
||||
defaultValue={apiKey ?? ""}
|
||||
className="bg-background"
|
||||
placeholder="lsv2_pt_..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
>
|
||||
Continue
|
||||
<ArrowRight className="size-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StreamSession
|
||||
apiKey={apiKey}
|
||||
apiUrl={apiUrl}
|
||||
assistantId={assistantId}
|
||||
>
|
||||
{children}
|
||||
</StreamSession>
|
||||
);
|
||||
};
|
||||
|
||||
// Create a custom hook to use the context
|
||||
export const useStreamContext = (): StreamContextType => {
|
||||
const context = useContext(StreamContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useStreamContext must be used within a StreamProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export default StreamContext;
|
||||
75
apps/web/src/providers/Thread.tsx
Normal file
75
apps/web/src/providers/Thread.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { validate } from "uuid";
|
||||
import { getApiKey } from "@/lib/api-key";
|
||||
import { Thread } from "@langchain/langgraph-sdk";
|
||||
import { useQueryState } from "nuqs";
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useState,
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
} from "react";
|
||||
import { createClient } from "./client";
|
||||
|
||||
interface ThreadContextType {
|
||||
getThreads: () => Promise<Thread[]>;
|
||||
threads: Thread[];
|
||||
setThreads: Dispatch<SetStateAction<Thread[]>>;
|
||||
threadsLoading: boolean;
|
||||
setThreadsLoading: Dispatch<SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
const ThreadContext = createContext<ThreadContextType | undefined>(undefined);
|
||||
|
||||
function getThreadSearchMetadata(
|
||||
assistantId: string,
|
||||
): { graph_id: string } | { assistant_id: string } {
|
||||
if (validate(assistantId)) {
|
||||
return { assistant_id: assistantId };
|
||||
} else {
|
||||
return { graph_id: assistantId };
|
||||
}
|
||||
}
|
||||
|
||||
export function ThreadProvider({ children }: { children: ReactNode }) {
|
||||
const [apiUrl] = useQueryState("apiUrl");
|
||||
const [assistantId] = useQueryState("assistantId");
|
||||
const [threads, setThreads] = useState<Thread[]>([]);
|
||||
const [threadsLoading, setThreadsLoading] = useState(false);
|
||||
|
||||
const getThreads = useCallback(async (): Promise<Thread[]> => {
|
||||
if (!apiUrl || !assistantId) return [];
|
||||
const client = createClient(apiUrl, getApiKey() ?? undefined);
|
||||
|
||||
const threads = await client.threads.search({
|
||||
metadata: {
|
||||
...getThreadSearchMetadata(assistantId),
|
||||
},
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
return threads;
|
||||
}, [apiUrl, assistantId]);
|
||||
|
||||
const value = {
|
||||
getThreads,
|
||||
threads,
|
||||
setThreads,
|
||||
threadsLoading,
|
||||
setThreadsLoading,
|
||||
};
|
||||
|
||||
return (
|
||||
<ThreadContext.Provider value={value}>{children}</ThreadContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useThreads() {
|
||||
const context = useContext(ThreadContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useThreads must be used within a ThreadProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
8
apps/web/src/providers/client.ts
Normal file
8
apps/web/src/providers/client.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { Client } from "@langchain/langgraph-sdk";
|
||||
|
||||
export function createClient(apiUrl: string, apiKey: string | undefined) {
|
||||
return new Client({
|
||||
apiKey,
|
||||
apiUrl,
|
||||
});
|
||||
}
|
||||
65
apps/web/tailwind.config.js
Normal file
65
apps/web/tailwind.config.js
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
darkMode: ["class"],
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{ts,tsx,js,jsx}",
|
||||
"./agent/**/*.{ts,tsx,js,jsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
components: {
|
||||
".scrollbar-pretty":
|
||||
"overflow-y-scroll [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-track]:bg-transparent",
|
||||
},
|
||||
colors: {
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover))",
|
||||
foreground: "hsl(var(--popover-foreground))",
|
||||
},
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
chart: {
|
||||
1: "hsl(var(--chart-1))",
|
||||
2: "hsl(var(--chart-2))",
|
||||
3: "hsl(var(--chart-3))",
|
||||
4: "hsl(var(--chart-4))",
|
||||
5: "hsl(var(--chart-5))",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require("tailwindcss-animate"), require("tailwind-scrollbar")],
|
||||
};
|
||||
34
apps/web/tsconfig.json
Normal file
34
apps/web/tsconfig.json
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"target": "ES2017"
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
"src/hooks/use-threads",
|
||||
"src/app/thread/[threadId]"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue