First commit

This commit is contained in:
William Fu-Hinthorn
2024-12-15 20:58:05 -08:00
commit ff38f50a35
46 changed files with 19577 additions and 0 deletions
View File
+14
View File
@@ -0,0 +1,14 @@
SUPABASE_URL=https://abcd123456789.supabase.co
SUPABASE_SERVICE_KEY=
SUPABASE_JWT_SECRET=
# To separate your traces from other application
LANGSMITH_PROJECT=new-agent
# The following depend on your selected configuration
## LLM choice:
ANTHROPIC_API_KEY=....
FIREWORKS_API_KEY=...
OPENAI_API_KEY=...
+42
View File
@@ -0,0 +1,42 @@
# 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]
python-version: ["3.11", "3.12"]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
uv venv
uv pip install -r pyproject.toml
uv pip install -U pytest-asyncio
- name: Run integration tests
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY }}
LANGSMITH_TRACING: true
run: |
uv run pytest tests/integration_tests
+164
View File
@@ -0,0 +1,164 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
.langgraph_api
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
uv.lock
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 LangChain
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.
+67
View File
@@ -0,0 +1,67 @@
.PHONY: all format lint test tests test_watch integration_tests docker_tests help extended_tests
# Default target executed when no arguments are given to make.
all: help
# Define a variable for the test file path.
TEST_FILE ?= tests/unit_tests/
test:
python -m pytest $(TEST_FILE)
integration_tests:
python -m pytest tests/integration_tests
test_watch:
python -m ptw --snapshot-update --now . -- -vv tests/unit_tests
test_profile:
python -m pytest -vv tests/unit_tests/ --profile-svg
extended_tests:
python -m pytest --only-extended $(TEST_FILE)
######################
# LINTING AND FORMATTING
######################
# Define a variable for Python and notebook files.
PYTHON_FILES=src/
MYPY_CACHE=.mypy_cache
lint format: PYTHON_FILES=.
lint_diff format_diff: PYTHON_FILES=$(shell git diff --name-only --diff-filter=d main | grep -E '\.py$$|\.ipynb$$')
lint_package: PYTHON_FILES=src
lint_tests: PYTHON_FILES=tests
lint_tests: MYPY_CACHE=.mypy_cache_test
lint lint_diff lint_package lint_tests:
python -m ruff check .
[ "$(PYTHON_FILES)" = "" ] || python -m ruff format $(PYTHON_FILES) --diff
[ "$(PYTHON_FILES)" = "" ] || python -m ruff check --select I $(PYTHON_FILES)
[ "$(PYTHON_FILES)" = "" ] || python -m mypy --strict $(PYTHON_FILES)
[ "$(PYTHON_FILES)" = "" ] || mkdir -p $(MYPY_CACHE) && python -m mypy --strict $(PYTHON_FILES) --cache-dir $(MYPY_CACHE)
format format_diff:
ruff format $(PYTHON_FILES)
ruff check --select I --fix $(PYTHON_FILES)
spell_check:
codespell --toml pyproject.toml
spell_fix:
codespell --toml pyproject.toml -w
######################
# HELP
######################
help:
@echo '----'
@echo 'format - run code formatters'
@echo 'lint - run linters'
@echo 'test - run unit tests'
@echo 'tests - run unit tests'
@echo 'test TEST_FILE=<test_file> - run all tests in file'
@echo 'test_watch - run unit tests in watch mode'
+213
View File
@@ -0,0 +1,213 @@
# Supabase Auth for LangGraph
[![Open in - LangGraph Studio](https://img.shields.io/badge/Open_in-LangGraph_Studio-00324d.svg?logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI4NS4zMzMiIGhlaWdodD0iODUuMzMzIiB2ZXJzaW9uPSIxLjAiIHZpZXdCb3g9IjAgMCA2NCA2NCI+PHBhdGggZD0iTTEzIDcuOGMtNi4zIDMuMS03LjEgNi4zLTYuOCAyNS43LjQgMjQuNi4zIDI0LjUgMjUuOSAyNC41QzU3LjUgNTggNTggNTcuNSA1OCAzMi4zIDU4IDcuMyA1Ni43IDYgMzIgNmMtMTIuOCAwLTE2LjEuMy0xOSAxLjhtMzcuNiAxNi42YzIuOCAyLjggMy40IDQuMiAzLjQgNy42cy0uNiA0LjgtMy40IDcuNkw0Ny4yIDQzSDE2LjhsLTMuNC0zLjRjLTQuOC00LjgtNC44LTEwLjQgMC0xNS4ybDMuNC0zLjRoMzAuNHoiLz48cGF0aCBkPSJNMTguOSAyNS42Yy0xLjEgMS4zLTEgMS43LjQgMi41LjkuNiAxLjcgMS44IDEuNyAyLjcgMCAxIC43IDIuOCAxLjYgNC4xIDEuNCAxLjkgMS40IDIuNS4zIDMuMi0xIC42LS42LjkgMS40LjkgMS41IDAgMi43LS41IDIuNy0xIDAtLjYgMS4xLS44IDIuNi0uNGwyLjYuNy0xLjgtMi45Yy01LjktOS4zLTkuNC0xMi4zLTExLjUtOS44TTM5IDI2YzAgMS4xLS45IDIuNS0yIDMuMi0yLjQgMS41LTIuNiAzLjQtLjUgNC4yLjguMyAyIDEuNyAyLjUgMy4xLjYgMS41IDEuNCAyLjMgMiAyIDEuNS0uOSAxLjItMy41LS40LTMuNS0yLjEgMC0yLjgtMi44LS44LTMuMyAxLjYtLjQgMS42LS41IDAtLjYtMS4xLS4xLTEuNS0uNi0xLjItMS42LjctMS43IDMuMy0yLjEgMy41LS41LjEuNS4yIDEuNi4zIDIuMiAwIC43LjkgMS40IDEuOSAxLjYgMi4xLjQgMi4zLTIuMy4yLTMuMi0uOC0uMy0yLTEuNy0yLjUtMy4xLTEuMS0zLTMtMy4zLTMtLjUiLz48L3N2Zz4=)](https://langgraph-studio.vercel.app/templates/open?githubUrl=https://github.com/langchain-ai/custom-auth)
This template implements access control in a LangGraph deployment using OAuth2 with Google, managing user information in Supabase. It combines a LangGraph backend with a React frontend to create a secure chatbot where users can only access their own conversation threads.
## Architecture
The application consists of two main components:
1. **Backend**: A LangGraph Cloud deployment running a chatbot with custom [authentication middleware](src/security/auth.py)
2. **Frontend**: A React application using Supabase Auth for user management
![Authentication Flow](./static/studio_ui.png)
There are two levels of checks in this template:
- **Authentication**: Verifies the identity of users (via Supabase JWT)
- **Authorization**: Controls what resources each user can access (threads, runs, etc.)
We present the end-to-end flow in more detail in [Authentication Flow](#authentication-flow) and [Authorization Flow](#authorization-flow) below.
## Getting Started
We will use Supabase for authentication and enable a "Sign in with Google" button for users to sign in to your deployment. To get started, follow these steps (largely adapted from [Login with Google](https://supabase.com/docs/guides/auth/social-login/auth-google?queryGroups=platform&platform=web&queryGroups=environment&environment=client#configure-your-services-id)):
### 1. Set up Supabase
1. Create a new project at [supabase.com](https://supabase.com)
2. Copy your project's URL, Service Key, and JWT secret from the project settings > API screen into your `.env` file. This will let your LangGraph backend connect to your Supabase project.
```bash
cp .env.example .env
```
and set the following environment variables:
```bash
SUPABASE_URL=https://abcd123456789.supabase.co
SUPABASE_SERVICE_KEY=
SUPABASE_JWT_SECRET=
```
3. Copy your project's public Anon key and URL from the same screen into the .env file of the frontend folder. This will let your React frontend connect to your Supabase project.
```bash
cd app/supabase-react
```
```bash
REACT_APP_SUPABASE_URL=https://ynlkmijkhkvwcqnuillj.supabase.co
REACT_APP_SUPABASE_ANON_KEY=CHANGEME
```
4. Enable google authentication by going to Authentication > Providers in your Supabase dashboard. Copy your `Callback URL (for OAuth)` to use for the next step. Leave this tab open for step 3.
### 2. Configure Google OAuth
To enable "Sign in with Google", follow these steps:
1. Create a Google Cloud project at [console.cloud.google.com](https://console.cloud.google.com)
2. Enable the Google OAuth2 API
3. Create OAuth 2.0 credentials:
- Application type: Web application
- Name: Your app name
- Authorized JavaScript origins:
- `http://localhost:3000` (for local development)
- Your production frontend URL (We will update this later)
- Authorized redirect URIs:
- `https://<YOUR_DOMAIN>.supabase.co/auth/v1/callback` (copied from the previous step)
4. Save your Client ID and Client Secret
Now, switch back to the Supabase dashboard from step 1 above (in Authentication > Providers > Google) and:
5. Paste your Google Client ID and Client Secret from step 2 in the Google OAuth credentials
### 3. Run locally
1. Start the LangGraph backend:
Add an API key for your LLM provider (by default, ANTHROPIC_API_KEY) to your `.env` file, and then
run the local backend server:
```bash
pip install -U "langgraph-cli[inmem]" && pip install -e .
langgraph dev
```
Or as a one liner (if you've [installed uv](https://docs.astral.sh/uv/getting-started/installation/)):
```bash
uvx --from "langgraph-cli[inmem]" --with-editable . --python 3.11 langgraph dev
```
2. Next, start the frontend:
```bash
cd app/supabase-react
npm run start
```
If you've completed the steps above, you will see a "Sign in with Google" button on the home page. Once you log in, you can chat with the LangGraph backend. If you sign in with a different account, you will only be able to see your own conversation threads.
### 4. Deployment
Once you've gotten the server working locally, you can proceed to deployment.
1. Push your code to GitHub
```bash
git init
git add .
git commit -m "Initial commit"
git remote add origin https://github.com/USERNAME/REPOSITORY.git
git push -u origin main
```
2. Deploy the LangGraph backend by signing in at [smith.langchain.com](https://smith.langchain.com/) going to the "LangGraph Platform" page. Once you've connected your GitHub repository, you can deploy your repository to LangGraph. Copy the environment variables from your `.env` file in to the appropriate form.
Once the deployment is complete, copy the LangGraph deployment URL. We will use this as the the `REACT_APP_DEPLOYMENT_URL` variable when deploying the frontend.
3. Deploy the frontend (e.g., on Vercel). Sign in at [vercel.com](https://vercel.com/) and connect your GitHub repository. Copy the environment variables from your `app/supabase-react/.env` file, making sure to include the `REACT_APP_DEPLOYMENT_URL` variable. Then deploy the frontend.
Once the deployment is complete, copy the frontend URL and add it to the list of "Authorized JavaScript origins" in your Google OAuth credentials (from step 2 above).
Additionally, you must add your frontend URL to the list of "Authorized redirect URIs" in your Supabase dashboard (from step 1 above). This can be found in the "Authentication > URL Configuration" section.
Once you've completed the steps above, you should be good to go!
## Authentication Flow
This template implements JWT Bearer token authentication:
1. User navigates to the your frontend application
2. Browser checks authentication state via the Supabase Auth Provider checks authentication state
3. If not authenticated, user is signs in via:
- OAuth2 with Google
- Email/password authentication
4. This sends a request to Supabase's authentication service:
- Creates a JWT containing user claims
- Signs it with your Supabase JWT secret
- Returns only the signed token to frontend
5. Frontend then attaches the signed JWT as Bearer token to API requests to the LangGraph backend
6. LangGraph backend then calls your authentication middleware ([`auth.py`](src/security/auth.py)):
- Verifies JWT signature
- Validates claims against Supabase Auth service using the project secret key
The secret key (`SUPABASE_JWT_SECRET`) is only known to:
- Supabase's authentication servers
- Your LangGraph backend
## Authorization Flow
After a user is authenticated, the backend enforces per-user resource isolation using the `add_owner` handler in `auth.py`.
This handler does two things:
1. For resource creation or modification, it adds the authenticated user's identity as the `owner` in that resource's metadata. This ensures that we can filter resources based on user identity.
2. It returns a filter dictionary that enforces access control on all operations (create, read, update, delete) on the resource, so only the owner can access it.
This means that even if a user has a valid JWT tokena and obtains another user's thread ID, they cannot:
- View the thread's messages
- Add messages to the thread
- Delete or modify the thread
The authorization is enforced at the database level, not just in the UI, making it impossible to write scripts that bypass authorization.
## Implementation Details
### Backend Authentication (`src/security/auth.py`)
The backend implements custom authentication middleware that:
1. Validates JWT tokens against Supabase
2. Associates user identity with resources
3. Enforces access control on all operations (create, read, update, delete)
Key components:
- `get_current_user`: Validates JWT tokens and authenticates users
- `add_owner`: Adds user identity to resource metadata
- Resource filtering ensures users can only access their own data
### Frontend Integration
The React frontend (`app/supabase-react`) handles:
- User authentication state management
- JWT token management
- Secure API calls to the LangGraph backend
## Customization
1. **Auth Providers**: Configure additional OAuth providers in Supabase
2. **Access Control**: Modify `add_owner` to implement custom access control logic
3. **Resource Sharing**: Extend the model to support shared resources between users
## Next Steps
For more advanced features and examples, refer to:
- [LangGraph Documentation](https://github.com/langchain-ai/langgraph)
- [Supabase Auth Documentation](https://supabase.com/docs/guides/auth)
LangGraph Studio integrates with [LangSmith](https://smith.langchain.com/) for debugging and monitoring your authenticated deployment.
<!--
Configuration auto-generated by `langgraph template lock`. DO NOT EDIT MANUALLY.
{
"config_schemas": {
"agent": {
"type": "object",
"properties": {}
}
}
}
-->
+4
View File
@@ -0,0 +1,4 @@
REACT_APP_SUPABASE_URL=your-project-url
REACT_APP_SUPABASE_ANON_KEY=your-anon-key
REACT_APP_ASSISTANT_ID=agent # This is either an assistant ID or the graph name in your langgraph.json file
REACT_APP_DEPLOYMENT_URL=http://localhost:2024 # The URL of your LangGraph deployment
+23
View File
@@ -0,0 +1,23 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
+70
View File
@@ -0,0 +1,70 @@
# Getting Started with Create React App
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
## Available Scripts
In the project directory, you can run:
### `npm start`
Runs the app in the development mode.\
Open [http://localhost:3000](http://localhost:3000) to view it in your browser.
The page will reload when you make changes.\
You may also see any lint errors in the console.
### `npm test`
Launches the test runner in the interactive watch mode.\
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
### `npm run build`
Builds the app for production to the `build` folder.\
It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.\
Your app is ready to be deployed!
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
### `npm run eject`
**Note: this is a one-way operation. Once you `eject`, you can't go back!**
If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own.
You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it.
## Learn More
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
To learn React, check out the [React documentation](https://reactjs.org/).
### Code Splitting
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
### Analyzing the Bundle Size
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
### Making a Progressive Web App
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
### Advanced Configuration
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
### Deployment
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
### `npm run build` fails to minify
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)
+17470
View File
File diff suppressed because it is too large Load Diff
+47
View File
@@ -0,0 +1,47 @@
{
"name": "supabase-react",
"version": "0.1.0",
"private": true,
"dependencies": {
"@langchain/langgraph-sdk": "^0.0.32",
"@supabase/auth-ui-react": "^0.4.7",
"@supabase/auth-ui-shared": "^0.1.8",
"@supabase/supabase-js": "^2.47.7",
"cra-template": "1.2.0",
"lodash": "^4.17.21",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-scripts": "5.0.1",
"tailwindcss": "^3.4.16",
"web-vitals": "^2.1.4"
},
"devDependencies": {
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"prettier": "^3.4.2"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

+49
View File
@@ -0,0 +1,49 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#404040" />
<meta
name="description"
content="Secure chat application powered by Supabase Auth and LangGraph"
/>
<meta name="keywords" content="chat, AI, Supabase, authentication, LangGraph" />
<meta name="author" content="LangGraph" />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<!-- Open Graph / Social Media Meta Tags -->
<meta property="og:title" content="LangGraph Chat" />
<meta property="og:description" content="Secure chat application powered by Supabase Auth and LangGraph" />
<meta property="og:type" content="website" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>LangGraph Chat</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

+25
View File
@@ -0,0 +1,25 @@
{
"short_name": "LangGraph Chat",
"name": "LangGraph Chat - Secure AI Chat with custom Auth",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#404040",
"background_color": "#f9fafb"
}
+3
View File
@@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:
+38
View File
@@ -0,0 +1,38 @@
.App {
text-align: center;
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
+133
View File
@@ -0,0 +1,133 @@
import "./index.css";
import React, { useState, useEffect } from "react";
import { createClient } from "@supabase/supabase-js";
import { Auth } from "@supabase/auth-ui-react";
import { ThemeSupa } from "@supabase/auth-ui-shared";
import { Chat } from "./components/Chat";
import { ChatProvider } from "./contexts/ChatContext";
import { AuthProvider } from "./contexts/AuthContext";
const SUPABASE_PROJECT_URL = process.env.REACT_APP_SUPABASE_URL;
const ANON_KEY = process.env.REACT_APP_SUPABASE_ANON_KEY;
const REDIRECT_TO = process.env.REACT_APP_REDIRECT_TO ?? window.location.origin;
// Initialize the Supabase client
const supabase = createClient(SUPABASE_PROJECT_URL, ANON_KEY);
// Error boundary component
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
render() {
if (this.state.hasError) {
return (
<div style={{ padding: "20px", color: "red" }}>
<h2>Something went wrong.</h2>
<pre>{this.state.error.toString()}</pre>
</div>
);
}
return this.props.children;
}
}
export default function App() {
const [session, setSession] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
// Get initial session
supabase.auth
.getSession()
.then(({ data: { session } }) => {
setSession(session);
})
.catch((err) => {
console.error("Error getting session:", err);
setError(err);
});
// Listen for auth changes
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((_event, session) => {
setSession(session);
});
// Cleanup subscription
return () => {
if (subscription) subscription.unsubscribe();
};
}, []);
if (error) {
return (
<div style={{ padding: "20px", color: "red" }}>
Error: {error.message}
</div>
);
}
if (!session) {
return (
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "100vh",
backgroundColor: "#f9fafb",
}}
>
<div
style={{
width: "100%",
maxWidth: "420px",
padding: "20px",
backgroundColor: "white",
borderRadius: "8px",
boxShadow: "0 2px 4px rgba(0, 0, 0, 0.1)",
}}
>
<ErrorBoundary>
<Auth
supabaseClient={supabase}
appearance={{
theme: ThemeSupa,
variables: {
default: {
colors: {
brand: "#404040",
brandAccent: "#2d2d2d",
},
},
},
}}
providers={[
"google",
// Can enable other providers
// 'github',
]}
redirectTo={REDIRECT_TO}
/>
</ErrorBoundary>
</div>
</div>
);
}
return (
<AuthProvider supabase={supabase}>
<ChatProvider>
<Chat />
</ChatProvider>
</AuthProvider>
);
}
+8
View File
@@ -0,0 +1,8 @@
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});
+118
View File
@@ -0,0 +1,118 @@
import { useState, useRef, useEffect } from "react";
import { useChat } from "../contexts/ChatContext";
import { ChatSidebar } from "./ChatSidebar";
import { ProfileMenu } from "./ProfileMenu";
export function Chat() {
const [input, setInput] = useState("");
const { messages, sendMessage, isLoading } = useChat();
const messagesEndRef = useRef(null);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
};
useEffect(() => {
scrollToBottom();
}, [messages]);
const handleSubmit = async (e) => {
e.preventDefault();
if (!input.trim()) return;
await sendMessage(input);
setInput("");
};
return (
<div className="flex h-screen bg-gray-50">
{/* Sidebar */}
<ChatSidebar />
{/* Main Chat Area */}
<div className="flex-1 flex flex-col">
{/* Chat header */}
<div className="p-4 bg-white border-b shadow-sm flex justify-between items-center">
<h1 className="text-xl font-semibold text-gray-800">LangGraph Chat</h1>
<ProfileMenu />
</div>
{/* Messages area */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.map((message, index) => (
<div
key={index}
className={`flex ${
message.role === "user" ? "justify-end" : "justify-start"
} animate-fade-in`}
>
<div
className={`max-w-[80%] p-4 rounded-lg shadow-sm ${
message.role === "user"
? "bg-blue-500 text-white ml-4"
: "bg-white text-gray-800 mr-4"
} ${
isLoading &&
index === messages.length - 1 &&
message.role === "assistant"
? "animate-pulse"
: ""
}`}
>
<div className="whitespace-pre-wrap">{message.content}</div>
</div>
</div>
))}
<div ref={messagesEndRef} />
</div>
{/* Input area */}
<div className="border-t bg-white p-4">
<form onSubmit={handleSubmit} className="max-w-4xl mx-auto">
<div className="flex space-x-4">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Type your message..."
className="flex-1 p-3 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-gray-50"
disabled={isLoading}
/>
<button
type="submit"
disabled={isLoading || !input.trim()}
className="px-6 py-3 bg-blue-500 text-white rounded-lg hover:bg-blue-600
disabled:opacity-50 disabled:cursor-not-allowed transition-colors
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
{isLoading ? (
<div className="flex items-center">
<svg className="animate-spin h-5 w-5 mr-2" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
fill="none"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
Sending...
</div>
) : (
"Send"
)}
</button>
</div>
</form>
</div>
</div>
</div>
);
}
@@ -0,0 +1,162 @@
import { useState, useRef, useEffect } from 'react';
import { useChat } from '../contexts/ChatContext';
export function ChatSidebar() {
const { threads, currentThreadId, createNewThread, switchThread, deleteThread } = useChat();
const [isCollapsed, setIsCollapsed] = useState(false);
const [width, setWidth] = useState(256); // 16 * 16 = 256px default
const sidebarRef = useRef(null);
const isResizing = useRef(false);
useEffect(() => {
const handleMouseMove = (e) => {
if (!isResizing.current) return;
const newWidth = e.clientX;
if (newWidth >= 200 && newWidth <= 600) { // Min 200px, max 600px
setWidth(newWidth);
}
};
const handleMouseUp = () => {
isResizing.current = false;
document.body.style.cursor = 'default';
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, []);
const startResizing = () => {
isResizing.current = true;
document.body.style.cursor = 'ew-resize';
};
const handleDelete = async (e, threadId) => {
e.stopPropagation(); // Prevent triggering thread switch
if (window.confirm('Are you sure you want to delete this thread?')) {
await deleteThread(threadId);
}
};
return (
<div
ref={sidebarRef}
className={`bg-gray-50 border-r border-gray-200 flex flex-col h-screen relative transition-all duration-300 ease-in-out ${
isCollapsed ? 'w-12' : ''
}`}
style={{ width: isCollapsed ? '48px' : `${width}px` }}
>
{/* Collapse Toggle Button */}
<button
onClick={() => setIsCollapsed(!isCollapsed)}
className="absolute -right-3 top-4 bg-gray-200 rounded-full p-1 hover:bg-gray-300 z-10"
title={isCollapsed ? "Expand sidebar" : "Collapse sidebar"}
>
<svg
xmlns="http://www.w3.org/2000/svg"
className={`h-4 w-4 transition-transform ${isCollapsed ? 'rotate-180' : ''}`}
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
</button>
{/* Resize Handle */}
<div
className="absolute right-0 top-0 bottom-0 w-1 cursor-ew-resize hover:bg-blue-500 transition-colors"
onMouseDown={startResizing}
/>
{!isCollapsed && (
<>
{/* New Chat Button */}
<div className="p-4">
<button
onClick={createNewThread}
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z"
clipRule="evenodd"
/>
</svg>
New Chat
</button>
</div>
{/* Chat List */}
<div className="flex-1 overflow-y-auto">
{threads.map((thread) => {
const title = thread.values?.title || "New Chat";
const description = thread.values?.description;
const lastMessage = thread.values?.messages?.[thread.values.messages.length - 1];
const messageContent = typeof lastMessage?.content === 'string'
? lastMessage.content
: lastMessage?.content?.text || "No messages yet";
return (
<div
key={thread.thread_id}
className="flex items-center hover:bg-gray-100 transition-colors relative [&:hover>button:last-child]:block"
>
<button
onClick={() => switchThread(thread.thread_id)}
className={`flex-1 text-left p-3 group ${
currentThreadId === thread.thread_id ? "bg-gray-100" : ""
}`}
>
<div className="font-medium text-gray-900 truncate">
{title}
</div>
<div
className="text-sm text-gray-500 truncate group-hover:whitespace-normal group-hover:overflow-visible group-hover:absolute group-hover:bg-white group-hover:shadow-lg group-hover:p-2 group-hover:rounded group-hover:z-10 group-hover:max-w-md"
title={description || messageContent}
>
{description || messageContent}
</div>
</button>
<button
onClick={(e) => handleDelete(e, thread.thread_id)}
className="hidden absolute right-2 p-2 text-gray-400 hover:text-red-600 transition-colors"
title="Delete thread"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg>
</button>
</div>
);
})}
</div>
</>
)}
</div>
);
}
@@ -0,0 +1,95 @@
import { useState, useRef, useEffect } from 'react';
import { useAuth } from '../contexts/AuthContext';
export function ProfileMenu() {
const { user, signOut } = useAuth();
const [isOpen, setIsOpen] = useState(false);
const menuRef = useRef();
const userName = user?.user_metadata?.full_name || user?.user_metadata?.name || user?.email;
const userAvatar = user?.user_metadata?.avatar_url || user?.user_metadata?.picture;
useEffect(() => {
const handleClickOutside = (event) => {
if (menuRef.current && !menuRef.current.contains(event.target)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
return (
<div className="relative" ref={menuRef}>
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center space-x-2 rounded-full hover:bg-gray-100 p-1"
title={userName}
>
{userAvatar ? (
<img
src={userAvatar}
alt={userName}
className="h-8 w-8 rounded-full"
/>
) : (
<div className="h-8 w-8 rounded-full bg-gray-200 flex items-center justify-center">
<span className="text-gray-600 text-sm">
{userName?.charAt(0)?.toUpperCase()}
</span>
</div>
)}
</button>
{isOpen && (
<div className="absolute right-0 mt-2 w-64 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5">
<div className="py-1">
{/* User Info */}
<div className="px-4 py-3 border-b">
<div className="flex items-center space-x-3">
{userAvatar && (
<img
src={userAvatar}
alt={userName}
className="h-10 w-10 rounded-full"
/>
)}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">
{userName}
</p>
<p className="text-sm text-gray-500 truncate">
{user?.email}
</p>
</div>
</div>
</div>
{/* Menu Items */}
<button
onClick={signOut}
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 flex items-center space-x-2"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
/>
</svg>
<span>Sign out</span>
</button>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,52 @@
import { createContext, useContext, useEffect, useState } from 'react';
const AuthContext = createContext({});
export function AuthProvider({ children, supabase }) {
const [user, setUser] = useState(null);
const [session, setSession] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Check active sessions and sets the user
supabase.auth.getSession().then(({ data: { session } }) => {
setUser(session?.user ?? null);
setSession(session);
setLoading(false);
});
// Listen for changes on auth state (sign in, sign out, etc.)
const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
setUser(session?.user ?? null);
setSession(session);
setLoading(false);
});
return () => subscription.unsubscribe();
}, [supabase.auth]);
const signOut = async () => {
try {
await supabase.auth.signOut();
} catch (error) {
console.error('Error signing out:', error.message);
}
};
const value = {
signOut,
user,
session,
loading
};
return (
<AuthContext.Provider value={value}>
{!loading && children}
</AuthContext.Provider>
);
}
export const useAuth = () => {
return useContext(AuthContext);
};
@@ -0,0 +1,189 @@
import { createContext, useContext, useState, useMemo } from "react";
import { Client } from "@langchain/langgraph-sdk";
import { useThreadManager } from "../hooks/useThreadManager";
import { useAuth } from "./AuthContext";
const ChatContext = createContext();
const ASSISTANT_ID = process.env.REACT_APP_ASSISTANT_ID ?? "agent";
const DEPLOYMENT_URL =
process.env.REACT_APP_DEPLOYMENT_URL ?? "http://localhost:2024";
console.log("DEPLOYMENT_URL", DEPLOYMENT_URL);
export function ChatProvider({ children }) {
const { session } = useAuth();
const client = useMemo(() => {
return new Client({
apiUrl: DEPLOYMENT_URL,
defaultHeaders: {
Authorization: `Bearer ${session?.access_token}`,
},
});
}, [session?.access_token]);
const {
threads,
currentThreadId,
isLoading: isThreadsLoading,
createNewThread,
deleteThread,
setCurrentThreadId,
setThreads,
} = useThreadManager(session?.user?.id || "default-user", client);
const [messages, setMessages] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const handleCreateNewThread = async () => {
const thread = await createNewThread();
if (thread) {
setMessages([]);
setCurrentThreadId(thread.thread_id);
}
};
const sendMessage = async (content) => {
if (!currentThreadId) {
console.error("No thread ID available");
return;
}
setIsLoading(true);
try {
// Add user message immediately
const newMessage = { role: "user", content };
setMessages((prev) => [...prev, newMessage]);
const input = {
messages: [{ role: "human", content }],
};
const config = {
configurable: { model_name: "openai" },
};
const streamResponse = client.runs.stream(currentThreadId, ASSISTANT_ID, {
input,
config,
streamMode: ["messages-tuple", "updates"],
});
let assistantMessage = "";
for await (const chunk of streamResponse) {
if (chunk.event === "messages") {
const messages = chunk.data;
const [messageChunk, metadata] = messages;
if (
messageChunk?.content !== undefined &&
messageChunk.type === "AIMessageChunk" &&
metadata?.langgraph_node === "chatbot"
) {
assistantMessage += messageChunk.content;
setMessages((prev) => {
const lastMessage = prev[prev.length - 1];
if (lastMessage?.role === "assistant") {
return [
...prev.slice(0, -1),
{ role: "assistant", content: assistantMessage },
];
} else {
return [
...prev,
{ role: "assistant", content: assistantMessage },
];
}
});
}
} else if (chunk.event === "updates" && chunk.data?.generate_title) {
setThreads((prevThreads) =>
prevThreads.map((thread) =>
thread.thread_id === currentThreadId
? {
...thread,
values: {
...thread.values,
title: chunk.data.generate_title.title,
description: chunk.data.generate_title.description,
},
metadata: {
...thread.metadata,
...chunk.data.generate_title,
},
}
: thread
)
);
}
}
} catch (error) {
console.error("Error sending message:", error);
} finally {
setIsLoading(false);
}
};
const switchThread = async (threadId) => {
if (
!threadId ||
typeof threadId !== "string" ||
!threadId.match(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
)
) {
console.error("Invalid thread ID format");
return;
}
try {
setCurrentThreadId(threadId);
setMessages([]); // Clear messages initially
// Fetch thread data
const thread = await client.threads.get(threadId, {
headers: {
Authorization: `Bearer ${session?.access_token}`,
},
});
if (thread && thread.values?.messages) {
const formattedMessages = thread.values.messages.map((msg) => ({
role: msg.type === "human" ? "user" : "assistant",
content: msg.content,
}));
setMessages(formattedMessages);
}
} catch (error) {
console.error("Error switching thread:", error);
setCurrentThreadId(null);
setMessages([]);
}
};
return (
<ChatContext.Provider
value={{
messages,
sendMessage,
isLoading,
threads,
currentThreadId,
createNewThread: handleCreateNewThread,
switchThread,
deleteThread,
isThreadsLoading,
}}
>
{children}
</ChatContext.Provider>
);
}
export const useChat = () => {
const context = useContext(ChatContext);
if (!context) {
throw new Error("useChat must be used within a ChatProvider");
}
return context;
};
@@ -0,0 +1,201 @@
import { useState, useCallback, useEffect } from "react";
import debounce from "lodash/debounce";
const THREAD_ID_KEY = "langgraph_thread_id";
export function useThreadManager(userId, client) {
const [threads, setThreads] = useState([]);
const [currentThreadId, setCurrentThreadId] = useState(null);
const [isLoading, setIsLoading] = useState(false);
// Debounced version of thread fetching
const debouncedFetchThreads = useCallback(
debounce(async () => {
if (!userId || !client) return;
setIsLoading(true);
try {
const userThreads = await client.threads.search({
limit: 100,
});
// Sort threads by creation time, newest first
const sortedThreads = userThreads
.filter(thread => thread?.metadata?.created_at)
.sort((a, b) => new Date(b.metadata.created_at) - new Date(a.metadata.created_at));
console.dir(sortedThreads);
setThreads(sortedThreads);
} catch (error) {
console.error("Error fetching threads:", error);
} finally {
setIsLoading(false);
}
}, 300),
[userId, client]
);
// Load threads on mount and when userId changes
useEffect(() => {
if (client) {
debouncedFetchThreads();
return () => debouncedFetchThreads.cancel();
}
}, [userId, client]);
// Initialize or restore current thread
useEffect(() => {
if (!client) return;
const initializeThread = async () => {
if (currentThreadId) {
const thread = await getThreadById(currentThreadId);
if (thread) return;
}
const storedThreadId = localStorage.getItem(THREAD_ID_KEY);
if (storedThreadId) {
try {
const thread = await getThreadById(storedThreadId);
if (thread) {
setCurrentThreadId(storedThreadId);
return;
}
} catch (error) {
console.error("Error restoring thread:", error);
}
}
// If we have existing threads, use the most recent one
if (threads.length > 0) {
setCurrentThreadId(threads[0].thread_id);
localStorage.setItem(THREAD_ID_KEY, threads[0].thread_id);
return;
}
// Only create a new thread if we have no threads at all
const newThread = await createNewThread();
if (newThread) {
setCurrentThreadId(newThread.thread_id);
}
};
initializeThread();
}, [client, currentThreadId, threads]);
const getThreadById = async (threadId) => {
if (!client || !threadId || typeof threadId !== 'string' || !threadId.match(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i)) {
console.error("Invalid thread ID format or missing client");
return null;
}
try {
return await client.threads.get(threadId);
} catch (error) {
console.error("Error getting thread:", error);
return null;
}
};
const createNewThread = async () => {
if (!client) return null;
try {
const thread = await client.threads.create({
metadata: {
user_id: userId,
created_at: new Date().toISOString(),
title: "New Chat"
},
});
localStorage.setItem(THREAD_ID_KEY, thread.thread_id);
// Add the new thread to the list
setThreads((prev) => [{
...thread,
title: "New Chat",
}, ...prev]);
return thread;
} catch (error) {
console.error("Error creating thread:", error);
return null;
}
};
const deleteThread = async (threadId) => {
if (!client) return;
try {
// First delete from the server
await client.threads.delete(threadId);
// Then update the UI
setThreads((prev) => {
const updatedThreads = prev.filter((t) => t.thread_id !== threadId);
// If we're deleting the current thread, create a new one
if (threadId === currentThreadId) {
// If we have other threads, switch to the most recent one
if (updatedThreads.length > 0) {
const nextThread = updatedThreads[0];
setCurrentThreadId(nextThread.thread_id);
localStorage.setItem(THREAD_ID_KEY, nextThread.thread_id);
} else {
// Only create a new thread if we have no threads left
createNewThread();
}
}
return updatedThreads;
});
} catch (error) {
console.error("Error deleting thread:", error);
await debouncedFetchThreads();
}
};
const updateThreadMetadata = async (threadId, metadata) => {
if (!client) return;
try {
await client.threads.update(threadId, {
metadata: {
...metadata,
updated_at: new Date().toISOString()
}
});
setThreads((prev) =>
prev.map((thread) =>
thread.thread_id === threadId
? {
...thread,
metadata: {
...thread.metadata,
...metadata,
updated_at: new Date().toISOString()
}
}
: thread
)
);
} catch (error) {
console.error("Error updating thread metadata:", error);
}
};
return {
threads,
currentThreadId,
isLoading,
createNewThread,
deleteThread,
updateThreadMetadata,
setCurrentThreadId,
refreshThreads: debouncedFetchThreads,
setThreads,
};
}
+17
View File
@@ -0,0 +1,17 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
+17
View File
@@ -0,0 +1,17 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

+13
View File
@@ -0,0 +1,13 @@
const reportWebVitals = onPerfEntry => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;
+5
View File
@@ -0,0 +1,5 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';
+10
View File
@@ -0,0 +1,10 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./src/**/*.{js,jsx,ts,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}
+10
View File
@@ -0,0 +1,10 @@
{
"dependencies": ["."],
"graphs": {
"agent": "./src/agent/graph.py:graph"
},
"env": ".env",
"auth": {
"path": "./src/security/auth.py:auth"
}
}
+7
View File
@@ -0,0 +1,7 @@
{
"dependencies": ["."],
"graphs": {
"agent": "./src/agent/graph.py:graph"
},
"env": ".env"
}
+66
View File
@@ -0,0 +1,66 @@
[project]
name = "agent"
version = "0.0.1"
description = "Starter template for making a new agent LangGraph."
authors = [
{ name = "William Fu-Hinthorn", email = "13333726+hinthornw@users.noreply.github.com" },
]
readme = "README.md"
license = { text = "MIT" }
requires-python = ">=3.9"
dependencies = [
"langchain>=0.3.12",
"langchain-anthropic>=0.3.0",
"langgraph-sdk>=0.1.46",
"langgraph>=0.2.6",
"pyjwt>=2.10.1",
"python-dotenv>=1.0.1",
]
[project.optional-dependencies]
dev = ["mypy>=1.11.1", "ruff>=0.6.1"]
[build-system]
requires = ["setuptools>=73.0.0", "wheel"]
build-backend = "setuptools.build_meta"
[tool.setuptools]
packages = ["langgraph.templates.agent", "agent"]
[tool.setuptools.package-dir]
"langgraph.templates.agent" = "src/agent"
"agent" = "src/agent"
[tool.setuptools.package-data]
"*" = ["py.typed"]
[tool.ruff]
lint.select = [
"E", # pycodestyle
"F", # pyflakes
"I", # isort
"D", # pydocstyle
"D401", # First line should be in imperative mood
"T201",
"UP",
]
lint.ignore = [
"UP006",
"UP007",
# We actually do want to import from typing_extensions
"UP035",
# Relax the convention by _not_ requiring documentation for every function parameter.
"D417",
"E501",
]
[tool.ruff.lint.per-file-ignores]
"tests/*" = ["D", "UP"]
[tool.ruff.lint.pydocstyle]
convention = "google"
[dependency-groups]
dev = [
"langgraph-cli[inmem]>=0.1.64",
"ruff>=0.8.2",
]
+8
View File
@@ -0,0 +1,8 @@
"""New LangGraph Agent.
This module defines a custom graph.
"""
from agent.graph import graph
__all__ = ["graph"]
+28
View File
@@ -0,0 +1,28 @@
"""Define the configurable parameters for the agent."""
from __future__ import annotations
from dataclasses import dataclass, fields
from typing import Optional
from langchain_core.runnables import RunnableConfig
@dataclass(kw_only=True)
class Configuration:
"""The configuration for the agent."""
# Changeme: Add configurable values here!
# these values can be pre-set when you
# create assistants (https://langchain-ai.github.io/langgraph/cloud/how-tos/configuration_cloud/)
# and when you invoke the graph
model: str = "anthropic:claude-3-5-haiku-latest"
@classmethod
def from_runnable_config(
cls, config: Optional[RunnableConfig] = None
) -> Configuration:
"""Create a Configuration instance from a RunnableConfig object."""
configurable = (config.get("configurable") or {}) if config else {}
_fields = {f.name for f in fields(cls) if f.init}
return cls(**{k: v for k, v in configurable.items() if k in _fields})
+73
View File
@@ -0,0 +1,73 @@
"""Define a simple chatbot agent.
This agent returns a predefined response without using an actual LLM.
"""
from typing import Any
from langchain.chat_models import init_chat_model
from langchain_core.runnables import RunnableConfig
from langgraph.graph import StateGraph
from langgraph.types import Command
from pydantic import BaseModel
from agent.configuration import Configuration
from agent.state import State
model = init_chat_model()
async def chatbot(state: State, config: RunnableConfig) -> Command:
"""Each node does work."""
configuration = Configuration.from_runnable_config(config)
response = await model.ainvoke(
state.messages,
{
"configurable": {
**(config.get("configurable") or {}),
"model": configuration.model,
}
},
)
route = "generate_title" if len(state.messages) <= 1 else []
return Command(update={"messages": response}, goto=route)
class Title(BaseModel):
"""Generate a concise, fitting title for this conversation."""
title: str
description: str
async def generate_title(state: State, config: RunnableConfig) -> dict[str, Any]:
"""Generate a concise, fitting title for this conversation."""
configuration = Configuration.from_runnable_config(config)
response = await model.with_structured_output(Title).ainvoke(
[
{
"role": "system",
"content": "Generate a disting, concise, fitting title for this conversation.",
},
*state.messages[:-1],
],
{
"configurable": {
**(config.get("configurable") or {}),
"model": configuration.model,
}
},
)
return {"title": response.title, "description": response.description}
# Define a new graph
builder = (
StateGraph(State, config_schema=Configuration)
.add_node(chatbot)
.add_node(generate_title)
.add_edge("__start__", "chatbot")
)
graph = builder.compile()
graph.name = "My Chatbot" # This defines the custom name in LangSmith
+23
View File
@@ -0,0 +1,23 @@
"""Define the state structures for the agent."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Annotated
from langchain_core.messages import AnyMessage
from langgraph.graph.message import add_messages
@dataclass
class State:
"""Defines the input state for the agent, representing a narrower interface to the outside world.
This class is used to define the initial state and structure of incoming data.
See: https://langchain-ai.github.io/langgraph/concepts/low_level/#state
for more information.
"""
messages: Annotated[list[AnyMessage], add_messages]
title: str | None = None
description: str | None = None
+74
View File
@@ -0,0 +1,74 @@
"""Authentication & authorization example."""
import os
from datetime import timedelta
import httpx
import jwt
from jwt.exceptions import InvalidTokenError
from langgraph_sdk import Auth
SUPABASE_URL = os.environ["SUPABASE_URL"]
SUPABASE_SERVICE_KEY = os.environ["SUPABASE_SERVICE_KEY"]
SUPABASE_JWT_SECRET = os.environ["SUPABASE_JWT_SECRET"]
ALGORITHM = "HS256"
AUTH_EXCEPTION = Auth.exceptions.HTTPException(
status_code=401,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
auth = Auth()
@auth.authenticate
async def get_current_user(
authorization: str | None, # "Bearer <token>""
) -> tuple[list[str], Auth.types.MinimalUserDict]:
"""Authenticate the user's JWT token."""
assert authorization
try:
token = authorization.split(" ", 1)[1]
payload = jwt.decode(
token,
SUPABASE_JWT_SECRET,
algorithms=[ALGORITHM],
audience="authenticated",
leeway=timedelta(seconds=60),
)
async with httpx.AsyncClient(timeout=5) as client:
response = await client.get(
f"{SUPABASE_URL}/auth/v1/user",
headers={
"Authorization": f"Bearer {token}",
"apikey": SUPABASE_SERVICE_KEY,
},
)
except (IndexError, InvalidTokenError, ConnectionError) as e:
raise AUTH_EXCEPTION from e
if response.status_code != 200:
raise AUTH_EXCEPTION
user_data = response.json()
scopes = [payload["role"]]
return scopes, {
"identity": user_data["id"],
"display_name": user_data.get("user_metadata", {}).get("full_name"),
"is_authenticated": True,
}
@auth.on
async def add_owner(
ctx: Auth.types.AuthContext,
value: dict,
):
"""Add the owner to the resource metadata and return filters."""
filters = {"owner": ctx.user.identity}
metadata = value.setdefault("metadata", {})
metadata.update(filters)
return filters
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

+1
View File
@@ -0,0 +1 @@
"""Define any integration tests you want in this directory."""
+10
View File
@@ -0,0 +1,10 @@
import pytest
from agent import graph
from langsmith import unit
@pytest.mark.asyncio
@unit
async def test_agent_simple_passthrough() -> None:
res = await graph.ainvoke({"changeme": "some_val"})
assert res is not None
+1
View File
@@ -0,0 +1 @@
"""Define any unit tests you may want in this directory."""
+5
View File
@@ -0,0 +1,5 @@
from agent.configuration import Configuration
def test_configuration_empty() -> None:
Configuration.from_runnable_config({})