├── .env.example ├── .env.gcp.yaml.example ├── .github ├── actions │ └── poetry_setup │ │ └── action.yml └── workflows │ ├── _lint.yml │ ├── build_deploy_image.yml │ └── ci.yml ├── .gitignore ├── API.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── _static ├── agent.png ├── chat.png ├── chatbot.png ├── configure.png └── rag.png ├── auth.md ├── backend ├── .gitignore ├── Dockerfile ├── Makefile ├── README.md ├── app │ ├── __init__.py │ ├── agent.py │ ├── agent_types │ │ ├── __init__.py │ │ ├── prompts.py │ │ ├── tools_agent.py │ │ └── xml_agent.py │ ├── api │ │ ├── __init__.py │ │ ├── assistants.py │ │ ├── runs.py │ │ └── threads.py │ ├── auth │ │ ├── __init__.py │ │ ├── handlers.py │ │ └── settings.py │ ├── chatbot.py │ ├── checkpoint.py │ ├── ingest.py │ ├── lifespan.py │ ├── llms.py │ ├── message_types.py │ ├── parsing.py │ ├── retrieval.py │ ├── schema.py │ ├── server.py │ ├── storage.py │ ├── stream.py │ ├── tools.py │ └── upload.py ├── log_config.json ├── migrations │ ├── 000001_create_extensions_and_first_tables.down.sql │ ├── 000001_create_extensions_and_first_tables.up.sql │ ├── 000002_checkpoints_update_schema.down.sql │ ├── 000002_checkpoints_update_schema.up.sql │ ├── 000003_create_user.down.sql │ ├── 000003_create_user.up.sql │ ├── 000004_add_metadata_to_thread.down.sql │ ├── 000004_add_metadata_to_thread.up.sql │ ├── 000005_advanced_checkpoints_schema.down.sql │ └── 000005_advanced_checkpoints_schema.up.sql ├── poetry.lock ├── pyproject.toml └── tests │ ├── __init__.py │ └── unit_tests │ ├── __init__.py │ ├── agent_executor │ ├── __init__.py │ ├── test_parsing.py │ └── test_upload.py │ ├── app │ ├── __init__.py │ ├── helpers.py │ ├── test_app.py │ └── test_auth.py │ ├── conftest.py │ ├── fixtures │ ├── __init__.py │ ├── sample.docx │ ├── sample.epub │ ├── sample.html │ ├── sample.odt │ ├── sample.pdf │ ├── sample.rtf │ └── sample.txt │ ├── test_imports.py │ └── utils.py ├── docker-compose-prod.yml ├── docker-compose.yml ├── frontend ├── .eslintrc.cjs ├── .gitignore ├── Dockerfile ├── README.md ├── index.html ├── package.json ├── postcss.config.js ├── src │ ├── App.tsx │ ├── api │ │ ├── assistants.ts │ │ └── threads.ts │ ├── components │ │ ├── Chat.tsx │ │ ├── ChatList.tsx │ │ ├── Config.tsx │ │ ├── ConfigList.tsx │ │ ├── Document.tsx │ │ ├── FileUpload.tsx │ │ ├── JsonEditor.tsx │ │ ├── LangSmithActions.tsx │ │ ├── Layout.tsx │ │ ├── Message.tsx │ │ ├── MessageEditor.tsx │ │ ├── NewChat.tsx │ │ ├── NotFound.tsx │ │ ├── OrphanChat.tsx │ │ ├── String.tsx │ │ ├── StringEditor.tsx │ │ ├── Tool.tsx │ │ └── TypingBox.tsx │ ├── constants.ts │ ├── hooks │ │ ├── useChatList.ts │ │ ├── useChatMessages.ts │ │ ├── useConfigList.ts │ │ ├── useMessageEditing.ts │ │ ├── useSchemas.ts │ │ ├── useStatePersist.tsx │ │ ├── useStreamState.tsx │ │ ├── useThreadAndAssistant.ts │ │ └── useToolsSchemas.ts │ ├── index.css │ ├── main.tsx │ ├── types.ts │ ├── utils │ │ ├── cn.ts │ │ ├── defaults.ts │ │ ├── formTypes.ts │ │ ├── json-refs.d.ts │ │ ├── json-refs.js │ │ ├── simplifySchema.ts │ │ └── str.ts │ └── vite-env.d.ts ├── tailwind.config.js ├── tsconfig.json ├── tsconfig.node.json ├── vite.config.ts └── yarn.lock └── tools └── redis_to_postgres ├── Dockerfile ├── README.md ├── docker-compose.yml └── migrate_data.py /.env.example: -------------------------------------------------------------------------------- 1 | OPENAI_API_KEY=placeholder 2 | ANTHROPIC_API_KEY=placeholder 3 | YDC_API_KEY=placeholder 4 | TAVILY_API_KEY=placeholder 5 | AZURE_OPENAI_DEPLOYMENT_NAME=placeholder 6 | AZURE_OPENAI_API_KEY=placeholder 7 | AZURE_OPENAI_API_BASE=placeholder 8 | AZURE_OPENAI_API_VERSION=placeholder 9 | AZURE_OPENAI_EMBEDDINGS_DEPLOYMENT_NAME=placeholder 10 | CONNERY_RUNNER_URL=https://your-personal-connery-runner-url 11 | CONNERY_RUNNER_API_KEY=placeholder 12 | PROXY_URL=your_proxy_url 13 | POSTGRES_PORT=placeholder 14 | POSTGRES_DB=placeholder 15 | POSTGRES_USER=placeholder 16 | POSTGRES_PASSWORD=placeholder 17 | SCARF_NO_ANALYTICS=true -------------------------------------------------------------------------------- /.env.gcp.yaml.example: -------------------------------------------------------------------------------- 1 | OPENAI_API_KEY: your_secret_key_here 2 | LANGCHAIN_TRACING_V2: "true" 3 | LANGCHAIN_PROJECT: langserve-launch-example 4 | LANGCHAIN_API_KEY: your_secret_key_here 5 | FIREWORKS_API_KEY: your_secret_here 6 | AWS_ACCESS_KEY_ID: your_secret_here 7 | AWS_SECRET_ACCESS_KEY: your_secret_here 8 | AZURE_OPENAI_DEPLOYMENT_NAME: your_secret_here 9 | AZURE_OPENAI_API_BASE: your_secret_here 10 | AZURE_OPENAI_API_VERSION: your_secret_here 11 | AZURE_OPENAI_API_KEY: your_secret_here 12 | KAY_API_KEY: your_secret_here 13 | CONNERY_RUNNER_URL: https://your-personal-connery-runner-url 14 | CONNERY_RUNNER_API_KEY: your_secret_here 15 | POSTGRES_HOST: your_postgres_host_here 16 | POSTGRES_PORT: your_postgres_port_here 17 | POSTGRES_DB: your_postgres_db_here 18 | POSTGRES_USER: your_postgres_user_here 19 | POSTGRES_PASSWORD: your_postgres_password_here 20 | -------------------------------------------------------------------------------- /.github/actions/poetry_setup/action.yml: -------------------------------------------------------------------------------- 1 | # An action for setting up poetry install with caching. 2 | # Using a custom action since the default action does not 3 | # take poetry install groups into account. 4 | # Action code from: 5 | # https://github.com/actions/setup-python/issues/505#issuecomment-1273013236 6 | name: poetry-install-with-caching 7 | description: Poetry install with support for caching of dependency groups. 8 | 9 | inputs: 10 | python-version: 11 | description: Python version, supporting MAJOR.MINOR only 12 | required: true 13 | 14 | poetry-version: 15 | description: Poetry version 16 | required: true 17 | 18 | cache-key: 19 | description: Cache key to use for manual handling of caching 20 | required: true 21 | 22 | working-directory: 23 | description: Directory whose poetry.lock file should be cached 24 | required: true 25 | 26 | runs: 27 | using: composite 28 | steps: 29 | - uses: actions/setup-python@v4 30 | name: Setup python ${{ inputs.python-version }} 31 | with: 32 | python-version: ${{ inputs.python-version }} 33 | 34 | - uses: actions/cache@v3 35 | id: cache-bin-poetry 36 | name: Cache Poetry binary - Python ${{ inputs.python-version }} 37 | env: 38 | SEGMENT_DOWNLOAD_TIMEOUT_MIN: "1" 39 | with: 40 | path: | 41 | /opt/pipx/venvs/poetry 42 | # This step caches the poetry installation, so make sure it's keyed on the poetry version as well. 43 | key: bin-poetry-${{ runner.os }}-${{ runner.arch }}-py-${{ inputs.python-version }}-${{ inputs.poetry-version }} 44 | 45 | - name: Refresh shell hashtable and fixup softlinks 46 | if: steps.cache-bin-poetry.outputs.cache-hit == 'true' 47 | shell: bash 48 | env: 49 | POETRY_VERSION: ${{ inputs.poetry-version }} 50 | PYTHON_VERSION: ${{ inputs.python-version }} 51 | run: | 52 | set -eux 53 | 54 | # Refresh the shell hashtable, to ensure correct `which` output. 55 | hash -r 56 | 57 | # `actions/cache@v3` doesn't always seem able to correctly unpack softlinks. 58 | # Delete and recreate the softlinks pipx expects to have. 59 | rm /opt/pipx/venvs/poetry/bin/python 60 | cd /opt/pipx/venvs/poetry/bin 61 | ln -s "$(which "python$PYTHON_VERSION")" python 62 | chmod +x python 63 | cd /opt/pipx_bin/ 64 | ln -s /opt/pipx/venvs/poetry/bin/poetry poetry 65 | chmod +x poetry 66 | 67 | # Ensure everything got set up correctly. 68 | /opt/pipx/venvs/poetry/bin/python --version 69 | /opt/pipx_bin/poetry --version 70 | 71 | - name: Install poetry 72 | if: steps.cache-bin-poetry.outputs.cache-hit != 'true' 73 | shell: bash 74 | env: 75 | POETRY_VERSION: ${{ inputs.poetry-version }} 76 | PYTHON_VERSION: ${{ inputs.python-version }} 77 | run: pipx install "poetry==$POETRY_VERSION" --python "python$PYTHON_VERSION" --verbose 78 | 79 | - name: Restore pip and poetry cached dependencies 80 | uses: actions/cache@v3 81 | env: 82 | SEGMENT_DOWNLOAD_TIMEOUT_MIN: "4" 83 | WORKDIR: ${{ inputs.working-directory == '' && '.' || inputs.working-directory }} 84 | with: 85 | path: | 86 | ~/.cache/pip 87 | ~/.cache/pypoetry/virtualenvs 88 | ~/.cache/pypoetry/cache 89 | ~/.cache/pypoetry/artifacts 90 | ${{ env.WORKDIR }}/.venv 91 | key: py-deps-${{ runner.os }}-${{ runner.arch }}-py-${{ inputs.python-version }}-poetry-${{ inputs.poetry-version }}-${{ inputs.cache-key }}-${{ hashFiles(format('{0}/**/poetry.lock', env.WORKDIR)) }} 92 | -------------------------------------------------------------------------------- /.github/workflows/_lint.yml: -------------------------------------------------------------------------------- 1 | name: lint 2 | 3 | on: 4 | workflow_call: 5 | inputs: 6 | working-directory: 7 | required: true 8 | type: string 9 | description: "From which folder this pipeline executes" 10 | 11 | env: 12 | POETRY_VERSION: "1.5.1" 13 | WORKDIR: ${{ inputs.working-directory == '' && '.' || inputs.working-directory }} 14 | 15 | jobs: 16 | build: 17 | runs-on: ubuntu-latest 18 | strategy: 19 | matrix: 20 | # Only lint on the min and max supported Python versions. 21 | # It's extremely unlikely that there's a lint issue on any version in between 22 | # that doesn't show up on the min or max versions. 23 | # 24 | # GitHub rate-limits how many jobs can be running at any one time. 25 | # Starting new jobs is also relatively slow, 26 | # so linting on fewer versions makes CI faster. 27 | python-version: 28 | - "3.9" 29 | - "3.11" 30 | steps: 31 | - uses: actions/checkout@v3 32 | - name: Set up Python ${{ matrix.python-version }} + Poetry ${{ env.POETRY_VERSION }} 33 | uses: "./.github/actions/poetry_setup" 34 | with: 35 | python-version: ${{ matrix.python-version }} 36 | poetry-version: ${{ env.POETRY_VERSION }} 37 | working-directory: ${{ inputs.working-directory }} 38 | cache-key: lint-with-extras 39 | 40 | - name: Check Poetry File 41 | shell: bash 42 | working-directory: ${{ inputs.working-directory }} 43 | run: | 44 | poetry check 45 | 46 | - name: Check lock file 47 | shell: bash 48 | working-directory: ${{ inputs.working-directory }} 49 | run: | 50 | poetry lock --check 51 | 52 | - name: Install dependencies 53 | # Also installs dev/lint/test/typing dependencies, to ensure we have 54 | # type hints for as many of our libraries as possible. 55 | # This helps catch errors that require dependencies to be spotted, for example: 56 | # https://github.com/langchain-ai/langchain/pull/10249/files#diff-935185cd488d015f026dcd9e19616ff62863e8cde8c0bee70318d3ccbca98341 57 | # 58 | # If you change this configuration, make sure to change the `cache-key` 59 | # in the `poetry_setup` action above to stop using the old cache. 60 | # It doesn't matter how you change it, any change will cause a cache-bust. 61 | working-directory: ${{ inputs.working-directory }} 62 | run: | 63 | poetry install --with dev,lint,test 64 | # Add typing dependencies once we roll out mypy 65 | # poetry install --with dev,lint,test,typing 66 | 67 | - name: Analysing the code with our lint 68 | working-directory: ${{ inputs.working-directory }} 69 | env: 70 | BLACK_CACHE_DIR: .black_cache 71 | run: | 72 | make lint 73 | -------------------------------------------------------------------------------- /.github/workflows/build_deploy_image.yml: -------------------------------------------------------------------------------- 1 | name: Build, Push, and Deploy Open GPTS 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | workflow_dispatch: 7 | 8 | jobs: 9 | build-and-push: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v3 15 | 16 | - name: Set up Short Hash 17 | run: | 18 | echo "GIT_SHORT_SHA=$(git rev-parse --short HEAD)" >> $GITHUB_ENV 19 | 20 | - name: Set up depot.dev multi-arch runner 21 | uses: depot/setup-action@v1 22 | 23 | - name: Login to DockerHub 24 | uses: docker/login-action@v2 25 | with: 26 | username: ${{ secrets.LANGCHAIN_DOCKERHUB_USERNAME }} 27 | password: ${{ secrets.LANGCHAIN_DOCKERHUB_PASSWORD }} 28 | 29 | - name: Set up Docker Buildx 30 | uses: docker/setup-buildx-action@v3 31 | 32 | - name: Build and push 33 | uses: docker/build-push-action@v5 34 | with: 35 | push: true 36 | platforms: linux/amd64,linux/arm64 37 | tags: "docker.io/langchain/open-gpts:${{ env.GIT_SHORT_SHA }}, docker.io/langchain/open-gpts:latest" 38 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: CI 3 | 4 | on: 5 | push: 6 | branches: [main] 7 | pull_request: # Trigger on all PRs, ensuring required actions to be run. 8 | workflow_dispatch: # Allows to trigger the workflow manually in GitHub UI 9 | 10 | # If another push to the same PR or branch happens while this workflow is still running, 11 | # cancel the earlier run in favor of the next run. 12 | # 13 | # There's no point in testing an outdated version of the code. GitHub only allows 14 | # a limited number of job runners to be active at the same time, so it's better to cancel 15 | # pointless jobs early so that more useful jobs can run sooner. 16 | concurrency: 17 | group: ${{ github.workflow }}-${{ github.ref }} 18 | cancel-in-progress: true 19 | 20 | env: 21 | POETRY_VERSION: "1.5.1" 22 | WORKDIR: "./backend" 23 | 24 | jobs: 25 | lint: 26 | uses: ./.github/workflows/_lint.yml 27 | with: 28 | working-directory: "./backend" 29 | secrets: inherit 30 | 31 | test: 32 | timeout-minutes: 5 33 | runs-on: ubuntu-latest 34 | defaults: 35 | run: 36 | working-directory: ${{ env.WORKDIR }} 37 | strategy: 38 | matrix: 39 | python-version: 40 | - "3.9" 41 | - "3.10" 42 | - "3.11" 43 | name: Python ${{ matrix.python-version }} tests 44 | services: 45 | # Label used to access the service container 46 | postgres: 47 | image: pgvector/pgvector:pg16 48 | env: 49 | POSTGRES_USER: postgres 50 | POSTGRES_PASSWORD: postgres 51 | POSTGRES_DB: postgres 52 | # Set health checks to wait until postgres has started 53 | options: >- 54 | --health-cmd pg_isready 55 | --health-interval 10s 56 | --health-timeout 5s 57 | --health-retries 5 58 | ports: 59 | - "5432:5432" 60 | steps: 61 | - uses: actions/checkout@v3 62 | - name: Set up Python ${{ matrix.python-version }} + Poetry ${{ env.POETRY_VERSION }} 63 | uses: "./.github/actions/poetry_setup" 64 | with: 65 | python-version: ${{ matrix.python-version }} 66 | poetry-version: ${{ env.POETRY_VERSION }} 67 | working-directory: . 68 | cache-key: langserve-all 69 | - name: Install dependencies 70 | run: | 71 | poetry install --with test 72 | - name: Install golang-migrate 73 | run: | 74 | wget -O golang-migrate.deb https://github.com/golang-migrate/migrate/releases/download/v4.17.0/migrate.linux-amd64.deb 75 | sudo dpkg -i golang-migrate.deb && rm golang-migrate.deb 76 | - name: Run tests 77 | env: 78 | POSTGRES_HOST: localhost 79 | POSTGRES_PORT: 5432 80 | POSTGRES_DB: postgres 81 | POSTGRES_USER: postgres 82 | POSTGRES_PASSWORD: postgres 83 | SCARF_NO_ANALYTICS: true 84 | run: make test 85 | 86 | frontend-lint-and-build: 87 | runs-on: ubuntu-latest 88 | needs: [lint, test] 89 | steps: 90 | - uses: actions/checkout@v3 91 | - name: Setup Node.js (LTS) 92 | uses: actions/setup-node@v3 93 | with: 94 | node-version: '20' 95 | cache: 'yarn' 96 | cache-dependency-path: frontend/yarn.lock 97 | - name: Install frontend dependencies 98 | run: yarn install 99 | working-directory: ./frontend 100 | - name: Run frontend lint 101 | run: yarn lint 102 | working-directory: ./frontend 103 | - name: Build frontend 104 | run: yarn build 105 | working-directory: ./frontend 106 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.env 2 | .env.gcp.yaml 3 | postgres-volume/ 4 | redis-volume/ 5 | backend/ui 6 | 7 | # Operating System generated files 8 | .DS_Store 9 | Thumbs.db 10 | ehthumbs.db 11 | Desktop.ini 12 | 13 | # Python artifacts 14 | __pycache__/ 15 | *.py[cod] 16 | .venv/ 17 | *.egg-info/ 18 | dist/ 19 | 20 | # Node.js / frontend artifacts 21 | node_modules/ 22 | /dist 23 | /dist-ssr 24 | .npm 25 | .npmrc 26 | .yarn-cache 27 | .yarn-integrity 28 | .yarn.lock 29 | package-lock.json 30 | .pnpm-lock.yaml 31 | 32 | # IDEs and editors 33 | .vscode/* 34 | !.vscode/extensions.json # Include recommended extensions for VS Code users 35 | .idea/ 36 | *.sublime-* 37 | *.sublime-workspace 38 | *.atom/ 39 | *.iml 40 | 41 | # Microsoft Visual Studio 42 | *.suo 43 | *.ntvs* 44 | *.njsproj 45 | *.sln 46 | 47 | # Swap and Temporary Files 48 | *.swp 49 | *.swo 50 | *~ 51 | *.bak 52 | *.tmp 53 | *.temp 54 | 55 | # Log files 56 | *.log* 57 | logs/ 58 | npm-debug.log* 59 | yarn-debug.log* 60 | yarn-error.log* 61 | pnpm-debug.log* 62 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Contributor License Agreement 4 | 5 | We are grateful to the contributors who help evolve OpenGPTs and dedicate their time to the project. As the primary sponsor of OpenGPTs, LangChain, Inc. aims to build products in the open that benefit thousands of developers while allowing us to build a sustainable business. For all code contributions to OpenGPTs, we ask that contributors complete and sign a Contributor License Agreement (“CLA”). The agreement between contributors and the project is explicit, so OpenGPTs users can be confident in the legal status of the source code and their right to use it.The CLA does not change the terms of the underlying license, OpenGPTs License, used by our software. 6 | 7 | Before you can contribute to OpenGPTs, a bot will comment on the PR asking you to agree to the CLA if you haven't already. Agreeing to the CLA is required before code can be merged and only needs to happen on the first contribution to the project. All subsequent contributions will fall under the same CLA. -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20 AS builder 2 | 3 | WORKDIR /frontend 4 | 5 | COPY ./frontend/package.json ./frontend/yarn.lock ./ 6 | 7 | RUN yarn --network-timeout 600000 --frozen-lockfile 8 | 9 | COPY ./frontend ./ 10 | 11 | RUN rm -rf .env 12 | 13 | RUN yarn build 14 | 15 | # Backend Dockerfile 16 | FROM python:3.11 17 | 18 | ARG TARGETOS 19 | ARG TARGETARCH 20 | ARG TARGETVARIANT 21 | 22 | # Install system dependencies 23 | RUN apt-get update && rm -rf /var/lib/apt/lists/* 24 | RUN wget -O golang-migrate.deb https://github.com/golang-migrate/migrate/releases/download/v4.17.0/migrate.${TARGETOS}-${TARGETARCH}${TARGETVARIANT}.deb \ 25 | && dpkg -i golang-migrate.deb \ 26 | && rm golang-migrate.deb 27 | 28 | # Install Poetry 29 | RUN pip install poetry 30 | 31 | # Set the working directory 32 | WORKDIR /backend 33 | 34 | # Copy only dependencies 35 | COPY ./backend/pyproject.toml ./backend/poetry.lock* ./ 36 | 37 | # Install dependencies 38 | # --only main: Skip installing packages listed in the [tool.poetry.dev-dependencies] section 39 | RUN poetry config virtualenvs.create false \ 40 | && poetry install --no-interaction --no-ansi --only main 41 | 42 | # Copy the rest of backend 43 | COPY ./backend . 44 | 45 | # Copy the frontend build 46 | COPY --from=builder /frontend/dist ./ui 47 | 48 | ENTRYPOINT [ "uvicorn", "app.server:app", "--host", "0.0.0.0", "--log-config", "log_config.json" ] 49 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) LangChain, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /_static/agent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/langchain-ai/opengpts/7ab956faa74a115fb6e2bdbaad03cfeecc3474fe/_static/agent.png -------------------------------------------------------------------------------- /_static/chat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/langchain-ai/opengpts/7ab956faa74a115fb6e2bdbaad03cfeecc3474fe/_static/chat.png -------------------------------------------------------------------------------- /_static/chatbot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/langchain-ai/opengpts/7ab956faa74a115fb6e2bdbaad03cfeecc3474fe/_static/chatbot.png -------------------------------------------------------------------------------- /_static/configure.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/langchain-ai/opengpts/7ab956faa74a115fb6e2bdbaad03cfeecc3474fe/_static/configure.png -------------------------------------------------------------------------------- /_static/rag.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/langchain-ai/opengpts/7ab956faa74a115fb6e2bdbaad03cfeecc3474fe/_static/rag.png -------------------------------------------------------------------------------- /auth.md: -------------------------------------------------------------------------------- 1 | # Auth 2 | 3 | By default, we're using cookies as a mock auth method. It's for trying out OpenGPTs. 4 | For production, we recommend using JWT auth, outlined below. 5 | 6 | ## JWT Auth: Options 7 | 8 | There are two ways to use JWT: Local and OIDC. The main difference is in how the key 9 | used to decode the JWT is obtained. For the Local method, you'll provide the decode 10 | key as a Base64-encoded string in an environment variable. For the OIDC method, the 11 | key is obtained from the OIDC provider automatically. 12 | 13 | ### JWT OIDC 14 | 15 | If you're looking to integrate with an identity provider, OIDC is the way to go. 16 | It will figure out the decode key for you, so you don't have to worry about it. 17 | Just set `AUTH_TYPE=jwt_oidc` along with the issuer and audience. Audience can 18 | be one or many - just separate them with commas. 19 | 20 | ```bash 21 | export AUTH_TYPE=jwt_oidc 22 | export JWT_ISS= 23 | export JWT_AUD= # or ,,... 24 | ``` 25 | 26 | ### JWT Local 27 | 28 | To use JWT Local, set `AUTH_TYPE=jwt_local`. Then, set the issuer, audience, 29 | algorithm used to sign the JWT, and the decode key in Base64 format. 30 | 31 | ```bash 32 | export AUTH_TYPE=jwt_local 33 | export JWT_ISS= 34 | export JWT_AUD= 35 | export JWT_ALG= # e.g. ES256 36 | export JWT_DECODE_KEY_B64= 37 | ``` 38 | 39 | Base64 is used for the decode key because handling multiline strings in environment 40 | variables is error-prone. Base64 makes it a one-liner, easy to paste in and use. 41 | 42 | 43 | ## Making Requests 44 | 45 | To make authenticated requests, include the JWT in the `Authorization` header as a Bearer token: 46 | 47 | ``` 48 | Authorization: Bearer 49 | ``` 50 | 51 | 52 | -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | .envrc 2 | ui 3 | -------------------------------------------------------------------------------- /backend/Dockerfile: -------------------------------------------------------------------------------- 1 | # Backend Dockerfile 2 | FROM python:3.11 3 | 4 | ARG TARGETOS 5 | ARG TARGETARCH 6 | ARG TARGETVARIANT 7 | 8 | # Install system dependencies 9 | RUN apt-get update && rm -rf /var/lib/apt/lists/* 10 | RUN wget -O golang-migrate.deb https://github.com/golang-migrate/migrate/releases/download/v4.17.0/migrate.${TARGETOS}-${TARGETARCH}${TARGETVARIANT}.deb \ 11 | && dpkg -i golang-migrate.deb \ 12 | && rm golang-migrate.deb 13 | 14 | # Install Poetry 15 | RUN pip install poetry 16 | 17 | # Set the working directory 18 | WORKDIR /backend 19 | 20 | # Copy only dependencies 21 | COPY pyproject.toml poetry.lock* ./ 22 | 23 | # Install all dependencies 24 | RUN poetry config virtualenvs.create false \ 25 | && poetry install --no-interaction --no-ansi 26 | 27 | # Copy the rest of application code 28 | COPY . . 29 | 30 | HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --start-interval=1s --retries=3 CMD [ "curl", "-f", "http://localhost:8000/health" ] 31 | 32 | ENTRYPOINT [ "uvicorn", "app.server:app", "--host", "0.0.0.0", "--log-config", "log_config.json" ] 33 | -------------------------------------------------------------------------------- /backend/Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all lint format test help 2 | 3 | # Default target executed when no arguments are given to make. 4 | all: help 5 | 6 | build_ui: 7 | cd ../frontend && yarn build && cp -r dist/* ../backend/ui 8 | 9 | ###################### 10 | # TESTING AND COVERAGE 11 | ###################### 12 | 13 | # Define a variable for the test file path. 14 | TEST_FILE ?= tests/unit_tests/ 15 | 16 | start: 17 | poetry run uvicorn app.server:app --reload --port 8100 --log-config log_config.json 18 | 19 | migrate: 20 | migrate -database postgres://$(POSTGRES_USER):$(POSTGRES_PASSWORD)@$(POSTGRES_HOST):$(POSTGRES_PORT)/$(POSTGRES_DB)?sslmode=disable -path ./migrations up 21 | 22 | test: 23 | # We need to update handling of env variables for tests 24 | YDC_API_KEY=placeholder OPENAI_API_KEY=placeholder poetry run pytest $(TEST_FILE) 25 | 26 | 27 | test_watch: 28 | # We need to update handling of env variables for tests 29 | YDC_API_KEY=placeholder OPENAI_API_KEY=placeholder poetry run ptw . -- $(TEST_FILE) 30 | 31 | ###################### 32 | # LINTING AND FORMATTING 33 | ###################### 34 | 35 | # Define a variable for Python and notebook files. 36 | PYTHON_FILES=. 37 | lint format: PYTHON_FILES=. 38 | lint_diff format_diff: PYTHON_FILES=$(shell git diff --relative=. --name-only --diff-filter=d master | grep -E '\.py$$|\.ipynb$$') 39 | 40 | lint lint_diff: 41 | poetry run ruff . 42 | poetry run ruff format $(PYTHON_FILES) --check 43 | 44 | format format_diff: 45 | poetry run ruff format $(PYTHON_FILES) 46 | poetry run ruff --select I --fix $(PYTHON_FILES) 47 | 48 | spell_check: 49 | poetry run codespell --toml pyproject.toml 50 | 51 | spell_fix: 52 | poetry run codespell --toml pyproject.toml -w 53 | 54 | ###################### 55 | # HELP 56 | ###################### 57 | 58 | help: 59 | @echo '====================' 60 | @echo '-- LINTING --' 61 | @echo 'format - run code formatters' 62 | @echo 'lint - run linters' 63 | @echo 'spell_check - run codespell on the project' 64 | @echo 'spell_fix - run codespell on the project and fix the errors' 65 | @echo '-- TESTS --' 66 | @echo 'coverage - run unit tests and generate coverage report' 67 | @echo 'test - run unit tests' 68 | @echo 'test TEST_FILE= - run all tests in file' 69 | @echo '-- DOCUMENTATION tasks are from the top-level Makefile --' 70 | -------------------------------------------------------------------------------- /backend/README.md: -------------------------------------------------------------------------------- 1 | # backend 2 | 3 | ## Database Migrations 4 | 5 | ### Migration 5 - Checkpoint Management Update 6 | This migration introduces a significant change to thread checkpoint management: 7 | 8 | #### Changes 9 | - Transitions from single-table pickle storage to a robust multi-table checkpoint management system 10 | - Implements LangGraph's latest checkpoint architecture for improved state persistence 11 | - Preserves existing checkpoint data by renaming `checkpoints` table to `old_checkpoints` 12 | - Introduces three new tables for better checkpoint management: 13 | - `checkpoints`: Core checkpoint metadata 14 | - `checkpoint_blobs`: Actual checkpoint data storage (compatible with LangGraph state serialization) 15 | - `checkpoint_writes`: Tracks checkpoint write operations 16 | - Adds runtime initialization via `ensure_setup()` in the lifespan event 17 | 18 | #### Impact 19 | - **Breaking Change**: Historical threads/checkpoints (pre-migration) will not be accessible in the UI 20 | - Previous checkpoint data remains preserved but inaccessible in the new system 21 | - Designed to work seamlessly with LangGraph's state persistence requirements 22 | 23 | #### Migration Details 24 | - **Up Migration**: Safely preserves existing data by renaming the table 25 | - **Down Migration**: Restores original table structure if needed 26 | - New checkpoint management tables are automatically created at application startup 27 | -------------------------------------------------------------------------------- /backend/app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/langchain-ai/opengpts/7ab956faa74a115fb6e2bdbaad03cfeecc3474fe/backend/app/__init__.py -------------------------------------------------------------------------------- /backend/app/agent_types/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/langchain-ai/opengpts/7ab956faa74a115fb6e2bdbaad03cfeecc3474fe/backend/app/agent_types/__init__.py -------------------------------------------------------------------------------- /backend/app/agent_types/prompts.py: -------------------------------------------------------------------------------- 1 | xml_template = """{system_message} 2 | 3 | You have access to the following tools: 4 | 5 | {tools} 6 | 7 | In order to use a tool, you can use and tags. You will then get back a response in the form 8 | For example, if you have a tool called 'search' that could run a google search, in order to search for the weather in SF you would respond: 9 | 10 | searchweather in SF 11 | 64 degrees 12 | 13 | When you are done, you can respond as normal to the user. 14 | 15 | Example 1: 16 | 17 | Human: Hi! 18 | 19 | Assistant: Hi! How are you? 20 | 21 | Human: What is the weather in SF? 22 | Assistant: searchweather in SF 23 | 64 degrees 24 | It is 64 degrees in SF 25 | 26 | 27 | Begin!""" 28 | -------------------------------------------------------------------------------- /backend/app/agent_types/tools_agent.py: -------------------------------------------------------------------------------- 1 | from typing import cast 2 | 3 | from langchain.tools import BaseTool 4 | from langchain_core.language_models.base import LanguageModelLike 5 | from langchain_core.messages import ( 6 | AIMessage, 7 | FunctionMessage, 8 | HumanMessage, 9 | SystemMessage, 10 | ToolMessage, 11 | ) 12 | from langgraph.checkpoint.base import BaseCheckpointSaver 13 | from langgraph.graph import END 14 | from langgraph.graph.message import MessageGraph 15 | from langgraph.prebuilt import ToolExecutor, ToolInvocation 16 | 17 | from app.message_types import LiberalToolMessage 18 | 19 | 20 | def get_tools_agent_executor( 21 | tools: list[BaseTool], 22 | llm: LanguageModelLike, 23 | system_message: str, 24 | interrupt_before_action: bool, 25 | checkpoint: BaseCheckpointSaver, 26 | ): 27 | async def _get_messages(messages): 28 | msgs = [] 29 | for m in messages: 30 | if isinstance(m, LiberalToolMessage): 31 | _dict = m.model_dump() 32 | _dict["content"] = str(_dict["content"]) 33 | m_c = ToolMessage(**_dict) 34 | msgs.append(m_c) 35 | elif isinstance(m, FunctionMessage): 36 | # anthropic doesn't like function messages 37 | msgs.append(HumanMessage(content=str(m.content))) 38 | else: 39 | msgs.append(m) 40 | 41 | return [SystemMessage(content=system_message)] + msgs 42 | 43 | if tools: 44 | llm_with_tools = llm.bind_tools(tools) 45 | else: 46 | llm_with_tools = llm 47 | agent = _get_messages | llm_with_tools 48 | tool_executor = ToolExecutor(tools) 49 | 50 | # Define the function that determines whether to continue or not 51 | def should_continue(messages): 52 | last_message = messages[-1] 53 | # If there is no function call, then we finish 54 | if not last_message.tool_calls: 55 | return "end" 56 | # Otherwise if there is, we continue 57 | else: 58 | return "continue" 59 | 60 | # Define the function to execute tools 61 | async def call_tool(messages): 62 | actions: list[ToolInvocation] = [] 63 | # Based on the continue condition 64 | # we know the last message involves a function call 65 | last_message = cast(AIMessage, messages[-1]) 66 | for tool_call in last_message.tool_calls: 67 | # We construct a ToolInvocation from the function_call 68 | actions.append( 69 | ToolInvocation( 70 | tool=tool_call["name"], 71 | tool_input=tool_call["args"], 72 | ) 73 | ) 74 | # We call the tool_executor and get back a response 75 | responses = await tool_executor.abatch(actions) 76 | # We use the response to create a ToolMessage 77 | tool_messages = [ 78 | LiberalToolMessage( 79 | tool_call_id=tool_call["id"], 80 | name=tool_call["name"], 81 | content=response, 82 | ) 83 | for tool_call, response in zip(last_message.tool_calls, responses) 84 | ] 85 | return tool_messages 86 | 87 | workflow = MessageGraph() 88 | 89 | # Define the two nodes we will cycle between 90 | workflow.add_node("agent", agent) 91 | workflow.add_node("action", call_tool) 92 | 93 | # Set the entrypoint as `agent` 94 | # This means that this node is the first one called 95 | workflow.set_entry_point("agent") 96 | 97 | # We now add a conditional edge 98 | workflow.add_conditional_edges( 99 | # First, we define the start node. We use `agent`. 100 | # This means these are the edges taken after the `agent` node is called. 101 | "agent", 102 | # Next, we pass in the function that will determine which node is called next. 103 | should_continue, 104 | # Finally we pass in a mapping. 105 | # The keys are strings, and the values are other nodes. 106 | # END is a special node marking that the graph should finish. 107 | # What will happen is we will call `should_continue`, and then the output of that 108 | # will be matched against the keys in this mapping. 109 | # Based on which one it matches, that node will then be called. 110 | { 111 | # If `tools`, then we call the tool node. 112 | "continue": "action", 113 | # Otherwise we finish. 114 | "end": END, 115 | }, 116 | ) 117 | 118 | # We now add a normal edge from `tools` to `agent`. 119 | # This means that after `tools` is called, `agent` node is called next. 120 | workflow.add_edge("action", "agent") 121 | 122 | # Finally, we compile it! 123 | # This compiles it into a LangChain Runnable, 124 | # meaning you can use it as you would any other runnable 125 | return workflow.compile( 126 | checkpointer=checkpoint, 127 | interrupt_before=["action"] if interrupt_before_action else None, 128 | ) 129 | -------------------------------------------------------------------------------- /backend/app/agent_types/xml_agent.py: -------------------------------------------------------------------------------- 1 | from langchain.tools import BaseTool 2 | from langchain.tools.render import render_text_description 3 | from langchain_core.language_models.base import LanguageModelLike 4 | from langchain_core.messages import ( 5 | AIMessage, 6 | FunctionMessage, 7 | HumanMessage, 8 | SystemMessage, 9 | ) 10 | from langgraph.checkpoint.base import BaseCheckpointSaver 11 | from langgraph.graph import END 12 | from langgraph.graph.message import MessageGraph 13 | from langgraph.prebuilt import ToolExecutor, ToolInvocation 14 | 15 | from app.agent_types.prompts import xml_template 16 | from app.message_types import LiberalFunctionMessage 17 | 18 | 19 | def _collapse_messages(messages): 20 | log = "" 21 | if isinstance(messages[-1], AIMessage): 22 | scratchpad = messages[:-1] 23 | final = messages[-1] 24 | else: 25 | scratchpad = messages 26 | final = None 27 | if len(scratchpad) % 2 != 0: 28 | raise ValueError("Unexpected") 29 | for i in range(0, len(scratchpad), 2): 30 | action = messages[i] 31 | observation = messages[i + 1] 32 | log += f"{action.content}{observation.content}" 33 | if final is not None: 34 | log += final.content 35 | return AIMessage(content=log) 36 | 37 | 38 | def construct_chat_history(messages): 39 | collapsed_messages = [] 40 | temp_messages = [] 41 | for message in messages: 42 | if isinstance(message, HumanMessage): 43 | if temp_messages: 44 | collapsed_messages.append(_collapse_messages(temp_messages)) 45 | temp_messages = [] 46 | collapsed_messages.append(message) 47 | elif isinstance(message, LiberalFunctionMessage): 48 | _dict = message.model_dump() 49 | _dict["content"] = str(_dict["content"]) 50 | m_c = FunctionMessage(**_dict) 51 | temp_messages.append(m_c) 52 | else: 53 | temp_messages.append(message) 54 | 55 | # Don't forget to add the last non-human message if it exists 56 | if temp_messages: 57 | collapsed_messages.append(_collapse_messages(temp_messages)) 58 | 59 | return collapsed_messages 60 | 61 | 62 | def get_xml_agent_executor( 63 | tools: list[BaseTool], 64 | llm: LanguageModelLike, 65 | system_message: str, 66 | interrupt_before_action: bool, 67 | checkpoint: BaseCheckpointSaver, 68 | ): 69 | formatted_system_message = xml_template.format( 70 | system_message=system_message, 71 | tools=render_text_description(tools), 72 | tool_names=", ".join([t.name for t in tools]), 73 | ) 74 | 75 | llm_with_stop = llm.bind(stop=["", ""]) 76 | 77 | def _get_messages(messages): 78 | return [ 79 | SystemMessage(content=formatted_system_message) 80 | ] + construct_chat_history(messages) 81 | 82 | agent = _get_messages | llm_with_stop 83 | tool_executor = ToolExecutor(tools) 84 | 85 | # Define the function that determines whether to continue or not 86 | def should_continue(messages): 87 | last_message = messages[-1] 88 | if "" in last_message.content: 89 | return "continue" 90 | else: 91 | return "end" 92 | 93 | # Define the function to execute tools 94 | async def call_tool(messages): 95 | # Based on the continue condition 96 | # we know the last message involves a function call 97 | last_message = messages[-1] 98 | # We construct an ToolInvocation from the function_call 99 | tool, tool_input = last_message.content.split("") 100 | _tool = tool.split("")[1] 101 | if "" not in tool_input: 102 | _tool_input = "" 103 | else: 104 | _tool_input = tool_input.split("")[1] 105 | if "" in _tool_input: 106 | _tool_input = _tool_input.split("")[0] 107 | action = ToolInvocation( 108 | tool=_tool, 109 | tool_input=_tool_input, 110 | ) 111 | # We call the tool_executor and get back a response 112 | response = await tool_executor.ainvoke(action) 113 | # We use the response to create a FunctionMessage 114 | function_message = LiberalFunctionMessage(content=response, name=action.tool) 115 | # We return a list, because this will get added to the existing list 116 | return function_message 117 | 118 | workflow = MessageGraph() 119 | 120 | # Define the two nodes we will cycle between 121 | workflow.add_node("agent", agent) 122 | workflow.add_node("action", call_tool) 123 | 124 | # Set the entrypoint as `agent` 125 | # This means that this node is the first one called 126 | workflow.set_entry_point("agent") 127 | 128 | # We now add a conditional edge 129 | workflow.add_conditional_edges( 130 | # First, we define the start node. We use `agent`. 131 | # This means these are the edges taken after the `agent` node is called. 132 | "agent", 133 | # Next, we pass in the function that will determine which node is called next. 134 | should_continue, 135 | # Finally we pass in a mapping. 136 | # The keys are strings, and the values are other nodes. 137 | # END is a special node marking that the graph should finish. 138 | # What will happen is we will call `should_continue`, and then the output of that 139 | # will be matched against the keys in this mapping. 140 | # Based on which one it matches, that node will then be called. 141 | { 142 | # If `tools`, then we call the tool node. 143 | "continue": "action", 144 | # Otherwise we finish. 145 | "end": END, 146 | }, 147 | ) 148 | 149 | # We now add a normal edge from `tools` to `agent`. 150 | # This means that after `tools` is called, `agent` node is called next. 151 | workflow.add_edge("action", "agent") 152 | 153 | # Finally, we compile it! 154 | # This compiles it into a LangChain Runnable, 155 | # meaning you can use it as you would any other runnable 156 | return workflow.compile( 157 | checkpointer=checkpoint, 158 | interrupt_before=["action"] if interrupt_before_action else None, 159 | ) 160 | -------------------------------------------------------------------------------- /backend/app/api/__init__.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | 3 | from app.api.assistants import router as assistants_router 4 | from app.api.runs import router as runs_router 5 | from app.api.threads import router as threads_router 6 | 7 | router = APIRouter() 8 | 9 | 10 | @router.get("/ok") 11 | async def ok(): 12 | return {"ok": True} 13 | 14 | 15 | router.include_router( 16 | assistants_router, 17 | prefix="/assistants", 18 | tags=["assistants"], 19 | ) 20 | router.include_router( 21 | runs_router, 22 | prefix="/runs", 23 | tags=["runs"], 24 | ) 25 | router.include_router( 26 | threads_router, 27 | prefix="/threads", 28 | tags=["threads"], 29 | ) 30 | -------------------------------------------------------------------------------- /backend/app/api/assistants.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated, List 2 | from uuid import uuid4 3 | 4 | from fastapi import APIRouter, HTTPException, Path 5 | from pydantic import BaseModel, Field 6 | 7 | import app.storage as storage 8 | from app.auth.handlers import AuthedUser 9 | from app.schema import Assistant 10 | 11 | router = APIRouter() 12 | 13 | 14 | class AssistantPayload(BaseModel): 15 | """Payload for creating an assistant.""" 16 | 17 | name: Annotated[str, Field(description="The name of the assistant.")] 18 | config: Annotated[dict, Field(description="The assistant config.")] 19 | public: Annotated[ 20 | bool, Field(default=False, description="Whether the assistant is public.") 21 | ] 22 | 23 | 24 | AssistantID = Annotated[str, Path(description="The ID of the assistant.")] 25 | 26 | 27 | @router.get("/") 28 | async def list_assistants(user: AuthedUser) -> List[Assistant]: 29 | """List all assistants for the current user.""" 30 | return await storage.list_assistants(user.user_id) 31 | 32 | 33 | @router.get("/public/") 34 | async def list_public_assistants() -> List[Assistant]: 35 | """List all public assistants.""" 36 | return await storage.list_public_assistants() 37 | 38 | 39 | @router.get("/{aid}") 40 | async def get_assistant( 41 | user: AuthedUser, 42 | aid: AssistantID, 43 | ) -> Assistant: 44 | """Get an assistant by ID.""" 45 | assistant = await storage.get_assistant(user.user_id, aid) 46 | if not assistant: 47 | raise HTTPException(status_code=404, detail="Assistant not found") 48 | return assistant 49 | 50 | 51 | @router.post("") 52 | async def create_assistant( 53 | user: AuthedUser, 54 | payload: AssistantPayload, 55 | ) -> Assistant: 56 | """Create an assistant.""" 57 | return await storage.put_assistant( 58 | user.user_id, 59 | str(uuid4()), 60 | name=payload.name, 61 | config=payload.config, 62 | public=payload.public, 63 | ) 64 | 65 | 66 | @router.put("/{aid}") 67 | async def upsert_assistant( 68 | user: AuthedUser, 69 | aid: AssistantID, 70 | payload: AssistantPayload, 71 | ) -> Assistant: 72 | """Create or update an assistant.""" 73 | return await storage.put_assistant( 74 | user.user_id, 75 | aid, 76 | name=payload.name, 77 | config=payload.config, 78 | public=payload.public, 79 | ) 80 | 81 | 82 | @router.delete("/{aid}") 83 | async def delete_assistant( 84 | user: AuthedUser, 85 | aid: AssistantID, 86 | ): 87 | """Delete an assistant by ID.""" 88 | await storage.delete_assistant(user.user_id, aid) 89 | return {"status": "ok"} 90 | -------------------------------------------------------------------------------- /backend/app/api/runs.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Optional, Sequence, Union 2 | from uuid import UUID 3 | 4 | import langsmith.client 5 | from fastapi import APIRouter, BackgroundTasks, HTTPException 6 | from fastapi.exceptions import RequestValidationError 7 | from langchain_core.messages import AnyMessage 8 | from langchain_core.runnables import RunnableConfig 9 | from langsmith.utils import tracing_is_enabled 10 | from pydantic import BaseModel, Field, ValidationError 11 | from sse_starlette import EventSourceResponse 12 | 13 | from app.agent import agent, chat_retrieval, chatbot 14 | from app.auth.handlers import AuthedUser 15 | from app.storage import get_assistant, get_thread 16 | from app.stream import astream_state, to_sse 17 | 18 | router = APIRouter() 19 | 20 | 21 | class CreateRunPayload(BaseModel): 22 | """Payload for creating a run.""" 23 | 24 | thread_id: str 25 | input: Optional[Union[Sequence[AnyMessage], Dict[str, Any]]] = Field( 26 | default_factory=dict 27 | ) 28 | config: Optional[RunnableConfig] = None 29 | 30 | 31 | async def _run_input_and_config(payload: CreateRunPayload, user_id: str): 32 | thread = await get_thread(user_id, payload.thread_id) 33 | if not thread: 34 | raise HTTPException(status_code=404, detail="Thread not found") 35 | 36 | assistant = await get_assistant(user_id, str(thread.assistant_id)) 37 | if not assistant: 38 | raise HTTPException(status_code=404, detail="Assistant not found") 39 | 40 | config: RunnableConfig = { 41 | **assistant.config, 42 | "configurable": { 43 | **assistant.config["configurable"], 44 | **((payload.config or {}).get("configurable") or {}), 45 | "user_id": user_id, 46 | "thread_id": str(thread.thread_id), 47 | "assistant_id": str(assistant.assistant_id), 48 | }, 49 | } 50 | 51 | try: 52 | if payload.input is not None: 53 | # Get the bot type from config 54 | bot_type = config["configurable"].get("type", "agent") 55 | # Get the correct schema based on bot type 56 | if bot_type == "chat_retrieval": 57 | schema = chat_retrieval.get_input_schema() 58 | elif bot_type == "chatbot": 59 | schema = chatbot.get_input_schema() 60 | else: # default to agent 61 | schema = agent.get_input_schema() 62 | # Validate against the correct schema 63 | schema.model_validate(payload.input) 64 | except ValidationError as e: 65 | raise RequestValidationError(e.errors(), body=payload) 66 | 67 | return payload.input, config 68 | 69 | 70 | @router.post("") 71 | async def create_run( 72 | payload: CreateRunPayload, 73 | user: AuthedUser, 74 | background_tasks: BackgroundTasks, 75 | ): 76 | """Create a run.""" 77 | input_, config = await _run_input_and_config(payload, user.user_id) 78 | background_tasks.add_task(agent.ainvoke, input_, config) 79 | return {"status": "ok"} # TODO add a run id 80 | 81 | 82 | @router.post("/stream") 83 | async def stream_run( 84 | payload: CreateRunPayload, 85 | user: AuthedUser, 86 | ): 87 | """Create a run.""" 88 | input_, config = await _run_input_and_config(payload, user.user_id) 89 | 90 | return EventSourceResponse(to_sse(astream_state(agent, input_, config))) 91 | 92 | 93 | @router.get("/input_schema") 94 | async def input_schema() -> dict: 95 | """Return the input schema of the runnable.""" 96 | return agent.get_input_schema().model_json_schema() 97 | 98 | 99 | @router.get("/output_schema") 100 | async def output_schema() -> dict: 101 | """Return the output schema of the runnable.""" 102 | return agent.get_output_schema().model_json_schema() 103 | 104 | 105 | @router.get("/config_schema") 106 | async def config_schema() -> dict: 107 | """Return the config schema of the runnable.""" 108 | return agent.config_schema().model_json_schema() 109 | 110 | 111 | if tracing_is_enabled(): 112 | langsmith_client = langsmith.client.Client() 113 | 114 | class FeedbackCreateRequest(BaseModel): 115 | """ 116 | Shared information between create requests of feedback and feedback objects 117 | """ 118 | 119 | run_id: UUID 120 | """The associated run ID this feedback is logged for.""" 121 | 122 | key: str 123 | """The metric name, tag, or aspect to provide feedback on.""" 124 | 125 | score: Optional[Union[float, int, bool]] = None 126 | """Value or score to assign the run.""" 127 | 128 | value: Optional[Union[float, int, bool, str, Dict]] = None 129 | """The display value for the feedback if not a metric.""" 130 | 131 | comment: Optional[str] = None 132 | """Comment or explanation for the feedback.""" 133 | 134 | @router.post("/feedback") 135 | def create_run_feedback(feedback_create_req: FeedbackCreateRequest) -> dict: 136 | """ 137 | Send feedback on an individual run to langsmith 138 | 139 | Note that a successful response means that feedback was successfully 140 | submitted. It does not guarantee that the feedback is recorded by 141 | langsmith. Requests may be silently rejected if they are 142 | unauthenticated or invalid by the server. 143 | """ 144 | 145 | langsmith_client.create_feedback( 146 | feedback_create_req.run_id, 147 | feedback_create_req.key, 148 | score=feedback_create_req.score, 149 | value=feedback_create_req.value, 150 | comment=feedback_create_req.comment, 151 | source_info={ 152 | "from_langserve": True, 153 | }, 154 | ) 155 | 156 | return {"status": "ok"} 157 | -------------------------------------------------------------------------------- /backend/app/api/threads.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated, Any, Dict, List, Optional, Sequence, Union 2 | from uuid import uuid4 3 | 4 | from fastapi import APIRouter, HTTPException, Path 5 | from langchain.schema.messages import AnyMessage 6 | from pydantic import BaseModel, Field 7 | 8 | import app.storage as storage 9 | from app.auth.handlers import AuthedUser 10 | from app.schema import Thread 11 | 12 | router = APIRouter() 13 | 14 | 15 | ThreadID = Annotated[str, Path(description="The ID of the thread.")] 16 | 17 | 18 | class ThreadPutRequest(BaseModel): 19 | """Payload for creating a thread.""" 20 | 21 | name: Annotated[str, Field(description="The name of the thread.")] 22 | assistant_id: Annotated[str, Field(description="The ID of the assistant to use.")] 23 | 24 | 25 | class ThreadPostRequest(BaseModel): 26 | """Payload for adding state to a thread.""" 27 | 28 | values: Union[Sequence[AnyMessage], Dict[str, Any]] 29 | config: Optional[Dict[str, Any]] = None 30 | 31 | 32 | @router.get("/") 33 | async def list_threads(user: AuthedUser) -> List[Thread]: 34 | """List all threads for the current user.""" 35 | return await storage.list_threads(user.user_id) 36 | 37 | 38 | @router.get("/{tid}/state") 39 | async def get_thread_state( 40 | user: AuthedUser, 41 | tid: ThreadID, 42 | ): 43 | """Get state for a thread.""" 44 | thread = await storage.get_thread(user.user_id, tid) 45 | if not thread: 46 | raise HTTPException(status_code=404, detail="Thread not found") 47 | assistant = await storage.get_assistant(user.user_id, thread.assistant_id) 48 | if not assistant: 49 | raise HTTPException(status_code=400, detail="Thread has no assistant") 50 | return await storage.get_thread_state( 51 | user_id=user.user_id, 52 | thread_id=tid, 53 | assistant=assistant, 54 | ) 55 | 56 | 57 | @router.post("/{tid}/state") 58 | async def add_thread_state( 59 | user: AuthedUser, 60 | tid: ThreadID, 61 | payload: ThreadPostRequest, 62 | ): 63 | """Add state to a thread.""" 64 | thread = await storage.get_thread(user.user_id, tid) 65 | if not thread: 66 | raise HTTPException(status_code=404, detail="Thread not found") 67 | assistant = await storage.get_assistant(user.user_id, thread.assistant_id) 68 | if not assistant: 69 | raise HTTPException(status_code=400, detail="Thread has no assistant") 70 | return await storage.update_thread_state( 71 | payload.config or {"configurable": {"thread_id": tid}}, 72 | payload.values, 73 | user_id=user.user_id, 74 | assistant=assistant, 75 | ) 76 | 77 | 78 | @router.get("/{tid}/history") 79 | async def get_thread_history( 80 | user: AuthedUser, 81 | tid: ThreadID, 82 | ): 83 | """Get all past states for a thread.""" 84 | thread = await storage.get_thread(user.user_id, tid) 85 | if not thread: 86 | raise HTTPException(status_code=404, detail="Thread not found") 87 | assistant = await storage.get_assistant(user.user_id, thread.assistant_id) 88 | if not assistant: 89 | raise HTTPException(status_code=400, detail="Thread has no assistant") 90 | return await storage.get_thread_history( 91 | user_id=user.user_id, 92 | thread_id=tid, 93 | assistant=assistant, 94 | ) 95 | 96 | 97 | @router.get("/{tid}") 98 | async def get_thread( 99 | user: AuthedUser, 100 | tid: ThreadID, 101 | ) -> Thread: 102 | """Get a thread by ID.""" 103 | thread = await storage.get_thread(user.user_id, tid) 104 | if not thread: 105 | raise HTTPException(status_code=404, detail="Thread not found") 106 | return thread 107 | 108 | 109 | @router.post("") 110 | async def create_thread( 111 | user: AuthedUser, 112 | thread_put_request: ThreadPutRequest, 113 | ) -> Thread: 114 | """Create a thread.""" 115 | return await storage.put_thread( 116 | user.user_id, 117 | str(uuid4()), 118 | assistant_id=thread_put_request.assistant_id, 119 | name=thread_put_request.name, 120 | ) 121 | 122 | 123 | @router.put("/{tid}") 124 | async def upsert_thread( 125 | user: AuthedUser, 126 | tid: ThreadID, 127 | thread_put_request: ThreadPutRequest, 128 | ) -> Thread: 129 | """Update a thread.""" 130 | return await storage.put_thread( 131 | user.user_id, 132 | tid, 133 | assistant_id=thread_put_request.assistant_id, 134 | name=thread_put_request.name, 135 | ) 136 | 137 | 138 | @router.delete("/{tid}") 139 | async def delete_thread( 140 | user: AuthedUser, 141 | tid: ThreadID, 142 | ): 143 | """Delete a thread by ID.""" 144 | await storage.delete_thread(user.user_id, tid) 145 | return {"status": "ok"} 146 | -------------------------------------------------------------------------------- /backend/app/auth/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/langchain-ai/opengpts/7ab956faa74a115fb6e2bdbaad03cfeecc3474fe/backend/app/auth/__init__.py -------------------------------------------------------------------------------- /backend/app/auth/handlers.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from functools import lru_cache 3 | from typing import Annotated 4 | 5 | import jwt 6 | import requests 7 | from fastapi import Depends, HTTPException, Request 8 | from fastapi.security.http import HTTPBearer 9 | 10 | import app.storage as storage 11 | from app.auth.settings import AuthType, settings 12 | from app.schema import User 13 | 14 | 15 | class AuthHandler(ABC): 16 | @abstractmethod 17 | async def __call__(self, request: Request) -> User: 18 | """Auth handler that returns a user object or raises an HTTPException.""" 19 | 20 | 21 | class NOOPAuth(AuthHandler): 22 | _default_sub = "static-default-user-id" 23 | 24 | async def __call__(self, request: Request) -> User: 25 | sub = request.cookies.get("opengpts_user_id") or self._default_sub 26 | user, _ = await storage.get_or_create_user(sub) 27 | return user 28 | 29 | 30 | class JWTAuthBase(AuthHandler): 31 | async def __call__(self, request: Request) -> User: 32 | http_bearer = await HTTPBearer()(request) 33 | token = http_bearer.credentials 34 | 35 | try: 36 | payload = self.decode_token(token, self.get_decode_key(token)) 37 | except jwt.PyJWTError as e: 38 | raise HTTPException(status_code=401, detail=str(e)) 39 | 40 | user, _ = await storage.get_or_create_user(payload["sub"]) 41 | return user 42 | 43 | @abstractmethod 44 | def decode_token(self, token: str, decode_key: str) -> dict: 45 | ... 46 | 47 | @abstractmethod 48 | def get_decode_key(self, token: str) -> str: 49 | ... 50 | 51 | 52 | class JWTAuthLocal(JWTAuthBase): 53 | """Auth handler that uses a hardcoded decode key from env.""" 54 | 55 | def decode_token(self, token: str, decode_key: str) -> dict: 56 | return jwt.decode( 57 | token, 58 | decode_key, 59 | issuer=settings.jwt_local.iss, 60 | audience=settings.jwt_local.aud, 61 | algorithms=[settings.jwt_local.alg.upper()], 62 | options={"require": ["exp", "iss", "aud", "sub"]}, 63 | ) 64 | 65 | def get_decode_key(self, token: str) -> str: 66 | return settings.jwt_local.decode_key 67 | 68 | 69 | class JWTAuthOIDC(JWTAuthBase): 70 | """Auth handler that uses OIDC discovery to get the decode key.""" 71 | 72 | def decode_token(self, token: str, decode_key: str) -> dict: 73 | alg = self._decode_complete_unverified(token)["header"]["alg"] 74 | return jwt.decode( 75 | token, 76 | decode_key, 77 | issuer=settings.jwt_oidc.iss, 78 | audience=settings.jwt_oidc.aud, 79 | algorithms=[alg.upper()], 80 | options={"require": ["exp", "iss", "aud", "sub"]}, 81 | ) 82 | 83 | def get_decode_key(self, token: str) -> str: 84 | unverified = self._decode_complete_unverified(token) 85 | issuer = unverified["payload"].get("iss") 86 | kid = unverified["header"].get("kid") 87 | return self._get_jwk_client(issuer).get_signing_key(kid).key 88 | 89 | @lru_cache 90 | def _decode_complete_unverified(self, token: str) -> dict: 91 | return jwt.api_jwt.decode_complete(token, options={"verify_signature": False}) 92 | 93 | @lru_cache 94 | def _get_jwk_client(self, issuer: str) -> jwt.PyJWKClient: 95 | """ 96 | lru_cache ensures a single instance of PyJWKClient per issuer. This is 97 | so that we can take advantage of jwks caching (and invalidation) handled 98 | by PyJWKClient. 99 | """ 100 | url = issuer.rstrip("/") + "/.well-known/openid-configuration" 101 | config = requests.get(url).json() 102 | return jwt.PyJWKClient(config["jwks_uri"], cache_jwk_set=True) 103 | 104 | 105 | @lru_cache(maxsize=1) 106 | def get_auth_handler() -> AuthHandler: 107 | if settings.auth_type == AuthType.JWT_LOCAL: 108 | return JWTAuthLocal() 109 | elif settings.auth_type == AuthType.JWT_OIDC: 110 | return JWTAuthOIDC() 111 | return NOOPAuth() 112 | 113 | 114 | async def auth_user( 115 | request: Request, auth_handler: AuthHandler = Depends(get_auth_handler) 116 | ): 117 | return await auth_handler(request) 118 | 119 | 120 | AuthedUser = Annotated[User, Depends(auth_user)] 121 | -------------------------------------------------------------------------------- /backend/app/auth/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | from base64 import b64decode 3 | from enum import Enum 4 | from typing import List, Optional, Union 5 | 6 | from pydantic import ConfigDict, field_validator, model_validator 7 | from pydantic_settings import BaseSettings 8 | 9 | 10 | class AuthType(Enum): 11 | NOOP = "noop" 12 | JWT_LOCAL = "jwt_local" 13 | JWT_OIDC = "jwt_oidc" 14 | 15 | 16 | class JWTSettingsBase(BaseSettings): 17 | iss: str 18 | aud: Union[str, list[str]] 19 | 20 | @field_validator("aud", mode="before") 21 | @classmethod 22 | def set_aud(cls, v) -> Union[str, List[str]]: 23 | if isinstance(v, str) and "," in v: 24 | return v.split(",") 25 | return v 26 | 27 | model_config = ConfigDict( 28 | env_prefix="jwt_", 29 | ) 30 | 31 | 32 | class JWTSettingsLocal(JWTSettingsBase): 33 | decode_key_b64: str 34 | decode_key: str = None 35 | alg: str 36 | 37 | @field_validator("decode_key", mode="before") 38 | @classmethod 39 | def set_decode_key(cls, v, info): 40 | """ 41 | Key may be a multiline string (e.g. in the case of a public key), so to 42 | be able to set it from env, we set it as a base64 encoded string and 43 | decode it here. 44 | """ 45 | decode_key_b64 = info.data.get("decode_key_b64") 46 | if decode_key_b64: 47 | return b64decode(decode_key_b64).decode("utf-8") 48 | return v 49 | 50 | 51 | class JWTSettingsOIDC(JWTSettingsBase): 52 | ... 53 | 54 | 55 | class Settings(BaseSettings): 56 | auth_type: AuthType 57 | jwt_local: Optional[JWTSettingsLocal] = None 58 | jwt_oidc: Optional[JWTSettingsOIDC] = None 59 | 60 | @model_validator(mode="before") 61 | @classmethod 62 | def check_jwt_settings(cls, values): 63 | auth_type = values.get("auth_type") 64 | if auth_type == AuthType.JWT_LOCAL and values.get("jwt_local") is None: 65 | raise ValueError( 66 | "jwt local settings must be set when auth type is jwt_local." 67 | ) 68 | if auth_type == AuthType.JWT_OIDC and values.get("jwt_oidc") is None: 69 | raise ValueError( 70 | "jwt oidc settings must be set when auth type is jwt_oidc." 71 | ) 72 | return values 73 | 74 | 75 | auth_type = AuthType(os.getenv("AUTH_TYPE", AuthType.NOOP.value).lower()) 76 | kwargs = {"auth_type": auth_type} 77 | if auth_type == AuthType.JWT_LOCAL: 78 | kwargs["jwt_local"] = JWTSettingsLocal() 79 | elif auth_type == AuthType.JWT_OIDC: 80 | kwargs["jwt_oidc"] = JWTSettingsOIDC() 81 | settings = Settings(**kwargs) 82 | -------------------------------------------------------------------------------- /backend/app/chatbot.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated, List 2 | 3 | from langchain_core.language_models.base import LanguageModelLike 4 | from langchain_core.messages import BaseMessage, SystemMessage 5 | from langgraph.checkpoint.base import BaseCheckpointSaver 6 | from langgraph.graph.state import StateGraph 7 | 8 | from app.message_types import add_messages_liberal 9 | 10 | 11 | def get_chatbot_executor( 12 | llm: LanguageModelLike, 13 | system_message: str, 14 | checkpoint: BaseCheckpointSaver, 15 | ): 16 | def _get_messages(messages): 17 | return [SystemMessage(content=system_message)] + messages 18 | 19 | chatbot = _get_messages | llm 20 | 21 | workflow = StateGraph(Annotated[List[BaseMessage], add_messages_liberal]) 22 | workflow.add_node("chatbot", chatbot) 23 | workflow.set_entry_point("chatbot") 24 | workflow.set_finish_point("chatbot") 25 | app = workflow.compile(checkpointer=checkpoint) 26 | return app 27 | -------------------------------------------------------------------------------- /backend/app/checkpoint.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Any, AsyncIterator, Optional, Sequence 3 | 4 | import structlog 5 | from langgraph.checkpoint.base import ( 6 | ChannelVersions, 7 | Checkpoint, 8 | CheckpointMetadata, 9 | CheckpointTuple, 10 | RunnableConfig, 11 | ) 12 | from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver 13 | from langgraph.checkpoint.postgres.base import BasePostgresSaver 14 | from langgraph.checkpoint.serde.base import SerializerProtocol 15 | from psycopg import AsyncPipeline 16 | from psycopg_pool import AsyncConnectionPool 17 | 18 | logger = structlog.get_logger(__name__) 19 | 20 | 21 | class AsyncPostgresCheckpoint(BasePostgresSaver): 22 | """A singleton implementation of AsyncPostgresSaver with separate setup.""" 23 | 24 | _instance = None 25 | 26 | def __new__(cls, *args, **kwargs): 27 | if not cls._instance: 28 | cls._instance = super().__new__(cls) 29 | return cls._instance 30 | 31 | def __init__( 32 | self, 33 | pipe: Optional[AsyncPipeline] = None, 34 | serde: Optional[SerializerProtocol] = None, 35 | ) -> None: 36 | if not hasattr(self, "_initialized"): 37 | super().__init__(serde=serde) 38 | # Initialize basic attributes 39 | self.pipe = pipe 40 | self.serde = serde 41 | self._initialized = True 42 | self._setup_complete = False 43 | self.async_postgres_saver = None 44 | 45 | async def ensure_setup(self) -> None: 46 | """Ensure the instance is set up before use.""" 47 | if not self._setup_complete: 48 | await self.setup() 49 | self._setup_complete = True 50 | 51 | async def setup(self) -> None: 52 | """Internal setup method.""" 53 | try: 54 | conninfo = ( 55 | f"postgresql://{os.environ['POSTGRES_USER']}:" 56 | f"{os.environ['POSTGRES_PASSWORD']}@" 57 | f"{os.environ['POSTGRES_HOST']}:" 58 | f"{os.environ['POSTGRES_PORT']}/" 59 | f"{os.environ['POSTGRES_DB']}" 60 | ) 61 | 62 | pool = AsyncConnectionPool( 63 | conninfo=conninfo, 64 | kwargs={"autocommit": True, "prepare_threshold": 0}, 65 | open=False, # Don't open in constructor 66 | ) 67 | await pool.open() 68 | 69 | self.async_postgres_saver = AsyncPostgresSaver( 70 | conn=pool, pipe=self.pipe, serde=self.serde 71 | ) 72 | 73 | # Setup will create/migrate the tables if they don't exist 74 | await self.async_postgres_saver.setup() 75 | 76 | logger.warning("Checkpoint setup complete.") 77 | except Exception as e: 78 | logger.error(f"Failed to set up AsyncPostgresCheckpoint: {e}") 79 | raise 80 | 81 | async def alist( 82 | self, 83 | config: Optional[RunnableConfig], 84 | *, 85 | filter: Optional[dict[str, Any]] = None, 86 | before: Optional[RunnableConfig] = None, 87 | limit: Optional[int] = None, 88 | ) -> AsyncIterator[CheckpointTuple]: 89 | """List checkpoints from the database asynchronously.""" 90 | async for checkpoint in self.async_postgres_saver.alist( 91 | config, filter=filter, before=before, limit=limit 92 | ): 93 | yield checkpoint 94 | 95 | async def aget_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]: 96 | """Get a checkpoint tuple from the database asynchronously.""" 97 | return await self.async_postgres_saver.aget_tuple(config) 98 | 99 | async def aput( 100 | self, 101 | config: RunnableConfig, 102 | checkpoint: Checkpoint, 103 | metadata: CheckpointMetadata, 104 | new_versions: ChannelVersions, 105 | ) -> RunnableConfig: 106 | """Save a checkpoint to the database asynchronously.""" 107 | return await self.async_postgres_saver.aput( 108 | config, checkpoint, metadata, new_versions 109 | ) 110 | 111 | async def aput_writes( 112 | self, 113 | config: RunnableConfig, 114 | writes: Sequence[tuple[str, Any]], 115 | task_id: str, 116 | ) -> None: 117 | """Store intermediate writes linked to a checkpoint asynchronously.""" 118 | await self.async_postgres_saver.aput_writes(config, writes, task_id) 119 | -------------------------------------------------------------------------------- /backend/app/ingest.py: -------------------------------------------------------------------------------- 1 | """Code to ingest blob into a vectorstore. 2 | 3 | Code is responsible for taking binary data, parsing it and then indexing it 4 | into a vector store. 5 | 6 | This code should be agnostic to how the blob got generated; i.e., it does not 7 | know about server/uploading etc. 8 | """ 9 | from typing import List 10 | 11 | from langchain.text_splitter import TextSplitter 12 | from langchain_community.document_loaders import Blob 13 | from langchain_community.document_loaders.base import BaseBlobParser 14 | from langchain_core.documents import Document 15 | from langchain_core.vectorstores import VectorStore 16 | 17 | 18 | def _update_document_metadata(document: Document, namespace: str) -> None: 19 | """Mutation in place that adds a namespace to the document metadata.""" 20 | document.metadata["namespace"] = namespace 21 | 22 | 23 | def _sanitize_document_content(document: Document) -> Document: 24 | """Sanitize the document.""" 25 | # Without this, PDF ingestion fails with 26 | # "A string literal cannot contain NUL (0x00) characters". 27 | document.page_content = document.page_content.replace("\x00", "x") 28 | 29 | 30 | # PUBLIC API 31 | 32 | 33 | def ingest_blob( 34 | blob: Blob, 35 | parser: BaseBlobParser, 36 | text_splitter: TextSplitter, 37 | vectorstore: VectorStore, 38 | namespace: str, 39 | *, 40 | batch_size: int = 100, 41 | ) -> List[str]: 42 | """Ingest a document into the vectorstore.""" 43 | docs_to_index = [] 44 | ids = [] 45 | for document in parser.lazy_parse(blob): 46 | docs = text_splitter.split_documents([document]) 47 | for doc in docs: 48 | _sanitize_document_content(doc) 49 | _update_document_metadata(doc, namespace) 50 | docs_to_index.extend(docs) 51 | 52 | if len(docs_to_index) >= batch_size: 53 | ids.extend(vectorstore.add_documents(docs_to_index)) 54 | docs_to_index = [] 55 | 56 | if docs_to_index: 57 | ids.extend(vectorstore.add_documents(docs_to_index)) 58 | 59 | return ids 60 | -------------------------------------------------------------------------------- /backend/app/lifespan.py: -------------------------------------------------------------------------------- 1 | import os 2 | from contextlib import asynccontextmanager 3 | 4 | import asyncpg 5 | import orjson 6 | import structlog 7 | from fastapi import FastAPI 8 | 9 | from app.checkpoint import AsyncPostgresCheckpoint 10 | 11 | _pg_pool = None 12 | 13 | 14 | def get_pg_pool() -> asyncpg.pool.Pool: 15 | return _pg_pool 16 | 17 | 18 | async def _init_connection(conn) -> None: 19 | await conn.set_type_codec( 20 | "json", 21 | encoder=lambda v: orjson.dumps(v).decode(), 22 | decoder=orjson.loads, 23 | schema="pg_catalog", 24 | ) 25 | await conn.set_type_codec( 26 | "jsonb", 27 | encoder=lambda v: orjson.dumps(v).decode(), 28 | decoder=orjson.loads, 29 | schema="pg_catalog", 30 | ) 31 | await conn.set_type_codec( 32 | "uuid", encoder=lambda v: str(v), decoder=lambda v: v, schema="pg_catalog" 33 | ) 34 | 35 | 36 | @asynccontextmanager 37 | async def lifespan(app: FastAPI): 38 | structlog.configure( 39 | processors=[ 40 | structlog.stdlib.filter_by_level, 41 | structlog.stdlib.PositionalArgumentsFormatter(), 42 | structlog.processors.StackInfoRenderer(), 43 | structlog.processors.UnicodeDecoder(), 44 | structlog.stdlib.render_to_log_kwargs, 45 | ], 46 | logger_factory=structlog.stdlib.LoggerFactory(), 47 | wrapper_class=structlog.stdlib.BoundLogger, 48 | cache_logger_on_first_use=True, 49 | ) 50 | 51 | global _pg_pool 52 | 53 | _pg_pool = await asyncpg.create_pool( 54 | database=os.environ["POSTGRES_DB"], 55 | user=os.environ["POSTGRES_USER"], 56 | password=os.environ["POSTGRES_PASSWORD"], 57 | host=os.environ["POSTGRES_HOST"], 58 | port=os.environ["POSTGRES_PORT"], 59 | init=_init_connection, 60 | ) 61 | await AsyncPostgresCheckpoint().ensure_setup() 62 | yield 63 | await _pg_pool.close() 64 | _pg_pool = None 65 | -------------------------------------------------------------------------------- /backend/app/llms.py: -------------------------------------------------------------------------------- 1 | import os 2 | from functools import lru_cache 3 | from urllib.parse import urlparse 4 | 5 | import boto3 6 | import httpx 7 | import structlog 8 | from langchain_anthropic import ChatAnthropic 9 | from langchain_community.chat_models import BedrockChat, ChatFireworks 10 | from langchain_community.chat_models.ollama import ChatOllama 11 | from langchain_google_vertexai import ChatVertexAI 12 | from langchain_openai import AzureChatOpenAI, ChatOpenAI 13 | 14 | logger = structlog.get_logger(__name__) 15 | 16 | 17 | @lru_cache(maxsize=4) 18 | def get_openai_llm(model: str = "gpt-3.5-turbo", azure: bool = False): 19 | proxy_url = os.getenv("PROXY_URL") 20 | http_client = None 21 | if proxy_url: 22 | parsed_url = urlparse(proxy_url) 23 | if parsed_url.scheme and parsed_url.netloc: 24 | http_client = httpx.AsyncClient(proxies=proxy_url) 25 | else: 26 | logger.warn("Invalid proxy URL provided. Proceeding without proxy.") 27 | 28 | if not azure: 29 | try: 30 | openai_model = model 31 | llm = ChatOpenAI( 32 | http_client=http_client, 33 | model=openai_model, 34 | temperature=0, 35 | ) 36 | except Exception as e: 37 | logger.error( 38 | f"Failed to instantiate ChatOpenAI due to: {str(e)}. Falling back to AzureChatOpenAI." 39 | ) 40 | llm = AzureChatOpenAI( 41 | http_client=http_client, 42 | temperature=0, 43 | deployment_name=os.environ["AZURE_OPENAI_DEPLOYMENT_NAME"], 44 | azure_endpoint=os.environ["AZURE_OPENAI_API_BASE"], 45 | openai_api_version=os.environ["AZURE_OPENAI_API_VERSION"], 46 | openai_api_key=os.environ["AZURE_OPENAI_API_KEY"], 47 | ) 48 | else: 49 | llm = AzureChatOpenAI( 50 | http_client=http_client, 51 | temperature=0, 52 | deployment_name=os.environ["AZURE_OPENAI_DEPLOYMENT_NAME"], 53 | azure_endpoint=os.environ["AZURE_OPENAI_API_BASE"], 54 | openai_api_version=os.environ["AZURE_OPENAI_API_VERSION"], 55 | openai_api_key=os.environ["AZURE_OPENAI_API_KEY"], 56 | ) 57 | return llm 58 | 59 | 60 | @lru_cache(maxsize=2) 61 | def get_anthropic_llm(bedrock: bool = False): 62 | if bedrock: 63 | client = boto3.client( 64 | "bedrock-runtime", 65 | region_name="us-west-2", 66 | aws_access_key_id=os.environ.get("AWS_ACCESS_KEY_ID"), 67 | aws_secret_access_key=os.environ.get("AWS_SECRET_ACCESS_KEY"), 68 | ) 69 | model = BedrockChat(model_id="anthropic.claude-v2", client=client) 70 | else: 71 | model = ChatAnthropic( 72 | model_name="claude-3-haiku-20240307", 73 | max_tokens_to_sample=2000, 74 | temperature=0, 75 | ) 76 | return model 77 | 78 | 79 | @lru_cache(maxsize=1) 80 | def get_google_llm(): 81 | return ChatVertexAI( 82 | model_name="gemini-pro", convert_system_message_to_human=True, streaming=True 83 | ) 84 | 85 | 86 | @lru_cache(maxsize=1) 87 | def get_mixtral_fireworks(): 88 | return ChatFireworks(model="accounts/fireworks/models/mixtral-8x7b-instruct") 89 | 90 | 91 | @lru_cache(maxsize=1) 92 | def get_ollama_llm(): 93 | model_name = os.environ.get("OLLAMA_MODEL") 94 | if not model_name: 95 | model_name = "llama2" 96 | ollama_base_url = os.environ.get("OLLAMA_BASE_URL") 97 | if not ollama_base_url: 98 | ollama_base_url = "http://localhost:11434" 99 | 100 | return ChatOllama(model=model_name, base_url=ollama_base_url) 101 | -------------------------------------------------------------------------------- /backend/app/message_types.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from langchain_core.messages import ( 4 | FunctionMessage, 5 | MessageLikeRepresentation, 6 | ToolMessage, 7 | _message_from_dict, 8 | ) 9 | from langgraph.graph.message import Messages, add_messages 10 | from pydantic import Field 11 | 12 | 13 | class LiberalFunctionMessage(FunctionMessage): 14 | content: Any = Field(default="") 15 | 16 | 17 | class LiberalToolMessage(ToolMessage): 18 | content: Any = Field(default="") 19 | 20 | 21 | def _convert_pydantic_dict_to_message( 22 | data: MessageLikeRepresentation, 23 | ) -> MessageLikeRepresentation: 24 | """Convert a dictionary to a message object if it matches message format.""" 25 | if ( 26 | isinstance(data, dict) 27 | and "content" in data 28 | and isinstance(data.get("type"), str) 29 | ): 30 | _type = data.pop("type") 31 | return _message_from_dict({"data": data, "type": _type}) 32 | return data 33 | 34 | 35 | def add_messages_liberal(left: Messages, right: Messages): 36 | # coerce to list 37 | if not isinstance(left, list): 38 | left = [left] 39 | if not isinstance(right, list): 40 | right = [right] 41 | return add_messages( 42 | [_convert_pydantic_dict_to_message(m) for m in left], 43 | [_convert_pydantic_dict_to_message(m) for m in right], 44 | ) 45 | -------------------------------------------------------------------------------- /backend/app/parsing.py: -------------------------------------------------------------------------------- 1 | """Module contains logic for parsing binary blobs into text.""" 2 | from langchain_community.document_loaders.parsers import BS4HTMLParser, PDFMinerParser 3 | from langchain_community.document_loaders.parsers.generic import MimeTypeBasedParser 4 | from langchain_community.document_loaders.parsers.msword import MsWordParser 5 | from langchain_community.document_loaders.parsers.txt import TextParser 6 | 7 | HANDLERS = { 8 | "application/pdf": PDFMinerParser(), 9 | "text/plain": TextParser(), 10 | "text/html": BS4HTMLParser(), 11 | "application/msword": MsWordParser(), 12 | "application/vnd.openxmlformats-officedocument.wordprocessingml.document": ( 13 | MsWordParser() 14 | ), 15 | } 16 | 17 | SUPPORTED_MIMETYPES = sorted(HANDLERS.keys()) 18 | 19 | # PUBLIC API 20 | 21 | MIMETYPE_BASED_PARSER = MimeTypeBasedParser( 22 | handlers=HANDLERS, 23 | fallback_parser=None, 24 | ) 25 | -------------------------------------------------------------------------------- /backend/app/retrieval.py: -------------------------------------------------------------------------------- 1 | import operator 2 | from typing import Annotated, List, Sequence, TypedDict 3 | from uuid import uuid4 4 | 5 | from langchain_core.language_models.base import LanguageModelLike 6 | from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage 7 | from langchain_core.prompts import PromptTemplate 8 | from langchain_core.retrievers import BaseRetriever 9 | from langchain_core.runnables import chain 10 | from langgraph.checkpoint.base import BaseCheckpointSaver 11 | from langgraph.graph import END 12 | from langgraph.graph.state import StateGraph 13 | 14 | from app.message_types import LiberalToolMessage, add_messages_liberal 15 | 16 | search_prompt = PromptTemplate.from_template( 17 | """Given the conversation below, come up with a search query to look up. 18 | 19 | This search query can be either a few words or question 20 | 21 | Return ONLY this search query, nothing more. 22 | 23 | >>> Conversation: 24 | {conversation} 25 | >>> END OF CONVERSATION 26 | 27 | Remember, return ONLY the search query that will help you when formulating a response to the above conversation.""" 28 | ) 29 | 30 | 31 | response_prompt_template = """{instructions} 32 | 33 | Respond to the user using ONLY the context provided below. Do not make anything up. 34 | 35 | {context}""" 36 | 37 | 38 | def get_retrieval_executor( 39 | llm: LanguageModelLike, 40 | retriever: BaseRetriever, 41 | system_message: str, 42 | checkpoint: BaseCheckpointSaver, 43 | ): 44 | class AgentState(TypedDict): 45 | messages: Annotated[List[BaseMessage], add_messages_liberal] 46 | msg_count: Annotated[int, operator.add] 47 | 48 | def _get_messages(messages): 49 | chat_history = [] 50 | for m in messages: 51 | if isinstance(m, AIMessage): 52 | if not m.tool_calls: 53 | chat_history.append(m) 54 | if isinstance(m, HumanMessage): 55 | chat_history.append(m) 56 | response = messages[-1].content 57 | content = "\n".join([d["page_content"] for d in response]) 58 | return [ 59 | SystemMessage( 60 | content=response_prompt_template.format( 61 | instructions=system_message, context=content 62 | ) 63 | ) 64 | ] + chat_history 65 | 66 | @chain 67 | async def get_search_query(messages: Sequence[BaseMessage]): 68 | convo = [] 69 | for m in messages: 70 | if isinstance(m, AIMessage): 71 | if "function_call" not in m.additional_kwargs: 72 | convo.append(f"AI: {m.content}") 73 | if isinstance(m, HumanMessage): 74 | convo.append(f"Human: {m.content}") 75 | conversation = "\n".join(convo) 76 | prompt = await search_prompt.ainvoke({"conversation": conversation}) 77 | response = await llm.ainvoke(prompt, {"tags": ["nostream"]}) 78 | return response 79 | 80 | async def invoke_retrieval(state: AgentState): 81 | messages = state["messages"] 82 | if len(messages) == 1: 83 | human_input = messages[-1].content 84 | return { 85 | "messages": [ 86 | AIMessage( 87 | content="", 88 | tool_calls=[ 89 | { 90 | "id": uuid4().hex, 91 | "name": "retrieval", 92 | "args": {"query": human_input}, 93 | } 94 | ], 95 | ) 96 | ] 97 | } 98 | else: 99 | search_query = await get_search_query.ainvoke(messages) 100 | return { 101 | "messages": [ 102 | AIMessage( 103 | id=search_query.id, 104 | content="", 105 | tool_calls=[ 106 | { 107 | "id": uuid4().hex, 108 | "name": "retrieval", 109 | "args": {"query": search_query.content}, 110 | } 111 | ], 112 | ) 113 | ] 114 | } 115 | 116 | async def retrieve(state: AgentState): 117 | messages = state["messages"] 118 | params = messages[-1].tool_calls[0] 119 | query = params["args"]["query"] 120 | response = await retriever.ainvoke(query) 121 | response = [doc.model_dump() for doc in response] 122 | msg = LiberalToolMessage( 123 | name="retrieval", content=response, tool_call_id=params["id"] 124 | ) 125 | return {"messages": [msg], "msg_count": 1} 126 | 127 | def call_model(state: AgentState): 128 | messages = state["messages"] 129 | response = llm.invoke(_get_messages(messages)) 130 | return {"messages": [response], "msg_count": 1} 131 | 132 | workflow = StateGraph(AgentState) 133 | workflow.add_node("invoke_retrieval", invoke_retrieval) 134 | workflow.add_node("retrieve", retrieve) 135 | workflow.add_node("response", call_model) 136 | workflow.set_entry_point("invoke_retrieval") 137 | workflow.add_edge("invoke_retrieval", "retrieve") 138 | workflow.add_edge("retrieve", "response") 139 | workflow.add_edge("response", END) 140 | app = workflow.compile(checkpointer=checkpoint) 141 | return app 142 | -------------------------------------------------------------------------------- /backend/app/schema.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Optional 3 | 4 | from pydantic import BaseModel 5 | 6 | 7 | class User(BaseModel): 8 | user_id: str 9 | """The ID of the user.""" 10 | sub: str 11 | """The sub of the user (from a JWT token).""" 12 | created_at: datetime 13 | """The time the user was created.""" 14 | 15 | 16 | class Assistant(BaseModel): 17 | assistant_id: str 18 | """The ID of the assistant.""" 19 | user_id: str 20 | """The ID of the user that owns the assistant.""" 21 | name: str 22 | """The name of the assistant.""" 23 | config: dict 24 | """The assistant config.""" 25 | updated_at: datetime 26 | """The last time the assistant was updated.""" 27 | public: bool = False 28 | """Whether the assistant is public.""" 29 | 30 | 31 | class Thread(BaseModel): 32 | thread_id: str 33 | """The ID of the thread.""" 34 | user_id: str 35 | """The ID of the user that owns the thread.""" 36 | assistant_id: Optional[str] = None 37 | """The assistant that was used in conjunction with this thread.""" 38 | name: str 39 | """The name of the thread.""" 40 | updated_at: datetime 41 | """The last time the thread was updated.""" 42 | metadata: Optional[dict] = None 43 | -------------------------------------------------------------------------------- /backend/app/server.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | 4 | import orjson 5 | import structlog 6 | from fastapi import FastAPI, Form, UploadFile 7 | from fastapi.exceptions import HTTPException 8 | from fastapi.staticfiles import StaticFiles 9 | 10 | import app.storage as storage 11 | from app.api import router as api_router 12 | from app.auth.handlers import AuthedUser 13 | from app.lifespan import lifespan 14 | from app.upload import convert_ingestion_input_to_blob, ingest_runnable 15 | 16 | logger = structlog.get_logger(__name__) 17 | 18 | app = FastAPI(title="OpenGPTs API", lifespan=lifespan) 19 | 20 | 21 | # Get root of app, used to point to directory containing static files 22 | ROOT = Path(__file__).parent.parent 23 | 24 | 25 | app.include_router(api_router) 26 | 27 | 28 | @app.post("/ingest", description="Upload files to the given assistant.") 29 | async def ingest_files( 30 | files: list[UploadFile], user: AuthedUser, config: str = Form(...) 31 | ) -> None: 32 | """Ingest a list of files.""" 33 | config = orjson.loads(config) 34 | 35 | assistant_id = config["configurable"].get("assistant_id") 36 | if assistant_id is not None: 37 | assistant = await storage.get_assistant(user.user_id, assistant_id) 38 | if assistant is None: 39 | raise HTTPException(status_code=404, detail="Assistant not found.") 40 | 41 | thread_id = config["configurable"].get("thread_id") 42 | if thread_id is not None: 43 | thread = await storage.get_thread(user.user_id, thread_id) 44 | if thread is None: 45 | raise HTTPException(status_code=404, detail="Thread not found.") 46 | 47 | file_blobs = [convert_ingestion_input_to_blob(file) for file in files] 48 | return ingest_runnable.batch(file_blobs, config) 49 | 50 | 51 | @app.get("/health") 52 | async def health() -> dict: 53 | return {"status": "ok"} 54 | 55 | 56 | ui_dir = str(ROOT / "ui") 57 | 58 | if os.path.exists(ui_dir): 59 | app.mount("", StaticFiles(directory=ui_dir, html=True), name="ui") 60 | else: 61 | logger.warn("No UI directory found, serving API only.") 62 | 63 | if __name__ == "__main__": 64 | import uvicorn 65 | 66 | uvicorn.run(app, host="0.0.0.0", port=8100) 67 | -------------------------------------------------------------------------------- /backend/app/stream.py: -------------------------------------------------------------------------------- 1 | import functools 2 | from typing import Any, AsyncIterator, Dict, Optional, Sequence, Union 3 | 4 | import orjson 5 | import structlog 6 | from langchain_core.messages import AnyMessage, BaseMessage, message_chunk_to_message 7 | from langchain_core.runnables import Runnable, RunnableConfig 8 | 9 | logger = structlog.get_logger(__name__) 10 | 11 | MessagesStream = AsyncIterator[Union[list[AnyMessage], str]] 12 | 13 | 14 | async def astream_state( 15 | app: Runnable, 16 | input: Union[Sequence[AnyMessage], Dict[str, Any]], 17 | config: RunnableConfig, 18 | ) -> MessagesStream: 19 | """Stream messages from the runnable.""" 20 | root_run_id: Optional[str] = None 21 | messages: dict[str, BaseMessage] = {} 22 | 23 | async for event in app.astream_events( 24 | input, config, version="v1", stream_mode="values", exclude_tags=["nostream"] 25 | ): 26 | if event["event"] == "on_chain_start" and not root_run_id: 27 | root_run_id = event["run_id"] 28 | yield root_run_id 29 | elif event["event"] == "on_chain_stream" and event["run_id"] == root_run_id: 30 | new_messages: list[BaseMessage] = [] 31 | 32 | # event["data"]["chunk"] is a Sequence[AnyMessage] or a Dict[str, Any] 33 | state_chunk_msgs: Union[Sequence[AnyMessage], Dict[str, Any]] = event[ 34 | "data" 35 | ]["chunk"] 36 | if isinstance(state_chunk_msgs, dict): 37 | state_chunk_msgs = event["data"]["chunk"]["messages"] 38 | 39 | for msg in state_chunk_msgs: 40 | msg_id = msg["id"] if isinstance(msg, dict) else msg.id 41 | if msg_id in messages and msg == messages[msg_id]: 42 | continue 43 | else: 44 | messages[msg_id] = msg 45 | new_messages.append(msg) 46 | if new_messages: 47 | yield new_messages 48 | elif event["event"] == "on_chat_model_stream": 49 | message: BaseMessage = event["data"]["chunk"] 50 | if message.id not in messages: 51 | messages[message.id] = message 52 | else: 53 | messages[message.id] += message 54 | yield [messages[message.id]] 55 | 56 | 57 | def _default(obj) -> Any: 58 | if hasattr(obj, "dict") and callable(obj.dict): 59 | return obj.dict() 60 | raise TypeError(f"Object of type {obj.__class__.__name__} is not JSON serializable") 61 | 62 | 63 | dumps = functools.partial(orjson.dumps, default=_default) 64 | 65 | 66 | async def to_sse(messages_stream: MessagesStream) -> AsyncIterator[dict]: 67 | """Consume the stream into an EventSourceResponse""" 68 | try: 69 | async for chunk in messages_stream: 70 | # EventSourceResponse expects a string for data 71 | # so after serializing into bytes, we decode into utf-8 72 | # to get a string. 73 | if isinstance(chunk, str): 74 | yield { 75 | "event": "metadata", 76 | "data": orjson.dumps({"run_id": chunk}).decode(), 77 | } 78 | else: 79 | yield { 80 | "event": "data", 81 | "data": dumps( 82 | [message_chunk_to_message(msg) for msg in chunk] 83 | ).decode(), 84 | } 85 | except Exception: 86 | logger.warn("error in stream", exc_info=True) 87 | yield { 88 | "event": "error", 89 | # Do not expose the error message to the client since 90 | # the message may contain sensitive information. 91 | # We'll add client side errors for validation as well. 92 | "data": orjson.dumps( 93 | {"status_code": 500, "message": "Internal Server Error"} 94 | ).decode(), 95 | } 96 | 97 | # Send an end event to signal the end of the stream 98 | yield {"event": "end"} 99 | -------------------------------------------------------------------------------- /backend/app/upload.py: -------------------------------------------------------------------------------- 1 | """API to deal with file uploads via a runnable. 2 | 3 | For now this code assumes that the content is a base64 encoded string. 4 | 5 | The details here might change in the future. 6 | 7 | For the time being, upload and ingestion are coupled 8 | """ 9 | 10 | from __future__ import annotations 11 | 12 | import mimetypes 13 | import os 14 | from typing import BinaryIO, List, Optional 15 | 16 | from fastapi import UploadFile 17 | from langchain_community.vectorstores.pgvector import PGVector 18 | from langchain_core.document_loaders.blob_loaders import Blob 19 | from langchain_core.runnables import ( 20 | ConfigurableField, 21 | RunnableConfig, 22 | RunnableSerializable, 23 | ) 24 | from langchain_core.vectorstores import VectorStore 25 | from langchain_openai import AzureOpenAIEmbeddings, OpenAIEmbeddings 26 | from langchain_text_splitters import RecursiveCharacterTextSplitter, TextSplitter 27 | from pydantic import ConfigDict 28 | 29 | from app.ingest import ingest_blob 30 | from app.parsing import MIMETYPE_BASED_PARSER 31 | 32 | 33 | def _guess_mimetype(file_name: str, file_bytes: bytes) -> str: 34 | """Guess the mime-type of a file based on its name or bytes.""" 35 | # Guess based on the file extension 36 | mime_type, _ = mimetypes.guess_type(file_name) 37 | 38 | # Return detected mime type from mimetypes guess, unless it's None 39 | if mime_type: 40 | return mime_type 41 | 42 | # Signature-based detection for common types 43 | if file_bytes.startswith(b"%PDF"): 44 | return "application/pdf" 45 | elif file_bytes.startswith( 46 | (b"\x50\x4B\x03\x04", b"\x50\x4B\x05\x06", b"\x50\x4B\x07\x08") 47 | ): 48 | return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" 49 | elif file_bytes.startswith(b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"): 50 | return "application/msword" 51 | elif file_bytes.startswith(b"\x09\x00\xff\x00\x06\x00"): 52 | return "application/vnd.ms-excel" 53 | 54 | # Check for CSV-like plain text content (commas, tabs, newlines) 55 | try: 56 | decoded = file_bytes[:1024].decode("utf-8", errors="ignore") 57 | if all(char in decoded for char in (",", "\n")) or all( 58 | char in decoded for char in ("\t", "\n") 59 | ): 60 | return "text/csv" 61 | elif decoded.isprintable() or decoded == "": 62 | return "text/plain" 63 | except UnicodeDecodeError: 64 | pass 65 | 66 | return "application/octet-stream" 67 | 68 | 69 | def convert_ingestion_input_to_blob(file: UploadFile) -> Blob: 70 | """Convert ingestion input to blob.""" 71 | file_data = file.file.read() 72 | file_name = file.filename 73 | 74 | # Check if file_name is a valid string 75 | if not isinstance(file_name, str): 76 | raise TypeError(f"Expected string for file name, got {type(file_name)}") 77 | 78 | mimetype = _guess_mimetype(file_name, file_data) 79 | return Blob.from_data( 80 | data=file_data, 81 | path=file_name, 82 | mime_type=mimetype, 83 | ) 84 | 85 | 86 | def _determine_azure_or_openai_embeddings() -> PGVector: 87 | if os.environ.get("OPENAI_API_KEY"): 88 | return PGVector( 89 | connection_string=PG_CONNECTION_STRING, 90 | embedding_function=OpenAIEmbeddings(), 91 | use_jsonb=True, 92 | ) 93 | if os.environ.get("AZURE_OPENAI_API_KEY"): 94 | return PGVector( 95 | connection_string=PG_CONNECTION_STRING, 96 | embedding_function=AzureOpenAIEmbeddings( 97 | azure_endpoint=os.environ.get("AZURE_OPENAI_API_BASE"), 98 | azure_deployment=os.environ.get( 99 | "AZURE_OPENAI_EMBEDDINGS_DEPLOYMENT_NAME" 100 | ), 101 | openai_api_version=os.environ.get("AZURE_OPENAI_API_VERSION"), 102 | ), 103 | use_jsonb=True, 104 | ) 105 | raise ValueError( 106 | "Either OPENAI_API_KEY or AZURE_OPENAI_API_KEY needs to be set for embeddings to work." 107 | ) 108 | 109 | 110 | class IngestRunnable(RunnableSerializable[BinaryIO, List[str]]): 111 | """Runnable for ingesting files into a vectorstore.""" 112 | 113 | text_splitter: TextSplitter 114 | vectorstore: VectorStore 115 | assistant_id: Optional[str] = None 116 | thread_id: Optional[str] = None 117 | """Ingested documents will be associated with assistant_id or thread_id. 118 | 119 | ID is used as the namespace, and is filtered on at query time. 120 | """ 121 | 122 | model_config = ConfigDict(arbitrary_types_allowed=True) 123 | 124 | @property 125 | def namespace(self) -> str: 126 | if (self.assistant_id is None and self.thread_id is None) or ( 127 | self.assistant_id is not None and self.thread_id is not None 128 | ): 129 | raise ValueError( 130 | "Exactly one of assistant_id or thread_id must be provided" 131 | ) 132 | return self.assistant_id if self.assistant_id is not None else self.thread_id 133 | 134 | def invoke(self, blob: Blob, config: Optional[RunnableConfig] = None) -> List[str]: 135 | out = ingest_blob( 136 | blob, 137 | MIMETYPE_BASED_PARSER, 138 | self.text_splitter, 139 | self.vectorstore, 140 | self.namespace, 141 | ) 142 | return out 143 | 144 | 145 | PG_CONNECTION_STRING = PGVector.connection_string_from_db_params( 146 | driver="psycopg2", 147 | host=os.environ["POSTGRES_HOST"], 148 | port=int(os.environ["POSTGRES_PORT"]), 149 | database=os.environ["POSTGRES_DB"], 150 | user=os.environ["POSTGRES_USER"], 151 | password=os.environ["POSTGRES_PASSWORD"], 152 | ) 153 | vstore = _determine_azure_or_openai_embeddings() 154 | 155 | 156 | ingest_runnable = IngestRunnable( 157 | text_splitter=RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200), 158 | vectorstore=vstore, 159 | ).configurable_fields( 160 | assistant_id=ConfigurableField( 161 | id="assistant_id", 162 | annotation=Optional[str], 163 | name="Assistant ID", 164 | ), 165 | thread_id=ConfigurableField( 166 | id="thread_id", 167 | annotation=Optional[str], 168 | name="Thread ID", 169 | ), 170 | ) 171 | -------------------------------------------------------------------------------- /backend/log_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "disable_existing_loggers": false, 4 | "formatters": { 5 | "default": { 6 | "()": "uvicorn.logging.DefaultFormatter", 7 | "fmt": "%(asctime)s - %(name)s - %(levelprefix)s %(message)s" 8 | }, 9 | "access": { 10 | "()": "uvicorn.logging.AccessFormatter", 11 | "fmt": "%(asctime)s - %(name)s - %(levelprefix)s %(client_addr)s - \"%(request_line)s\" %(status_code)s" 12 | }, 13 | "json": { 14 | "()": "pythonjsonlogger.jsonlogger.JsonFormatter", 15 | "fmt": "%(asctime)s - %(name)s - %(levelname)s %(message)s" 16 | } 17 | }, 18 | "handlers": { 19 | "default": { 20 | "formatter": "default", 21 | "class": "logging.StreamHandler", 22 | "stream": "ext://sys.stderr" 23 | }, 24 | "access": { 25 | "formatter": "access", 26 | "class": "logging.StreamHandler", 27 | "stream": "ext://sys.stdout" 28 | }, 29 | "file": { 30 | "formatter": "json", 31 | "class": "logging.handlers.RotatingFileHandler", 32 | "filename": "./app.log", 33 | "mode": "a+", 34 | "maxBytes": 10000000, 35 | "backupCount": 1 36 | } 37 | }, 38 | "root": { 39 | "handlers": [ 40 | "default", 41 | "file" 42 | ], 43 | "level": "INFO" 44 | }, 45 | "loggers": { 46 | "app": { 47 | "handlers": [ 48 | "default", 49 | "file" 50 | ], 51 | "level": "INFO", 52 | "propagate": false 53 | }, 54 | "uvicorn": { 55 | "handlers": [ 56 | "default", 57 | "file" 58 | ], 59 | "level": "INFO", 60 | "propagate": false 61 | }, 62 | "uvicorn.access": { 63 | "handlers": [ 64 | "access", 65 | "file" 66 | ], 67 | "level": "INFO", 68 | "propagate": false 69 | } 70 | } 71 | } -------------------------------------------------------------------------------- /backend/migrations/000001_create_extensions_and_first_tables.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS thread; 2 | DROP TABLE IF EXISTS assistant; 3 | DROP TABLE IF EXISTS checkpoints; 4 | -------------------------------------------------------------------------------- /backend/migrations/000001_create_extensions_and_first_tables.up.sql: -------------------------------------------------------------------------------- 1 | CREATE EXTENSION IF NOT EXISTS vector; 2 | CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; 3 | 4 | CREATE TABLE IF NOT EXISTS assistant ( 5 | assistant_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), 6 | user_id VARCHAR(255) NOT NULL, 7 | name VARCHAR(255) NOT NULL, 8 | config JSON NOT NULL, 9 | updated_at TIMESTAMP WITH TIME ZONE DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'), 10 | public BOOLEAN NOT NULL 11 | ); 12 | 13 | CREATE TABLE IF NOT EXISTS thread ( 14 | thread_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), 15 | assistant_id UUID REFERENCES assistant(assistant_id) ON DELETE SET NULL, 16 | user_id VARCHAR(255) NOT NULL, 17 | name VARCHAR(255) NOT NULL, 18 | updated_at TIMESTAMP WITH TIME ZONE DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') 19 | ); 20 | 21 | CREATE TABLE IF NOT EXISTS checkpoints ( 22 | thread_id TEXT PRIMARY KEY, 23 | checkpoint BYTEA 24 | ); -------------------------------------------------------------------------------- /backend/migrations/000002_checkpoints_update_schema.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE checkpoints 2 | DROP CONSTRAINT IF EXISTS checkpoints_pkey, 3 | ADD PRIMARY KEY (thread_id), 4 | DROP COLUMN IF EXISTS thread_ts, 5 | DROP COLUMN IF EXISTS parent_ts; 6 | -------------------------------------------------------------------------------- /backend/migrations/000002_checkpoints_update_schema.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE checkpoints 2 | ADD COLUMN IF NOT EXISTS thread_ts TIMESTAMPTZ, 3 | ADD COLUMN IF NOT EXISTS parent_ts TIMESTAMPTZ; 4 | 5 | UPDATE checkpoints 6 | SET thread_ts = CURRENT_TIMESTAMP AT TIME ZONE 'UTC' 7 | WHERE thread_ts IS NULL; 8 | 9 | ALTER TABLE checkpoints 10 | DROP CONSTRAINT IF EXISTS checkpoints_pkey, 11 | ADD PRIMARY KEY (thread_id, thread_ts) 12 | -------------------------------------------------------------------------------- /backend/migrations/000003_create_user.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE assistant 2 | DROP CONSTRAINT fk_assistant_user_id, 3 | ALTER COLUMN user_id TYPE VARCHAR USING (user_id::text); 4 | 5 | ALTER TABLE thread 6 | DROP CONSTRAINT fk_thread_user_id, 7 | ALTER COLUMN user_id TYPE VARCHAR USING (user_id::text); 8 | 9 | DROP TABLE IF EXISTS "user"; -------------------------------------------------------------------------------- /backend/migrations/000003_create_user.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS "user" ( 2 | user_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), 3 | sub VARCHAR(255) UNIQUE NOT NULL, 4 | created_at TIMESTAMP WITH TIME ZONE DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') 5 | ); 6 | 7 | INSERT INTO "user" (user_id, sub) 8 | SELECT DISTINCT user_id::uuid, user_id 9 | FROM assistant 10 | WHERE user_id IS NOT NULL 11 | ON CONFLICT (user_id) DO NOTHING; 12 | 13 | INSERT INTO "user" (user_id, sub) 14 | SELECT DISTINCT user_id::uuid, user_id 15 | FROM thread 16 | WHERE user_id IS NOT NULL 17 | ON CONFLICT (user_id) DO NOTHING; 18 | 19 | ALTER TABLE assistant 20 | ALTER COLUMN user_id TYPE UUID USING (user_id::UUID), 21 | ADD CONSTRAINT fk_assistant_user_id FOREIGN KEY (user_id) REFERENCES "user"(user_id); 22 | 23 | ALTER TABLE thread 24 | ALTER COLUMN user_id TYPE UUID USING (user_id::UUID), 25 | ADD CONSTRAINT fk_thread_user_id FOREIGN KEY (user_id) REFERENCES "user"(user_id); -------------------------------------------------------------------------------- /backend/migrations/000004_add_metadata_to_thread.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE thread 2 | DROP COLUMN metadata; -------------------------------------------------------------------------------- /backend/migrations/000004_add_metadata_to_thread.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE thread 2 | ADD COLUMN metadata JSONB; 3 | 4 | UPDATE thread 5 | SET metadata = json_build_object( 6 | 'assistant_type', (SELECT config->'configurable'->>'type' 7 | FROM assistant 8 | WHERE assistant.assistant_id = thread.assistant_id) 9 | ); -------------------------------------------------------------------------------- /backend/migrations/000005_advanced_checkpoints_schema.down.sql: -------------------------------------------------------------------------------- 1 | -- Drop the blob storage table 2 | DROP TABLE IF EXISTS checkpoint_blobs; 3 | 4 | -- Drop the writes tracking table 5 | DROP TABLE IF EXISTS checkpoint_writes; 6 | 7 | -- Drop the new checkpoints table that was created by the application 8 | DROP TABLE IF EXISTS checkpoints; 9 | 10 | -- Restore the original checkpoints table by renaming old_checkpoints back 11 | -- This preserves the original data that was saved before the migration 12 | ALTER TABLE old_checkpoints RENAME TO checkpoints; -------------------------------------------------------------------------------- /backend/migrations/000005_advanced_checkpoints_schema.up.sql: -------------------------------------------------------------------------------- 1 | -- BREAKING CHANGE WARNING: 2 | -- This migration represents a transition from pickle-based checkpointing to a new checkpoint system. 3 | -- As a result, any threads created before this migration will not be usable/clickable in the UI. 4 | -- old thread data remains in old_checkpoints table but cannot be accessed by the new version. 5 | 6 | -- Rename existing checkpoints table to preserve current data 7 | -- This is necessary because the application will create a new checkpoints table 8 | -- with an updated schema during runtime initialization. 9 | ALTER TABLE checkpoints RENAME TO old_checkpoints; -------------------------------------------------------------------------------- /backend/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "opengpts" 3 | version = "0.1.0" 4 | description = "" 5 | authors = ["Your Name "] 6 | readme = "README.md" 7 | packages = [{include = "app"}] 8 | 9 | [tool.poetry.dependencies] 10 | python = "^3.9.0,<3.12" 11 | sse-starlette = "^1.6.5" 12 | tomli-w = "^1.0.0" 13 | uvicorn = "^0.23.2" 14 | fastapi = "^0.103.2" 15 | # Uncomment if you need to work from a development branch 16 | # This will only work for local development though! 17 | # langchain = { git = "git@github.com:langchain-ai/langchain.git/", branch = "nc/subclass-runnable-binding" , subdirectory = "libs/langchain"} 18 | orjson = "^3.9.10" 19 | python-multipart = "^0.0.6" 20 | tiktoken = "^0" 21 | langchain = "^0.3" 22 | langgraph = "0.2.45" 23 | langgraph-checkpoint-postgres = "^2.0.2" 24 | pydantic = "^2" 25 | langchain-openai = "^0.2" 26 | beautifulsoup4 = "^4.12.3" 27 | boto3 = "^1.34.28" 28 | duckduckgo-search = "^5.3.0" 29 | arxiv = "^2.1.0" 30 | kay = "^0.1.2" 31 | xmltodict = "^0.13.0" 32 | wikipedia = "^1.4.0" 33 | langchain-google-vertexai = "^2.0" 34 | langchain-google-community = "^2.0.1" 35 | setuptools = "^69.0.3" 36 | pdfminer-six = "^20231228" 37 | fireworks-ai = "^0.11.2" 38 | httpx = { version = "^0", extras = ["socks"] } 39 | unstructured = {extras = ["doc", "docx"], version = "^0"} 40 | pgvector = "^0.2.5" 41 | psycopg2-binary = "^2.9.9" 42 | asyncpg = "^0.29.0" 43 | langchain-core = "^0.3" 44 | pyjwt = {extras = ["crypto"], version = "^2.8.0"} 45 | langchain-anthropic = "^0.2" 46 | structlog = "^24.1.0" 47 | python-json-logger = "^2.0.7" 48 | 49 | [tool.poetry.group.dev.dependencies] 50 | uvicorn = "^0.23.2" 51 | pygithub = "^2.1.1" 52 | 53 | [tool.poetry.group.lint.dependencies] 54 | ruff = "^0.1.4" 55 | codespell = "^2.2.0" 56 | 57 | [tool.poetry.group.test.dependencies] 58 | pytest = "^7.2.1" 59 | pytest-cov = "^4.0.0" 60 | pytest-asyncio = "^0.21.1" 61 | pytest-mock = "^3.11.1" 62 | pytest-socket = "^0.6.0" 63 | pytest-watch = "^4.2.0" 64 | pytest-timeout = "^2.2.0" 65 | 66 | [tool.coverage.run] 67 | omit = [ 68 | "tests/*", 69 | ] 70 | 71 | [tool.pytest.ini_options] 72 | # --strict-markers will raise errors on unknown marks. 73 | # https://docs.pytest.org/en/7.1.x/how-to/mark.html#raising-errors-on-unknown-marks 74 | # 75 | # https://docs.pytest.org/en/7.1.x/reference/reference.html 76 | # --strict-config any warnings encountered while parsing the `pytest` 77 | # section of the configuration file raise errors. 78 | addopts = "--strict-markers --strict-config --durations=5 -vv" 79 | # Use global timeout of 30 seconds for now. 80 | # Most tests should be closer to ~100 ms, but some of the tests involve 81 | # parsing files. We can adjust on a per test basis later on. 82 | timeout = 30 83 | asyncio_mode = "auto" 84 | 85 | 86 | [build-system] 87 | requires = ["poetry-core"] 88 | build-backend = "poetry.core.masonry.api" 89 | -------------------------------------------------------------------------------- /backend/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/langchain-ai/opengpts/7ab956faa74a115fb6e2bdbaad03cfeecc3474fe/backend/tests/__init__.py -------------------------------------------------------------------------------- /backend/tests/unit_tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/langchain-ai/opengpts/7ab956faa74a115fb6e2bdbaad03cfeecc3474fe/backend/tests/unit_tests/__init__.py -------------------------------------------------------------------------------- /backend/tests/unit_tests/agent_executor/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/langchain-ai/opengpts/7ab956faa74a115fb6e2bdbaad03cfeecc3474fe/backend/tests/unit_tests/agent_executor/__init__.py -------------------------------------------------------------------------------- /backend/tests/unit_tests/agent_executor/test_parsing.py: -------------------------------------------------------------------------------- 1 | """Test parsing logic.""" 2 | import mimetypes 3 | 4 | from langchain_community.document_loaders import Blob 5 | 6 | from app.parsing import MIMETYPE_BASED_PARSER, SUPPORTED_MIMETYPES 7 | from tests.unit_tests.fixtures import get_sample_paths 8 | 9 | 10 | def test_list_of_supported_mimetypes() -> None: 11 | """This list should generally grow! Protecting against typos in mimetypes.""" 12 | assert SUPPORTED_MIMETYPES == [ 13 | "application/msword", 14 | "application/pdf", 15 | "application/vnd.openxmlformats-officedocument.wordprocessingml.document", 16 | "text/html", 17 | "text/plain", 18 | ] 19 | 20 | 21 | def test_attempt_to_parse_each_fixture() -> None: 22 | """Attempt to parse supported fixtures.""" 23 | seen_mimetypes = set() 24 | for path in get_sample_paths(): 25 | type_, _ = mimetypes.guess_type(path) 26 | if type_ not in SUPPORTED_MIMETYPES: 27 | continue 28 | seen_mimetypes.add(type_) 29 | blob = Blob.from_path(path) 30 | documents = MIMETYPE_BASED_PARSER.parse(blob) 31 | try: 32 | assert len(documents) == 1 33 | doc = documents[0] 34 | assert "source" in doc.metadata 35 | assert doc.metadata["source"] == str(path) 36 | assert "🦜" in doc.page_content 37 | except Exception as e: 38 | raise AssertionError(f"Failed to parse {path}") from e 39 | 40 | known_missing = {"application/msword"} 41 | assert set(SUPPORTED_MIMETYPES) - known_missing == seen_mimetypes 42 | -------------------------------------------------------------------------------- /backend/tests/unit_tests/agent_executor/test_upload.py: -------------------------------------------------------------------------------- 1 | from io import BytesIO 2 | 3 | from fastapi import UploadFile 4 | from langchain.text_splitter import RecursiveCharacterTextSplitter 5 | 6 | from app.upload import IngestRunnable, _guess_mimetype, convert_ingestion_input_to_blob 7 | from tests.unit_tests.fixtures import get_sample_paths 8 | from tests.unit_tests.utils import InMemoryVectorStore 9 | 10 | 11 | def test_ingestion_runnable() -> None: 12 | """Test ingestion runnable""" 13 | vectorstore = InMemoryVectorStore() 14 | splitter = RecursiveCharacterTextSplitter() 15 | runnable = IngestRunnable( 16 | text_splitter=splitter, 17 | vectorstore=vectorstore, 18 | input_key="file_contents", 19 | assistant_id="TheParrot", 20 | ) 21 | # Simulate file data 22 | file_data = BytesIO(b"test data") 23 | file_data.seek(0) 24 | # Create UploadFile object 25 | file = UploadFile(filename="testfile.txt", file=file_data) 26 | 27 | # Convert the file to blob 28 | blob = convert_ingestion_input_to_blob(file) 29 | ids = runnable.invoke(blob) 30 | assert len(ids) == 1 31 | 32 | 33 | def test_mimetype_guessing() -> None: 34 | """Verify mimetype guessing for all fixtures.""" 35 | name_to_mime = {} 36 | for file in sorted(get_sample_paths()): 37 | data = file.read_bytes() 38 | name_to_mime[file.name] = _guess_mimetype(file.name, data) 39 | 40 | assert { 41 | "sample.docx": ( 42 | "application/vnd.openxmlformats-officedocument.wordprocessingml.document" 43 | ), 44 | "sample.epub": "application/epub+zip", 45 | "sample.html": "text/html", 46 | "sample.odt": "application/vnd.oasis.opendocument.text", 47 | "sample.pdf": "application/pdf", 48 | "sample.rtf": "application/rtf", 49 | "sample.txt": "text/plain", 50 | } == name_to_mime 51 | -------------------------------------------------------------------------------- /backend/tests/unit_tests/app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/langchain-ai/opengpts/7ab956faa74a115fb6e2bdbaad03cfeecc3474fe/backend/tests/unit_tests/app/__init__.py -------------------------------------------------------------------------------- /backend/tests/unit_tests/app/helpers.py: -------------------------------------------------------------------------------- 1 | from contextlib import asynccontextmanager 2 | 3 | from httpx import AsyncClient 4 | from typing_extensions import AsyncGenerator 5 | 6 | 7 | @asynccontextmanager 8 | async def get_client() -> AsyncGenerator[AsyncClient, None]: 9 | """Get the app.""" 10 | from app.server import app 11 | 12 | async with AsyncClient(app=app, base_url="http://test") as ac: 13 | yield ac 14 | -------------------------------------------------------------------------------- /backend/tests/unit_tests/app/test_app.py: -------------------------------------------------------------------------------- 1 | """Test the server and client together.""" 2 | 3 | from typing import Optional, Sequence 4 | from uuid import uuid4 5 | 6 | import asyncpg 7 | from pydantic import BaseModel 8 | 9 | from app.schema import Assistant, Thread 10 | from tests.unit_tests.app.helpers import get_client 11 | 12 | 13 | def _project(model: BaseModel, *, exclude_keys: Optional[Sequence[str]] = None) -> dict: 14 | """Return a dict with only the keys specified.""" 15 | d = model.model_dump() 16 | _exclude = set(exclude_keys) if exclude_keys else set() 17 | return {k: v for k, v in d.items() if k not in _exclude} 18 | 19 | 20 | async def test_list_and_create_assistants(pool: asyncpg.pool.Pool) -> None: 21 | """Test list and create assistants.""" 22 | headers = {"Cookie": "opengpts_user_id=1"} 23 | aid = str(uuid4()) 24 | 25 | async with pool.acquire() as conn: 26 | assert len(await conn.fetch("SELECT * FROM assistant;")) == 0 27 | 28 | async with get_client() as client: 29 | response = await client.get( 30 | "/assistants/", 31 | headers=headers, 32 | ) 33 | assert response.status_code == 200 34 | 35 | assert response.json() == [] 36 | 37 | # Create an assistant 38 | response = await client.put( 39 | f"/assistants/{aid}", 40 | json={"name": "bobby", "config": {}, "public": False}, 41 | headers=headers, 42 | ) 43 | assert response.status_code == 200 44 | assistant = Assistant.model_validate(response.json()) 45 | assert _project(assistant, exclude_keys=["updated_at", "user_id"]) == { 46 | "assistant_id": aid, 47 | "config": {}, 48 | "name": "bobby", 49 | "public": False, 50 | } 51 | async with pool.acquire() as conn: 52 | assert len(await conn.fetch("SELECT * FROM assistant;")) == 1 53 | 54 | response = await client.get("/assistants/", headers=headers) 55 | assistants = [Assistant.model_validate(d) for d in response.json()] 56 | assert [ 57 | _project(d, exclude_keys=["updated_at", "user_id"]) for d in assistants 58 | ] == [ 59 | { 60 | "assistant_id": aid, 61 | "config": {}, 62 | "name": "bobby", 63 | "public": False, 64 | } 65 | ] 66 | 67 | response = await client.put( 68 | f"/assistants/{aid}", 69 | json={"name": "bobby", "config": {}, "public": False}, 70 | headers=headers, 71 | ) 72 | 73 | assistant = Assistant.model_validate(response.json()) 74 | assert _project(assistant, exclude_keys=["updated_at", "user_id"]) == { 75 | "assistant_id": aid, 76 | "config": {}, 77 | "name": "bobby", 78 | "public": False, 79 | } 80 | 81 | # Check not visible to other users 82 | headers = {"Cookie": "opengpts_user_id=2"} 83 | response = await client.get("/assistants/", headers=headers) 84 | assert response.status_code == 200, response.text 85 | assert response.json() == [] 86 | 87 | 88 | async def test_threads(pool: asyncpg.pool.Pool) -> None: 89 | """Test put thread.""" 90 | headers = {"Cookie": "opengpts_user_id=1"} 91 | aid = str(uuid4()) 92 | tid = str(uuid4()) 93 | 94 | async with get_client() as client: 95 | response = await client.put( 96 | f"/assistants/{aid}", 97 | json={ 98 | "name": "assistant", 99 | "config": {"configurable": {"type": "chatbot"}}, 100 | "public": False, 101 | }, 102 | headers=headers, 103 | ) 104 | 105 | response = await client.put( 106 | f"/threads/{tid}", 107 | json={"name": "bobby", "assistant_id": aid}, 108 | headers=headers, 109 | ) 110 | assert response.status_code == 200, response.text 111 | _ = Thread.model_validate(response.json()) 112 | 113 | response = await client.get(f"/threads/{tid}/state", headers=headers) 114 | assert response.status_code == 200 115 | assert response.json() == {"values": None, "next": []} 116 | 117 | response = await client.get("/threads/", headers=headers) 118 | 119 | assert response.status_code == 200 120 | threads = [Thread.model_validate(d) for d in response.json()] 121 | assert [ 122 | _project(d, exclude_keys=["updated_at", "user_id"]) for d in threads 123 | ] == [ 124 | { 125 | "assistant_id": aid, 126 | "name": "bobby", 127 | "thread_id": tid, 128 | "metadata": {"assistant_type": "chatbot"}, 129 | } 130 | ] 131 | 132 | response = await client.put( 133 | f"/threads/{tid}", 134 | headers={"Cookie": "opengpts_user_id=2"}, 135 | ) 136 | assert response.status_code == 422 137 | -------------------------------------------------------------------------------- /backend/tests/unit_tests/app/test_auth.py: -------------------------------------------------------------------------------- 1 | from base64 import b64encode 2 | from datetime import datetime, timedelta, timezone 3 | from typing import Optional 4 | from unittest.mock import MagicMock, patch 5 | 6 | import jwt 7 | 8 | from app.auth.handlers import AuthedUser, get_auth_handler 9 | from app.auth.settings import ( 10 | AuthType, 11 | JWTSettingsLocal, 12 | JWTSettingsOIDC, 13 | ) 14 | from app.auth.settings import ( 15 | settings as auth_settings, 16 | ) 17 | from app.server import app 18 | from tests.unit_tests.app.helpers import get_client 19 | 20 | 21 | @app.get("/me") 22 | async def me(user: AuthedUser) -> dict: 23 | return user.model_dump() 24 | 25 | 26 | def _create_jwt( 27 | key: str, alg: str, payload: dict, headers: Optional[dict] = None 28 | ) -> str: 29 | return jwt.encode(payload, key, algorithm=alg, headers=headers) 30 | 31 | 32 | async def test_noop(): 33 | get_auth_handler.cache_clear() 34 | auth_settings.auth_type = AuthType.NOOP 35 | sub = "user_noop" 36 | 37 | async with get_client() as client: 38 | response = await client.get("/me", cookies={"opengpts_user_id": sub}) 39 | assert response.status_code == 200 40 | assert response.json()["sub"] == sub 41 | 42 | 43 | async def test_jwt_local(): 44 | get_auth_handler.cache_clear() 45 | auth_settings.auth_type = AuthType.JWT_LOCAL 46 | key = "key" 47 | auth_settings.jwt_local = JWTSettingsLocal( 48 | alg="HS256", 49 | iss="issuer", 50 | aud="audience", 51 | decode_key_b64=b64encode(key.encode("utf-8")), 52 | ) 53 | sub = "user_jwt_local" 54 | 55 | token = _create_jwt( 56 | key=key, 57 | alg=auth_settings.jwt_local.alg, 58 | payload={ 59 | "sub": sub, 60 | "iss": auth_settings.jwt_local.iss, 61 | "aud": auth_settings.jwt_local.aud, 62 | "exp": datetime.now(timezone.utc) + timedelta(days=1), 63 | }, 64 | ) 65 | 66 | async with get_client() as client: 67 | response = await client.get("/me", headers={"Authorization": f"Bearer {token}"}) 68 | assert response.status_code == 200 69 | assert response.json()["sub"] == sub 70 | 71 | # Test invalid token 72 | async with get_client() as client: 73 | response = await client.get("/me", headers={"Authorization": "Bearer xyz"}) 74 | assert response.status_code == 401 75 | 76 | 77 | async def test_jwt_oidc(): 78 | get_auth_handler.cache_clear() 79 | auth_settings.auth_type = AuthType.JWT_OIDC 80 | auth_settings.jwt_oidc = JWTSettingsOIDC(iss="issuer", aud="audience") 81 | sub = "user_jwt_oidc" 82 | key = "key" 83 | alg = "HS256" 84 | 85 | token = _create_jwt( 86 | key=key, 87 | alg=alg, 88 | payload={ 89 | "sub": sub, 90 | "iss": auth_settings.jwt_oidc.iss, 91 | "aud": auth_settings.jwt_oidc.aud, 92 | "exp": datetime.now(timezone.utc) + timedelta(days=1), 93 | }, 94 | headers={"kid": "kid", "alg": alg}, 95 | ) 96 | 97 | mock_jwk_client = MagicMock() 98 | mock_jwk_client.get_signing_key.return_value = MagicMock(key=key) 99 | 100 | with patch( 101 | "app.auth.handlers.JWTAuthOIDC._get_jwk_client", return_value=mock_jwk_client 102 | ): 103 | async with get_client() as client: 104 | response = await client.get( 105 | "/me", headers={"Authorization": f"Bearer {token}"} 106 | ) 107 | assert response.status_code == 200 108 | assert response.json()["sub"] == sub 109 | -------------------------------------------------------------------------------- /backend/tests/unit_tests/conftest.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import os 3 | import subprocess 4 | 5 | import asyncpg 6 | import pytest 7 | 8 | from app.auth.settings import AuthType 9 | from app.auth.settings import settings as auth_settings 10 | from app.lifespan import get_pg_pool, lifespan 11 | from app.server import app 12 | 13 | auth_settings.auth_type = AuthType.NOOP 14 | 15 | # Temporary handling of environment variables for testing 16 | os.environ["OPENAI_API_KEY"] = "test" 17 | 18 | TEST_DB = "test" 19 | assert os.environ["POSTGRES_DB"] != TEST_DB, "Test and main database conflict." 20 | os.environ["POSTGRES_DB"] = TEST_DB 21 | 22 | 23 | async def _get_conn() -> asyncpg.Connection: 24 | return await asyncpg.connect( 25 | user=os.environ["POSTGRES_USER"], 26 | password=os.environ["POSTGRES_PASSWORD"], 27 | host=os.environ["POSTGRES_HOST"], 28 | port=os.environ["POSTGRES_PORT"], 29 | database="postgres", 30 | ) 31 | 32 | 33 | async def _create_test_db() -> None: 34 | """Check if the test database exists and create it if it doesn't.""" 35 | conn = await _get_conn() 36 | exists = await conn.fetchval("SELECT 1 FROM pg_database WHERE datname=$1", TEST_DB) 37 | if not exists: 38 | await conn.execute(f'CREATE DATABASE "{TEST_DB}"') 39 | await conn.close() 40 | 41 | 42 | async def _drop_test_db() -> None: 43 | """Check if the test database exists and if so, drop it.""" 44 | conn = await _get_conn() 45 | exists = await conn.fetchval("SELECT 1 FROM pg_database WHERE datname=$1", TEST_DB) 46 | if exists: 47 | await conn.execute(f'DROP DATABASE "{TEST_DB}" WITH (FORCE)') 48 | await conn.close() 49 | 50 | 51 | def _migrate_test_db() -> None: 52 | subprocess.run(["make", "migrate"], check=True) 53 | 54 | 55 | @pytest.fixture(scope="session") 56 | async def _init_db(): 57 | """Initialize the test database.""" 58 | await _drop_test_db() # In case previous test session was abruptly terminated 59 | await _create_test_db() 60 | _migrate_test_db() 61 | 62 | 63 | @pytest.fixture(scope="session") 64 | async def pool(_init_db): 65 | """Initialize database pool with checkpointer.""" 66 | async with lifespan(app): 67 | yield get_pg_pool() 68 | await _drop_test_db() 69 | 70 | 71 | @pytest.fixture(scope="function", autouse=True) 72 | async def clear_test_db(pool): 73 | """Truncate all tables before each test.""" 74 | async with pool.acquire() as conn: 75 | query = """ 76 | DO 77 | $$ 78 | DECLARE 79 | r RECORD; 80 | BEGIN 81 | FOR r IN (SELECT tablename FROM pg_tables WHERE schemaname = 'public') LOOP 82 | EXECUTE 'TRUNCATE TABLE ' || quote_ident(r.tablename) || ' CASCADE;'; 83 | END LOOP; 84 | END 85 | $$; 86 | """ 87 | await conn.execute(query) 88 | 89 | 90 | @pytest.fixture(scope="session") 91 | def event_loop(request): 92 | loop = asyncio.get_event_loop_policy().new_event_loop() 93 | yield loop 94 | loop.close() 95 | -------------------------------------------------------------------------------- /backend/tests/unit_tests/fixtures/__init__.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import List 3 | 4 | HERE = Path(__file__).parent 5 | 6 | # PUBLIC API 7 | 8 | 9 | def get_sample_paths() -> List[Path]: 10 | """List all fixtures.""" 11 | return list(HERE.glob("sample.*")) 12 | -------------------------------------------------------------------------------- /backend/tests/unit_tests/fixtures/sample.docx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/langchain-ai/opengpts/7ab956faa74a115fb6e2bdbaad03cfeecc3474fe/backend/tests/unit_tests/fixtures/sample.docx -------------------------------------------------------------------------------- /backend/tests/unit_tests/fixtures/sample.epub: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/langchain-ai/opengpts/7ab956faa74a115fb6e2bdbaad03cfeecc3474fe/backend/tests/unit_tests/fixtures/sample.epub -------------------------------------------------------------------------------- /backend/tests/unit_tests/fixtures/sample.odt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/langchain-ai/opengpts/7ab956faa74a115fb6e2bdbaad03cfeecc3474fe/backend/tests/unit_tests/fixtures/sample.odt -------------------------------------------------------------------------------- /backend/tests/unit_tests/fixtures/sample.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/langchain-ai/opengpts/7ab956faa74a115fb6e2bdbaad03cfeecc3474fe/backend/tests/unit_tests/fixtures/sample.pdf -------------------------------------------------------------------------------- /backend/tests/unit_tests/fixtures/sample.txt: -------------------------------------------------------------------------------- 1 | 🦜️ LangChain 2 | 3 | 4 | 5 | 6 | Underline 7 | 8 | 9 | Bold 10 | 11 | 12 | Italics 13 | 14 | 15 | 16 | 17 | 18 | 19 | Col 1 20 | Col 2 21 | Row 1 22 | 1 23 | 2 24 | Row 2 25 | 3 26 | 4 27 | 28 | 29 | 30 | 31 | Link: https://www.langchain.com/ 32 | 33 | 34 | 35 | 36 | * Item 1 37 | * Item 2 38 | * Item 3 39 | * We also love cats 🐱 40 | 41 | 42 | Image -------------------------------------------------------------------------------- /backend/tests/unit_tests/test_imports.py: -------------------------------------------------------------------------------- 1 | """Shallow tests that make sure we can at least import the code.""" 2 | 3 | 4 | def test_import_app() -> None: 5 | """Test import app""" 6 | import app # noqa: F401 7 | -------------------------------------------------------------------------------- /backend/tests/unit_tests/utils.py: -------------------------------------------------------------------------------- 1 | """Test ingestion utilities.""" 2 | from typing import Any, Dict, Iterable, List, Optional, Sequence, Type 3 | 4 | from langchain.schema import Document 5 | from langchain.schema.embeddings import Embeddings 6 | from langchain.schema.vectorstore import VST, VectorStore 7 | 8 | 9 | class InMemoryVectorStore(VectorStore): 10 | """In-memory implementation of VectorStore using a dictionary.""" 11 | 12 | def __init__(self) -> None: 13 | """Vector store interface for testing things in memory.""" 14 | self.store: Dict[str, Document] = {} 15 | 16 | def delete(self, ids: Optional[Sequence[str]] = None, **kwargs: Any) -> None: 17 | """Delete the given documents from the store using their IDs.""" 18 | if ids: 19 | for _id in ids: 20 | self.store.pop(_id, None) 21 | 22 | async def adelete(self, ids: Optional[Sequence[str]] = None, **kwargs: Any) -> None: 23 | """Delete the given documents from the store using their IDs.""" 24 | if ids: 25 | for _id in ids: 26 | self.store.pop(_id, None) 27 | 28 | def add_documents( 29 | self, 30 | documents: Sequence[Document], 31 | *, 32 | ids: Optional[Sequence[str]] = None, 33 | **kwargs: Any, 34 | ) -> List[str]: 35 | """Add the given documents to the store (insert behavior).""" 36 | if ids and len(ids) != len(documents): 37 | raise ValueError( 38 | f"Expected {len(ids)} ids, got {len(documents)} documents." 39 | ) 40 | 41 | if not ids: 42 | start_idx = max(self.store.keys(), default=0) 43 | ids = [str(x) for x in (range(start_idx, start_idx + len(documents)))] 44 | 45 | for _id, document in zip(ids, documents): 46 | if _id in self.store: 47 | raise ValueError( 48 | f"Document with uid {_id} already exists in the store." 49 | ) 50 | self.store[_id] = document 51 | return ids 52 | 53 | async def aadd_documents( 54 | self, 55 | documents: Sequence[Document], 56 | *, 57 | ids: Optional[Sequence[str]] = None, 58 | **kwargs: Any, 59 | ) -> List[str]: 60 | if ids and len(ids) != len(documents): 61 | raise ValueError( 62 | f"Expected {len(ids)} ids, got {len(documents)} documents." 63 | ) 64 | 65 | if not ids: 66 | start_idx = max(self.store.keys(), default=0) 67 | ids = [str(x) for x in (range(start_idx, start_idx + len(documents)))] 68 | 69 | for _id, document in zip(ids, documents): 70 | if _id in self.store: 71 | raise ValueError( 72 | f"Document with uid {_id} already exists in the store." 73 | ) 74 | self.store[_id] = document 75 | return list(ids) 76 | 77 | def add_texts( 78 | self, 79 | texts: Iterable[str], 80 | metadatas: Optional[List[Dict[Any, Any]]] = None, 81 | **kwargs: Any, 82 | ) -> List[str]: 83 | """Add the given texts to the store (insert behavior).""" 84 | 85 | raise NotImplementedError() 86 | 87 | @classmethod 88 | def from_texts( 89 | cls: Type[VST], 90 | texts: List[str], 91 | embedding: Embeddings, 92 | metadatas: Optional[List[Dict[Any, Any]]] = None, 93 | **kwargs: Any, 94 | ) -> VST: 95 | """Create a vector store from a list of texts.""" 96 | raise NotImplementedError() 97 | 98 | def similarity_search( 99 | self, query: str, k: int = 4, **kwargs: Any 100 | ) -> List[Document]: 101 | """Find the most similar documents to the given query.""" 102 | raise NotImplementedError() 103 | -------------------------------------------------------------------------------- /docker-compose-prod.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | postgres: 5 | image: pgvector/pgvector:pg16 6 | healthcheck: 7 | test: pg_isready -U $POSTGRES_USER 8 | start_interval: 1s 9 | start_period: 5s 10 | interval: 5s 11 | retries: 5 12 | ports: 13 | - "5433:5432" 14 | env_file: 15 | - .env 16 | volumes: 17 | - ./postgres-volume:/var/lib/postgresql/data 18 | postgres-setup: 19 | image: migrate/migrate 20 | depends_on: 21 | postgres: 22 | condition: service_healthy 23 | volumes: 24 | - ./backend/migrations:/migrations 25 | env_file: 26 | - .env 27 | command: ["-path", "/migrations", "-database", "postgres://$POSTGRES_USER:$POSTGRES_PASSWORD@postgres:$POSTGRES_PORT/$POSTGRES_DB?sslmode=disable", "up"] 28 | backend: 29 | container_name: opengpts-backend 30 | image: docker.io/langchain/open-gpts:latest 31 | ports: 32 | - "8100:8000" # Backend is accessible on localhost:8100 and serves the frontend 33 | depends_on: 34 | postgres-setup: 35 | condition: service_completed_successfully 36 | env_file: 37 | - .env 38 | environment: 39 | POSTGRES_HOST: "postgres" 40 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | postgres: 5 | image: pgvector/pgvector:pg16 6 | healthcheck: 7 | test: pg_isready -U $POSTGRES_USER 8 | start_interval: 1s 9 | start_period: 5s 10 | interval: 5s 11 | retries: 5 12 | ports: 13 | - "5433:5432" 14 | env_file: 15 | - .env 16 | volumes: 17 | - ./postgres-volume:/var/lib/postgresql/data 18 | postgres-setup: 19 | image: migrate/migrate 20 | depends_on: 21 | postgres: 22 | condition: service_healthy 23 | volumes: 24 | - ./backend/migrations:/migrations 25 | env_file: 26 | - .env 27 | command: ["-path", "/migrations", "-database", "postgres://$POSTGRES_USER:$POSTGRES_PASSWORD@postgres:$POSTGRES_PORT/$POSTGRES_DB?sslmode=disable", "up"] 28 | backend: 29 | container_name: opengpts-backend 30 | build: 31 | context: backend 32 | ports: 33 | - "8100:8000" # Backend is accessible on localhost:8100 34 | depends_on: 35 | postgres-setup: 36 | condition: service_completed_successfully 37 | env_file: 38 | - .env 39 | volumes: 40 | - ./backend:/backend 41 | environment: 42 | POSTGRES_HOST: "postgres" 43 | command: 44 | - --reload 45 | frontend: 46 | container_name: opengpts-frontend 47 | build: 48 | context: frontend 49 | depends_on: 50 | backend: 51 | condition: service_healthy 52 | volumes: 53 | - ./frontend/src:/frontend/src 54 | ports: 55 | - "5173:5173" # Frontend is accessible on localhost:5173 56 | environment: 57 | VITE_BACKEND_URL: "http://backend:8000" 58 | -------------------------------------------------------------------------------- /frontend/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | "eslint:recommended", 6 | "plugin:@typescript-eslint/recommended", 7 | "plugin:react-hooks/recommended", 8 | ], 9 | ignorePatterns: ["dist", ".eslintrc.cjs"], 10 | parser: "@typescript-eslint/parser", 11 | plugins: ["react-refresh"], 12 | rules: { 13 | "react-refresh/only-export-components": [ 14 | "warn", 15 | { allowConstantExport: true }, 16 | ], 17 | "@typescript-eslint/no-unused-vars": 0, 18 | }, 19 | }; 20 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | # Frontend Dockerfile 2 | FROM node:20 3 | 4 | # Set the working directory 5 | WORKDIR /frontend 6 | 7 | # Copy the package.json and yarn.lock 8 | COPY ./package.json ./ 9 | COPY ./yarn.lock ./ 10 | 11 | # Install Yarn and dependencies 12 | RUN yarn install 13 | 14 | # Copy the rest of the frontend code 15 | COPY . . 16 | 17 | # Expose the port the frontend runs on 18 | EXPOSE 5173 19 | 20 | # Command to start the frontend 21 | CMD ["yarn", "dev"] 22 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # React + TypeScript + Vite 2 | 3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. 4 | 5 | Currently, two official plugins are available: 6 | 7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh 8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh 9 | 10 | ## Expanding the ESLint configuration 11 | 12 | If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: 13 | 14 | - Configure the top-level `parserOptions` property like this: 15 | 16 | ```js 17 | parserOptions: { 18 | ecmaVersion: 'latest', 19 | sourceType: 'module', 20 | project: ['./tsconfig.json', './tsconfig.node.json'], 21 | tsconfigRootDir: __dirname, 22 | }, 23 | ``` 24 | 25 | - Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked` 26 | - Optionally add `plugin:@typescript-eslint/stylistic-type-checked` 27 | - Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list 28 | -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | OpenGPTs 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "private": true, 4 | "version": "0.0.0", 5 | "packageManager": "yarn@1.22.19", 6 | "type": "module", 7 | "scripts": { 8 | "dev": "vite --host", 9 | "build": "tsc && vite build", 10 | "lint": "prettier -c src && tsc --noEmit && eslint src --ext ts,tsx --report-unused-disable-directives", 11 | "preview": "vite preview", 12 | "format": "prettier -w src" 13 | }, 14 | "dependencies": { 15 | "@codemirror/lang-json": "^6.0.1", 16 | "@headlessui/react": "^1.7.17", 17 | "@heroicons/react": "^2.0.18", 18 | "@microsoft/fetch-event-source": "^2.0.1", 19 | "@tailwindcss/forms": "^0.5.6", 20 | "@tailwindcss/typography": "^0.5.10", 21 | "@uiw/react-codemirror": "^4.21.25", 22 | "clsx": "^2.0.0", 23 | "dompurify": "^3.0.6", 24 | "lodash": "^4.17.21", 25 | "marked": "^9.1.5", 26 | "react": "^18.2.0", 27 | "react-dom": "^18.2.0", 28 | "react-dropzone": "^14.2.3", 29 | "react-query": "^3.39.3", 30 | "react-router-dom": "^6.22.3", 31 | "tailwind-merge": "^2.0.0", 32 | "uuid": "^9.0.1" 33 | }, 34 | "devDependencies": { 35 | "@types/dompurify": "^3.0.4", 36 | "@types/lodash": "^4.14.201", 37 | "@types/react": "^18.2.15", 38 | "@types/react-dom": "^18.2.7", 39 | "@types/uuid": "^9.0.8", 40 | "@typescript-eslint/eslint-plugin": "^6.0.0", 41 | "@typescript-eslint/parser": "^6.0.0", 42 | "@vitejs/plugin-react": "^4.0.3", 43 | "autoprefixer": "^10.4.16", 44 | "eslint": "^8.45.0", 45 | "eslint-plugin-react-hooks": "^4.6.0", 46 | "eslint-plugin-react-refresh": "^0.4.3", 47 | "postcss": "^8.4.31", 48 | "prettier": "^3.2.5", 49 | "tailwindcss": "^3.3.5", 50 | "typescript": "^5.0.2", 51 | "vite": "^4.4.5" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /frontend/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /frontend/src/api/assistants.ts: -------------------------------------------------------------------------------- 1 | import { Config } from "../hooks/useConfigList"; 2 | 3 | export async function getAssistant( 4 | assistantId: string, 5 | ): Promise { 6 | try { 7 | const response = await fetch(`/assistants/${assistantId}`); 8 | if (!response.ok) { 9 | return null; 10 | } 11 | return (await response.json()) as Config; 12 | } catch (error) { 13 | console.error("Failed to fetch assistant:", error); 14 | return null; 15 | } 16 | } 17 | 18 | export async function getAssistants(): Promise { 19 | try { 20 | const response = await fetch(`/assistants/`); 21 | if (!response.ok) { 22 | return null; 23 | } 24 | return (await response.json()) as Config[]; 25 | } catch (error) { 26 | console.error("Failed to fetch assistants:", error); 27 | return null; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /frontend/src/api/threads.ts: -------------------------------------------------------------------------------- 1 | import { Chat } from "../types"; 2 | 3 | export async function getThread(threadId: string): Promise { 4 | try { 5 | const response = await fetch(`/threads/${threadId}`); 6 | if (!response.ok) { 7 | return null; 8 | } 9 | return (await response.json()) as Chat; 10 | } catch (error) { 11 | console.error("Failed to fetch assistant:", error); 12 | return null; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /frontend/src/components/Chat.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from "react"; 2 | import { StreamStateProps } from "../hooks/useStreamState"; 3 | import { useChatMessages } from "../hooks/useChatMessages"; 4 | import TypingBox from "./TypingBox"; 5 | import { MessageViewer } from "./Message"; 6 | import { 7 | ArrowDownCircleIcon, 8 | CheckCircleIcon, 9 | } from "@heroicons/react/24/outline"; 10 | import { MessageWithFiles } from "../utils/formTypes.ts"; 11 | import { useParams } from "react-router-dom"; 12 | import { useThreadAndAssistant } from "../hooks/useThreadAndAssistant.ts"; 13 | import { useMessageEditing } from "../hooks/useMessageEditing.ts"; 14 | import { MessageEditor } from "./MessageEditor.tsx"; 15 | import { Message } from "../types.ts"; 16 | 17 | interface ChatProps extends Pick { 18 | startStream: ( 19 | message: MessageWithFiles | null, 20 | thread_id: string, 21 | assistantType: string, 22 | ) => Promise; 23 | } 24 | 25 | function usePrevious(value: T): T | undefined { 26 | const ref = useRef(); 27 | useEffect(() => { 28 | ref.current = value; 29 | }); 30 | return ref.current; 31 | } 32 | 33 | function CommitEdits(props: { 34 | editing: Record; 35 | commitEdits: () => Promise; 36 | }) { 37 | const [inflight, setInflight] = useState(false); 38 | return ( 39 |
40 |
41 | {Object.keys(props.editing).length} message(s) edited. 42 |
43 | 61 |
62 | ); 63 | } 64 | 65 | export function Chat(props: ChatProps) { 66 | const { chatId } = useParams(); 67 | const { messages, next, refreshMessages } = useChatMessages( 68 | chatId ?? null, 69 | props.stream, 70 | props.stopStream, 71 | ); 72 | const { currentChat, assistantConfig, isLoading } = useThreadAndAssistant(); 73 | const { editing, recordEdits, commitEdits, abandonEdits } = useMessageEditing( 74 | chatId, 75 | refreshMessages, 76 | ); 77 | 78 | const prevMessages = usePrevious(messages); 79 | useEffect(() => { 80 | scrollTo({ 81 | top: document.body.scrollHeight, 82 | behavior: 83 | prevMessages && prevMessages?.length === messages?.length 84 | ? "smooth" 85 | : undefined, 86 | }); 87 | // eslint-disable-next-line react-hooks/exhaustive-deps 88 | }, [messages]); 89 | 90 | if (isLoading) return
Loading...
; 91 | if (!currentChat || !assistantConfig) return
No data.
; 92 | 93 | return ( 94 |
95 | {messages?.map((msg, i) => 96 | editing[msg.id] ? ( 97 | abandonEdits(msg)} 102 | /> 103 | ) : ( 104 | recordEdits(msg)} 113 | alwaysShowControls={i === messages.length - 1} 114 | /> 115 | ), 116 | )} 117 | {(props.stream?.status === "inflight" || messages === null) && ( 118 |
119 | ... 120 |
121 | )} 122 | {props.stream?.status === "error" && ( 123 |
124 | An error has occurred. Please try again. 125 |
126 | )} 127 | {next.length > 0 && 128 | props.stream?.status !== "inflight" && 129 | Object.keys(editing).length === 0 && ( 130 |
133 | props.startStream( 134 | null, 135 | currentChat.thread_id, 136 | assistantConfig.config.configurable?.type as string, 137 | ) 138 | } 139 | > 140 | 141 | Click to continue. 142 |
143 | )} 144 |
145 | {commitEdits && Object.keys(editing).length > 0 ? ( 146 | 147 | ) : ( 148 | 150 | props.startStream( 151 | msg, 152 | currentChat.thread_id, 153 | assistantConfig.config.configurable?.type as string, 154 | ) 155 | } 156 | onInterrupt={ 157 | props.stream?.status === "inflight" ? props.stopStream : undefined 158 | } 159 | inflight={props.stream?.status === "inflight"} 160 | currentConfig={assistantConfig} 161 | currentChat={currentChat} 162 | /> 163 | )} 164 |
165 |
166 | ); 167 | } 168 | -------------------------------------------------------------------------------- /frontend/src/components/ConfigList.tsx: -------------------------------------------------------------------------------- 1 | import { TYPES } from "../constants"; 2 | import { Config, ConfigListProps } from "../hooks/useConfigList"; 3 | import { cn } from "../utils/cn"; 4 | import { PencilSquareIcon, TrashIcon } from "@heroicons/react/24/outline"; 5 | import { Link } from "react-router-dom"; 6 | 7 | function ConfigItem(props: { 8 | config: Config; 9 | currentConfig: Config | null; 10 | enterConfig: (id: string | null) => void; 11 | deleteConfig: (id: string) => void; 12 | }) { 13 | return ( 14 |
  • 15 |
    props.enterConfig(props.config.assistant_id)} 17 | className={cn( 18 | props.config.assistant_id === props.currentConfig?.assistant_id 19 | ? "bg-gray-50 text-indigo-600" 20 | : "text-gray-700 hover:text-indigo-600 hover:bg-gray-50", 21 | "group flex gap-x-3 rounded-md p-2 leading-6 cursor-pointer", 22 | )} 23 | > 24 | 32 | {props.config.name?.[0] ?? " "} 33 | 34 |
    35 | 36 | {props.config.name} 37 | 38 | 39 | { 40 | TYPES[ 41 | (props.config.config.configurable?.type ?? 42 | "agent") as keyof typeof TYPES 43 | ]?.title 44 | } 45 | 46 |
    47 | event.stopPropagation()} 51 | > 52 | 53 | 54 | { 58 | event.preventDefault(); 59 | if ( 60 | window.confirm( 61 | `Are you sure you want to delete bot "${props.config.name}?"`, 62 | ) 63 | ) { 64 | props.deleteConfig(props.config.assistant_id); 65 | } 66 | }} 67 | > 68 | 69 | 70 |
    71 |
  • 72 | ); 73 | } 74 | 75 | export function ConfigList(props: { 76 | configs: ConfigListProps["configs"]; 77 | currentConfig: Config | null; 78 | enterConfig: (id: string | null) => void; 79 | deleteConfig: (id: string) => void; 80 | }) { 81 | return ( 82 | <> 83 |
    84 | Your Saved Bots 85 |
    86 |
      87 | {props.configs 88 | ?.filter((a) => a.mine) 89 | .map((assistant) => ( 90 | 97 | )) ?? ( 98 |
    • 99 | ... 100 |
    • 101 | )} 102 |
    103 | 104 |
    105 | Public Bots 106 |
    107 |
      108 | {props.configs 109 | ?.filter((a) => !a.mine) 110 | .map((assistant) => ( 111 | 118 | )) ?? ( 119 |
    • 120 | ... 121 |
    • 122 | )} 123 |
    124 | 125 | ); 126 | } 127 | -------------------------------------------------------------------------------- /frontend/src/components/Document.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo, useState } from "react"; 2 | import { ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/24/outline"; 3 | import { cn } from "../utils/cn"; 4 | import { MessageDocument } from "../types"; 5 | import { StringViewer } from "./String"; 6 | 7 | function isValidHttpUrl(str: string) { 8 | let url; 9 | 10 | try { 11 | url = new URL(str); 12 | } catch (_) { 13 | return false; 14 | } 15 | 16 | return url.protocol === "http:" || url.protocol === "https:"; 17 | } 18 | 19 | function DocumentViewer(props: { 20 | document: MessageDocument; 21 | markdown?: boolean; 22 | className?: string; 23 | }) { 24 | const [open, setOpen] = useState(false); 25 | 26 | const metadata = useMemo(() => { 27 | return Object.keys(props.document.metadata) 28 | .sort((a, b) => { 29 | const aValue = JSON.stringify(props.document.metadata[a]); 30 | const bValue = JSON.stringify(props.document.metadata[b]); 31 | 32 | const aLines = aValue.split("\n"); 33 | const bLines = bValue.split("\n"); 34 | 35 | if (aLines.length !== bLines.length) { 36 | return aLines.length - bLines.length; 37 | } 38 | 39 | return aValue.length - bValue.length; 40 | }) 41 | .map((key) => { 42 | const value = props.document.metadata[key]; 43 | return { 44 | key, 45 | value: 46 | typeof value === "string" || typeof value === "number" 47 | ? `${value}` 48 | : JSON.stringify(value), 49 | }; 50 | }); 51 | }, [props.document.metadata]); 52 | 53 | if (!open) { 54 | return ( 55 | 68 | ); 69 | } 70 | 71 | return ( 72 |
    78 | 81 | 82 | 83 | 87 | 88 | 89 | {metadata.map(({ key, value }, idx) => { 90 | return ( 91 | 95 | {key} 96 | {isValidHttpUrl(value) ? ( 97 | 98 | {value} 99 | 100 | ) : ( 101 | {value} 102 | )} 103 | 104 | ); 105 | })} 106 | 107 | 108 |
    109 | ); 110 | } 111 | 112 | export function DocumentList(props: { 113 | documents: MessageDocument[]; 114 | markdown?: boolean; 115 | }) { 116 | return ( 117 |
    118 |
    119 | {props.documents.map((document, idx) => ( 120 | 126 | ))} 127 |
    128 |
    129 | ); 130 | } 131 | -------------------------------------------------------------------------------- /frontend/src/components/FileUpload.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | import { DropzoneState } from "react-dropzone"; 3 | import { XCircleIcon } from "@heroicons/react/24/outline"; 4 | 5 | const baseStyle = { 6 | flex: 1, 7 | display: "flex", 8 | flexDirection: "column", 9 | alignItems: "center", 10 | padding: "20px", 11 | borderWidth: 2, 12 | borderRadius: 2, 13 | borderColor: "#eeeeee", 14 | borderStyle: "dashed", 15 | backgroundColor: "#fafafa", 16 | color: "#bdbdbd", 17 | outline: "none", 18 | transition: "border .24s ease-in-out", 19 | }; 20 | 21 | const focusedStyle = { 22 | borderColor: "#2196f3", 23 | }; 24 | 25 | const acceptStyle = { 26 | borderColor: "#00e676", 27 | }; 28 | 29 | const rejectStyle = { 30 | borderColor: "#ff1744", 31 | }; 32 | 33 | function Label(props: { id: string; title: string }) { 34 | return ( 35 | 41 | ); 42 | } 43 | 44 | export function FileUploadDropzone(props: { 45 | state: DropzoneState; 46 | files: File[]; 47 | setFiles: React.Dispatch>; 48 | className?: string; 49 | }) { 50 | const { getRootProps, getInputProps, fileRejections } = props.state; 51 | 52 | const files = props.files.map((file, i) => ( 53 |
  • 54 | {file.name} - {file.size} bytes 55 | 58 | props.setFiles((files) => files.filter((f) => f !== file)) 59 | } 60 | > 61 | 62 | 63 |
  • 64 | )); 65 | 66 | const style = useMemo( 67 | () => 68 | ({ 69 | ...baseStyle, 70 | ...(props.state.isFocused ? focusedStyle : {}), 71 | ...(props.state.isDragAccept ? acceptStyle : {}), 72 | ...(props.state.isDragReject ? rejectStyle : {}), 73 | }) as React.CSSProperties, 74 | [props.state.isFocused, props.state.isDragAccept, props.state.isDragReject], 75 | ); 76 | 77 | return ( 78 |
    79 | 85 |
    86 | 87 |

    88 | Drag n' drop some files here, or click to select files. 89 |
    90 | Accepted files: .txt, .csv, .html, .docx, .pdf. 91 |
    92 | No file should exceed 10 MB. 93 |

    94 | {fileRejections.length > 0 && ( 95 |
    96 |
      97 | {fileRejections.map((reject, i) => ( 98 |
    • 99 | {reject.file.name} - {reject.errors[0].message} 100 |
    • 101 | ))} 102 |
    103 |
    104 | )} 105 |
    106 |
    107 | ); 108 | } 109 | -------------------------------------------------------------------------------- /frontend/src/components/JsonEditor.tsx: -------------------------------------------------------------------------------- 1 | import CodeMirror from "@uiw/react-codemirror"; 2 | import { json } from "@codemirror/lang-json"; 3 | import { EditorView, keymap } from "@codemirror/view"; 4 | import { defaultKeymap } from "@codemirror/commands"; 5 | import { cn } from "../utils/cn"; 6 | 7 | export function JsonEditor(props: { 8 | value?: string; 9 | onChange?: (data: string) => void; 10 | height?: string; 11 | }) { 12 | return ( 13 |
    14 | true }, ...defaultKeymap]), 21 | json(), 22 | EditorView.lineWrapping, 23 | EditorView.theme({ 24 | "&.cm-editor": { 25 | backgroundColor: "transparent", 26 | transform: "translateX(-1px)", 27 | }, 28 | "&.cm-focused": { 29 | outline: "none", 30 | }, 31 | green: { 32 | background: "green", 33 | }, 34 | "& .cm-content": { 35 | padding: "12px", 36 | }, 37 | "& .cm-line": { 38 | fontFamily: "'Fira Code', monospace", 39 | padding: 0, 40 | overflowAnchor: "none", 41 | fontVariantLigatures: "none", 42 | }, 43 | "& .cm-gutters.cm-gutters": { 44 | backgroundColor: "transparent", 45 | }, 46 | "& .cm-lineNumbers .cm-gutterElement.cm-activeLineGutter": { 47 | marginLeft: "1px", 48 | }, 49 | "& .cm-lineNumbers": { 50 | minWidth: "42px", 51 | }, 52 | "& .cm-foldPlaceholder": { 53 | padding: "0px 4px", 54 | color: "hsl(var(--ls-gray-100))", 55 | backgroundColor: "hsl(var(--divider-500))", 56 | borderColor: "hsl(var(--divider-700))", 57 | }, 58 | '& .cm-gutterElement span[title="Fold line"]': { 59 | transform: "translateY(-4px)", 60 | display: "inline-block", 61 | }, 62 | }), 63 | ]} 64 | /> 65 |
    66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /frontend/src/components/LangSmithActions.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | HandThumbDownIcon, 3 | HandThumbUpIcon, 4 | EllipsisHorizontalIcon, 5 | CheckIcon, 6 | } from "@heroicons/react/24/outline"; 7 | import { useState } from "react"; 8 | 9 | export function LangSmithActions(props: { runId: string }) { 10 | const [state, setState] = useState<{ 11 | score: number; 12 | inflight: boolean; 13 | } | null>(null); 14 | const sendFeedback = async (score: number) => { 15 | setState({ score, inflight: true }); 16 | await fetch(`/runs/feedback`, { 17 | method: "POST", 18 | body: JSON.stringify({ 19 | run_id: props.runId, 20 | key: "user_score", 21 | score: score, 22 | }), 23 | headers: { 24 | "Content-Type": "application/json", 25 | }, 26 | }); 27 | setState({ score, inflight: false }); 28 | }; 29 | return ( 30 |
    31 | 46 | 61 |
    62 | ); 63 | } 64 | -------------------------------------------------------------------------------- /frontend/src/components/Layout.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment } from "react"; 2 | import { Dialog, Transition } from "@headlessui/react"; 3 | import { Bars3Icon, XMarkIcon } from "@heroicons/react/24/outline"; 4 | 5 | export function Layout(props: { 6 | sidebarOpen: boolean; 7 | setSidebarOpen: (open: boolean) => void; 8 | sidebar: React.ReactNode; 9 | children: React.ReactNode; 10 | subtitle?: React.ReactNode; 11 | }) { 12 | return ( 13 | <> 14 | 15 | 20 | 29 |
    30 | 31 | 32 |
    33 | 42 | 43 | 52 |
    53 | 64 |
    65 |
    66 | {/* Sidebar component, swap this element with another sidebar if you like */} 67 |
    68 | 73 |
    74 |
    75 |
    76 |
    77 |
    78 |
    79 | 80 | {/* Static sidebar for desktop */} 81 |
    82 | {/* Sidebar component, swap this element with another sidebar if you like */} 83 |
    84 | 89 |
    90 |
    91 | 92 |
    93 | 101 |
    102 | {props.subtitle ? ( 103 | <> 104 | OpenGPTs: {props.subtitle} 105 | 106 | ) : ( 107 | "OpenGPTs" 108 | )} 109 |
    110 |
    111 | Research Preview: this is unauthenticated and all data can be found. 112 | Do not use with sensitive data 113 |
    114 |
    115 | 116 |
    117 |
    {props.children}
    118 |
    119 | 120 | ); 121 | } 122 | -------------------------------------------------------------------------------- /frontend/src/components/Message.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useState } from "react"; 2 | import { MessageDocument, Message as MessageType } from "../types"; 3 | import { str } from "../utils/str"; 4 | import { cn } from "../utils/cn"; 5 | import { PencilSquareIcon } from "@heroicons/react/24/outline"; 6 | import { LangSmithActions } from "./LangSmithActions"; 7 | import { DocumentList } from "./Document"; 8 | import { omit } from "lodash"; 9 | import { StringViewer } from "./String"; 10 | import { ToolRequest, ToolResponse } from "./Tool"; 11 | 12 | function isDocumentContent( 13 | content: MessageType["content"], 14 | ): content is MessageDocument[] { 15 | return ( 16 | Array.isArray(content) && 17 | content.every((d) => typeof d === "object" && !!d && !!d.page_content) 18 | ); 19 | } 20 | 21 | export function MessageContent(props: { content: MessageType["content"] }) { 22 | if (typeof props.content === "string") { 23 | if (!props.content.trim()) { 24 | return null; 25 | } 26 | return ; 27 | } else if (isDocumentContent(props.content)) { 28 | return ; 29 | } else if ( 30 | Array.isArray(props.content) && 31 | props.content.every( 32 | (it) => typeof it === "object" && !!it && typeof it.content === "string", 33 | ) 34 | ) { 35 | return ( 36 | ({ 39 | page_content: it.content, 40 | metadata: omit(it, "content"), 41 | }))} 42 | /> 43 | ); 44 | } else { 45 | let content = props.content; 46 | if (Array.isArray(content)) { 47 | content = content.filter((it) => 48 | typeof it === "object" && !!it && "type" in it 49 | ? it.type !== "tool_use" 50 | : true, 51 | ); 52 | } 53 | if ( 54 | Array.isArray(content) 55 | ? content.length === 0 56 | : Object.keys(content).length === 0 57 | ) { 58 | return null; 59 | } 60 | return
    {str(content)}
    ; 61 | } 62 | } 63 | 64 | export const MessageViewer = memo(function ( 65 | props: MessageType & { 66 | runId?: string; 67 | startEditing?: () => void; 68 | alwaysShowControls?: boolean; 69 | }, 70 | ) { 71 | const [open, setOpen] = useState(false); 72 | const contentIsDocuments = 73 | ["function", "tool"].includes(props.type) && 74 | isDocumentContent(props.content); 75 | const showContent = 76 | ["function", "tool"].includes(props.type) && !contentIsDocuments 77 | ? open 78 | : true; 79 | return ( 80 |
    81 |
    82 |
    88 |
    89 | {props.type} 90 |
    91 | {props.startEditing && ( 92 | 101 | )} 102 |
    103 |
    104 | {["function", "tool"].includes(props.type) && ( 105 | 110 | )} 111 | {props.tool_calls?.map((call) => ( 112 | 113 | ))} 114 | {showContent && } 115 |
    116 |
    117 | {props.runId && ( 118 |
    119 | 120 |
    121 | )} 122 |
    123 | ); 124 | }); 125 | -------------------------------------------------------------------------------- /frontend/src/components/NewChat.tsx: -------------------------------------------------------------------------------- 1 | import { ConfigList } from "./ConfigList"; 2 | import { Schemas } from "../hooks/useSchemas"; 3 | import TypingBox from "./TypingBox"; 4 | import { Config } from "./Config"; 5 | import { 6 | ConfigListProps, 7 | Config as ConfigInterface, 8 | } from "../hooks/useConfigList"; 9 | import { cn } from "../utils/cn"; 10 | import { MessageWithFiles } from "../utils/formTypes.ts"; 11 | import { useNavigate, useParams } from "react-router-dom"; 12 | import { useThreadAndAssistant } from "../hooks/useThreadAndAssistant.ts"; 13 | 14 | interface NewChatProps extends ConfigListProps { 15 | configSchema: Schemas["configSchema"]; 16 | configDefaults: Schemas["configDefaults"]; 17 | enterConfig: (id: string | null) => void; 18 | deleteConfig: (id: string) => Promise; 19 | startChat: ( 20 | config: ConfigInterface, 21 | message: MessageWithFiles, 22 | ) => Promise; 23 | } 24 | 25 | export function NewChat(props: NewChatProps) { 26 | const navigator = useNavigate(); 27 | const { assistantId } = useParams(); 28 | 29 | const { assistantConfig, isLoading } = useThreadAndAssistant(); 30 | 31 | if (isLoading) return
    Loading...
    ; 32 | if (!assistantConfig) 33 | return
    Could not find assistant with given id.
    ; 34 | 35 | return ( 36 |
    42 |
    43 |
    44 | navigator(`/assistant/${id}`)} 48 | deleteConfig={props.deleteConfig} 49 | /> 50 |
    51 | 52 |
    53 |
    54 | 62 |
    63 |
    64 |
    65 |
    66 | { 68 | if (assistantConfig) { 69 | await props.startChat(assistantConfig, msg); 70 | } 71 | }} 72 | currentConfig={assistantConfig} 73 | currentChat={null} 74 | /> 75 |
    76 |
    77 | ); 78 | } 79 | -------------------------------------------------------------------------------- /frontend/src/components/NotFound.tsx: -------------------------------------------------------------------------------- 1 | export function NotFound() { 2 | return
    Page not found.
    ; 3 | } 4 | -------------------------------------------------------------------------------- /frontend/src/components/OrphanChat.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { Config } from "../hooks/useConfigList"; 3 | import { Chat } from "../types"; 4 | import { getAssistants } from "../api/assistants"; 5 | import { useThreadAndAssistant } from "../hooks/useThreadAndAssistant"; 6 | 7 | export function OrphanChat(props: { 8 | chat: Chat; 9 | updateChat: ( 10 | name: string, 11 | thread_id: string, 12 | assistant_id: string | null, 13 | ) => Promise; 14 | }) { 15 | const [newConfigId, setNewConfigId] = useState(null as string | null); 16 | const [configs, setConfigs] = useState([]); 17 | const { invalidateChat } = useThreadAndAssistant(); 18 | 19 | const update = async () => { 20 | if (!newConfigId) { 21 | alert("Please select a bot."); 22 | return; 23 | } 24 | const updatedChat = await props.updateChat( 25 | props.chat.thread_id, 26 | props.chat.name, 27 | newConfigId, 28 | ); 29 | invalidateChat(updatedChat.thread_id); 30 | }; 31 | 32 | const botTypeToName = (botType: string) => { 33 | switch (botType) { 34 | case "chatbot": 35 | return "Chatbot"; 36 | case "chat_retrieval": 37 | return "RAG"; 38 | case "agent": 39 | return "Assistant"; 40 | default: 41 | return botType; 42 | } 43 | }; 44 | 45 | useEffect(() => { 46 | async function fetchConfigs() { 47 | const configs = await getAssistants(); 48 | const suitableConfigs = configs 49 | ? configs.filter( 50 | (config) => 51 | config.config.configurable?.type === 52 | props.chat.metadata?.assistant_type, 53 | ) 54 | : []; 55 | setConfigs(suitableConfigs); 56 | } 57 | 58 | fetchConfigs(); 59 | }, [props.chat.metadata?.assistant_type]); 60 | 61 | return ( 62 |
    63 | {configs.length ? ( 64 |
    { 66 | e.preventDefault(); 67 | await update(); 68 | }} 69 | className="space-y-4 max-w-xl w-full px-4" 70 | > 71 |
    72 | This chat has no bot attached. To continue chatting, please attach a 73 | bot. 74 |
    75 |
    76 |
    77 | 88 |
    89 | 95 |
    96 |
    97 | ) : ( 98 |
    99 |
    100 | This chat has no bot attached. To continue chatting, you need to 101 | attach a bot. However, there are no suitable bots available for this 102 | chat. Please create a new bot with type{" "} 103 | {botTypeToName(props.chat.metadata?.assistant_type as string)} and 104 | try again. 105 |
    106 |
    107 | )} 108 |
    109 | ); 110 | } 111 | -------------------------------------------------------------------------------- /frontend/src/components/String.tsx: -------------------------------------------------------------------------------- 1 | import { MarkedOptions, marked } from "marked"; 2 | import DOMPurify from "dompurify"; 3 | import { cn } from "../utils/cn"; 4 | 5 | const OPTIONS: MarkedOptions = { 6 | gfm: true, 7 | breaks: true, 8 | }; 9 | 10 | export function StringViewer(props: { 11 | value: string; 12 | className?: string; 13 | markdown?: boolean; 14 | }) { 15 | return props.markdown ? ( 16 |
    22 | ) : ( 23 |
    24 | {props.value} 25 |
    26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /frontend/src/components/StringEditor.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "../utils/cn"; 2 | 3 | const COMMON_CLS = cn( 4 | "text-sm col-[1] row-[1] m-0 resize-none overflow-hidden whitespace-pre-wrap break-words bg-transparent px-2 py-1 rounded shadow-none", 5 | ); 6 | 7 | export function StringEditor(props: { 8 | value?: string | null | undefined; 9 | placeholder?: string; 10 | className?: string; 11 | onChange?: (e: string) => void; 12 | autoFocus?: boolean; 13 | readOnly?: boolean; 14 | cursorPointer?: boolean; 15 | disabled?: boolean; 16 | fullHeight?: boolean; 17 | }) { 18 | return ( 19 |
    25 |