diff --git a/apps/web/.codespellignore b/apps/web/.codespellignore new file mode 100644 index 00000000..74ae90bd --- /dev/null +++ b/apps/web/.codespellignore @@ -0,0 +1 @@ +productionize \ No newline at end of file diff --git a/apps/web/.dockerignore b/apps/web/.dockerignore new file mode 100644 index 00000000..d97359f1 --- /dev/null +++ b/apps/web/.dockerignore @@ -0,0 +1,4 @@ +node_modules +.next +.git +.env \ No newline at end of file diff --git a/apps/web/.env.example b/apps/web/.env.example new file mode 100644 index 00000000..a38138d3 --- /dev/null +++ b/apps/web/.env.example @@ -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_..." diff --git a/apps/web/.gitignore b/apps/web/.gitignore new file mode 100644 index 00000000..2b60de5c --- /dev/null +++ b/apps/web/.gitignore @@ -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 diff --git a/apps/web/.prettierignore b/apps/web/.prettierignore new file mode 100644 index 00000000..e9e1e3c6 --- /dev/null +++ b/apps/web/.prettierignore @@ -0,0 +1,6 @@ +# Ignore artifacts: +build +coverage + +# +pnpm-lock.yaml diff --git a/apps/web/README.md b/apps/web/README.md new file mode 100644 index 00000000..6a0a74e3 --- /dev/null +++ b/apps/web/README.md @@ -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>() { + 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 ( + <> +
setOpen(!open)} + className="cursor-pointer rounded-lg border p-4" + > +

{props.title}

+

{props.description}

+
+ + +

{props.content}

+
+ + ); +} +``` + +## 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 + }, +}); +``` diff --git a/apps/web/components.json b/apps/web/components.json new file mode 100644 index 00000000..6296a74d --- /dev/null +++ b/apps/web/components.json @@ -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" +} diff --git a/apps/web/eslint.config.js b/apps/web/eslint.config.js new file mode 100644 index 00000000..af6a2b56 --- /dev/null +++ b/apps/web/eslint.config.js @@ -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 }, + ], + }, + }, +); diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs new file mode 100644 index 00000000..fc5e0f6d --- /dev/null +++ b/apps/web/next.config.mjs @@ -0,0 +1,10 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + experimental: { + serverActions: { + bodySizeLimit: "10mb", + }, + }, +}; + +export default nextConfig; diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 00000000..96220289 --- /dev/null +++ b/apps/web/package.json @@ -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" +} diff --git a/apps/web/postcss.config.mjs b/apps/web/postcss.config.mjs new file mode 100644 index 00000000..c2ddf748 --- /dev/null +++ b/apps/web/postcss.config.mjs @@ -0,0 +1,5 @@ +export default { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; diff --git a/apps/web/prettier.config.js b/apps/web/prettier.config.js new file mode 100644 index 00000000..b889faa0 --- /dev/null +++ b/apps/web/prettier.config.js @@ -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; diff --git a/apps/web/public/logo.svg b/apps/web/public/logo.svg new file mode 100644 index 00000000..5d168a94 --- /dev/null +++ b/apps/web/public/logo.svg @@ -0,0 +1,8 @@ + \ No newline at end of file diff --git a/apps/web/src/app/api/[..._path]/route.ts b/apps/web/src/app/api/[..._path]/route.ts new file mode 100644 index 00000000..5524d1f0 --- /dev/null +++ b/apps/web/src/app/api/[..._path]/route.ts @@ -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 + }); diff --git a/apps/web/src/app/favicon.ico b/apps/web/src/app/favicon.ico new file mode 100644 index 00000000..2a7a6db0 Binary files /dev/null and b/apps/web/src/app/favicon.ico differ diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css new file mode 100644 index 00000000..3882f6f7 --- /dev/null +++ b/apps/web/src/app/globals.css @@ -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); + } +} diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx new file mode 100644 index 00000000..af31f8e5 --- /dev/null +++ b/apps/web/src/app/layout.tsx @@ -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 ( + + + {children} + + + ); +} diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx new file mode 100644 index 00000000..19675ded --- /dev/null +++ b/apps/web/src/app/page.tsx @@ -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 ( + Loading (layout)...}> + + + + + + + + + + ); +} diff --git a/apps/web/src/components/icons/github.tsx b/apps/web/src/components/icons/github.tsx new file mode 100644 index 00000000..168f15c4 --- /dev/null +++ b/apps/web/src/components/icons/github.tsx @@ -0,0 +1,12 @@ +export const GitHubSVG = ({ width = "100%", height = "100%" }) => ( + + GitHub + + +); diff --git a/apps/web/src/components/icons/langgraph.tsx b/apps/web/src/components/icons/langgraph.tsx new file mode 100644 index 00000000..4bac592f --- /dev/null +++ b/apps/web/src/components/icons/langgraph.tsx @@ -0,0 +1,27 @@ +export function LangGraphLogoSVG({ + className, + width, + height, +}: { + width?: number; + height?: number; + className?: string; +}) { + return ( + + + + ); +} diff --git a/apps/web/src/components/thread/ContentBlocksPreview.tsx b/apps/web/src/components/thread/ContentBlocksPreview.tsx new file mode 100644 index 00000000..c9703f12 --- /dev/null +++ b/apps/web/src/components/thread/ContentBlocksPreview.tsx @@ -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 = ({ + blocks, + onRemove, + size = "md", + className, +}) => { + if (!blocks.length) return null; + return ( +
+ {blocks.map((block, idx) => ( + onRemove(idx)} + size={size} + /> + ))} +
+ ); +}; diff --git a/apps/web/src/components/thread/MultimodalPreview.tsx b/apps/web/src/components/thread/MultimodalPreview.tsx new file mode 100644 index 00000000..0f55573a --- /dev/null +++ b/apps/web/src/components/thread/MultimodalPreview.tsx @@ -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 = ({ + 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 ( +
+ {String(block.metadata?.name + {removable && ( + + )} +
+ ); + } + + // 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 ( +
+
+ +
+ + {String(filename)} + + {removable && ( + + )} +
+ ); + } + + // Fallback for unknown types + return ( +
+ + Unsupported file type + {removable && ( + + )} +
+ ); +}; diff --git a/apps/web/src/components/thread/agent-inbox/components/inbox-item-input.tsx b/apps/web/src/components/thread/agent-inbox/components/inbox-item-input.tsx new file mode 100644 index 00000000..879e16f6 --- /dev/null +++ b/apps/web/src/components/thread/agent-inbox/components/inbox-item-input.tsx @@ -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 ( + + ); +} + +function ArgsRenderer({ args }: { args: Record }) { + return ( +
+ {Object.entries(args).map(([k, v]) => { + let value = ""; + if (["string", "number"].includes(typeof v)) { + value = v.toString(); + } else { + value = JSON.stringify(v, null); + } + + return ( +
+

+ {prettifyText(k)}: +

+ + {value} + +
+ ); + })} +
+ ); +} + +interface InboxItemInputProps { + interruptValue: HumanInterrupt; + humanResponse: HumanResponseWithEdits[]; + supportsMultipleMethods: boolean; + acceptAllowed: boolean; + hasEdited: boolean; + hasAddedResponse: boolean; + initialValues: Record; + + streaming: boolean; + streamFinished: boolean; + + setHumanResponse: React.Dispatch< + React.SetStateAction + >; + setSelectedSubmitType: React.Dispatch< + React.SetStateAction + >; + setHasAddedResponse: React.Dispatch>; + setHasEdited: React.Dispatch>; + + handleSubmit: ( + e: React.MouseEvent | React.KeyboardEvent, + ) => Promise; +} + +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 | React.KeyboardEvent, + ) => Promise; +}) { + 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 ( +
+
+

+ Respond to assistant +

+ { + onResponseChange("", res); + }} + /> +
+ + {showArgsInResponse && ( + + )} + +
+

Response

+