init commit

This commit is contained in:
bracesproul
2025-03-04 14:07:58 -08:00
commit 1ca8c2ea05
59 changed files with 6994 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
IST
afterAll
+2
View File
@@ -0,0 +1,2 @@
node_modules
dist
+10
View File
@@ -0,0 +1,10 @@
root = true
[*]
end_of_line = lf
insert_final_newline = true
[*.{js,json,yml}]
charset = utf-8
indent_style = space
indent_size = 2
+60
View File
@@ -0,0 +1,60 @@
module.exports = {
extends: [
"eslint:recommended",
"prettier",
"plugin:@typescript-eslint/recommended",
],
parserOptions: {
ecmaVersion: 12,
parser: "@typescript-eslint/parser",
project: "./tsconfig.json",
sourceType: "module",
},
plugins: ["import", "@typescript-eslint", "no-instanceof"],
ignorePatterns: [
".eslintrc.cjs",
"scripts",
"node_modules",
"dist",
"dist-cjs",
"*.js",
"*.cjs",
"*.d.ts",
],
rules: {
"@typescript-eslint/explicit-module-boundary-types": 0,
"@typescript-eslint/no-empty-function": 0,
"@typescript-eslint/no-shadow": 0,
"@typescript-eslint/no-empty-interface": 0,
"@typescript-eslint/no-use-before-define": ["error", "nofunc"],
"@typescript-eslint/no-unused-vars": ["warn", { args: "none" }],
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/no-misused-promises": "error",
"@typescript-eslint/no-explicit-any": 0,
camelcase: 0,
"class-methods-use-this": 0,
"import/extensions": [2, "ignorePackages"],
"import/no-extraneous-dependencies": [
"error",
{ devDependencies: ["**/*.test.ts"] },
],
"import/no-unresolved": 0,
"import/prefer-default-export": 0,
"keyword-spacing": "error",
"max-classes-per-file": 0,
"max-len": 0,
"no-await-in-loop": 0,
"no-bitwise": 0,
"no-console": 0,
"no-restricted-syntax": 0,
"no-shadow": 0,
"no-continue": 0,
"no-underscore-dangle": 0,
"no-use-before-define": 0,
"no-useless-constructor": 0,
"no-return-await": 0,
"consistent-return": 0,
"no-else-return": 0,
"new-cap": ["error", { properties: false, capIsNew: false }],
},
};
+70
View File
@@ -0,0 +1,70 @@
# Run formatting on all PRs
name: CI
on:
push:
branches: ["main"]
pull_request:
workflow_dispatch: # Allows triggering the workflow manually in GitHub UI
# If another push to the same PR or branch happens while this workflow is still running,
# cancel the earlier run in favor of the next run.
#
# There's no point in testing an outdated version of the code. GitHub only allows
# a limited number of job runners to be active at the same time, so it's better to cancel
# pointless jobs early so that more useful jobs can run sooner.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
format:
name: Check formatting
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js 18.x
uses: actions/setup-node@v3
with:
node-version: 18.x
cache: "yarn"
- name: Install dependencies
run: yarn install --immutable --mode=skip-build
- name: Check formatting
run: yarn format:check
lint:
name: Check linting
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js 18.x
uses: actions/setup-node@v3
with:
node-version: 18.x
cache: "yarn"
- name: Install dependencies
run: yarn install --immutable --mode=skip-build
- name: Check linting
run: yarn run lint
readme-spelling:
name: Check README spelling
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: codespell-project/actions-codespell@v2
with:
ignore_words_file: .codespellignore
path: README.md
check-spelling:
name: Check code spelling
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: codespell-project/actions-codespell@v2
with:
ignore_words_file: .codespellignore
path: src
+40
View File
@@ -0,0 +1,40 @@
# This workflow will run integration tests for the current project once per day
name: Integration Tests
on:
schedule:
- cron: "37 14 * * *" # Run at 7:37 AM Pacific Time (14:37 UTC) every day
workflow_dispatch: # Allows triggering the workflow manually in GitHub UI
# If another scheduled run starts while this workflow is still running,
# cancel the earlier run in favor of the next run.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
integration-tests:
name: Integration Tests
strategy:
matrix:
os: [ubuntu-latest]
node-version: [18.x, 20.x]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
cache: "yarn"
- name: Install dependencies
run: yarn install --immutable
- name: Build project
run: yarn build
- name: Run integration tests
run: yarn test:int
+41
View File
@@ -0,0 +1,41 @@
# This workflow will run unit tests for the current project
name: Unit Tests
on:
push:
branches: ["main"]
pull_request:
workflow_dispatch: # Allows triggering the workflow manually in GitHub UI
# If another push to the same PR or branch happens while this workflow is still running,
# cancel the earlier run in favor of the next run.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
unit-tests:
name: Unit Tests
strategy:
matrix:
os: [ubuntu-latest]
node-version: [18.x, 20.x]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
cache: "yarn"
- name: Install dependencies
run: yarn install --immutable
- name: Build project
run: yarn build
- name: Run tests
run: yarn test
+30
View File
@@ -0,0 +1,30 @@
index.cjs
index.js
index.d.ts
node_modules
dist
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
yarn-error.log
.turbo
**/.turbo
**/.eslintcache
.env
.env.full
.env.quickstart
.ipynb_checkpoints
!src/create-langgraph-chat-app/index.js
# LangGraph API
.langgraph_api
__pycache__/
.mypy_cache/
.ruff_cache/
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Brace Sproul
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+68
View File
@@ -0,0 +1,68 @@
# create-langgraph-chat-app
A CLI tool to bootstrap a LangGraph chat application quickly.
## Description
This package provides a CLI tool to create a LangGraph chat application with minimal configuration. It sets up a Vite-based React application that can connect to your LangGraph deployment.
## Usage
```bash
# Using npx (recommended)
npx create-langgraph-chat-app
# Or install globally
npm install -g create-langgraph-chat-app
create-langgraph-chat-app
```
The CLI will prompt you for:
1. Deployment URL (default: http://localhost:2024)
2. Default graph/assistant ID (default: agent)
3. Project name
## Development
### Setup
```bash
# Clone the repository
git clone https://github.com/yourusername/create-langgraph-chat-app.git
cd create-langgraph-chat-app
# Install dependencies
yarn install
# Build the package
yarn build
```
### Testing Locally
You can test the CLI locally by linking the package:
```bash
# In the project directory
npm link
# Then run
create-langgraph-chat-app
```
### Publishing
To publish to npm:
```bash
# Make sure you're logged in to npm
npm login
# Publish the package
npm publish
```
## License
MIT
+18
View File
@@ -0,0 +1,18 @@
export default {
preset: "ts-jest/presets/default-esm",
moduleNameMapper: {
"^(\\.{1,2}/.*)\\.js$": "$1",
},
transform: {
"^.+\\.tsx?$": [
"ts-jest",
{
useESM: true,
},
],
},
extensionsToTreatAsEsm: [".ts"],
setupFiles: ["dotenv/config"],
passWithNoTests: true,
testTimeout: 20_000,
};
+48
View File
@@ -0,0 +1,48 @@
{
"name": "create-langgraph-chat-app-dev",
"version": "0.1.0",
"description": "CLI tool to create a LangGraph chat application with one command",
"packageManager": "yarn@1.22.22",
"main": "src/index.ts",
"author": "Brace Sproul",
"license": "MIT",
"private": true,
"type": "module",
"scripts": {
"build": "yarn clean && tsc",
"clean": "rm -rf dist",
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --testPathPattern=\\.test\\.ts$ --testPathIgnorePatterns=\\.int\\.test\\.ts$",
"test:int": "node --experimental-vm-modules node_modules/jest/bin/jest.js --testPathPattern=\\.int\\.test\\.ts$",
"test:single": "NODE_OPTIONS=--experimental-vm-modules yarn run jest --config jest.config.js --testTimeout 100000",
"format": "prettier --write .",
"lint": "eslint src",
"lint:fix": "eslint src --fix",
"format:check": "prettier --check ."
},
"dependencies": {
"chalk": "^5.3.0",
"fs-extra": "^11.2.0",
"prompts": "^2.4.2"
},
"devDependencies": {
"@eslint/eslintrc": "^3.1.0",
"@eslint/js": "^9.9.1",
"@jest/globals": "^29.7.0",
"@tsconfig/recommended": "^1.0.7",
"@types/jest": "^29.5.0",
"@types/node": "^22.10.6",
"@typescript-eslint/eslint-plugin": "^5.59.8",
"@typescript-eslint/parser": "^5.59.8",
"dotenv": "^16.4.7",
"eslint": "^8.41.0",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-import": "^2.27.5",
"eslint-plugin-no-instanceof": "^1.0.1",
"eslint-plugin-prettier": "^4.2.1",
"jest": "^29.7.0",
"prettier": "^3.3.3",
"ts-jest": "^29.1.0",
"tsx": "^4.19.2",
"typescript": "^5.3.3"
}
}
+33
View File
@@ -0,0 +1,33 @@
# create-langgraph-chat-app
A CLI tool to quickly set up a LangGraph chat application with Vite.
## Usage
```bash
# Using npx (recommended)
npx create-langgraph-chat-app
# Or you can install globally
npm install -g create-langgraph-chat-app
create-langgraph-chat-app
```
## Features
- Quick setup of a LangGraph chat application
- Customizable deployment URL and graph/assistant ID
- Vite-based frontend for fast development
- Ready-to-use configuration
## Options
During setup, you will be prompted for:
1. Deployment URL (default: http://localhost:2024)
2. Default graph/assistant ID (default: agent)
3. Project name
## License
MIT
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env node
import path from 'path';
import fs from 'fs-extra';
import chalk from 'chalk';
import prompts from 'prompts';
import { fileURLToPath } from 'url';
// Get the directory name of the current module
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
async function init() {
console.log(`
${chalk.green('Welcome to create-langgraph-chat-app!')}
Let's set up your new LangGraph chat application.
`);
// Collect user inputs
const questions = await prompts([
{
type: 'text',
name: 'deploymentUrl',
message: 'What is the URL to your deployment?',
initial: 'http://localhost:2024'
},
{
type: 'text',
name: 'graphId',
message: 'What is the default graph/assistant ID?',
initial: 'agent'
},
{
type: 'text',
name: 'projectName',
message: 'What is the name of your project?',
initial: 'langgraph-chat-app'
}
]);
const { deploymentUrl, graphId, projectName } = questions;
// Create project directory
const targetDir = path.join(process.cwd(), projectName);
if (fs.existsSync(targetDir)) {
console.error(chalk.red(`Error: Directory ${projectName} already exists.`));
process.exit(1);
}
// Log the collected values
console.log(chalk.blue('\nConfiguration:'));
console.log(`Deployment URL: ${chalk.green(deploymentUrl)}`);
console.log(`Graph/Assistant ID: ${chalk.green(graphId)}`);
console.log(`Project will be created at: ${chalk.green(targetDir)}\n`);
// Create the project directory
fs.mkdirSync(targetDir, { recursive: true });
console.log(chalk.yellow('Creating project files...'));
// Copy all the template files to the target directory
const templateDir = path.join(__dirname, 'template');
fs.copySync(templateDir, targetDir);
// Create config file with the collected values
fs.writeFileSync(
path.join(targetDir, 'config.json'),
JSON.stringify({ deploymentUrl, graphId }, null, 2)
);
// Update package.json with project name
const pkgJsonPath = path.join(targetDir, 'package.json');
const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));
pkgJson.name = projectName;
fs.writeFileSync(pkgJsonPath, JSON.stringify(pkgJson, null, 2));
console.log(chalk.green('\nSuccess!'));
console.log(`
Your LangGraph chat app has been created at ${chalk.green(targetDir)}
To get started:
${chalk.cyan(`cd ${projectName}`)}
${chalk.cyan('npm install')} (or ${chalk.cyan('yarn')})
${chalk.cyan('npm run dev')} (or ${chalk.cyan('yarn dev')})
This will start a development server at:
${chalk.cyan('http://localhost:5173')}
Your app is configured to connect to:
${chalk.cyan(deploymentUrl)}
Using graph ID: ${chalk.cyan(graphId)}
You can modify these settings in ${chalk.cyan('config.json')}
`);
}
init().catch((err) => {
console.error(chalk.red('Error:'), err);
process.exit(1);
});
@@ -0,0 +1,27 @@
{
"name": "create-langgraph-chat-app",
"version": "0.1.0",
"description": "Create a LangGraph chat app with one command",
"main": "index.js",
"type": "module",
"bin": {
"create-langgraph-chat-app": "index.js"
},
"engines": {
"node": ">=16.0.0"
},
"dependencies": {
"chalk": "^5.3.0",
"fs-extra": "^11.2.0",
"prompts": "^2.4.2"
},
"keywords": [
"langgraph",
"starter",
"template",
"create-app",
"vite"
],
"author": "Your Name",
"license": "MIT"
}
@@ -0,0 +1,31 @@
# 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?
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
@@ -0,0 +1,4 @@
{
"tabWidth": 2,
"useTabs": false
}
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
@@ -0,0 +1,28 @@
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,
"react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true },
],
},
},
);
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>LangGraph Chat</title>
<link href="/src/styles.css" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
@@ -0,0 +1,74 @@
{
"name": "langgraph-chat",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"format": "prettier --write .",
"preview": "vite preview"
},
"dependencies": {
"@assistant-ui/react": "^0.8.0",
"@assistant-ui/react-markdown": "^0.8.0",
"@langchain/core": "^0.3.41",
"@langchain/google-genai": "^0.1.10",
"@langchain/langgraph": "^0.2.49",
"@langchain/langgraph-api": "*",
"@langchain/langgraph-cli": "*",
"@langchain/langgraph-sdk": "*",
"@langchain/openai": "^0.4.4",
"@radix-ui/react-avatar": "^1.1.3",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.8",
"@tailwindcss/postcss": "^4.0.9",
"@tailwindcss/vite": "^4.0.9",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"esbuild": "^0.25.0",
"esbuild-plugin-tailwindcss": "^2.0.1",
"framer-motion": "^12.4.9",
"lucide-react": "^0.476.0",
"next-themes": "^0.4.4",
"prettier": "^3.5.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-markdown": "^10.0.1",
"react-router-dom": "^6.17.0",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.1",
"tailwind-merge": "^3.0.2",
"tailwindcss-animate": "^1.0.7",
"use-query-params": "^2.2.1",
"use-stick-to-bottom": "^1.0.46",
"uuid": "^11.0.5",
"zod": "^3.24.2"
},
"resolutions": {
"@langchain/langgraph-api": "next",
"@langchain/langgraph-cli": "next",
"@langchain/langgraph-sdk": "next"
},
"devDependencies": {
"@eslint/js": "^9.19.0",
"@types/node": "^22.13.5",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"eslint": "^9.19.0",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.18",
"globals": "^15.14.0",
"tailwind-scrollbar": "^4.0.1",
"tailwindcss": "^4.0.6",
"typescript": "~5.7.2",
"typescript-eslint": "^8.22.0",
"vite": "^6.1.0"
},
"packageManager": "pnpm@10.5.1+sha512.c424c076bd25c1a5b188c37bb1ca56cc1e136fbf530d98bcb3289982a08fd25527b8c9c4ec113be5e3393c39af04521dd647bcf1d0801eaf8ac6a7b14da313af"
}
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,8 @@
import "./App.css";
import { Thread } from "@/components/thread";
function App() {
return <Thread />;
}
export default App;
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long
@@ -0,0 +1,90 @@
import { Button } from "@/components/ui/button";
import { useThreads } from "@/hooks/useThreads";
import { Thread } from "@langchain/langgraph-sdk";
import { useEffect, useState } from "react";
import { getContentString } from "../utils";
import { useQueryParam, StringParam, BooleanParam } from "use-query-params";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
function ThreadList({
threads,
onThreadClick,
}: {
threads: Thread[];
onThreadClick?: (threadId: string) => void;
}) {
const [threadId, setThreadId] = useQueryParam("threadId", StringParam);
return (
<div className="h-full flex flex-col gap-2 items-start justify-start 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">
<Button
variant="ghost"
className="truncate text-left items-start justify-start w-[264px]"
onClick={(e) => {
e.preventDefault();
onThreadClick?.(t.thread_id);
if (t.thread_id === threadId) return;
setThreadId(t.thread_id);
}}
>
{itemText}
</Button>
</div>
);
})}
</div>
);
}
export default function ThreadHistory() {
const [threads, setThreads] = useState<Thread[]>([]);
const [chatHistoryOpen, setChatHistoryOpen] = useQueryParam(
"chatHistoryOpen",
BooleanParam,
);
const { getThreads } = useThreads();
useEffect(() => {
getThreads().then(setThreads).catch(console.error);
}, []);
return (
<>
<div className="hidden lg:flex flex-col border-r-[1px] border-slate-300 items-start justify-start gap-6 h-screen w-[300px] shrink-0 px-2 py-4 shadow-inner-right">
<h1 className="text-2xl font-medium pl-4">Thread History</h1>
<ThreadList threads={threads} />
</div>
<Sheet open={!!chatHistoryOpen} onOpenChange={setChatHistoryOpen}>
<SheetContent side="left" className="lg:hidden flex">
<SheetHeader>
<SheetTitle>Thread History</SheetTitle>
</SheetHeader>
<ThreadList
threads={threads}
onThreadClick={() => setChatHistoryOpen((o) => !o)}
/>
</SheetContent>
</Sheet>
</>
);
}
@@ -0,0 +1,292 @@
import { v4 as uuidv4 } from "uuid";
import { ReactNode, useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
import { useStreamContext } from "@/providers/Stream";
import { useState, FormEvent } from "react";
import { Input } from "../ui/input";
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,
SquarePen,
} from "lucide-react";
import { BooleanParam, StringParam, useQueryParam } from "use-query-params";
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
import ThreadHistory from "./history";
import { toast } from "sonner";
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="w-4 h-4" />
<span>Scroll to bottom</span>
</Button>
);
}
export function Thread() {
const [threadId, setThreadId] = useQueryParam("threadId", StringParam);
const [_, setChatHistoryOpen] = useQueryParam(
"chatHistoryOpen",
BooleanParam,
);
const [input, setInput] = useState("");
const [firstTokenReceived, setFirstTokenReceived] = useState(false);
const stream = useStreamContext();
const messages = stream.messages;
const isLoading = stream.isLoading;
const lastError = useRef<string | undefined>(undefined);
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() || isLoading) return;
setFirstTokenReceived(false);
const newHumanMessage: Message = {
id: uuidv4(),
type: "human",
content: input,
};
const toolMessages = ensureToolCallsHaveResponses(stream.messages);
stream.submit(
{ messages: [...toolMessages, newHumanMessage] },
{
streamMode: ["values"],
optimisticValues: (prev) => ({
...prev,
messages: [
...(prev.messages ?? []),
...toolMessages,
newHumanMessage,
],
}),
},
);
setInput("");
};
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"],
});
};
return (
<div className="flex w-full h-screen overflow-hidden">
<ThreadHistory />
<div
className={cn(
"flex-1 flex flex-col min-w-0 overflow-hidden",
!threadId && "grid-rows-[1fr]",
)}
>
{threadId && (
<div className="flex items-center justify-between gap-3 p-2 pl-4 z-10 relative">
<div className="flex gap-2 items-center justify-start">
<button
className="flex gap-2 items-center cursor-pointer"
onClick={() => setThreadId(null)}
>
<LangGraphLogoSVG width={32} height={32} />
<span className="text-xl font-semibold tracking-tight">
LangGraph Chat
</span>
</button>
<Button
className="flex lg:hidden"
variant="ghost"
onClick={() => setChatHistoryOpen((p) => !p)}
>
<PanelRightOpen />
</Button>
</div>
<TooltipIconButton
size="lg"
className="p-4"
tooltip="New thread"
variant="ghost"
onClick={() => setThreadId(null)}
>
<SquarePen className="size-5" />
</TooltipIconButton>
<div className="absolute inset-x-0 top-full h-5 bg-gradient-to-b from-background to-background/0" />
</div>
)}
<StickToBottom className="relative flex-1 overflow-hidden">
<StickyToBottomContent
className={cn(
"absolute inset-0 overflow-y-scroll [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-track]:bg-transparent",
!threadId && "flex flex-col items-stretch mt-[25vh]",
threadId && "grid grid-rows-[1fr_auto]",
)}
contentClassName="pt-8 pb-16 px-4 max-w-4xl 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}
/>
),
)}
{isLoading && !firstTokenReceived && (
<AssistantMessageLoading />
)}
</>
}
footer={
<div className="sticky flex flex-col items-center gap-8 bottom-8 px-4">
{!threadId && (
<div className="flex gap-3 items-center">
<LangGraphLogoSVG className="flex-shrink-0 h-8" />
<h1 className="text-2xl font-semibold tracking-tight">
LangGraph Chat
</h1>
</div>
)}
<ScrollToBottom className="absolute bottom-full left-1/2 -translate-x-1/2 mb-4 animate-in fade-in-0 zoom-in-95" />
<div className="bg-background rounded-2xl border shadow-md mx-auto w-full max-w-4xl">
<form
onSubmit={handleSubmit}
className="grid grid-rows-[1fr_auto] gap-2 max-w-4xl mx-auto"
>
<Input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Type your message..."
className="px-4 py-6 border-none bg-transparent shadow-none ring-0 outline-none focus:outline-none focus:ring-0"
/>
<div className="flex items-center justify-end p-2 pt-0">
{stream.isLoading ? (
<Button key="stop" onClick={() => stream.stop()}>
<LoaderCircle className="w-4 h-4 animate-spin" />
Cancel
</Button>
) : (
<Button
type="submit"
disabled={isLoading || !input.trim()}
>
Send
</Button>
)}
</div>
</form>
</div>
</div>
}
/>
</StickToBottom>
</div>
</div>
);
}
@@ -0,0 +1,214 @@
"use client";
import "@assistant-ui/react-markdown/styles/dot.css";
import {
CodeHeaderProps,
unstable_memoizeMarkdownComponents as memoizeMarkdownComponents,
useIsMarkdownCodeBlock,
} from "@assistant-ui/react-markdown";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { FC, memo, useState } from "react";
import { CheckIcon, CopyIcon } from "lucide-react";
import { TooltipIconButton } from "@/components/thread/tooltip-icon-button";
import { cn } from "@/lib/utils";
const MarkdownTextImpl = ({ children }: { children: string }) => {
return (
<ReactMarkdown remarkPlugins={[remarkGfm]} components={defaultComponents}>
{children}
</ReactMarkdown>
);
};
export const MarkdownText = memo(MarkdownTextImpl);
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 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 defaultComponents = memoizeMarkdownComponents({
h1: ({ className, ...props }) => (
<h1
className={cn(
"mb-8 scroll-m-20 text-4xl font-extrabold tracking-tight last:mb-0",
className,
)}
{...props}
/>
),
h2: ({ className, ...props }) => (
<h2
className={cn(
"mb-4 mt-8 scroll-m-20 text-3xl font-semibold tracking-tight first:mt-0 last:mb-0",
className,
)}
{...props}
/>
),
h3: ({ className, ...props }) => (
<h3
className={cn(
"mb-4 mt-6 scroll-m-20 text-2xl font-semibold tracking-tight first:mt-0 last:mb-0",
className,
)}
{...props}
/>
),
h4: ({ className, ...props }) => (
<h4
className={cn(
"mb-4 mt-6 scroll-m-20 text-xl font-semibold tracking-tight first:mt-0 last:mb-0",
className,
)}
{...props}
/>
),
h5: ({ className, ...props }) => (
<h5
className={cn(
"my-4 text-lg font-semibold first:mt-0 last:mb-0",
className,
)}
{...props}
/>
),
h6: ({ className, ...props }) => (
<h6
className={cn("my-4 font-semibold first:mt-0 last:mb-0", className)}
{...props}
/>
),
p: ({ className, ...props }) => (
<p
className={cn("mb-5 mt-5 leading-7 first:mt-0 last:mb-0", className)}
{...props}
/>
),
a: ({ className, ...props }) => (
<a
className={cn(
"text-primary font-medium underline underline-offset-4",
className,
)}
{...props}
/>
),
blockquote: ({ className, ...props }) => (
<blockquote
className={cn("border-l-2 pl-6 italic", className)}
{...props}
/>
),
ul: ({ className, ...props }) => (
<ul
className={cn("my-5 ml-6 list-disc [&>li]:mt-2", className)}
{...props}
/>
),
ol: ({ className, ...props }) => (
<ol
className={cn("my-5 ml-6 list-decimal [&>li]:mt-2", className)}
{...props}
/>
),
hr: ({ className, ...props }) => (
<hr className={cn("my-5 border-b", className)} {...props} />
),
table: ({ className, ...props }) => (
<table
className={cn(
"my-5 w-full border-separate border-spacing-0 overflow-y-auto",
className,
)}
{...props}
/>
),
th: ({ className, ...props }) => (
<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 }) => (
<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 }) => (
<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 }) => (
<sup
className={cn("[&>a]:text-xs [&>a]:no-underline", className)}
{...props}
/>
),
pre: ({ className, ...props }) => (
<pre
className={cn(
"overflow-x-auto rounded-b-lg bg-black p-4 text-white",
className,
)}
{...props}
/>
),
code: function Code({ className, ...props }) {
const isCodeBlock = useIsMarkdownCodeBlock();
return (
<code
className={cn(!isCodeBlock && "rounded font-semibold", className)}
{...props}
/>
);
},
CodeHeader,
});
@@ -0,0 +1,115 @@
import { useStreamContext } from "@/providers/Stream";
import { Checkpoint, Message } from "@langchain/langgraph-sdk";
import { getContentString } from "../utils";
import { BranchSwitcher, CommandBar } from "./shared";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { MarkdownText } from "../markdown-text";
import { LoadExternalComponent } from "@langchain/langgraph-sdk/react-ui/client";
import { cn } from "@/lib/utils";
import { ToolCalls } from "./tool-calls";
function CustomComponent({
message,
thread,
}: {
message: Message;
thread: ReturnType<typeof useStreamContext>;
}) {
const meta = thread.getMessagesMetadata(message);
const seenState = meta?.firstSeenState;
const customComponent = seenState?.values.ui
?.slice()
.reverse()
.find(
({ additional_kwargs }) =>
additional_kwargs.run_id === seenState.metadata?.run_id,
);
if (!customComponent) {
return null;
}
return (
<div key={message.id}>
{customComponent && (
<LoadExternalComponent
assistantId="agent"
stream={thread}
message={customComponent}
/>
)}
</div>
);
}
export function AssistantMessage({
message,
isLoading,
handleRegenerate,
}: {
message: Message;
isLoading: boolean;
handleRegenerate: (parentCheckpoint: Checkpoint | null | undefined) => void;
}) {
const contentString = getContentString(message.content);
const thread = useStreamContext();
const meta = thread.getMessagesMetadata(message);
const parentCheckpoint = meta?.firstSeenState?.parent_checkpoint;
const hasToolCalls =
"tool_calls" in message &&
message.tool_calls &&
message.tool_calls.length > 0;
return (
<div className="flex items-start mr-auto gap-2 group">
<Avatar>
<AvatarFallback>A</AvatarFallback>
</Avatar>
<div className="flex flex-col gap-2">
{hasToolCalls && <ToolCalls toolCalls={message.tool_calls} />}
<CustomComponent message={message} thread={thread} />
{contentString.length > 0 && (
<div className="rounded-2xl bg-muted px-4 py-2">
<MarkdownText>{contentString}</MarkdownText>
</div>
)}
<div
className={cn(
"flex gap-2 items-center mr-auto 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="flex items-start mr-auto gap-2">
<Avatar>
<AvatarFallback>A</AvatarFallback>
</Avatar>
<div className="flex items-center gap-1 rounded-2xl bg-muted px-4 py-2 h-8">
<div className="w-1.5 h-1.5 rounded-full bg-foreground/50 animate-[pulse_1.5s_ease-in-out_infinite]"></div>
<div className="w-1.5 h-1.5 rounded-full bg-foreground/50 animate-[pulse_1.5s_ease-in-out_0.5s_infinite]"></div>
<div className="w-1.5 h-1.5 rounded-full bg-foreground/50 animate-[pulse_1.5s_ease-in-out_1s_infinite]"></div>
</div>
</div>
);
}
@@ -0,0 +1,120 @@
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";
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(
"flex items-center ml-auto gap-2 group",
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}
/>
) : (
<p className="text-right py-1">{contentString}</p>
)}
<div
className={cn(
"flex gap-2 items-center ml-auto transition-opacity",
"opacity-0 group-focus-within:opacity-100 group-hover:opacity-100",
isEditing && "opacity-100",
)}
>
<BranchSwitcher
branch={meta?.branch}
branchOptions={meta?.branchOptions}
onSelect={(branch) => thread.setBranch(branch)}
isLoading={isLoading}
/>
<CommandBar
isLoading={isLoading}
content={contentString}
isEditing={isEditing}
setIsEditing={(c) => {
if (c) {
setValue(contentString);
}
setIsEditing(c);
}}
handleSubmitEdit={handleSubmitEdit}
isHumanMessage={true}
/>
</div>
</div>
</div>
);
}
@@ -0,0 +1,215 @@
import {
XIcon,
SendHorizontal,
RefreshCcw,
Pencil,
Copy,
CopyCheck,
ChevronLeft,
ChevronRight,
} from "lucide-react";
import { TooltipIconButton } from "../tooltip-icon-button";
import { AnimatePresence, motion } from "framer-motion";
import { useState } from "react";
import { Button } from "@/components/ui/button";
function ContentCopyable({
content,
disabled,
}: {
content: string;
disabled: boolean;
}) {
const [copied, setCopied] = useState(false);
const handleCopy = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.stopPropagation();
navigator.clipboard.writeText(content);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<TooltipIconButton
onClick={(e) => handleCopy(e)}
variant="ghost"
tooltip="Copy content"
disabled={disabled}
>
<AnimatePresence mode="wait" initial={false}>
{copied ? (
<motion.div
key="check"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.15 }}
>
<CopyCheck className="text-green-500" />
</motion.div>
) : (
<motion.div
key="copy"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.15 }}
>
<Copy />
</motion.div>
)}
</AnimatePresence>
</TooltipIconButton>
);
}
export function BranchSwitcher({
branch,
branchOptions,
onSelect,
isLoading,
}: {
branch: string | undefined;
branchOptions: string[] | undefined;
onSelect: (branch: string) => void;
isLoading: boolean;
}) {
if (!branchOptions || !branch) return null;
const index = branchOptions.indexOf(branch);
return (
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
className="size-6 p-1"
onClick={() => {
const prevBranch = branchOptions[index - 1];
if (!prevBranch) return;
onSelect(prevBranch);
}}
disabled={isLoading}
>
<ChevronLeft />
</Button>
<span className="text-sm">
{index + 1} / {branchOptions.length}
</span>
<Button
variant="ghost"
size="icon"
className="size-6 p-1"
onClick={() => {
const nextBranch = branchOptions[index + 1];
if (!nextBranch) return;
onSelect(nextBranch);
}}
disabled={isLoading}
>
<ChevronRight />
</Button>
</div>
);
}
export function CommandBar({
content,
isHumanMessage,
isAiMessage,
isEditing,
setIsEditing,
handleSubmitEdit,
handleRegenerate,
isLoading,
}: {
content: string;
isHumanMessage?: boolean;
isAiMessage?: boolean;
isEditing?: boolean;
setIsEditing?: React.Dispatch<React.SetStateAction<boolean>>;
handleSubmitEdit?: () => void;
handleRegenerate?: () => void;
isLoading: boolean;
}) {
if (isHumanMessage && isAiMessage) {
throw new Error(
"Can only set one of isHumanMessage or isAiMessage to true, not both.",
);
}
if (!isHumanMessage && !isAiMessage) {
throw new Error(
"One of isHumanMessage or isAiMessage must be set to true.",
);
}
if (
isHumanMessage &&
(isEditing === undefined ||
setIsEditing === undefined ||
handleSubmitEdit === undefined)
) {
throw new Error(
"If isHumanMessage is true, all of isEditing, setIsEditing, and handleSubmitEdit must be set.",
);
}
const showEdit =
isHumanMessage &&
isEditing !== undefined &&
!!setIsEditing &&
!!handleSubmitEdit;
if (isHumanMessage && isEditing && !!setIsEditing && !!handleSubmitEdit) {
return (
<div className="flex items-center gap-2">
<TooltipIconButton
disabled={isLoading}
tooltip="Cancel edit"
variant="ghost"
onClick={() => {
setIsEditing(false);
}}
>
<XIcon />
</TooltipIconButton>
<TooltipIconButton
disabled={isLoading}
tooltip="Submit"
variant="secondary"
onClick={handleSubmitEdit}
>
<SendHorizontal />
</TooltipIconButton>
</div>
);
}
return (
<div className="flex items-center gap-2">
<ContentCopyable content={content} disabled={isLoading} />
{isAiMessage && !!handleRegenerate && (
<TooltipIconButton
disabled={isLoading}
tooltip="Refresh"
variant="ghost"
onClick={handleRegenerate}
>
<RefreshCcw />
</TooltipIconButton>
)}
{showEdit && (
<TooltipIconButton
disabled={isLoading}
tooltip="Edit"
variant="ghost"
onClick={() => {
setIsEditing?.(true);
}}
>
<Pencil />
</TooltipIconButton>
)}
</div>
);
}
@@ -0,0 +1,53 @@
import { AIMessage } from "@langchain/langgraph-sdk";
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="space-y-4">
{toolCalls.map((tc, idx) => {
const args = tc.args as Record<string, any>;
if (!tc.args || Object.keys(args).length === 0) return null;
return (
<div
key={idx}
className="border border-gray-200 rounded-lg overflow-hidden"
>
<div className="bg-gray-50 px-4 py-2 border-b border-gray-200">
<h3 className="text-lg font-medium text-gray-900">{tc.name}</h3>
</div>
<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 text-gray-900 whitespace-nowrap">
{key}
</td>
<td className="px-4 py-2 text-sm text-gray-500">
{isComplexValue(value) ? (
<code className="bg-gray-50 rounded px-2 py-1 font-mono text-sm">
{JSON.stringify(value, null, 2)}
</code>
) : (
String(value)
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
})}
</div>
);
}
@@ -0,0 +1,44 @@
"use client";
import { forwardRef } from "react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Button, ButtonProps } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export type TooltipIconButtonProps = ButtonProps & {
tooltip: string;
side?: "top" | "bottom" | "left" | "right";
};
export const TooltipIconButton = forwardRef<
HTMLButtonElement,
TooltipIconButtonProps
>(({ children, tooltip, side = "bottom", className, ...rest }, ref) => {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
{...rest}
className={cn("size-6 p-1", className)}
ref={ref}
>
{children}
<span className="sr-only">{tooltip}</span>
</Button>
</TooltipTrigger>
<TooltipContent side={side}>{tooltip}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
});
TooltipIconButton.displayName = "TooltipIconButton";
@@ -0,0 +1,9 @@
import type { Message } from "@langchain/langgraph-sdk";
export function getContentString(content: Message["content"]): string {
if (typeof content === "string") return content;
const texts = content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.text);
return texts.join(" ");
}
@@ -0,0 +1,51 @@
import * as React from "react";
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import { cn } from "@/lib/utils";
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
className,
)}
{...props}
/>
);
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
);
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted flex size-full items-center justify-center rounded-full",
className,
)}
{...props}
/>
);
}
export { Avatar, AvatarImage, AvatarFallback };
@@ -0,0 +1,60 @@
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",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
type ButtonProps = React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
};
function Button({
className,
variant,
size,
asChild = false,
...props
}: ButtonProps) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants, type ButtonProps };
@@ -0,0 +1,20 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"border-input file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className,
)}
{...props}
/>
);
}
export { Input };
@@ -0,0 +1,22 @@
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cn } from "@/lib/utils";
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className,
)}
{...props}
/>
);
}
export { Label };
@@ -0,0 +1,54 @@
"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 right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
onClick={() => setShowPassword((prev) => !prev)}
>
{showPassword ? (
<EyeIcon className="h-4 w-4" aria-hidden="true" />
) : (
<EyeOffIcon className="h-4 w-4" aria-hidden="true" />
)}
<span className="sr-only">
{showPassword ? "Hide password" : "Show password"}
</span>
</Button>
{/* hides browsers password toggles */}
<style>{`
.hide-password-toggle::-ms-reveal,
.hide-password-toggle::-ms-clear {
visibility: hidden;
pointer-events: none;
display: none;
}
`}</style>
</div>
);
});
PasswordInput.displayName = "PasswordInput";
@@ -0,0 +1,137 @@
import * as React from "react";
import * as SheetPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80",
className,
)}
{...props}
/>
);
}
function SheetContent({
className,
children,
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left";
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className,
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
);
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
);
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
);
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
);
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
};
@@ -0,0 +1,27 @@
import { useTheme } from "next-themes";
import { Toaster as Sonner, ToasterProps } from "sonner";
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme();
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground font-medium",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground font-medium",
},
}}
{...props}
/>
);
};
export { Toaster };
@@ -0,0 +1,18 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
{...props}
/>
);
}
export { Textarea };
@@ -0,0 +1,59 @@
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils";
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
);
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
);
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit rounded-md px-3 py-1.5 text-xs text-balance",
className,
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
@@ -0,0 +1,46 @@
import { validate } from "uuid";
import { getApiKey } from "@/lib/api-key";
import { Client, Thread } from "@langchain/langgraph-sdk";
import { useQueryParam, StringParam } from "use-query-params";
function createClient(apiUrl: string, apiKey: string | undefined) {
return new Client({
apiKey,
apiUrl,
});
}
function getThreadSearchMetadata(
assistantId: string,
): { graph_id: string } | { assistant_id: string } {
// Assume if the ID is a UUID, it's an assistant ID. Otherwise, it's a graph ID.
if (validate(assistantId)) {
return { assistant_id: assistantId };
} else {
return { graph_id: assistantId };
}
}
export function useThreads() {
const [apiUrl] = useQueryParam("apiUrl", StringParam);
const [assistantId] = useQueryParam("assistantId", StringParam);
const getThreads = 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;
};
return {
getThreads,
};
}
@@ -0,0 +1,134 @@
@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;
}
}
@layer utilities {
.shadow-inner-right {
box-shadow: inset -9px 0 6px -1px rgb(0 0 0 / 0.02);
}
.shadow-inner-left {
box-shadow: inset 9px 0 6px -1px rgb(0 0 0 / 0.02);
}
}
@@ -0,0 +1,10 @@
export function getApiKey(): string | null {
try {
if (typeof window === "undefined") return null;
return window.localStorage.getItem("lg:chat:apiKey") ?? null;
} catch {
// no-op
}
return null;
}
@@ -0,0 +1,34 @@
import { v4 as uuidv4 } from "uuid";
import { Message, ToolMessage } from "@langchain/langgraph-sdk";
export const DO_NOT_RENDER_ID_PREFIX = "do-not-render-";
export function ensureToolCallsHaveResponses(messages: Message[]): Message[] {
const newMessages: ToolMessage[] = [];
messages.forEach((message, index) => {
if (message.type !== "ai" || message.tool_calls?.length === 0) {
// If it's not an AI message, or it doesn't have tool calls, we can ignore.
return;
}
// If it has tool calls, ensure the message which follows this is a tool message
const followingMessage = messages[index + 1];
if (followingMessage && followingMessage.type === "tool") {
// Following message is a tool message, so we can ignore.
return;
}
// Since the following message is not a tool message, we must create a new tool message
newMessages.push(
...(message.tool_calls?.map((tc) => ({
type: "tool" as const,
tool_call_id: tc.id ?? "",
id: `${DO_NOT_RENDER_ID_PREFIX}${uuidv4()}`,
name: tc.name,
content: "Successfully handled tool call.",
})) ?? []),
);
});
return newMessages;
}
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
@@ -0,0 +1,19 @@
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App.tsx";
import { StreamProvider } from "./providers/Stream.tsx";
import { QueryParamProvider } from "use-query-params";
import { ReactRouter6Adapter } from "use-query-params/adapters/react-router-6";
import { BrowserRouter } from "react-router-dom";
import { Toaster } from "@/components/ui/sonner";
createRoot(document.getElementById("root")!).render(
<BrowserRouter>
<QueryParamProvider adapter={ReactRouter6Adapter}>
<StreamProvider>
<App />
</StreamProvider>
</QueryParamProvider>
<Toaster />
</BrowserRouter>,
);
@@ -0,0 +1,190 @@
import React, { createContext, useContext, ReactNode, useState } from "react";
import { useStream } from "@langchain/langgraph-sdk/react";
import { type Message } from "@langchain/langgraph-sdk";
import type {
UIMessage,
RemoveUIMessage,
} from "@langchain/langgraph-sdk/react-ui/types";
import { useQueryParam, StringParam } from "use-query-params";
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";
export type StateType = { messages: Message[]; ui?: UIMessage[] };
const useTypedStream = useStream<
StateType,
{
UpdateType: {
messages?: Message[] | Message | string;
ui?: (UIMessage | RemoveUIMessage)[] | UIMessage | RemoveUIMessage;
};
CustomUpdateType: UIMessage | RemoveUIMessage;
}
>;
type StreamContextType = ReturnType<typeof useTypedStream>;
const StreamContext = createContext<StreamContextType | undefined>(undefined);
const StreamSession = ({
children,
apiKey,
apiUrl,
assistantId,
}: {
children: ReactNode;
apiKey: string | null;
apiUrl: string;
assistantId: string;
}) => {
const [threadId, setThreadId] = useQueryParam("threadId", StringParam);
const streamValue = useTypedStream({
apiUrl,
apiKey: apiKey ?? undefined,
assistantId,
threadId: threadId ?? null,
onThreadId: setThreadId,
});
if (streamValue.error) {
if (typeof streamValue.error === "object") {
console.log((streamValue.error as any)?.["message"]);
}
}
return (
<StreamContext.Provider value={streamValue}>
{children}
</StreamContext.Provider>
);
};
export const StreamProvider: React.FC<{ children: ReactNode }> = ({
children,
}) => {
const [apiUrl, setApiUrl] = useQueryParam("apiUrl", StringParam);
const [apiKey, _setApiKey] = useState(() => {
return getApiKey();
});
const setApiKey = (key: string) => {
window.localStorage.setItem("lg:chat:apiKey", key);
_setApiKey(key);
};
const [assistantId, setAssistantId] = useQueryParam(
"assistantId",
StringParam,
);
if (!apiUrl || !assistantId) {
return (
<div className="flex items-center justify-center min-h-screen w-full p-4">
<div className="animate-in fade-in-0 zoom-in-95 flex flex-col border bg-background shadow-lg rounded-lg max-w-2xl">
<div className="flex flex-col gap-2 mt-14 p-6 border-b">
<div className="flex items-start flex-col gap-2">
<LangGraphLogoSVG className="h-7" />
<h1 className="text-xl font-semibold tracking-tight">
LangGraph Chat
</h1>
</div>
<p className="text-muted-foreground">
Welcome to LangGraph 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="flex flex-col gap-6 p-6 bg-muted/50"
>
<div className="flex flex-col gap-2">
<Label htmlFor="apiUrl">Deployment URL</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 ?? "http://localhost:2024"}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="assistantId">Assistant / Graph ID</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 ?? "agent"}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="apiKey">LangSmith API Key</Label>
<p className="text-muted-foreground text-sm">
This value is stored in your browser's local storage and is only
used to authenticate requests sent to your LangGraph server.
</p>
<PasswordInput
id="apiKey"
name="apiKey"
defaultValue={apiKey ?? ""}
className="bg-background"
placeholder="lsv2_pt_..."
/>
</div>
<div className="flex justify-end mt-2">
<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;
@@ -0,0 +1 @@
/// <reference types="vite/client" />
@@ -0,0 +1,57 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ["class"],
content: ["./index.html", "./src/**/*.{ts,tsx,js,jsx}"],
theme: {
extend: {
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
colors: {
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
chart: {
1: "hsl(var(--chart-1))",
2: "hsl(var(--chart-2))",
3: "hsl(var(--chart-3))",
4: "hsl(var(--chart-4))",
5: "hsl(var(--chart-5))",
},
},
},
},
plugins: [require("tailwindcss-animate"), require("tailwind-scrollbar")],
};
@@ -0,0 +1,30 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2023",
"useDefineForClassFields": true,
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ES2022",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src", "agent"]
}
@@ -0,0 +1,13 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
+13
View File
@@ -0,0 +1,13 @@
// This file serves as the main entry point for the project
// When building the project, the code for the CLI lives in
// src/create-langgraph-chat-app/
/**
* The create-langgraph-chat-app CLI
* This package provides a command-line interface for creating a new LangGraph chat application
*
* @packageDocumentation
*/
// Re-export CLI functionality
export * from './create-langgraph-chat-app/index.js';
+25
View File
@@ -0,0 +1,25 @@
{
"extends": "@tsconfig/recommended",
"compilerOptions": {
"target": "ES2021",
"lib": ["ES2021", "ES2022.Object", "DOM"],
"module": "NodeNext",
"moduleResolution": "nodenext",
"esModuleInterop": true,
"noImplicitReturns": true,
"declaration": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"useDefineForClassFields": true,
"strictPropertyInitialization": false,
"allowJs": true,
"strict": true,
"strictFunctionTypes": false,
"outDir": "dist",
"types": ["jest", "node"],
"resolveJsonModule": true
},
"include": ["**/*.ts", "**/*.js", "jest.setup.cjs"],
"exclude": ["node_modules", "dist"]
}
+4012
View File
File diff suppressed because it is too large Load Diff