commit 1ca8c2ea050e99ec30d5dfb9ac6f1354eccf11ef Author: bracesproul Date: Tue Mar 4 14:07:58 2025 -0800 init commit diff --git a/.codespellignore b/.codespellignore new file mode 100644 index 0000000..b1b26be --- /dev/null +++ b/.codespellignore @@ -0,0 +1,2 @@ +IST +afterAll \ No newline at end of file diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..76add87 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,2 @@ +node_modules +dist \ No newline at end of file diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..1ed453a --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 0000000..1d4de29 --- /dev/null +++ b/.eslintrc.cjs @@ -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 }], + }, +}; diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a0ef73e --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml new file mode 100644 index 0000000..b49c101 --- /dev/null +++ b/.github/workflows/integration-tests.yml @@ -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 diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml new file mode 100644 index 0000000..d07c2d9 --- /dev/null +++ b/.github/workflows/unit-tests.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8081d1b --- /dev/null +++ b/.gitignore @@ -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/ \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b2122bc --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..396b2b3 --- /dev/null +++ b/README.md @@ -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 diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..9e89374 --- /dev/null +++ b/jest.config.js @@ -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, +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..1c262ea --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/create-langgraph-chat-app/README.md b/src/create-langgraph-chat-app/README.md new file mode 100644 index 0000000..46e0b6c --- /dev/null +++ b/src/create-langgraph-chat-app/README.md @@ -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 \ No newline at end of file diff --git a/src/create-langgraph-chat-app/index.js b/src/create-langgraph-chat-app/index.js new file mode 100644 index 0000000..762b132 --- /dev/null +++ b/src/create-langgraph-chat-app/index.js @@ -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); +}); \ No newline at end of file diff --git a/src/create-langgraph-chat-app/package.json b/src/create-langgraph-chat-app/package.json new file mode 100644 index 0000000..7bc865e --- /dev/null +++ b/src/create-langgraph-chat-app/package.json @@ -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" +} \ No newline at end of file diff --git a/src/create-langgraph-chat-app/template/.gitignore b/src/create-langgraph-chat-app/template/.gitignore new file mode 100644 index 0000000..441cc01 --- /dev/null +++ b/src/create-langgraph-chat-app/template/.gitignore @@ -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 \ No newline at end of file diff --git a/src/create-langgraph-chat-app/template/.prettierrc b/src/create-langgraph-chat-app/template/.prettierrc new file mode 100644 index 0000000..222861c --- /dev/null +++ b/src/create-langgraph-chat-app/template/.prettierrc @@ -0,0 +1,4 @@ +{ + "tabWidth": 2, + "useTabs": false +} diff --git a/src/create-langgraph-chat-app/template/components.json b/src/create-langgraph-chat-app/template/components.json new file mode 100644 index 0000000..6296a74 --- /dev/null +++ b/src/create-langgraph-chat-app/template/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/src/create-langgraph-chat-app/template/eslint.config.js b/src/create-langgraph-chat-app/template/eslint.config.js new file mode 100644 index 0000000..79a552e --- /dev/null +++ b/src/create-langgraph-chat-app/template/eslint.config.js @@ -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 }, + ], + }, + }, +); diff --git a/src/create-langgraph-chat-app/template/index.html b/src/create-langgraph-chat-app/template/index.html new file mode 100644 index 0000000..5084932 --- /dev/null +++ b/src/create-langgraph-chat-app/template/index.html @@ -0,0 +1,14 @@ + + + + + + + LangGraph Chat + + + +
+ + + diff --git a/src/create-langgraph-chat-app/template/package.json b/src/create-langgraph-chat-app/template/package.json new file mode 100644 index 0000000..737acb3 --- /dev/null +++ b/src/create-langgraph-chat-app/template/package.json @@ -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" +} diff --git a/src/create-langgraph-chat-app/template/public/vite.svg b/src/create-langgraph-chat-app/template/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/src/create-langgraph-chat-app/template/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/create-langgraph-chat-app/template/src/App.css b/src/create-langgraph-chat-app/template/src/App.css new file mode 100644 index 0000000..e69de29 diff --git a/src/create-langgraph-chat-app/template/src/App.tsx b/src/create-langgraph-chat-app/template/src/App.tsx new file mode 100644 index 0000000..f1b3264 --- /dev/null +++ b/src/create-langgraph-chat-app/template/src/App.tsx @@ -0,0 +1,8 @@ +import "./App.css"; +import { Thread } from "@/components/thread"; + +function App() { + return ; +} + +export default App; diff --git a/src/create-langgraph-chat-app/template/src/assets/react.svg b/src/create-langgraph-chat-app/template/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/src/create-langgraph-chat-app/template/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/create-langgraph-chat-app/template/src/components/icons/langgraph.tsx b/src/create-langgraph-chat-app/template/src/components/icons/langgraph.tsx new file mode 100644 index 0000000..4bac592 --- /dev/null +++ b/src/create-langgraph-chat-app/template/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/src/create-langgraph-chat-app/template/src/components/thread/history/index.tsx b/src/create-langgraph-chat-app/template/src/components/thread/history/index.tsx new file mode 100644 index 0000000..6bd8b9d --- /dev/null +++ b/src/create-langgraph-chat-app/template/src/components/thread/history/index.tsx @@ -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 ( +
+ {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 ( +
+ +
+ ); + })} +
+ ); +} + +export default function ThreadHistory() { + const [threads, setThreads] = useState([]); + const [chatHistoryOpen, setChatHistoryOpen] = useQueryParam( + "chatHistoryOpen", + BooleanParam, + ); + + const { getThreads } = useThreads(); + + useEffect(() => { + getThreads().then(setThreads).catch(console.error); + }, []); + + return ( + <> +
+

Thread History

+ +
+ + + + Thread History + + setChatHistoryOpen((o) => !o)} + /> + + + + ); +} diff --git a/src/create-langgraph-chat-app/template/src/components/thread/index.tsx b/src/create-langgraph-chat-app/template/src/components/thread/index.tsx new file mode 100644 index 0000000..0acbc07 --- /dev/null +++ b/src/create-langgraph-chat-app/template/src/components/thread/index.tsx @@ -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 ( +
+
+ {props.content} +
+ + {props.footer} +
+ ); +} + +function ScrollToBottom(props: { className?: string }) { + const { isAtBottom, scrollToBottom } = useStickToBottomContext(); + + if (isAtBottom) return null; + return ( + + ); +} + +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(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: ( +

+ Error: {message} +

+ ), + 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 ( +
+ +
+ {threadId && ( +
+
+ + +
+ + setThreadId(null)} + > + + + +
+
+ )} + + + + {messages + .filter((m) => !m.id?.startsWith(DO_NOT_RENDER_ID_PREFIX)) + .map((message, index) => + message.type === "human" ? ( + + ) : ( + + ), + )} + {isLoading && !firstTokenReceived && ( + + )} + + } + footer={ +
+ {!threadId && ( +
+ +

+ LangGraph Chat +

+
+ )} + + + +
+
+ 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" + /> + +
+ {stream.isLoading ? ( + + ) : ( + + )} +
+
+
+
+ } + /> +
+
+
+ ); +} diff --git a/src/create-langgraph-chat-app/template/src/components/thread/markdown-text.tsx b/src/create-langgraph-chat-app/template/src/components/thread/markdown-text.tsx new file mode 100644 index 0000000..51603fd --- /dev/null +++ b/src/create-langgraph-chat-app/template/src/components/thread/markdown-text.tsx @@ -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 ( + + {children} + + ); +}; + +export const MarkdownText = memo(MarkdownTextImpl); + +const CodeHeader: FC = ({ language, code }) => { + const { isCopied, copyToClipboard } = useCopyToClipboard(); + const onCopy = () => { + if (!code || isCopied) return; + copyToClipboard(code); + }; + + return ( +
+ {language} + + {!isCopied && } + {isCopied && } + +
+ ); +}; + +const useCopyToClipboard = ({ + copiedDuration = 3000, +}: { + copiedDuration?: number; +} = {}) => { + const [isCopied, setIsCopied] = useState(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 }) => ( +

+ ), + h2: ({ className, ...props }) => ( +

+ ), + h3: ({ className, ...props }) => ( +

+ ), + h4: ({ className, ...props }) => ( +

+ ), + h5: ({ className, ...props }) => ( +

+ ), + h6: ({ className, ...props }) => ( +
+ ), + p: ({ className, ...props }) => ( +

+ ), + a: ({ className, ...props }) => ( + + ), + blockquote: ({ className, ...props }) => ( +

+ ), + ul: ({ className, ...props }) => ( +
    li]:mt-2", className)} + {...props} + /> + ), + ol: ({ className, ...props }) => ( +
      li]:mt-2", className)} + {...props} + /> + ), + hr: ({ className, ...props }) => ( +
      + ), + table: ({ className, ...props }) => ( + + ), + th: ({ className, ...props }) => ( + td:first-child]:rounded-bl-lg [&:last-child>td:last-child]:rounded-br-lg", + className, + )} + {...props} + /> + ), + sup: ({ className, ...props }) => ( + a]:text-xs [&>a]:no-underline", className)} + {...props} + /> + ), + pre: ({ className, ...props }) => ( +
      +  ),
      +  code: function Code({ className, ...props }) {
      +    const isCodeBlock = useIsMarkdownCodeBlock();
      +    return (
      +      
      +    );
      +  },
      +  CodeHeader,
      +});
      diff --git a/src/create-langgraph-chat-app/template/src/components/thread/messages/ai.tsx b/src/create-langgraph-chat-app/template/src/components/thread/messages/ai.tsx
      new file mode 100644
      index 0000000..e269a63
      --- /dev/null
      +++ b/src/create-langgraph-chat-app/template/src/components/thread/messages/ai.tsx
      @@ -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;
      +}) {
      +  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 (
      +    
      + {customComponent && ( + + )} +
      + ); +} + +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 ( +
      + + A + +
      + {hasToolCalls && } + + {contentString.length > 0 && ( +
      + {contentString} +
      + )} +
      + thread.setBranch(branch)} + isLoading={isLoading} + /> + handleRegenerate(parentCheckpoint)} + /> +
      +
      +
      + ); +} + +export function AssistantMessageLoading() { + return ( +
      + + A + +
      +
      +
      +
      +
      +
      + ); +} diff --git a/src/create-langgraph-chat-app/template/src/components/thread/messages/human.tsx b/src/create-langgraph-chat-app/template/src/components/thread/messages/human.tsx new file mode 100644 index 0000000..340a634 --- /dev/null +++ b/src/create-langgraph-chat-app/template/src/components/thread/messages/human.tsx @@ -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>; + onSubmit: () => void; +}) { + const handleKeyDown = (e: React.KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { + e.preventDefault(); + onSubmit(); + } + }; + + return ( +
      + ), + td: ({ className, ...props }) => ( + + ), + tr: ({ className, ...props }) => ( +