mirror of
https://github.com/langchain-ai/custom-auth.git
synced 2026-06-30 21:47:55 -04:00
First commit
This commit is contained in:
@@ -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=...
|
||||
@@ -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
@@ -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
|
||||
@@ -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.
|
||||
@@ -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'
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
# Supabase Auth for LangGraph
|
||||
|
||||
[](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
|
||||
|
||||

|
||||
|
||||
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": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
-->
|
||||
@@ -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
|
||||
@@ -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*
|
||||
@@ -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)
|
||||
Generated
+17470
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -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 |
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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 |
@@ -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;
|
||||
@@ -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';
|
||||
@@ -0,0 +1,10 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
"./src/**/*.{js,jsx,ts,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"dependencies": ["."],
|
||||
"graphs": {
|
||||
"agent": "./src/agent/graph.py:graph"
|
||||
},
|
||||
"env": ".env",
|
||||
"auth": {
|
||||
"path": "./src/security/auth.py:auth"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"dependencies": ["."],
|
||||
"graphs": {
|
||||
"agent": "./src/agent/graph.py:graph"
|
||||
},
|
||||
"env": ".env"
|
||||
}
|
||||
@@ -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",
|
||||
]
|
||||
@@ -0,0 +1,8 @@
|
||||
"""New LangGraph Agent.
|
||||
|
||||
This module defines a custom graph.
|
||||
"""
|
||||
|
||||
from agent.graph import graph
|
||||
|
||||
__all__ = ["graph"]
|
||||
@@ -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})
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 |
@@ -0,0 +1 @@
|
||||
"""Define any integration tests you want in this directory."""
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
"""Define any unit tests you may want in this directory."""
|
||||
@@ -0,0 +1,5 @@
|
||||
from agent.configuration import Configuration
|
||||
|
||||
|
||||
def test_configuration_empty() -> None:
|
||||
Configuration.from_runnable_config({})
|
||||
Reference in New Issue
Block a user