chore: import upstream snapshot with attribution
CI / e2e-tests (push) Waiting to run
CI / check-backend (push) Waiting to run
CI / check-frontend (push) Waiting to run
CI / tests (push) Waiting to run
CI / Run CI (push) Blocked by required conditions
Copilot Setup Steps / copilot-setup-steps (push) Waiting to run

This commit is contained in:
wehub-resource-sync
2026-07-13 12:48:47 +08:00
commit b7f52be4c9
653 changed files with 105877 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
../.shared/agents
+1
View File
@@ -0,0 +1 @@
../.shared/commands
+1
View File
@@ -0,0 +1 @@
../.shared/rules
+20
View File
@@ -0,0 +1,20 @@
{
"permissions": {
"allow": [
"Bash(tsc --noEmit)",
"Bash(uv run mypy*)",
"Bash(uv run pytest*)",
"Bash(uv run ruff*)",
"Bash(uv run scripts/*)",
"Bash(pnpm install*)",
"Bash(pnpm run build*)",
"Bash(pnpm run lint*)",
"Bash(pnpm run format*)",
"Bash(pnpm test*)",
"Bash(pnpm run test*)"
]
},
"enabledPlugins": {
"superpowers@claude-plugins-official": true
}
}
+1
View File
@@ -0,0 +1 @@
../.shared/skills
+1
View File
@@ -0,0 +1 @@
../.shared/agents
+1
View File
@@ -0,0 +1 @@
../.shared/commands
+1
View File
@@ -0,0 +1 @@
../.shared/rules
+7
View File
@@ -0,0 +1,7 @@
{
"plugins": {
"superpowers": {
"enabled": true
}
}
}
+1
View File
@@ -0,0 +1 @@
../.shared/skills
+13
View File
@@ -0,0 +1,13 @@
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
max_line_length = 80
quote_type = single
[*.py]
indent_size = 4
trim_trailing_whitespace = true
+1
View File
@@ -0,0 +1 @@
* text=auto
+1
View File
@@ -0,0 +1 @@
* @hayescode @asvishnyakov @sandangel
+41
View File
@@ -0,0 +1,41 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: needs-triage
assignees: ''
type: 'Bug'
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.
+20
View File
@@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: needs-triage
assignees: ''
type: 'Feature'
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
@@ -0,0 +1,26 @@
name: Install Node, pnpm and dependencies.
description: Install Node, pnpm and dependencies using cache.
inputs:
node-version:
description: Node.js version
required: true
default: 'lts/*'
runs:
using: composite
steps:
- uses: pnpm/action-setup@v5.0.0
name: Install pnpm
with:
run_install: false
- name: Use Node.js
uses: actions/setup-node@v6.3.0
with:
node-version: ${{ inputs.node-version }}
registry-url: 'https://registry.npmjs.org'
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Install JS dependencies
run: pnpm install
shell: bash
@@ -0,0 +1,37 @@
name: Install Python, uv and dependencies.
description: Install Python, uv and project dependencies using cache
inputs:
python-version:
description: Python version
required: true
default: '3.10'
uv-version:
description: uv version
required: true
default: 'latest'
uv-args:
description: Extra uv args, for example dependencies to install, e.g. --extra tests --extra dev.
required: false
working-directory:
description: Working directory for uv command.
required: false
default: .
runs:
using: composite
steps:
- name: Install uv
uses: astral-sh/setup-uv@v8.0.0
with:
version: ${{ inputs.uv-version }}
enable-cache: true
- name: Set up Python ${{ inputs.python-version }}
id: setup_python
uses: actions/setup-python@v6.2.0
with:
python-version: ${{ inputs.python-version }}
- name: Install Python dependencies
run: uv sync --all-packages ${{ inputs.uv-args }}
shell: bash
working-directory: ${{ inputs.working-directory }}
+194
View File
@@ -0,0 +1,194 @@
# Chainlit Development Instructions
Chainlit is a Python framework for building conversational AI applications with Python backend and React frontend. It uses uv for Python dependency management and pnpm for Node.js packages.
Always reference these instructions first and fallback to search or bash commands only when you encounter unexpected information that does not match the info here.
## Working Effectively
### Bootstrap, Build, and Test the Repository
**CRITICAL**: All commands must complete - NEVER CANCEL any build or test operations. Use appropriate timeouts.
1. **Install Dependencies (Required first)**:
```bash
# Install uv (if not available)
python3 -m pip install pipx
python3 -m pipx install uv
export PATH="$HOME/.local/bin:$PATH"
# Install pnpm (if not available)
npm install -g pnpm
# Install Python dependencies - takes ~2 minutes, NEVER CANCEL
cd backend
uv sync --extra tests --extra mypy --extra dev --extra custom-data
# Timeout: Use 300+ seconds (5+ minutes)
# Install Node.js dependencies - takes ~3 minutes, NEVER CANCEL
cd ..
pnpm install --frozen-lockfile
# Timeout: Use 600+ seconds (10+ minutes)
# NOTE: Cypress download may fail due to network restrictions - this is expected in CI environments
```
2. **Run Tests**:
```bash
# Backend tests - takes ~17 seconds, NEVER CANCEL
cd backend
export PATH="$HOME/.local/bin:$PATH"
uv run pytest --cov=chainlit/
# Timeout: Use 120+ seconds (2+ minutes)
# Frontend tests - takes ~4 seconds
cd ../frontend
pnpm test
# Timeout: Use 60 seconds
# E2E tests require Cypress download - may not work in restricted environments
# If available: pnpm test:e2e (takes variable time depending on tests)
```
3. **Run Development Servers**:
```bash
# Start backend (in one terminal)
cd backend
export PATH="$HOME/.local/bin:$PATH"
uv run chainlit run chainlit/sample/hello.py -h
# Available at http://localhost:8000
# Start frontend dev server (in another terminal)
cd frontend
pnpm run dev
# Available at http://localhost:5173/
```
## Validation
### Manual Validation Requirements
- **ALWAYS** manually validate any changes by running complete scenarios.
- **ALWAYS** test the Chainlit application after making changes.
- Create a test app and verify it runs: `uv run chainlit run /path/to/test.py -h`
- **ALWAYS** run through at least one complete user workflow after making changes.
### Linting and Formatting - takes ~2 minutes, NEVER CANCEL
```bash
# Lint (check)
pnpm lint
# Timeout: Use 300+ seconds (5+ minutes)
# Lint (auto-fix)
pnpm lint:fix
# Check formatting
pnpm format-check
# Fix formatting
pnpm format
# Python (scripts/ wrappers around ruff and mypy)
uv run scripts/lint.py
uv run scripts/format.py
uv run scripts/format.py --check
```
### CI Requirements
- **ALWAYS** run `pnpm lint` and `pnpm format-check` before committing or the CI (.github/workflows/ci.yaml) will fail.
- The CI runs: pytest, lint-backend, lint-ui, and e2e-tests.
- **NEVER CANCEL** any CI commands - they take time but must complete.
## Key Project Structure
### Repository Root
```
/
├── README.md
├── CONTRIBUTING.md
├── package.json # Root pnpm workspace config
├── pnpm-workspace.yaml # Workspace definition
├── backend/ # Python backend with uv
├── frontend/ # React frontend app
├── libs/
│ ├── react-client/ # React client library
│ └── copilot/ # Copilot functionality
├── cypress/ # E2E tests
└── .github/
├── workflows/ # CI/CD pipelines
└── actions/ # Reusable GitHub actions
```
### Working with the Backend
- **Technology**: Python 3.10+ with uv, FastAPI, SocketIO
- **Entry point**: `backend/chainlit/`
- **Tests**: `backend/tests/`
- **Dependencies**: Defined in `backend/pyproject.toml`
- **Hello app**: `backend/chainlit/sample/hello.py`
### Working with the Frontend
- **Technology**: React 18+ with Vite, TypeScript, Tailwind CSS
- **Entry point**: `frontend/src/`
- **Dependencies**: Defined in `frontend/package.json`
- **Build output**: `frontend/dist/`
## Common Tasks
### Creating a New Chainlit App
```python
# Create app.py
import chainlit as cl
@cl.on_message
async def main(message: cl.Message):
await cl.Message(content=f"You said: {message.content}").send()
# Run it
uv run chainlit run app.py -w
```
### Timing Expectations
- **pnpm install**: ~3 minutes (may fail on Cypress - this is normal)
- **uv install**: ~2 minutes
- **pnpm build**: ~1 minute
- **pnpm run lint**: ~2 minutes
- **Backend tests**: ~17 seconds
- **Frontend tests**: ~4 seconds
- **pnpm format-check**: ~12 seconds
- **pnpm format**: ~12 seconds
### Common Gotchas
- **NEVER CANCEL** long-running operations - they need time to complete.
- Cypress download often fails in CI environments - this is expected.
- Use `uv run` prefix for all Python commands in backend.
- Use `export PATH="$HOME/.local/bin:$PATH"` to ensure uv is available.
- Python lint/format/type-check: use `uv run scripts/lint.py`, `uv run scripts/format.py`, `uv run scripts/type_check.py` from repo root.
- Frontend dev server connects to backend at localhost:8000.
- Always start backend before frontend for development.
### File Locations for Quick Reference
- **Main CLI**: `backend/chainlit/cli/`
- **Server code**: `backend/chainlit/server.py`
- **Frontend app**: `frontend/src/App.tsx`
- **React client**: `libs/react-client/src/`
- **CI workflows**: `.github/workflows/ci.yaml`
- **uv config**: `backend/pyproject.toml`
- **Frontend config**: `frontend/package.json`
## Requirements
- **Python**: >= 3.10
- **Node.js**: >= 20 (24+ recommended)
- **uv**: 2.1.3 (install via pipx)
- **pnpm**: Latest (install via npm)
+29
View File
@@ -0,0 +1,29 @@
name: Check backend
on: [workflow_call]
permissions:
contents: read
jobs:
check-backend:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- name: 'Linting: backend'
command: uv run --no-project scripts/lint.py
- name: 'Formatting: backend'
command: uv run --no-project scripts/format.py --check
- name: 'Type checking: backend'
command: uv run --no-project scripts/type_check.py
name: ${{ matrix.name }}
steps:
- uses: actions/checkout@v6
- uses: ./.github/actions/uv-python-install
name: Install Python, uv and Python dependencies
with:
uv-args: --no-install-workspace --all-extras --dev
- name: ${{ matrix.name }}
run: ${{ matrix.command }}
+29
View File
@@ -0,0 +1,29 @@
name: Check frontend & libs
on: [workflow_call]
permissions:
contents: read
jobs:
check-frontend:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- name: 'Linting: frontend'
command: pnpm lint
- name: 'Formatting: frontend'
command: pnpm format-check
- name: 'Type checking: frontend'
command: pnpm type-check
name: ${{ matrix.name }}
steps:
- uses: actions/checkout@v6
- uses: ./.github/actions/pnpm-node-install
name: Install Node, pnpm and dependencies.
- name: Build @chainlit/react-client
run: pnpm --filter @chainlit/react-client build
- name: ${{ matrix.name }}
run: ${{ matrix.command }}
+42
View File
@@ -0,0 +1,42 @@
name: CI
on:
workflow_call:
workflow_dispatch:
merge_group:
pull_request:
branches: [main, dev, 'release/**']
push:
branches: [main, dev, 'release/**']
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref_name != 'main' }}
permissions: read-all
jobs:
check-backend:
uses: ./.github/workflows/check-backend.yaml
secrets: inherit
check-frontend:
uses: ./.github/workflows/check-frontend.yaml
secrets: inherit
tests:
uses: ./.github/workflows/tests.yaml
secrets: inherit
e2e-tests:
uses: ./.github/workflows/e2e-tests.yaml
secrets: inherit
ci:
runs-on: ubuntu-slim
name: Run CI
if: always() # This ensures the job always runs
needs: [check-backend, check-frontend, tests, e2e-tests]
steps:
# Propagate failure
- name: Check dependent jobs
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'action_required') || contains(needs.*.result, 'timed_out')
run: |
echo "Not all required jobs succeeded"
exit 1
+30
View File
@@ -0,0 +1,30 @@
name: Close inactive issues and pull requests
on:
schedule:
- cron: '30 1 * * *'
workflow_dispatch:
jobs:
close-issues:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- uses: actions/stale@v9
with:
operations-per-run: 400
ascending: true
days-before-issue-stale: 14
days-before-issue-close: 7
stale-issue-label: 'stale'
exempt-issue-labels: 'enhancement,dev-tooling,e2e-tests,unit-tests,keep-for-a-while'
stale-issue-message: 'This issue is stale because it has been open for 14 days with no activity.'
close-issue-message: 'This issue was closed because it has been inactive for 7 days since being marked as stale.'
days-before-pr-stale: 14
days-before-pr-close: 7
stale-pr-label: 'stale'
exempt-pr-labels: 'enhancement,dev-tooling,e2e-tests,unit-tests,keep-for-a-while'
stale-pr-message: 'This PR is stale because it has been open for 14 days with no activity.'
close-pr-message: 'This PR was closed because it has been inactive for 7 days since being marked as stale.'
repo-token: ${{ secrets.GITHUB_TOKEN }}
@@ -0,0 +1,41 @@
name: 'Copilot Setup Steps'
# Automatically run the setup steps when they are changed to allow for easy validation, and
# allow manual testing through the repository's "Actions" tab
# This workflow optimizes the GitHub Copilot coding agent's ephemeral development environment
on:
workflow_dispatch:
push:
paths:
- .github/workflows/copilot-setup-steps.yaml
pull_request:
paths:
- .github/workflows/copilot-setup-steps.yaml
jobs:
# The job MUST be called `copilot-setup-steps` or it will not be picked up by Copilot.
copilot-setup-steps:
runs-on: ubuntu-latest
timeout-minutes: 15
# Set the permissions to the lowest permissions possible needed for your steps.
# Copilot will be given its own token for its operations.
permissions:
# If you want to clone the repository as part of your setup steps, for example to install dependencies, you'll need the `contents: read` permission. If you don't clone the repository in your setup steps, Copilot will do this for you automatically after the steps complete.
contents: read
# You can define any steps you want, and they will run before the agent starts.
# If you do not check out your code, Copilot will do this for you.
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Install Node.js, pnpm and dependencies
uses: ./.github/actions/pnpm-node-install
- name: Install Python, uv and dependencies
uses: ./.github/actions/uv-python-install
with:
python-version: '3.10'
uv-version: 'latest'
uv-args: '--all-extras --dev'
+72
View File
@@ -0,0 +1,72 @@
name: E2E tests
on:
workflow_call:
inputs:
e2e_parallel_shards:
description: Number of parallel E2E shards per OS.
type: number
default: 5
permissions: read-all
jobs:
prepare:
name: Validate inputs and compute shard matrix
runs-on: ubuntu-slim
outputs:
indexes: ${{ steps.shard-indexes.outputs.indexes }}
steps:
- name: Validate e2e_parallel_shards
run: |
n="${{ inputs.e2e_parallel_shards }}"
if ! [[ "$n" =~ ^[0-9]+$ ]] || [[ "$n" -lt 1 ]]; then
echo "❌ Error: e2e_parallel_shards must be at least 1, got: $n"
exit 1
fi
echo "✅ Validation passed"
- id: shard-indexes
name: Compute shard indexes
run: |
json=$(jq -nc --argjson n "${{ inputs.e2e_parallel_shards }}" '[range(1; $n + 1)]')
echo "indexes=$json" >> "$GITHUB_OUTPUT"
e2e-tests:
needs: prepare
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
containers: ${{ fromJSON(needs.prepare.outputs.indexes) }}
name: ${{ matrix.os }}-${{ matrix.containers }}
env:
BACKEND_DIR: ./backend
# Single path for actions/cache on Linux + Windows (default Cypress dirs differ by OS).
CYPRESS_CACHE_FOLDER: ${{ github.workspace }}/.cypress-cache
steps:
- uses: actions/checkout@v6
- name: Cache Cypress binary
uses: actions/cache@v5
with:
path: .cypress-cache
key: cypress-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
- uses: ./.github/actions/pnpm-node-install
name: Install Node, pnpm and dependencies.
- uses: ./.github/actions/uv-python-install
name: Install Python, uv and Python & pnpm (uv does it automatically) dependencies
with:
working-directory: ${{ env.BACKEND_DIR }}
uv-args: --extra tests
- name: Run tests
env:
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
SPLIT: ${{ inputs.e2e_parallel_shards }}
SPLIT_INDEX1: ${{ matrix.containers }}
run: pnpm test:e2e
shell: bash
- name: Upload screenshots
uses: actions/upload-artifact@v7
if: always() && hashFiles('cypress/screenshots/**') != ''
with:
name: cypress-screenshots-${{ matrix.os }}-${{ matrix.containers }}
path: cypress/screenshots
+52
View File
@@ -0,0 +1,52 @@
name: Publish libs
on:
workflow_dispatch:
inputs:
dry_run:
description: 'Dry run (test publishing)'
required: false
default: false
type: boolean
release:
types: [published]
permissions: read-all
jobs:
validate:
name: Validate inputs
runs-on: ubuntu-slim
steps:
- name: Validate publishing branch and destination package index
run: |
if [[ "${{ github.ref_name }}" != "main" && "${{ github.event_name }}" != "release" ]]; then
if [[ "${{ inputs.dry_run }}" != "true" ]]; then
echo "❌ Error: Only build from main branch or release tag can be published to npm registry."
echo "Please check 'Dry run (test publishing)' when running from branch: ${{ github.ref_name }}"
exit 1
fi
fi
echo "✅ Validation passed"
ci:
needs: [validate]
uses: ./.github/workflows/ci.yaml
secrets: inherit
build-n-publish:
name: Upload libs release to npm registry
runs-on: ubuntu-latest
needs: [ci]
permissions:
contents: read
id-token: write # IMPORTANT: this permission is mandatory for trusted publishing
steps:
- uses: actions/checkout@v6
- uses: ./.github/actions/pnpm-node-install
name: Install Node, pnpm and dependencies.
- name: Build react-client
run: pnpm --filter @chainlit/react-client build
- name: Publish packages to npm
# --no-git-checks allows testing from non-main branches and publishing from release tags
run: pnpm publish --recursive --no-git-checks ${{ inputs.dry_run && '--dry-run' || '' }}
+80
View File
@@ -0,0 +1,80 @@
name: Publish
on:
workflow_dispatch:
inputs:
use_testpypi:
description: 'Publish to TestPyPI instead of PyPI'
required: false
default: false
type: boolean
release:
types: [published]
permissions: read-all
jobs:
validate:
name: Validate inputs
runs-on: ubuntu-slim
steps:
- name: Validate publishing branch and destination package index
run: |
if [[ "${{ github.ref_name }}" != "main" && "${{ github.event_name }}" != "release" ]]; then
if [[ "${{ inputs.use_testpypi }}" != "true" ]]; then
echo "❌ Error: Only build from main branch or release tag can be published to PyPI."
echo "Please check 'Publish to TestPyPI instead of PyPI' when running from branch: ${{ github.ref_name }}"
exit 1
fi
fi
echo "✅ Validation passed"
ci:
needs: [validate]
uses: ./.github/workflows/ci.yaml
secrets: inherit
build-n-publish:
name: Upload release to PyPI/TestPyPI
runs-on: ubuntu-latest
needs: [ci]
env:
name: ${{ inputs.use_testpypi && 'testpypi' || 'pypi' }}
url: ${{ inputs.use_testpypi && 'https://test.pypi.org/project/chainlit' || 'https://pypi.org/project/chainlit' }}
BACKEND_DIR: ./backend
permissions:
contents: read
id-token: write # IMPORTANT: this permission is mandatory for trusted publishing
steps:
- uses: actions/checkout@v6
- uses: ./.github/actions/pnpm-node-install
name: Install Node, pnpm and dependencies.
- uses: ./.github/actions/uv-python-install
name: Install Python, uv and Python dependencies
with:
working-directory: ${{ env.BACKEND_DIR }}
- name: Build Python distribution
run: uv build
working-directory: ${{ env.BACKEND_DIR }}
- name: Check frontend and copilot folder included
run: |
pip install wheel
python -m wheel unpack dist/chainlit-*.whl -d unpacked
ls unpacked/chainlit-*/chainlit/frontend/dist
ls unpacked/chainlit-*/chainlit/copilot/dist
- name: Publish package distributions to TestPyPI
if: inputs.use_testpypi
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: dist
repository-url: https://test.pypi.org/legacy/
password: ${{ secrets.TEST_PYPI_API_TOKEN }}
verbose: true
- name: Publish package distributions to PyPI
if: ${{ !inputs.use_testpypi }}
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: dist
password: ${{ secrets.PYPI_API_TOKEN }}
+28
View File
@@ -0,0 +1,28 @@
name: Unit & integration tests
on: [workflow_call]
permissions: read-all
jobs:
tests:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.10', '3.11', '3.12', '3.13']
name: python ${{ matrix.python-version }}
env:
BACKEND_DIR: ./backend
steps:
- uses: actions/checkout@v6
- uses: ./.github/actions/pnpm-node-install
name: Install Node, pnpm and dependencies.
- uses: ./.github/actions/uv-python-install
name: Install Python, uv and Python dependencies
with:
python-version: ${{ matrix.python-version }}
uv-args: --extra tests --extra custom-data
- name: Run backend tests
run: uv run --no-project pytest --cov=chainlit/
- name: Run frontend tests
run: pnpm run test
+64
View File
@@ -0,0 +1,64 @@
build
dist
*.egg-info
.env
*.files
venv
.venv
.DS_Store
**/.chainlit/*
chainlit.md
cypress/screenshots
cypress/videos
cypress/downloads
__pycache__
.ipynb_checkpoints
*.db
.mypy_cache
chat_files
.chroma
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.pnpm-store
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.aider*
.coverage
.dmypy.json
.history
+1
View File
@@ -0,0 +1 @@
pnpm lint-staged
+5
View File
@@ -0,0 +1,5 @@
shared-workspace-lockfile=false
public-hoist-pattern[]=*eslint*
public-hoist-pattern[]=*prettier*
public-hoist-pattern[]=@types*
side-effects-cache=false
+1
View File
@@ -0,0 +1 @@
pnpm-lock.yaml
+19
View File
@@ -0,0 +1,19 @@
{
"semi": true,
"trailingComma": "none",
"singleQuote": true,
"plugins": ["@trivago/prettier-plugin-sort-imports"],
"importOrder": [
"pages/(.*)$",
"@chainlit/(.*)$",
"components/(.*)$",
"assets/(.*)$",
"hooks/(.*)$",
"state/(.*)$",
"types/(.*)$",
"^./*.*.css",
"^[./]"
],
"importOrderSeparation": true,
"importOrderSortSpecifiers": true
}
+2
View File
@@ -0,0 +1,2 @@
/cache
/project.local.yml
+156
View File
@@ -0,0 +1,156 @@
# the name by which the project can be referenced within Serena
project_name: 'chainlit'
# list of languages for which language servers are started; choose from:
# al bash clojure cpp csharp
# csharp_omnisharp dart elixir elm erlang
# fortran fsharp go groovy haskell
# java julia kotlin lua markdown
# matlab nix pascal perl php
# php_phpactor powershell python python_jedi r
# rego ruby ruby_solargraph rust scala
# swift terraform toml typescript typescript_vts
# vue yaml zig
# (This list may be outdated. For the current list, see values of Language enum here:
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
# Note:
# - For C, use cpp
# - For JavaScript, use typescript
# - For Free Pascal/Lazarus, use pascal
# Special requirements:
# Some languages require additional setup/installations.
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
# When using multiple languages, the first language server that supports a given file will be used for that file.
# The first language is the default language and the respective language server will be used as a fallback.
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
languages:
- typescript
- python
- bash
- markdown
- toml
- yaml
# the encoding used by text files in the project
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
encoding: 'utf-8'
# line ending convention to use when writing source files.
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
line_ending:
# The language backend to use for this project.
# If not set, the global setting from serena_config.yml is used.
# Valid values: LSP, JetBrains
# Note: the backend is fixed at startup. If a project with a different backend
# is activated post-init, an error will be returned.
language_backend:
# whether to use project's .gitignore files to ignore files
ignore_all_files_in_gitignore: true
# advanced configuration option allowing to configure language server-specific options.
# Maps the language key to the options.
# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available.
# No documentation on options means no options are available.
ls_specific_settings: {}
# list of additional paths to ignore in this project.
# Same syntax as gitignore, so you can use * and **.
# Note: global ignored_paths from serena_config.yml are also applied additively.
ignored_paths: []
# whether the project is in read-only mode
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
# Added on 2025-04-18
read_only: false
# list of tool names to exclude.
# This extends the existing exclusions (e.g. from the global configuration)
#
# Below is the complete list of tools for convenience.
# To make sure you have the latest list of tools, and to view their descriptions,
# execute `uv run scripts/print_tool_overview.py`.
#
# * `activate_project`: Activates a project by name.
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
# * `create_text_file`: Creates/overwrites a file in the project directory.
# * `delete_lines`: Deletes a range of lines within a file.
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
# * `execute_shell_command`: Executes a shell command.
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
# * `initial_instructions`: Gets the initial instructions for the current project.
# Should only be used in settings where the system prompt cannot be set,
# e.g. in clients you have no control over, like Claude Desktop.
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
# * `insert_at_line`: Inserts content at a given line in a file.
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
# * `list_memories`: Lists memories in Serena's project-specific memory store.
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
# * `read_file`: Reads a file within the project directory.
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
# * `remove_project`: Removes a project from the Serena configuration.
# * `replace_lines`: Replaces a range of lines within a file with new content.
# * `replace_symbol_body`: Replaces the full definition of a symbol.
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
# * `search_for_pattern`: Performs a search for a pattern in the project.
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
# * `switch_modes`: Activates modes by providing a list of their names
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
excluded_tools: []
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
# This extends the existing inclusions (e.g. from the global configuration).
included_optional_tools: []
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
fixed_tools: []
# list of mode names to that are always to be included in the set of active modes
# The full set of modes to be activated is base_modes + default_modes.
# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this setting overrides the global configuration.
# Set this to [] to disable base modes for this project.
# Set this to a list of mode names to always include the respective modes for this project.
base_modes:
# list of mode names that are to be activated by default.
# The full set of modes to be activated is base_modes + default_modes.
# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
# This setting can, in turn, be overridden by CLI parameters (--mode).
default_modes:
# initial prompt for the project. It will always be given to the LLM upon activating the project
# (contrary to the memories, which are loaded on demand).
initial_prompt: ''
# time budget (seconds) per tool call for the retrieval of additional symbol information
# such as docstrings or parameter information.
# This overrides the corresponding setting in the global configuration; see the documentation there.
# If null or missing, use the setting from the global configuration.
symbol_info_budget:
# list of regex patterns which, when matched, mark a memory entry as readonly.
# Extends the list from the global configuration, merging the two lists.
read_only_memory_patterns: []
# list of regex patterns for memories to completely ignore.
# Matching memories will not appear in list_memories or activate_project output
# and cannot be accessed via read_memory or write_memory.
# To access ignored memory files, use the read_file tool on the raw file path.
# Extends the list from the global configuration, merging the two lists.
# Example: ["_archive/.*", "_episodes/.*"]
ignored_memory_patterns: []
View File
View File
View File
@@ -0,0 +1,72 @@
---
name: karpathy-guidelines
description: Behavioral guidelines to reduce common LLM coding mistakes. Use when writing, reviewing, or refactoring code to avoid overcomplication, make surgical changes, surface assumptions, and define verifiable success criteria.
license: MIT
---
# Karpathy Guidelines
Behavioral guidelines to reduce common LLM coding mistakes, derived from [Andrej Karpathy's observations](https://x.com/karpathy/status/2015883857489522876) on LLM coding pitfalls.
**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment.
## 1. Think Before Coding
**Don't assume. Don't hide confusion. Surface tradeoffs.**
Before implementing:
- State your assumptions explicitly. If uncertain, ask.
- If multiple interpretations exist, present them - don't pick silently.
- If a simpler approach exists, say so. Push back when warranted.
- If something is unclear, stop. Name what's confusing. Ask.
## 2. Simplicity First
**Minimum code that solves the problem. Nothing speculative.**
- No features beyond what was asked.
- No abstractions for single-use code.
- No "flexibility" or "configurability" that wasn't requested.
- No error handling for impossible scenarios.
- If you write 200 lines and it could be 50, rewrite it.
Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
## 3. Surgical Changes
**Touch only what you must. Clean up only your own mess.**
When editing existing code:
- Don't "improve" adjacent code, comments, or formatting.
- Don't refactor things that aren't broken.
- Match existing style, even if you'd do it differently.
- If you notice unrelated dead code, mention it - don't delete it.
When your changes create orphans:
- Remove imports/variables/functions that YOUR changes made unused.
- Don't remove pre-existing dead code unless asked.
The test: Every changed line should trace directly to the user's request.
## 4. Goal-Driven Execution
**Define success criteria. Loop until verified.**
Transform tasks into verifiable goals:
- "Add validation" → "Write tests for invalid inputs, then make them pass"
- "Fix the bug" → "Write a test that reproduces it, then make it pass"
- "Refactor X" → "Ensure tests pass before and after"
For multi-step tasks, state a brief plan:
```
1. [Step] → verify: [check]
2. [Step] → verify: [check]
3. [Step] → verify: [check]
```
Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.
+195
View File
@@ -0,0 +1,195 @@
# AGENTS.md
This file provides guidance to AI agents when working with code in this repository.
## Backward Compatibility (CRITICAL)
All changes **MUST** be backward-compatible. If a refactor or breaking change is unavoidable, notify the user and stop — do not proceed without explicit approval. When approved, prefer adding a compatibility layer over keeping legacy code in place.
## MCP-First Approach (CRITICAL)
When available, **ALWAYS** prefer MCP servers over manual alternatives. Use **Context7** for docs/API references, **Serena** for code navigation/refactoring/memory, and **GitHub MCP** for issues/PRs/actions/commits/releases/code search. Fall back to CLI tools, direct file reads, or web searches **ONLY IF** the corresponding MCP is unavailable or cannot fulfill the request.
## Overview
Chainlit is a Python framework for building production-ready conversational AI applications. It consists of a Python/FastAPI backend and a React frontend, with a pnpm monorepo for the JS packages.
## Prerequisites
- Python: **3.13** (3.10+ is the framework's minimum, but development targets 3.13)
- Node.js: **24+**
- [uv](https://docs.astral.sh/uv/) — Python package manager
- [pnpm 9](https://pnpm.io/) — Node.js package manager (Corepack)
## Quick Start
### Install
| | Command | Directory |
| -------- | ---------------------- | ---------- |
| Backend | `uv sync --all-extras` | `backend/` |
| Frontend | `pnpm install` | repo root |
### Build
| | Command | Directory | What it does |
| ----------------- | -------------------------------------------- | ---------- | ----------------------------------------------------------------------------------------- |
| All JS packages | `pnpm build` | repo root | Build all workspace packages (frontend, react-client, copilot) via `pnpm run --recursive` |
| Backend (PyPI) | `uv build` | `backend/` | Build Python package — builds JS assets first, then bundles into the Python distribution |
| Single JS package | `pnpm --filter @chainlit/react-client build` | repo root | Build one package (useful for publishing) |
### Dev servers
| | Command | Directory | URL |
| -------- | ------------------------------------------------- | ----------- | ---------------------------------------- |
| Backend | `uv run chainlit run chainlit/sample/hello.py -h` | `backend/` | http://localhost:8000 |
| Frontend | `pnpm run dev` | `frontend/` | http://localhost:5173 (proxies to :8000) |
### Tests
| | Command | Directory |
| --------------------- | ---------------------------------- | ----------- |
| Backend (all) | `uv run pytest --cov=chainlit/` | `backend/` |
| Backend (single file) | `uv run pytest tests/test_file.py` | `backend/` |
| Frontend unit | `pnpm test` | `frontend/` |
| E2E (Cypress) | `pnpm test` | repo root |
### Lint & Format
| | Command | Directory |
| ------------------- | ---------------------------------- | --------- |
| Lint JS/TS | `pnpm lint` | repo root |
| Lint fix JS/TS | `pnpm lint:fix` | repo root |
| Format check JS/TS | `pnpm format-check` | repo root |
| Format fix JS/TS | `pnpm format` | repo root |
| Lint Python | `uv run scripts/lint.py` | repo root |
| Lint fix Python | `uv run scripts/lint.py --fix` | repo root |
| Format check Python | `uv run scripts/format.py --check` | repo root |
| Format fix Python | `uv run scripts/format.py` | repo root |
JS/TS lint and format commands accept file/directory arguments: `pnpm lint frontend/`, `pnpm format-check:files frontend/src/App.tsx`. Python scripts also accept file arguments: `uv run scripts/lint.py backend/chainlit/server.py`.
### Type checking
| | Command | Directory |
| ---------- | ------------------------------ | --------- |
| Python | `uv run scripts/type_check.py` | repo root |
| TypeScript | `pnpm type-check` | repo root |
Type checking runs on whole projects (no per-file mode).
Run `pnpm lint:fix` and `pnpm format` before committing — CI enforces checks on both.
### Commits
This project uses [Conventional Commits](https://www.conventionalcommits.org/). Format: `<type>(<optional scope>): <description>`.
Common types: `feat`, `fix`, `chore`, `docs`, `refactor`, `test`, `ci`. Scope is optional but encouraged (e.g. `fix(data): ...`, `feat(i18n): ...`).
All commits made with AI assistance **must** include a `Co-Authored-By` trailer identifying the AI agent. Add it as the last line of the commit message body:
```
Co-Authored-By: <Agent Name> <agent-email-or-noreply>
```
Examples:
- `Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>`
- `Co-Authored-By: GitHub Copilot <noreply@github.com>`
- `Co-Authored-By: Gemini CLI <noreply@google.com>`
## Tech Stack
| Layer | Stack |
| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------- |
| **Frontend** | React 18, TypeScript 5.2, Vite 5, Tailwind CSS 3, Vitest, Zod 3 |
| **Frontend (state & routing)** | Recoil, React Router 6, react-hook-form, socket.io-client, SWR |
| **Frontend (rendering)** | react-markdown + remark-gfm/math + rehype-katex/raw, highlight.js, lucide-react (icons), Radix UI (primitives), Plotly.js |
| **Backend** | Python 3.13, FastAPI, Starlette, Uvicorn, python-socketio, Pydantic 2, PyJWT, httpx |
| **LLM integrations** | MCP, LangChain, LlamaIndex, OpenAI SDK, Semantic Kernel, MistralAI |
| **Infra / persistence** | SQLAlchemy (PostgreSQL/SQLite), DynamoDB + S3 (boto3), Azure Blob / Data Lake, Google Cloud Storage, LiteralAI |
| **DX** | pre-commit hooks, linting, formatting, type checking, unit testing, E2E testing |
## Architecture
### Monorepo structure
```
backend/ # Python package (published to PyPI as "chainlit")
frontend/ # React app (built output served by backend)
libs/
react-client/ # @chainlit/react-client — published npm package with React hooks
copilot/ # Copilot widget (embedded chat bubble)
cypress/ # E2E tests
```
The pnpm workspace includes `frontend/`, `libs/react-client/`, and `libs/copilot/`. The built frontend assets are copied into `backend/chainlit/frontend/dist/` and served as static files.
### Backend (`backend/chainlit/`)
**Entry point for user apps**: `__init__.py` re-exports all public API decorators and classes.
**Key files:**
- `server.py` — FastAPI app, all REST routes (auth, elements, threads, file upload), serves the built frontend SPA, mounts the SocketIO app
- `socket.py` — SocketIO event handlers for real-time WebSocket communication (connect, message, audio, etc.)
- `callbacks.py` — Decorator functions registered via `@cl.on_message`, `@cl.on_chat_start`, `@cl.on_audio_chunk`, etc. These store functions on `config.code.*`
- `config.py` — Reads `.chainlit/config.toml` from `APP_ROOT`. `ChainlitConfig` holds both static TOML config and runtime user-registered callbacks. `APP_ROOT` defaults to `os.getcwd()`.
- `session.py``WebsocketSession` (per-connection state: user, files, MCP connections, message queue) and `HTTPSession`
- `context.py``ChainlitContext` per-coroutine context variable (similar to thread-local), providing access to the current session and emitter
- `emitter.py` — Sends events back to the frontend through the SocketIO session
- `data/base.py``BaseDataLayer` ABC for persistence (threads, steps, elements, users, feedback). Implementations: `sql_alchemy.py`, `dynamodb.py`, `literalai.py`
- `auth/` — JWT creation/validation (`jwt.py`), OAuth state cookies (`cookie.py`)
- `types.py` — Shared Pydantic models for API request/response types
**Data layer pattern**: The data layer is optional (no persistence by default). Register a custom implementation with `@cl.data_layer` decorator or use the built-in SQLAlchemy/DynamoDB/LiteralAI implementations. The `@queue_until_user_message()` decorator on `BaseDataLayer` methods queues write operations until the first user message arrives.
**Integrations**: `langchain/`, `llama_index/`, `openai/`, `semantic_kernel/`, `mistralai/` — each provides callback handlers that bridge those frameworks into Chainlit steps/messages.
### Frontend (`frontend/src/`)
React 18 + TypeScript + Vite, styled with Tailwind CSS and Radix UI primitives.
- `main.tsx` — React root, wraps app in `RecoilRoot` and `ChainlitContext.Provider`
- `App.tsx` — Handles auth readiness, chat profile selection, and WebSocket connection lifecycle
- `router.tsx` — Client-side routes: `/` (Home), `/thread/:id`, `/element/:id`, `/login`, `/login/callback`, `/share/:id`, `/env`
- `state/` — Recoil atoms: `chat.ts` (messages, elements, tasks), `project.ts` (config, session), `user.ts` (env vars)
- `components/chat/` — Core chat UI (message list, input bar, elements, audio)
- `components/header/` — Top navigation bar
- `components/LeftSidebar/` — Thread history sidebar
### `@chainlit/react-client` (`libs/react-client/src/`)
Publishable npm package — the bridge between the React UI and the backend WebSocket.
- `api.ts``ChainlitAPI` class: HTTP calls to backend REST endpoints
- `useChatSession.ts` — Manages socket.io connection lifecycle
- `useChatMessages.ts` — Exposes message tree state
- `useChatData.ts` — Exposes elements, actions, tasklists, connection status
- `useChatInteract.ts``sendMessage`, `replyMessage`, `callAction`, `stopTask`, `clear`
- `state.ts` — Recoil atoms shared between the lib and consuming apps
State is managed via Recoil; consuming apps must wrap the tree in `<RecoilRoot>` and provide a `ChainlitAPI` instance via `ChainlitContext.Provider`.
### Communication flow
1. User sends a message → `useChatInteract.sendMessage` → emits `client_message` over SocketIO
2. Backend `socket.py` handler receives it → calls `config.code.on_message(message)`
3. User's app calls `cl.Message(...).send()``emitter.py` emits `new_message` back over SocketIO
4. Frontend `useChatMessages` updates Recoil state → component re-renders
### App configuration
Apps configure Chainlit via `.chainlit/config.toml` (created automatically on first run). Key sections: `[project]` (auth, session timeouts, CORS), `[UI]` (name, theme, layout).
---
## Documentation Verification Requirements
Before writing/modifying code, verify against official docs.
**Lookup order**: Context7 MCP (preferred) → WebFetch → WebSearch.
Pre-resolved Context7 library IDs: [docs/context7.md](docs/context7.md)
Cross-reference API signatures and patterns during implementation. When uncertain, always check docs rather than relying on training data.
+1859
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
@AGENTS.md
+188
View File
@@ -0,0 +1,188 @@
# Contribute to Chainlit
To contribute to Chainlit, you first need to set up the project on your local machine.
## Table of Contents
<!--
Generated using https://ecotrust-canada.github.io/markdown-toc/.
I've copy/pasted the whole document there, and then formatted it with prettier.
-->
- [Contribute to Chainlit](#contribute-to-chainlit)
- [Table of Contents](#table-of-contents)
- [Local setup](#local-setup)
- [Requirements](#requirements)
- [Set up the repo](#set-up-the-repo)
- [Install dependencies](#install-dependencies)
- [Start the Chainlit server from source](#start-the-chainlit-server-from-source)
- [Start the UI from source](#start-the-ui-from-source)
- [Lint \& Format](#lint--format)
- [Run the tests](#run-the-tests)
- [Backend unit tests](#backend-unit-tests)
- [Frontend unit tests](#frontend-unit-tests)
- [E2E tests](#e2e-tests)
## Local setup
### Requirements
1. Python >= `3.10`
2. uv ([See how to install](https://docs.astral.sh/uv/getting-started/installation/))
3. NodeJS >= `24` ([See how to install](https://nodejs.org/en/download))
4. Pnpm ([See how to install](https://pnpm.io/installation))
> **Note**
> If you are on Windows, some pnpm commands won't work out of the box. You can fix this by changing the pnpm script-shell to bash: `pnpm config set script-shell "C:\\Program Files\\git\\bin\\bash.exe"` (default x64 install location, [Info](https://pnpm.io/cli/run#script-shell))
### Set up the repo
With this setup you can easily code in your fork and fetch updates from the main repository.
1. Go to [https://github.com/Chainlit/chainlit/fork](https://github.com/Chainlit/chainlit/fork) to fork the chainlit code into your own repository.
2. Clone your fork locally
```sh
git clone https://github.com/YOUR_USERNAME/YOUR_FORK.git
```
3. Go into your fork and list the current configured remote repository.
```sh
$ git remote -v
> origin https://github.com/YOUR_USERNAME/YOUR_FORK.git (fetch)
> origin https://github.com/YOUR_USERNAME/YOUR_FORK.git (push)
```
4. Specify the new remote upstream repository that will be synced with the fork.
```sh
git remote add upstream https://github.com/Chainlit/chainlit.git
```
5. Verify the new upstream repository you've specified for your fork.
```sh
$ git remote -v
> origin https://github.com/YOUR_USERNAME/YOUR_FORK.git (fetch)
> origin https://github.com/YOUR_USERNAME/YOUR_FORK.git (push)
> upstream https://github.com/Chainlit/chainlit.git (fetch)
> upstream https://github.com/Chainlit/chainlit.git (push)
```
### Install dependencies
The following command will install Python dependencies, Node (pnpm) dependencies and build the frontend.
```sh
uv sync --all-packages --all-extras --dev
```
## Start the Chainlit server from source
Start by running `backend/chainlit/sample/hello.py` as an example.
```sh
uv run chainlit run backend/chainlit/sample/hello.py
```
You should now be able to access the Chainlit app you just launched on `http://127.0.0.1:8000`.
If you've made it this far, you can now replace `chainlit/sample/hello.py` by your own target. 😎
## Start the UI from source
First, you will have to start the server either [from source](#start-the-chainlit-server-from-source) or with `chainlit run...`. Since we are starting the UI from source, you can start the server with the `-h` (headless) option.
Then, start the UI.
```sh
cd frontend
pnpm run dev
```
If you visit `http://localhost:5173/`, it should connect to your local server. If the local server is not running, it should say that it can't connect to the server.
## Lint & Format
Linting and formatting run from the **repo root** (not from individual packages). This ensures CI, lint-staged, and local commands all use the same tool invocation.
```sh
# Lint (CI uses this)
pnpm lint
# Lint and auto-fix
pnpm lint:fix
# Check formatting (CI uses this)
pnpm format-check
# Fix formatting
pnpm format
# Type check (TypeScript)
pnpm type-check
# Scope to specific files or directories
pnpm lint frontend/src/App.tsx
pnpm lint:fix frontend/
pnpm format-check:files frontend/
pnpm format:files frontend/src/App.tsx
# Python (wrapper scripts for linting, formatting, and type checking)
uv run scripts/lint.py # lint all
uv run scripts/lint.py backend/chainlit/server.py # lint single file
uv run scripts/lint.py --fix # automatically fix linting issues
uv run scripts/format.py # format all
uv run scripts/format.py backend/chainlit/server.py # format single file
uv run scripts/format.py --check # check formatting
uv run scripts/type_check.py # check types (whole project, no per-file mode)
```
> **Note**
> Linting and formatting scripts are defined only at the workspace root. Running `pnpm lint` from a sub-package directory won't work — always run from the repo root, passing a path argument to scope: `pnpm lint frontend/`.
## Run the tests
### Backend unit tests
This will run the backend's unit tests.
```sh
cd backend
uv run pytest --cov=chainlit
```
### Frontend unit tests
This will run the frontend's unit tests.
```
pnpm test
```
### E2E tests
You may need additional configuration or dependency installation to run Cypress. See the [Cypress system requirements](https://docs.cypress.io/app/get-started/install-cypress#System-requirements) for details.
This will run end to end tests, assessing both the frontend, the backend and their interaction. First install cypress with `pnpm exec cypress install`, and then run:
```sh
// from root
pnpm test:e2e # will do cypress run
pnpm test:e2e --spec cypress/e2e/copilot # will run single test with the name copilot
pnpm test:e2e --spec "cypress/e2e/copilot,cypress/e2e/data_layer" # will run two tests with the names copilot and data_layer
pnpm test:e2e --spec "cypress/e2e/**/async-*" # will run all async tests
pnpm test:e2e --spec "cypress/e2e/**/sync-*" # will run all sync tests
pnpm test:e2e --spec "cypress/e2e/**/spec.cy.ts" # will run all usual tests
```
(Go grab a cup of something, this will take a while.)
For debugging purposes, you can use the **interactive mode** (Cypress UI). Run:
```
pnpm test:e2e:interactive # runs `cypress open`
```
Once you create a pull request, the tests will automatically run. It is a good practice to run the tests locally before pushing.
+203
View File
@@ -0,0 +1,203 @@
Copyright 2023- The Chainlit team. All rights reserved.
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+3
View File
@@ -0,0 +1,3 @@
# Privacy Policy
Chainlit doesn't collect any data from its users after 2.6.1 release.
Symlink
+1
View File
@@ -0,0 +1 @@
backend/README.md
+7
View File
@@ -0,0 +1,7 @@
# WeHub 来源说明
- 原始项目:`Chainlit/chainlit`
- 原始仓库:https://github.com/Chainlit/chainlit
- 导入方式:上游默认分支的最新快照
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
+43
View File
@@ -0,0 +1,43 @@
# Release Engineering Instructions
This document outlines the steps for maintainers to create a new release of the project.
## Prerequisites
- You must have maintainer permissions on the repo to create a new release.
## Steps
1. **Determine the new version number**:
- We use semantic versioning (major.minor.patch).
- Increment the major version for breaking changes, minor version for new features, patch version for bug fixes only.
- If unsure, discuss with the maintainers to determine if it should be a major/minor version bump or new patch version.
2. **Bump the package version**:
- Update `version` in `backend/chainlit/version.py`.
- Update `version` in `libs/*/package.json` if there were any changes in the corresponding directories.
3. **Update the changelog**:
- Create a pull request to update the CHANGELOG.md file with the changes for the new release.
- Mark any breaking changes clearly.
- Get the changelog update PR reviewed and merged.
4. **Create a new release**:
- In the GitHub repo, go to the "Releases" page and click "Draft a new release".
- Input the new version number as the tag (e.g. 4.0.4).
- Use the "Generate release notes" button to auto-populate the release notes from the changelog.
- Review the release notes, make any needed edits for clarity.
- If this is a full release after an RC, remove any "-rc" suffix from the version number.
- Publish the release.
5. **Update any associated documentation and examples**:
- If needed, create PRs to update the version referenced in the docs and example code to match the newly released version.
- Especially important for documented breaking changes.
## RC (Release Candidate) Releases
- We create RC releases to allow testing before a full stable release
- Append "-rc" to the version number (e.g. 4.0.4-rc)
- Normally only bug fixes, no new features, between an RC and the final release version
Ping @dokterbob or @willydouhard for any questions or issues with the release process. Happy releasing!
+54
View File
@@ -0,0 +1,54 @@
# Security Policy
## Reporting a Vulnerability
Please use GitHub's private security advisory mechanism to report vulnerabilities.
On the repository page, go to **Security > Advisories** and click
**"Report a vulnerability"**. This opens a private channel visible only to maintainers,
keeping details out of public view until a fix is ready.
Do not file a public issue for security matters.
When writing your report it helps to include: a description of the vulnerability, the version
(or commit) you tested against, steps to reproduce or a proof-of-concept, and your assessment
of the likely impact. The more context you can provide, the faster we can triage and respond.
## What to Expect
- **Acknowledgement**: we aim to acknowledge reports within **5 business days**. Chainlit is
maintained by a small team and we are not always available at the same time, so occasional
delays are possible. If you have not heard back after a week, a follow-up nudge is welcome.
- **Resolution target**: we target a fix or mitigation within **90 days** of the initial
report. Complex or architecture-level issues may require more time; we will communicate
openly if that is the case.
- **Coordinated disclosure**: please do not publish details of the vulnerability until a
patch has been released or 90 days have passed since the report, whichever comes first.
If you need to publish sooner for any reason, let us know and we will do our best to
work with your timeline.
## Scope
Chainlit is a Python/TypeScript framework for building conversational AI applications.
Security-relevant areas include the FastAPI/SocketIO backend, authentication flows (JWT,
OAuth), the data persistence layer, and file upload handling.
When reporting, please note which features you had enabled and how the app was configured,
as that context helps us triage accurately.
Issues in third-party dependencies are generally best reported upstream, though we are happy
to discuss whether a Chainlit-level workaround makes sense in the meantime.
## No Bug Bounty
This is an open-source project with no commercial bug bounty programme.
We cannot offer financial rewards, but we will credit researchers in release notes and
security advisories unless you prefer to remain anonymous.
## Good Faith
We appreciate researchers who take the time to report issues responsibly.
If you act in good faith — give us a reasonable window to respond, avoid accessing user
data beyond what is needed to demonstrate the issue, and avoid disrupting live services —
we will treat your report with the same good faith in return.
Thank you for helping keep Chainlit and its users safe.
+119
View File
@@ -0,0 +1,119 @@
<h1 align="center">Welcome to Chainlit 👋</h1>
<p align="center">
<b>Build python production-ready conversational AI applications in minutes, not weeks ⚡️</b>
</p>
<p align="center">
<a href="https://discord.gg/k73SQ3FyUh" target="_blank">
<img src="https://img.shields.io/discord/1088038867602526210?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb"
alt="chat on Discord"></a>
<a href="https://twitter.com/chainlit_io" rel="nofollow"><img alt="Twitter" src="https://img.shields.io/twitter/url/https/twitter.com/chainlit_io.svg?style=social&label=Follow%20%40chainlit_io" style="max-width:100%;"></a>
<a href="https://pypistats.org/packages/chainlit" rel="nofollow"><img alt="Downloads" src="https://img.shields.io/pypi/dm/chainlit" style="max-width:100%;"></a>
<a href="https://github.com/chainlit/chainlit/graphs/contributors" rel="nofollow"><img alt="Contributors" src="https://img.shields.io/github/contributors/chainlit/chainlit" style="max-width:100%;"></a>
<a href="https://github.com/Chainlit/chainlit/actions/workflows/ci.yaml" rel="nofollow"><img alt="CI" src="https://github.com/Chainlit/chainlit/actions/workflows/ci.yaml/badge.svg" style="max-width:100%;"></a>
</p>
> ⚠️ **Notice:** Chainlit is now community-maintained.
>
> As of May 1st 2025, the original Chainlit team has stepped back from active development. The project is maintained by @Chainlit/chainlit-maintainers under a formal Maintainer Agreement.
>
> Maintainers are responsible for code review, releases, and security.
> Chainlit SAS provides no warranties on future updates.
>
> Want to help maintain? [Apply here →](https://docs.google.com/forms/d/e/1FAIpQLSf6CllNWnKBnDIoj0m-DnHU6b0dj8HYFGixKy-_qNi_rD4iNA/viewform)
<p align="center">
<a href="https://chainlit.io"><b>Website</b></a> •
<a href="https://docs.chainlit.io"><b>Documentation</b></a> •
<a href="https://help.chainlit.io"><b>Chainlit Help</b></a> •
<a href="https://github.com/Chainlit/cookbook"><b>Cookbook</b></a>
</p>
<p align="center">
<a href="https://trendshift.io/repositories/6708" target="_blank"><img src="https://trendshift.io/api/badge/repositories/6708" alt="Chainlit%2Fchainlit | Trendshift" style="width: 250px; height: 45px;" width="250" height="45"/></a>
</p>
https://github.com/user-attachments/assets/b3738aba-55c0-42fa-ac00-6efd1ee0d148
## Installation
Open a terminal and run:
```sh
pip install chainlit
chainlit hello
```
If this opens the `hello app` in your browser, you're all set!
### Development version
The latest in-development version can be installed straight from GitHub with:
```sh
pip install git+https://github.com/Chainlit/chainlit.git#subdirectory=backend/
```
(Requires Node and pnpm installed on the system.)
## 🚀 Quickstart
### 🐍 Pure Python
Create a new file `demo.py` with the following code:
```python
import chainlit as cl
@cl.step(type="tool")
async def tool():
# Fake tool
await cl.sleep(2)
return "Response from the tool!"
@cl.on_message # this function will be called every time a user inputs a message in the UI
async def main(message: cl.Message):
"""
This function is called every time a user inputs a message in the UI.
It sends back an intermediate response from the tool, followed by the final answer.
Args:
message: The user's message.
Returns:
None.
"""
# Call the tool
tool_res = await tool()
await cl.Message(content=tool_res).send()
```
Now run it!
```sh
chainlit run demo.py -w
```
<img src="/images/quick-start.png" alt="Quick Start"></img>
## 📚 More Examples - Cookbook
You can find various examples of Chainlit apps [here](https://github.com/Chainlit/cookbook) that leverage tools and services such as OpenAI, Anthropiс, LangChain, LlamaIndex, ChromaDB, Pinecone and more.
Tell us what you would like to see added in Chainlit using the Github issues or on [Discord](https://discord.gg/k73SQ3FyUh).
## 💁 Contributing
As an open-source initiative in a rapidly evolving domain, we welcome contributions, be it through the addition of new features or the improvement of documentation.
For detailed information on how to contribute, see [here](/CONTRIBUTING.md).
## 📃 License
Chainlit is open-source and licensed under the [Apache 2.0](LICENSE) license.
+110
View File
@@ -0,0 +1,110 @@
"""Build script gets called on uv/pip build."""
import pathlib
import shutil
import subprocess
import sys
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
class BuildError(Exception):
"""Custom exception for build failures"""
pass
def run_subprocess(cmd: list[str], cwd: pathlib.Path) -> None:
"""
Run a subprocess, allowing natural signal propagation.
Args:
cmd: Command and arguments as a list of strings
cwd: Working directory for the subprocess
"""
print(f"-- Running: {' '.join(cmd)}")
subprocess.run(cmd, cwd=cwd, check=True)
def pnpm_install(project_root: pathlib.Path, pnpm_path: str):
run_subprocess([pnpm_path, "install", "--frozen-lockfile"], project_root)
def pnpm_buildui(project_root: pathlib.Path, pnpm_path: str):
run_subprocess([pnpm_path, "build"], project_root)
def copy_directory(src: pathlib.Path, dst: pathlib.Path, description: str):
"""Copy directory with proper error handling"""
print(f"Copying {description} from {src} to {dst}")
try:
if dst.exists():
shutil.rmtree(dst)
dst.mkdir(parents=True)
shutil.copytree(src, dst, dirs_exist_ok=True)
except KeyboardInterrupt:
print("\nInterrupt received during copy operation...")
# Clean up partial copies
if dst.exists():
shutil.rmtree(dst)
raise
except Exception as e:
raise BuildError(f"Failed to copy {src} to {dst}: {e!s}")
def copy_frontend(project_root: pathlib.Path):
"""Copy the frontend dist directory to the backend for inclusion in the package."""
backend_frontend_dir = project_root / "backend" / "chainlit" / "frontend" / "dist"
frontend_dist = project_root / "frontend" / "dist"
copy_directory(frontend_dist, backend_frontend_dir, "frontend assets")
def copy_copilot(project_root: pathlib.Path):
"""Copy the copilot dist directory to the backend for inclusion in the package."""
backend_copilot_dir = project_root / "backend" / "chainlit" / "copilot" / "dist"
copilot_dist = project_root / "libs" / "copilot" / "dist"
copy_directory(copilot_dist, backend_copilot_dir, "copilot assets")
def build():
"""Main build function with proper error handling"""
print(
"\n-- Building frontend, this might take a while!\n\n"
" If you don't need to build the frontend and just want dependencies installed, use:\n"
" `uv sync --no-install-project --no-editable`\n"
)
try:
# Find directory containing this file
backend_dir = pathlib.Path(__file__).resolve().parent
project_root = backend_dir.parent
# Dirty hack to distinguish between building wheel from sdist and from source code
if not (project_root / "package.json").exists():
return
pnpm = shutil.which("pnpm")
if not pnpm:
raise BuildError("pnpm not found!")
pnpm_install(project_root, pnpm)
pnpm_buildui(project_root, pnpm)
copy_frontend(project_root)
copy_copilot(project_root)
except KeyboardInterrupt:
print("\nBuild interrupted by user")
sys.exit(1)
except BuildError as e:
print(f"\nBuild failed: {e!s}")
sys.exit(1)
except Exception as e:
print(f"\nUnexpected error: {e!s}")
sys.exit(1)
class CustomBuildHook(BuildHookInterface):
def initialize(self, _, __):
build()
+225
View File
@@ -0,0 +1,225 @@
import os
from dotenv import load_dotenv
# ruff: noqa: E402
# Keep this here to ensure imports have environment available.
env_file = os.getenv("CHAINLIT_ENV_FILE", ".env")
env_found = load_dotenv(dotenv_path=os.path.join(os.getcwd(), env_file))
from chainlit.logger import logger
if env_found:
logger.info(f"Loaded {env_file} file")
import asyncio
from typing import TYPE_CHECKING, Any, Dict
from literalai import ChatGeneration, CompletionGeneration, GenerationMessage
from pydantic.dataclasses import dataclass
import chainlit.input_widget as input_widget
from chainlit.action import Action
from chainlit.cache import cache
from chainlit.chat_context import chat_context
from chainlit.chat_settings import ChatSettings
from chainlit.context import context
from chainlit.element import (
Audio,
CustomElement,
Dataframe,
File,
Image,
Pdf,
Plotly,
Pyplot,
Task,
TaskList,
TaskStatus,
Text,
Video,
)
from chainlit.message import (
AskActionMessage,
AskElementMessage,
AskFileMessage,
AskUserMessage,
ErrorMessage,
Message,
)
from chainlit.mode import Mode, ModeOption
from chainlit.sidebar import ElementSidebar
from chainlit.step import Step, step
from chainlit.sync import make_async, run_sync
from chainlit.types import (
ChatProfile,
InputAudioChunk,
OutputAudioChunk,
Starter,
StarterCategory,
)
from chainlit.user import PersistedUser, User
from chainlit.user_session import user_session
from chainlit.utils import make_module_getattr
from chainlit.version import __version__
from .callbacks import (
action_callback,
author_rename,
data_layer,
header_auth_callback,
oauth_callback,
on_app_shutdown,
on_app_startup,
on_audio_chunk,
on_audio_end,
on_audio_start,
on_chat_end,
on_chat_resume,
on_chat_start,
on_feedback,
on_logout,
on_mcp_connect,
on_mcp_disconnect,
on_message,
on_settings_edit,
on_settings_update,
on_shared_thread_view,
on_slack_reaction_added,
on_stop,
on_window_message,
password_auth_callback,
send_window_message,
set_chat_profiles,
set_starter_categories,
set_starters,
)
if TYPE_CHECKING:
from chainlit.langchain.callbacks import (
AsyncLangchainCallbackHandler,
LangchainCallbackHandler,
)
from chainlit.llama_index.callbacks import LlamaIndexCallbackHandler
from chainlit.mistralai import instrument_mistralai
from chainlit.openai import instrument_openai
from chainlit.semantic_kernel import SemanticKernelFilter
def sleep(duration: int):
"""
Sleep for a given duration.
Args:
duration (int): The duration in seconds.
"""
return asyncio.sleep(duration)
@dataclass()
class CopilotFunction:
name: str
args: Dict[str, Any]
def acall(self):
return context.emitter.send_call_fn(self.name, self.args)
__getattr__ = make_module_getattr(
{
"LangchainCallbackHandler": "chainlit.langchain.callbacks",
"AsyncLangchainCallbackHandler": "chainlit.langchain.callbacks",
"LlamaIndexCallbackHandler": "chainlit.llama_index.callbacks",
"instrument_openai": "chainlit.openai",
"instrument_mistralai": "chainlit.mistralai",
"SemanticKernelFilter": "chainlit.semantic_kernel",
"server": "chainlit.server",
}
)
__all__ = [
"Action",
"AskActionMessage",
"AskElementMessage",
"AskFileMessage",
"AskUserMessage",
"AsyncLangchainCallbackHandler",
"Audio",
"ChatGeneration",
"ChatProfile",
"ChatSettings",
"CompletionGeneration",
"CopilotFunction",
"CustomElement",
"Dataframe",
"ElementSidebar",
"ErrorMessage",
"File",
"GenerationMessage",
"Image",
"InputAudioChunk",
"LangchainCallbackHandler",
"LlamaIndexCallbackHandler",
"Message",
"Mode",
"ModeOption",
"OutputAudioChunk",
"Pdf",
"PersistedUser",
"Plotly",
"Pyplot",
"SemanticKernelFilter",
"Starter",
"StarterCategory",
"Step",
"Task",
"TaskList",
"TaskStatus",
"Text",
"User",
"Video",
"__version__",
"action_callback",
"author_rename",
"cache",
"chat_context",
"context",
"data_layer",
"header_auth_callback",
"input_widget",
"instrument_mistralai",
"instrument_openai",
"make_async",
"oauth_callback",
"on_app_shutdown",
"on_app_startup",
"on_audio_chunk",
"on_audio_end",
"on_audio_start",
"on_chat_end",
"on_chat_resume",
"on_chat_start",
"on_feedback",
"on_logout",
"on_mcp_connect",
"on_mcp_disconnect",
"on_message",
"on_settings_edit",
"on_settings_update",
"on_shared_thread_view",
"on_slack_reaction_added",
"on_stop",
"on_window_message",
"password_auth_callback",
"run_sync",
"send_window_message",
"set_chat_profiles",
"set_starter_categories",
"set_starters",
"sleep",
"step",
"user_session",
]
def __dir__():
return __all__
+4
View File
@@ -0,0 +1,4 @@
from chainlit.cli import cli
if __name__ == "__main__":
cli(prog_name="chainlit")
+8
View File
@@ -0,0 +1,8 @@
"""Util functions which are explicitly not part of the public API."""
from pathlib import Path
def is_path_inside(child_path: Path, parent_path: Path) -> bool:
"""Check if the child path is inside the parent path."""
return parent_path.resolve() in child_path.resolve().parents
+33
View File
@@ -0,0 +1,33 @@
import uuid
from typing import Dict, Optional
from dataclasses_json import DataClassJsonMixin
from pydantic import Field
from pydantic.dataclasses import dataclass
from chainlit.context import context
@dataclass
class Action(DataClassJsonMixin):
# Name of the action, this should be used in the action_callback
name: str
# The parameters to call this action with.
payload: Dict
# The label of the action. This is what the user will see.
label: str = ""
# The tooltip of the action button. This is what the user will see when they hover the action.
tooltip: str = ""
# The lucid icon name for this action.
icon: Optional[str] = None
# This should not be set manually, only used internally.
forId: Optional[str] = None
# The ID of the action
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
async def send(self, for_id: str):
self.forId = for_id
await context.emitter.emit("action", self.to_dict())
async def remove(self):
await context.emitter.emit("remove_action", self.to_dict())
+100
View File
@@ -0,0 +1,100 @@
import os
from fastapi import Depends, HTTPException
from chainlit.config import config
from chainlit.data import get_data_layer
from chainlit.logger import logger
from chainlit.oauth_providers import get_configured_oauth_providers
from .cookie import (
OAuth2PasswordBearerWithCookie,
clear_auth_cookie,
get_token_from_cookies,
set_auth_cookie,
)
from .jwt import create_jwt, decode_jwt, get_jwt_secret
reuseable_oauth = OAuth2PasswordBearerWithCookie(tokenUrl="/login", auto_error=False)
def ensure_jwt_secret():
if require_login() and get_jwt_secret() is None:
raise ValueError(
"You must provide a JWT secret in the environment to use authentication. Run `chainlit create-secret` to generate one."
)
def is_oauth_enabled():
return config.code.oauth_callback and len(get_configured_oauth_providers()) > 0
def require_login():
return (
bool(os.environ.get("CHAINLIT_CUSTOM_AUTH"))
or config.code.password_auth_callback is not None
or config.code.header_auth_callback is not None
or is_oauth_enabled()
)
def get_configuration():
return {
"requireLogin": require_login(),
"passwordAuth": config.code.password_auth_callback is not None,
"headerAuth": config.code.header_auth_callback is not None,
"oauthProviders": (
get_configured_oauth_providers() if is_oauth_enabled() else []
),
"default_theme": config.ui.default_theme,
"ui": {
"login_page_image": config.ui.login_page_image,
"login_page_image_filter": config.ui.login_page_image_filter,
"login_page_image_dark_filter": config.ui.login_page_image_dark_filter,
},
}
async def authenticate_user(token: str = Depends(reuseable_oauth)):
try:
user = decode_jwt(token)
except Exception as e:
raise HTTPException(
status_code=401, detail="Invalid authentication token"
) from e
if data_layer := get_data_layer():
# Get or create persistent user if we've a data layer available.
try:
persisted_user = await data_layer.get_user(user.identifier)
if persisted_user is None:
persisted_user = await data_layer.create_user(user)
assert persisted_user
except Exception as e:
logger.exception("Unable to get persisted_user from data layer: %s", e)
return user
if user and user.display_name:
# Copy ephemeral display_name from authenticated user to persistent user.
persisted_user.display_name = user.display_name
return persisted_user
return user
async def get_current_user(token: str = Depends(reuseable_oauth)):
if not require_login():
return None
return await authenticate_user(token)
__all__ = [
"clear_auth_cookie",
"create_jwt",
"get_configuration",
"get_current_user",
"get_token_from_cookies",
"set_auth_cookie",
]
+199
View File
@@ -0,0 +1,199 @@
import os
from typing import Literal, Optional, cast
from fastapi import Request, Response
from fastapi.exceptions import HTTPException
from fastapi.security.base import SecurityBase
from fastapi.security.utils import get_authorization_scheme_param
from starlette.status import HTTP_401_UNAUTHORIZED
from chainlit.config import config
""" Module level cookie settings. """
_cookie_samesite = cast(
Literal["lax", "strict", "none"],
os.environ.get("CHAINLIT_COOKIE_SAMESITE", "lax"),
)
assert _cookie_samesite in [
"lax",
"strict",
"none",
], (
"Invalid value for CHAINLIT_COOKIE_SAMESITE. Must be one of 'lax', 'strict' or 'none'."
)
_cookie_secure = _cookie_samesite == "none"
if _cookie_root_path := os.environ.get("CHAINLIT_ROOT_PATH", None):
_cookie_path = os.environ.get(_cookie_root_path, "/")
else:
_cookie_path = os.environ.get("CHAINLIT_AUTH_COOKIE_PATH", "/")
_state_cookie_lifetime = int(
os.environ.get("CHAINLIT_STATE_COOKIE_LIFETIME", str(3 * 60))
)
_auth_cookie_name = os.environ.get("CHAINLIT_AUTH_COOKIE_NAME", "access_token")
_state_cookie_name = "oauth_state"
class OAuth2PasswordBearerWithCookie(SecurityBase):
"""
OAuth2 password flow with cookie support with fallback to bearer token.
"""
def __init__(
self,
tokenUrl: str,
scheme_name: Optional[str] = None,
auto_error: bool = True,
):
self.tokenUrl = tokenUrl
self.scheme_name = scheme_name or self.__class__.__name__
self.auto_error = auto_error
async def __call__(self, request: Request) -> Optional[str]:
# First try to get the token from the cookie
token = get_token_from_cookies(request.cookies)
# If no cookie, try the Authorization header as fallback
if not token:
# TODO: Only bother to check if cookie auth is explicitly disabled.
authorization = request.headers.get("Authorization")
if authorization:
scheme, token = get_authorization_scheme_param(authorization)
if scheme.lower() != "bearer":
if self.auto_error:
raise HTTPException(
status_code=HTTP_401_UNAUTHORIZED,
detail="Invalid authentication credentials",
headers={"WWW-Authenticate": "Bearer"},
)
else:
return None
else:
if self.auto_error:
raise HTTPException(
status_code=HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
else:
return None
return token
def _get_chunked_cookie(cookies: dict[str, str], name: str) -> Optional[str]:
# Gather all auth_chunk_i cookies, sorted by their index
chunk_parts = []
i = 0
while True:
cookie_key = f"{_auth_cookie_name}_{i}"
if cookie_key not in cookies:
break
chunk_parts.append(cookies[cookie_key])
i += 1
joined = "".join(chunk_parts)
return joined if joined != "" else None
def get_token_from_cookies(cookies: dict[str, str]) -> Optional[str]:
"""
Read all chunk cookies and reconstruct the token
"""
# Default/unchunked cookies
if value := cookies.get(_auth_cookie_name):
return value
return _get_chunked_cookie(cookies, _auth_cookie_name)
def set_auth_cookie(request: Request, response: Response, token: str):
"""
Helper function to set the authentication cookie with secure parameters
and remove any leftover chunks from a previously larger token.
"""
_chunk_size = 3000
existing_cookies = {
k for k in request.cookies.keys() if k.startswith(_auth_cookie_name)
}
if len(token) > _chunk_size:
chunks = [token[i : i + _chunk_size] for i in range(0, len(token), _chunk_size)]
for i, chunk in enumerate(chunks):
k = f"{_auth_cookie_name}_{i}"
response.set_cookie(
key=k,
value=chunk,
httponly=True,
secure=_cookie_secure,
samesite=_cookie_samesite,
max_age=config.project.user_session_timeout,
)
existing_cookies.discard(k)
else:
# Default (shorter cookies)
response.set_cookie(
key=_auth_cookie_name,
value=token,
httponly=True,
secure=_cookie_secure,
samesite=_cookie_samesite,
max_age=config.project.user_session_timeout,
)
existing_cookies.discard(_auth_cookie_name)
# Delete remaining prior cookies/cookie chunks
for k in existing_cookies:
response.delete_cookie(
key=k, path=_cookie_path, secure=_cookie_secure, samesite=_cookie_samesite
)
def clear_auth_cookie(request: Request, response: Response):
"""
Helper function to clear the authentication cookie
"""
existing_cookies = {
k for k in request.cookies.keys() if k.startswith(_auth_cookie_name)
}
for k in existing_cookies:
response.delete_cookie(
key=k, path=_cookie_path, secure=_cookie_secure, samesite=_cookie_samesite
)
def set_oauth_state_cookie(response: Response, token: str):
response.set_cookie(
_state_cookie_name,
token,
httponly=True,
samesite=_cookie_samesite,
secure=_cookie_secure,
max_age=_state_cookie_lifetime,
)
def validate_oauth_state_cookie(request: Request, state: str):
"""Check the state from the oauth provider against the browser cookie."""
oauth_state = request.cookies.get(_state_cookie_name)
if oauth_state != state:
raise Exception("oauth state does not correspond")
def clear_oauth_state_cookie(response: Response):
"""Oauth complete, delete state token."""
response.delete_cookie(_state_cookie_name) # Do we set path here?
+42
View File
@@ -0,0 +1,42 @@
import os
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, Optional
import jwt as pyjwt
from chainlit.config import config
from chainlit.user import User
def get_jwt_secret() -> Optional[str]:
return os.environ.get("CHAINLIT_AUTH_SECRET")
def create_jwt(data: User) -> str:
to_encode: Dict[str, Any] = data.to_dict()
to_encode.update(
{
"exp": datetime.now(timezone.utc)
+ timedelta(seconds=config.project.user_session_timeout),
"iat": datetime.now(timezone.utc), # Add issued at time
}
)
secret = get_jwt_secret()
assert secret
encoded_jwt = pyjwt.encode(to_encode, secret, algorithm="HS256")
return encoded_jwt
def decode_jwt(token: str) -> User:
secret = get_jwt_secret()
assert secret
dict = pyjwt.decode(
token,
secret,
algorithms=["HS256"],
options={"verify_signature": True},
)
del dict["exp"]
return User(**dict)
+62
View File
@@ -0,0 +1,62 @@
import importlib
import importlib.util
import os
import threading
from typing import Any
from chainlit.config import config
from chainlit.logger import logger
def init_lc_cache():
use_cache = config.project.cache is True and config.run.no_cache is False
if use_cache and importlib.util.find_spec("langchain") is not None:
try:
try:
set_llm_cache = importlib.import_module(
"langchain_core.globals"
).set_llm_cache
except ImportError:
set_llm_cache = importlib.import_module(
"langchain.globals"
).set_llm_cache
try:
SQLiteCache = importlib.import_module(
"langchain_community.cache"
).SQLiteCache
except ImportError:
SQLiteCache = importlib.import_module("langchain.cache").SQLiteCache
except (AttributeError, ImportError):
return
if config.project.lc_cache_path is not None:
set_llm_cache(SQLiteCache(database_path=config.project.lc_cache_path))
if not os.path.exists(config.project.lc_cache_path):
logger.info(
f"LangChain cache created at: {config.project.lc_cache_path}"
)
_cache: dict[tuple, Any] = {}
_cache_lock = threading.Lock()
def cache(func):
def wrapper(*args, **kwargs):
# Create a cache key based on the function name, arguments, and keyword arguments
cache_key = (
(func.__name__,) + args + tuple((k, v) for k, v in sorted(kwargs.items()))
)
with _cache_lock:
# Check if the result is already in the cache
if cache_key not in _cache:
# If not, call the function and store the result in the cache
_cache[cache_key] = func(*args, **kwargs)
return _cache[cache_key]
return wrapper
+553
View File
@@ -0,0 +1,553 @@
import inspect
from typing import Any, Awaitable, Callable, Dict, List, Optional, Union, overload
from fastapi import Request, Response
from mcp import ClientSession
from starlette.datastructures import Headers
from chainlit.action import Action
from chainlit.config import config
from chainlit.context import context
from chainlit.data.base import BaseDataLayer
from chainlit.mcp import McpConnection
from chainlit.message import Message
from chainlit.oauth_providers import get_configured_oauth_providers
from chainlit.step import Step, step
from chainlit.types import ChatProfile, Starter, StarterCategory, ThreadDict
from chainlit.user import User
from chainlit.utils import wrap_user_function
def on_app_startup(func: Callable[[], Union[None, Awaitable[None]]]) -> Callable:
"""
Hook to run code when the Chainlit application starts.
Useful for initializing resources, loading models, setting up database connections, etc.
The function can be synchronous or asynchronous.
Args:
func (Callable[[], Union[None, Awaitable[None]]]): The startup hook to execute. Takes no arguments.
Example:
@cl.on_app_startup
async def startup():
print("Application is starting!")
# Initialize resources here
Returns:
Callable[[], Union[None, Awaitable[None]]]: The decorated startup hook.
"""
config.code.on_app_startup = wrap_user_function(func, with_task=False)
return func
def on_app_shutdown(func: Callable[[], Union[None, Awaitable[None]]]) -> Callable:
"""
Hook to run code when the Chainlit application shuts down.
Useful for cleaning up resources, closing connections, saving state, etc.
The function can be synchronous or asynchronous.
Args:
func (Callable[[], Union[None, Awaitable[None]]]): The shutdown hook to execute. Takes no arguments.
Example:
@cl.on_app_shutdown
async def shutdown():
print("Application is shutting down!")
# Clean up resources here
Returns:
Callable[[], Union[None, Awaitable[None]]]: The decorated shutdown hook.
"""
config.code.on_app_shutdown = wrap_user_function(func, with_task=False)
return func
def password_auth_callback(
func: Callable[[str, str], Awaitable[Optional[User]]],
) -> Callable:
"""
Framework agnostic decorator to authenticate the user.
Args:
func (Callable[[str, str], Awaitable[Optional[User]]]): The authentication callback to execute. Takes the email and password as parameters.
Example:
@cl.password_auth_callback
async def password_auth_callback(username: str, password: str) -> Optional[User]:
Returns:
Callable[[str, str], Awaitable[Optional[User]]]: The decorated authentication callback.
"""
config.code.password_auth_callback = wrap_user_function(func)
return func
def header_auth_callback(
func: Callable[[Headers], Awaitable[Optional[User]]],
) -> Callable:
"""
Framework agnostic decorator to authenticate the user via a header
Args:
func (Callable[[Headers], Awaitable[Optional[User]]]): The authentication callback to execute.
Example:
@cl.header_auth_callback
async def header_auth_callback(headers: Headers) -> Optional[User]:
Returns:
Callable[[Headers], Awaitable[Optional[User]]]: The decorated authentication callback.
"""
config.code.header_auth_callback = wrap_user_function(func)
return func
def oauth_callback(
func: Callable[
[str, str, Dict[str, str], User, Optional[str]], Awaitable[Optional[User]]
],
) -> Callable:
"""
Framework agnostic decorator to authenticate the user via oauth
Args:
func (Callable[[str, str, Dict[str, str], User, Optional[str]], Awaitable[Optional[User]]]): The authentication callback to execute.
Example:
@cl.oauth_callback
async def oauth_callback(provider_id: str, token: str, raw_user_data: Dict[str, str], default_app_user: User, id_token: Optional[str]) -> Optional[User]:
Returns:
Callable[[str, str, Dict[str, str], User, Optional[str]], Awaitable[Optional[User]]]: The decorated authentication callback.
"""
if len(get_configured_oauth_providers()) == 0:
raise ValueError(
"You must set the environment variable for at least one oauth provider to use oauth authentication."
)
config.code.oauth_callback = wrap_user_function(func)
return func
def on_logout(func: Callable[[Request, Response], Any]) -> Callable:
"""
Function called when the user logs out.
Takes the FastAPI request and response as parameters.
"""
config.code.on_logout = wrap_user_function(func)
return func
def on_message(func: Callable) -> Callable:
"""
Framework agnostic decorator to react to messages coming from the UI.
The decorated function is called every time a new message is received.
Args:
func (Callable[[Message], Any]): The function to be called when a new message is received. Takes a cl.Message.
Returns:
Callable[[str], Any]: The decorated on_message function.
"""
async def with_parent_id(message: Message):
async with Step(name="on_message", type="run", parent_id=message.id) as s:
s.input = message.content
if len(inspect.signature(func).parameters) > 0:
await func(message)
else:
await func()
config.code.on_message = wrap_user_function(with_parent_id)
return func
async def send_window_message(data: Any):
"""
Send custom data to the host window via a window.postMessage event.
Args:
data (Any): The data to send with the event.
"""
await context.emitter.send_window_message(data)
def on_window_message(func: Callable[[str], Any]) -> Callable:
"""
Hook to react to javascript postMessage events coming from the UI.
Args:
func (Callable[[str], Any]): The function to be called when a window message is received.
Takes the message content as a string parameter.
Returns:
Callable[[str], Any]: The decorated on_window_message function.
"""
config.code.on_window_message = wrap_user_function(func)
return func
def on_chat_start(func: Callable) -> Callable:
"""
Hook to react to the user websocket connection event.
Args:
func (Callable[], Any]): The connection hook to execute.
Returns:
Callable[], Any]: The decorated hook.
"""
config.code.on_chat_start = wrap_user_function(
step(func, name="on_chat_start", type="run"), with_task=True
)
return func
def on_chat_resume(func: Callable[[ThreadDict], Any]) -> Callable:
"""
Hook to react to resume websocket connection event.
Args:
func (Callable[], Any]): The connection hook to execute.
Returns:
Callable[], Any]: The decorated hook.
"""
config.code.on_chat_resume = wrap_user_function(func, with_task=True)
return func
@overload
def set_chat_profiles(
func: Callable[[Optional["User"]], Awaitable[List["ChatProfile"]]],
) -> Callable[[Optional["User"]], Awaitable[List["ChatProfile"]]]: ...
@overload
def set_chat_profiles(
func: Callable[[Optional["User"], Optional["str"]], Awaitable[List["ChatProfile"]]],
) -> Callable[[Optional["User"], Optional["str"]], Awaitable[List["ChatProfile"]]]: ...
def set_chat_profiles(func):
"""
Programmatic declaration of the available chat profiles (can depend on the User from the session if authentication is setup).
Args:
func (Callable[[Optional["User"]], Awaitable[List["ChatProfile"]]]): The function declaring the chat profiles.
Returns:
Callable[[Optional["User"]], Awaitable[List["ChatProfile"]]]: The decorated function.
"""
config.code.set_chat_profiles = wrap_user_function(func)
return func
@overload
def set_starters(
func: Callable[[Optional["User"]], Awaitable[List["Starter"]]],
) -> Callable[[Optional["User"]], Awaitable[List["Starter"]]]: ...
@overload
def set_starters(
func: Callable[[Optional["User"], Optional["str"]], Awaitable[List["Starter"]]],
) -> Callable[[Optional["User"], Optional["str"]], Awaitable[List["Starter"]]]: ...
def set_starters(func):
"""
Programmatic declaration of the available starter (can depend on the User from the session if authentication is setup).
Args:
func (Callable[[Optional["User"], Optional["str"]], Awaitable[List["Starter"]]]): The function declaring the starters with optional user and language arguments.
Returns:
Callable[[Optional["User"], Optional["str"]], Awaitable[List["Starter"]]]: The decorated function.
"""
config.code.set_starters = wrap_user_function(func)
return func
@overload
def set_starter_categories(
func: Callable[[Optional["User"]], Awaitable[List["StarterCategory"]]],
) -> Callable[[Optional["User"]], Awaitable[List["StarterCategory"]]]: ...
@overload
def set_starter_categories(
func: Callable[
[Optional["User"], Optional["str"]], Awaitable[List["StarterCategory"]]
],
) -> Callable[
[Optional["User"], Optional["str"]], Awaitable[List["StarterCategory"]]
]: ...
@overload
def set_starter_categories(
func: Callable[
[Optional["User"], Optional["str"], Optional["str"]],
Awaitable[List["StarterCategory"]],
],
) -> Callable[
[Optional["User"], Optional["str"], Optional["str"]],
Awaitable[List["StarterCategory"]],
]: ...
def set_starter_categories(func):
"""
Programmatic declaration of starter categories with grouped starters.
Args:
func (Callable[[Optional["User"], Optional["str"], Optional["str"]], Awaitable[List["StarterCategory"]]]): The function declaring the starter categories with optional user, language, and chat profile arguments.
Returns:
Callable[[Optional["User"], Optional["str"], Optional["str"]], Awaitable[List["StarterCategory"]]]: The decorated function.
"""
config.code.set_starter_categories = wrap_user_function(func)
return func
def on_chat_end(func: Callable) -> Callable:
"""
Hook to react to the user websocket disconnect event.
Args:
func (Callable[], Any]): The disconnect hook to execute.
Returns:
Callable[], Any]: The decorated hook.
"""
config.code.on_chat_end = wrap_user_function(func, with_task=True)
return func
def on_audio_start(func: Callable) -> Callable:
"""
Hook to react to the user initiating audio.
Returns:
Callable[], Any]: The decorated hook.
"""
config.code.on_audio_start = wrap_user_function(func, with_task=False)
return func
def on_audio_chunk(func: Callable) -> Callable:
"""
Hook to react to the audio chunks being sent.
Args:
chunk (InputAudioChunk): The audio chunk being sent.
Returns:
Callable[], Any]: The decorated hook.
"""
config.code.on_audio_chunk = wrap_user_function(func, with_task=False)
return func
def on_audio_end(func: Callable) -> Callable:
"""
Hook to react to the audio stream ending. This is called after the last audio chunk is sent.
Returns:
Callable[], Any]: The decorated hook.
"""
config.code.on_audio_end = wrap_user_function(
step(func, name="on_audio_end", type="run"), with_task=True
)
return func
def author_rename(
func: Callable[[str], Awaitable[str]],
) -> Callable[[str], Awaitable[str]]:
"""
Useful to rename the author of message to display more friendly author names in the UI.
Args:
func (Callable[[str], Awaitable[str]]): The function to be called to rename an author. Takes the original author name as parameter.
Returns:
Callable[[Any, str], Awaitable[Any]]: The decorated function.
"""
config.code.author_rename = wrap_user_function(func)
return func
def on_mcp_connect(
func: Callable[[McpConnection, ClientSession], Awaitable[None]],
) -> Callable[[McpConnection, ClientSession], Awaitable[None]]:
"""
Called everytime an MCP is connected
"""
config.code.on_mcp_connect = wrap_user_function(func)
return func
def on_mcp_disconnect(
func: Callable[[str, ClientSession], Awaitable[None]],
) -> Callable[[str, ClientSession], Awaitable[None]]:
"""
Called everytime an MCP is disconnected
"""
config.code.on_mcp_disconnect = wrap_user_function(func)
return func
def on_stop(func: Callable) -> Callable:
"""
Hook to react to the user stopping a thread.
Args:
func (Callable[[], Any]): The stop hook to execute.
Returns:
Callable[[], Any]: The decorated stop hook.
"""
config.code.on_stop = wrap_user_function(func)
return func
def action_callback(name: str) -> Callable:
"""
Callback to call when an action is clicked in the UI.
Args:
func (Callable[[Action], Any]): The action callback to execute. First parameter is the action.
"""
def decorator(func: Callable[[Action], Any]):
config.code.action_callbacks[name] = wrap_user_function(func, with_task=False)
return func
return decorator
def on_settings_update(
func: Callable[[Dict[str, Any]], Any],
) -> Callable[[Dict[str, Any]], Any]:
"""
Hook to react to the user changing any settings.
Args:
func (Callable[], Any]): The hook to execute after settings were changed.
Returns:
Callable[], Any]: The decorated hook.
"""
config.code.on_settings_update = wrap_user_function(func, with_task=True)
return func
def on_settings_edit(
func: Callable[[Dict[str, Any]], Any],
) -> Callable[[Dict[str, Any]], Any]:
"""
Hook to react to the user editing any settings (on the fly).
Args:
func (Callable[], Any]): The hook to execute while settings are being edited.
Returns:
Callable[], Any]: The decorated hook.
"""
config.code.on_settings_edit = wrap_user_function(func, with_task=True)
return func
def data_layer(
func: Callable[[], BaseDataLayer],
) -> Callable[[], BaseDataLayer]:
"""
Hook to configure custom data layer.
"""
# We don't use wrap_user_function here because:
# 1. We don't need to support async here and;
# 2. We don't want to change the API for get_data_layer() to be async, everywhere (at this point).
config.code.data_layer = func
return func
def on_feedback(func: Callable) -> Callable:
"""
Hook to react to user feedback events from the UI.
The decorated function is called every time feedback is received.
Args:
func (Callable[[Feedback], Any]): The function to be called when feedback is received. Takes a cl.Feedback object.
Example:
@cl.on_feedback
async def on_feedback(feedback: Feedback):
print(f"Received feedback: {feedback.value} for step {feedback.forId}")
# Handle feedback here
Returns:
Callable[[Feedback], Any]: The decorated on_feedback function.
"""
config.code.on_feedback = wrap_user_function(func)
return func
def on_slack_reaction_added(func: Callable[[Dict[str, Any]], Any]) -> Callable:
"""
Hook to react to Slack reaction_added events.
The decorated function is called every time a user adds a reaction to a message in Slack.
Args:
func (Callable[[Dict[str, Any]], Any]): The function to be called when a reaction is added.
Takes a Slack event dictionary containing:
- reaction: The emoji reaction name (e.g., "thumbsup")
- user: The user ID who added the reaction
- item: Dictionary with type, ts, and channel of the reacted item
Example:
@cl.on_slack_reaction_added
async def handle_reaction(event: Dict[str, Any]):
reaction = event.get("reaction")
user_id = event.get("user")
print(f"User {user_id} added reaction {reaction}")
# Handle reaction here
Returns:
Callable[[Dict[str, Any]], Any]: The decorated on_slack_reaction_added function.
"""
config.code.on_slack_reaction_added = wrap_user_function(func)
return func
def on_shared_thread_view(
func: Callable[[ThreadDict, Optional[User]], Awaitable[bool]],
) -> Callable[[ThreadDict, Optional[User]], Awaitable[bool]]:
"""Hook to authorize viewing a shared thread.
Users must implement and return True to allow a non-author to view a thread.
Thread metadata contains "is_shared" boolean flag and "shared_at" timestamp for custom thread sharing.
Signature: async (thread: ThreadDict, viewer: Optional[User]) -> bool
"""
config.code.on_shared_thread_view = wrap_user_function(func)
return func
+64
View File
@@ -0,0 +1,64 @@
from typing import TYPE_CHECKING, Dict, List
from chainlit.context import context
if TYPE_CHECKING:
from chainlit.message import Message
chat_contexts: Dict[str, List["Message"]] = {}
class ChatContext:
def get(self) -> List["Message"]:
if not context.session:
return []
if context.session.id not in chat_contexts:
# Create a new chat context
chat_contexts[context.session.id] = []
return chat_contexts[context.session.id].copy()
def add(self, message: "Message"):
if not context.session:
return
if context.session.id not in chat_contexts:
chat_contexts[context.session.id] = []
if message not in chat_contexts[context.session.id]:
chat_contexts[context.session.id].append(message)
return message
def remove(self, message: "Message") -> bool:
if not context.session:
return False
if context.session.id not in chat_contexts:
return False
if message in chat_contexts[context.session.id]:
chat_contexts[context.session.id].remove(message)
return True
return False
def clear(self) -> None:
if context.session and context.session.id in chat_contexts:
chat_contexts[context.session.id] = []
def to_openai(self):
messages = []
for message in self.get():
if message.type == "assistant_message":
messages.append({"role": "assistant", "content": message.content})
elif message.type == "user_message":
messages.append({"role": "user", "content": message.content})
else:
messages.append({"role": "system", "content": message.content})
return messages
chat_context = ChatContext()
+49
View File
@@ -0,0 +1,49 @@
from typing import Any, List
from pydantic import Field
from pydantic.dataclasses import dataclass
from chainlit.context import context
from chainlit.input_widget import InputWidget, Tab
@dataclass
class ChatSettings:
"""Useful to create chat settings that the user can change."""
inputs: List[InputWidget] | List[Tab] = Field(default_factory=list, exclude=True)
def __init__(
self,
inputs: List[InputWidget] | List[Tab],
) -> None:
self.inputs = inputs
def settings(self):
def collect_settings(
values: dict[str, Any], inputs: List[InputWidget] | List[Tab]
) -> None:
for input in inputs:
if isinstance(input, Tab):
collect_settings(values, input.inputs)
else:
values[input.id] = input.initial
settings: dict[str, Any] = {}
collect_settings(settings, self.inputs)
return settings
def _inputs_as_dicts(self) -> List[dict[str, Any]]:
return [input_widget.to_dict() for input_widget in self.inputs]
async def refresh(self):
"""Push settings widgets to the UI without updating session chat_settings."""
settings = self.settings()
await context.emitter.emit("chat_settings", self._inputs_as_dicts())
return settings
async def send(self):
settings = self.settings()
context.emitter.set_chat_settings(settings)
await context.emitter.emit("chat_settings", self._inputs_as_dicts())
return settings
+244
View File
@@ -0,0 +1,244 @@
import asyncio
import logging
import os
import sys
import click
import nest_asyncio
import uvicorn
# Not sure if it is necessary to call nest_asyncio.apply() before the other imports
nest_asyncio.apply()
# ruff: noqa: E402
from chainlit.auth import ensure_jwt_secret
from chainlit.cache import init_lc_cache
from chainlit.config import (
BACKEND_ROOT,
DEFAULT_HOST,
DEFAULT_PORT,
DEFAULT_ROOT_PATH,
config,
init_config,
lint_translations,
load_module,
)
from chainlit.logger import logger
from chainlit.markdown import init_markdown
from chainlit.secret import random_secret
from chainlit.utils import check_file
logging.basicConfig(
level=logging.INFO,
stream=sys.stdout,
format="%(asctime)s - %(levelname)s - %(name)s - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
def assert_app():
if (
not config.code.on_chat_start
and not config.code.on_message
and not config.code.on_audio_chunk
):
raise Exception(
"You need to configure at least one of on_chat_start, on_message or on_audio_chunk callback"
)
# Create the main command group for Chainlit CLI
@click.group(context_settings={"auto_envvar_prefix": "CHAINLIT"})
@click.version_option(prog_name="Chainlit")
def cli():
return
# Define the function to run Chainlit with provided options
def run_chainlit(target: str):
host = os.environ.get("CHAINLIT_HOST", DEFAULT_HOST)
port = int(os.environ.get("CHAINLIT_PORT", DEFAULT_PORT))
root_path = os.environ.get("CHAINLIT_ROOT_PATH", DEFAULT_ROOT_PATH)
ssl_certfile = os.environ.get("CHAINLIT_SSL_CERT", None)
ssl_keyfile = os.environ.get("CHAINLIT_SSL_KEY", None)
ws_per_message_deflate_env = os.environ.get(
"UVICORN_WS_PER_MESSAGE_DEFLATE", "true"
)
ws_per_message_deflate = ws_per_message_deflate_env.lower() in [
"true",
"1",
"yes",
] # Convert to boolean
ws_protocol = os.environ.get("UVICORN_WS_PROTOCOL", "auto")
config.run.host = host
config.run.port = port
config.run.root_path = root_path
from chainlit.server import app
check_file(target)
# Load the module provided by the user
config.run.module_name = target
load_module(config.run.module_name)
ensure_jwt_secret()
assert_app()
# Create the chainlit.md file if it doesn't exist
init_markdown(config.root)
# Initialize the LangChain cache if installed and enabled
init_lc_cache()
log_level = "debug" if config.run.debug else "error"
# Start the server
async def start():
config = uvicorn.Config(
app,
host=host,
port=port,
ws=ws_protocol,
log_level=log_level,
ws_per_message_deflate=ws_per_message_deflate,
ssl_keyfile=ssl_keyfile,
ssl_certfile=ssl_certfile,
)
server = uvicorn.Server(config)
await server.serve()
# Run the asyncio event loop instead of uvloop to enable re entrance
asyncio.run(start())
# uvicorn.run(app, host=host, port=port, log_level=log_level)
# Define the "run" command for Chainlit CLI
@cli.command("run")
@click.argument("target", required=True, envvar="RUN_TARGET")
@click.option(
"-w",
"--watch",
default=False,
is_flag=True,
envvar="WATCH",
help="Reload the app when the module changes",
)
@click.option(
"-h",
"--headless",
default=False,
is_flag=True,
envvar="HEADLESS",
help="Will prevent to auto open the app in the browser",
)
@click.option(
"-d",
"--debug",
default=False,
is_flag=True,
envvar="DEBUG",
help="Set the log level to debug",
)
@click.option(
"-c",
"--ci",
default=False,
is_flag=True,
envvar="CI",
help="Flag to run in CI mode",
)
@click.option(
"--no-cache",
default=False,
is_flag=True,
envvar="NO_CACHE",
help="Useful to disable third parties cache, such as langchain.",
)
@click.option(
"--ssl-cert",
default=None,
envvar="CHAINLIT_SSL_CERT",
help="Specify the file path for the SSL certificate.",
)
@click.option(
"--ssl-key",
default=None,
envvar="CHAINLIT_SSL_KEY",
help="Specify the file path for the SSL key",
)
@click.option("--host", help="Specify a different host to run the server on")
@click.option("--port", help="Specify a different port to run the server on")
@click.option("--root-path", help="Specify a different root path to run the server on")
def chainlit_run(
target,
watch,
headless,
debug,
ci,
no_cache,
ssl_cert,
ssl_key,
host,
port,
root_path,
):
if host:
os.environ["CHAINLIT_HOST"] = host
if port:
os.environ["CHAINLIT_PORT"] = port
if bool(ssl_cert) != bool(ssl_key):
raise click.UsageError(
"Both --ssl-cert and --ssl-key must be provided together."
)
if ssl_cert:
os.environ["CHAINLIT_SSL_CERT"] = ssl_cert
os.environ["CHAINLIT_SSL_KEY"] = ssl_key
if root_path:
os.environ["CHAINLIT_ROOT_PATH"] = root_path
if ci:
logger.info("Running in CI mode")
no_cache = True
# This is required to have OpenAI LLM providers available for the CI run
os.environ["OPENAI_API_KEY"] = "sk-FAKE-OPENAI-API-KEY"
config.run.headless = headless
config.run.debug = debug
config.run.no_cache = no_cache
config.run.ci = ci
config.run.watch = watch
config.run.ssl_cert = ssl_cert
config.run.ssl_key = ssl_key
run_chainlit(target)
@cli.command("hello")
@click.argument("args", nargs=-1)
def chainlit_hello(args=None, **kwargs):
hello_path = os.path.join(BACKEND_ROOT, "sample", "hello.py")
run_chainlit(hello_path)
@cli.command("init")
@click.argument("args", nargs=-1)
def chainlit_init(args=None, **kwargs):
init_config(log=True)
@cli.command("create-secret")
@click.argument("args", nargs=-1)
def chainlit_create_secret(args=None, **kwargs):
print(
f'Copy the following secret into your .env file. Once it is set, changing it will logout all users with active sessions.\nCHAINLIT_AUTH_SECRET="{random_secret()}"'
)
@cli.command("lint-translations")
@click.argument("args", nargs=-1)
def chainlit_lint_translations(args=None, **kwargs):
lint_translations()
+710
View File
@@ -0,0 +1,710 @@
import json
import os
import site
import sys
from importlib import util
from pathlib import Path
from typing import (
TYPE_CHECKING,
Any,
Awaitable,
Callable,
Dict,
List,
Literal,
Optional,
Union,
)
import tomli
from pydantic import BaseModel, Field
from pydantic_settings import BaseSettings
from starlette.datastructures import Headers
from chainlit.data.base import BaseDataLayer
from chainlit.logger import logger
from chainlit.translations import lint_translation_json
from chainlit.version import __version__
from ._utils import is_path_inside
if TYPE_CHECKING:
from fastapi import Request, Response
from chainlit.action import Action
from chainlit.message import Message
from chainlit.types import (
ChatProfile,
Feedback,
InputAudioChunk,
Starter,
StarterCategory,
ThreadDict,
)
from chainlit.user import User
else:
# Pydantic needs to resolve forward annotations. Because all of these are used
# within `typing.Callable`, alias to `Any` as Pydantic does not perform validation
# of callable argument/return types anyway.
Request = Response = Action = Message = ChatProfile = InputAudioChunk = Starter = StarterCategory = ThreadDict = User = Feedback = Any # fmt: off
BACKEND_ROOT = os.path.dirname(__file__)
PACKAGE_ROOT = os.path.dirname(os.path.dirname(BACKEND_ROOT))
TRANSLATIONS_DIR = os.path.join(BACKEND_ROOT, "translations")
# Get the directory the script is running from
APP_ROOT = os.getenv("CHAINLIT_APP_ROOT", os.getcwd())
# Create the directory to store the uploaded files
FILES_DIRECTORY = Path(APP_ROOT) / ".files"
FILES_DIRECTORY.mkdir(exist_ok=True)
config_dir = os.path.join(APP_ROOT, ".chainlit")
public_dir = os.path.join(APP_ROOT, "public")
config_file = os.path.join(config_dir, "config.toml")
config_translation_dir = os.path.join(config_dir, "translations")
# Default config file created if none exists
DEFAULT_CONFIG_STR = f"""[project]
# List of environment variables to be provided by each user to use the app.
user_env = []
# Duration (in seconds) during which the session is saved when the connection is lost
session_timeout = 3600
# Duration (in seconds) of the user session expiry
user_session_timeout = 1296000 # 15 days
# Enable third parties caching (e.g., LangChain cache)
cache = false
# Whether to persist user environment variables (API keys) to the database
# Set to true to store user env vars in DB, false to exclude them for security
persist_user_env = false
# Whether to mask user environment variables (API keys) in the UI with password type
# Set to true to show API keys as ***, false to show them as plain text
mask_user_env = false
# Authorized origins
allow_origins = ["*"]
[features]
# Process and display HTML in messages. This can be a security risk (see https://stackoverflow.com/questions/19603097/why-is-it-dangerous-to-render-user-generated-html-or-javascript)
unsafe_allow_html = false
# Process and display mathematical expressions. This can clash with "$" characters in messages.
latex = false
# Enable rendering of user messages markdown
user_message_markdown = true
# Autoscroll new user messages at the top of the window
user_message_autoscroll = true
# Autoscroll new assistant messages
assistant_message_autoscroll = true
# Automatically tag threads with the current chat profile (if a chat profile is used)
auto_tag_thread = true
# Allow users to edit their own messages
edit_message = true
# Allow users to share threads (backend + UI). Requires an app-defined on_shared_thread_view callback.
allow_thread_sharing = false
# Enable favorite messages
favorites = false
[features.slack]
# Add emoji reaction when message is received (requires reactions:write OAuth scope)
reaction_on_message_received = false
# Authorize users to spontaneously upload files with messages
[features.spontaneous_file_upload]
enabled = true
# Define accepted file types using MIME types
# Examples:
# 1. For specific file types:
# accept = ["image/jpeg", "image/png", "application/pdf"]
# 2. For all files of certain type:
# accept = ["image/*", "audio/*", "video/*"]
# 3. For specific file extensions:
# accept = {{ "application/octet-stream" = [".xyz", ".pdb"] }}
# Note: Using "*/*" is not recommended as it may cause browser warnings
accept = ["*/*"]
max_files = 20
max_size_mb = 500
[features.audio]
# Enable audio features
enabled = false
# Sample rate of the audio
sample_rate = 24000
[features.mcp]
# Enable Model Context Protocol (MCP) features
enabled = false
[features.mcp.sse]
enabled = true
[features.mcp.streamable-http]
enabled = true
[features.mcp.stdio]
enabled = true
# Only the executables in the allow list can be used for MCP stdio server.
# Only need the base name of the executable, e.g. "npx", not "/usr/bin/npx".
# Please don't comment this line for now, we need it to parse the executable name.
allowed_executables = [ "npx", "uvx" ]
[UI]
# Name of the assistant.
name = "Assistant"
# default_theme = "dark"
# Force a specific language for all users (e.g., "en-US", "he-IL", "fr-FR")
# If not set, the browser's language will be used
# language = "en-US"
# layout = "wide"
# default_sidebar_state = "open" # Options: "open", "closed", "hidden"
# Chat settings display location: "message_composer" (default) or "sidebar" (header)
# chat_settings_location = "message_composer"
# Default state of chat settings sidebar when location is "sidebar"
# default_chat_settings_open = false
# Whether to prompt user confirmation on clicking 'New Chat'
confirm_new_chat = true
# Description of the assistant. This is used for HTML tags.
# description = ""
# Chain of Thought (CoT) display mode. Can be "hidden", "tool_call" or "full".
cot = "full"
# Specify a CSS file that can be used to customize the user interface.
# The CSS file can be served from the public directory or via an external link.
# custom_css = "/public/test.css"
# Specify additional attributes for a custom CSS file
# custom_css_attributes = "media=\\\"print\\\""
# Specify a JavaScript file that can be used to customize the user interface.
# The JavaScript file can be served from the public directory.
# custom_js = "/public/test.js"
# The style of alert boxes. Can be "classic" or "modern".
alert_style = "classic"
# Specify additional attributes for custom JS file
# custom_js_attributes = "async type = \\\"module\\\""
# Custom login page image, relative to public directory or external URL
# login_page_image = "/public/custom-background.jpg"
# Custom login page image filter (Tailwind internal filters, no dark/light variants)
# login_page_image_filter = "brightness-50 grayscale"
# login_page_image_dark_filter = "contrast-200 blur-sm"
# Specify a custom meta URL (used for meta tags like og:url)
# custom_meta_url = "https://github.com/Chainlit/chainlit"
# Specify a custom meta image url.
# custom_meta_image_url = "https://chainlit-cloud.s3.eu-west-3.amazonaws.com/logo/chainlit_banner.png"
# Load assistant logo directly from URL.
logo_file_url = ""
# Load assistant avatar image directly from URL.
default_avatar_file_url = ""
# Avatar size in pixels (default: 20).
# avatar_size = 20
# Specify a custom build directory for the frontend.
# This can be used to customize the frontend code.
# Be careful: If this is a relative path, it should not start with a slash.
# custom_build = "./public/build"
# Specify optional one or more custom links in the header.
# [[UI.header_links]]
# name = "Issues"
# display_name = "Report Issue"
# icon_url = "https://avatars.githubusercontent.com/u/128686189?s=200&v=4"
# url = "https://github.com/Chainlit/chainlit/issues"
# target = "_blank" (default) # Optional: "_self", "_parent", "_top".
[meta]
generated_by = "{__version__}"
"""
DEFAULT_HOST = "127.0.0.1"
DEFAULT_PORT = 8000
DEFAULT_ROOT_PATH = ""
class RunSettings(BaseModel):
# Name of the module (python file) used in the run command
module_name: Optional[str] = None
host: str = DEFAULT_HOST
port: int = DEFAULT_PORT
ssl_cert: Optional[str] = None
ssl_key: Optional[str] = None
root_path: str = DEFAULT_ROOT_PATH
headless: bool = False
watch: bool = False
no_cache: bool = False
debug: bool = False
ci: bool = False
class PaletteOptions(BaseModel):
main: Optional[str] = ""
light: Optional[str] = ""
dark: Optional[str] = ""
class TextOptions(BaseModel):
primary: Optional[str] = ""
secondary: Optional[str] = ""
class Palette(BaseModel):
primary: Optional[PaletteOptions] = None
background: Optional[str] = ""
paper: Optional[str] = ""
text: Optional[TextOptions] = None
class SpontaneousFileUploadFeature(BaseModel):
enabled: Optional[bool] = None
accept: Optional[Union[List[str], Dict[str, List[str]]]] = None
max_files: Optional[int] = None
max_size_mb: Optional[int] = None
class AudioFeature(BaseModel):
sample_rate: int = 24000
enabled: bool = False
class McpSseFeature(BaseModel):
enabled: bool = True
class McpStreamableHttpFeature(BaseModel):
enabled: bool = True
class McpStdioFeature(BaseModel):
enabled: bool = True
allowed_executables: Optional[list[str]] = None
class SlackFeature(BaseModel):
reaction_on_message_received: bool = False
class McpFeature(BaseModel):
enabled: bool = False
sse: McpSseFeature = Field(default_factory=McpSseFeature)
streamable_http: McpStreamableHttpFeature = Field(
default_factory=McpStreamableHttpFeature
)
stdio: McpStdioFeature = Field(default_factory=McpStdioFeature)
class FeaturesSettings(BaseModel):
spontaneous_file_upload: Optional[SpontaneousFileUploadFeature] = None
audio: Optional[AudioFeature] = Field(default_factory=AudioFeature)
mcp: McpFeature = Field(default_factory=McpFeature)
slack: SlackFeature = Field(default_factory=SlackFeature)
latex: bool = False
user_message_markdown: bool = True
user_message_autoscroll: bool = True
assistant_message_autoscroll: bool = True
unsafe_allow_html: bool = False
auto_tag_thread: bool = True
edit_message: bool = True
allow_thread_sharing: bool = False
favorites: bool = False
class HeaderLink(BaseModel):
name: str
icon_url: str
url: str
display_name: Optional[str] = None
target: Optional[Literal["_blank", "_self", "_parent", "_top"]] = None
class UISettings(BaseModel):
name: str
description: str = ""
cot: Literal["hidden", "tool_call", "full"] = "full"
default_theme: Optional[Literal["light", "dark"]] = "dark"
language: Optional[str] = None
layout: Optional[Literal["default", "wide"]] = "default"
default_sidebar_state: Optional[Literal["open", "closed", "hidden"]] = "open"
chat_settings_location: Optional[Literal["message_composer", "sidebar"]] = (
"message_composer"
)
default_chat_settings_open: bool = False
confirm_new_chat: bool = True
github: Optional[str] = None
custom_css: Optional[str] = None
custom_css_attributes: Optional[str] = ""
custom_js: Optional[str] = None
alert_style: Optional[Literal["classic", "modern"]] = "classic"
custom_js_attributes: Optional[str] = "defer"
login_page_image: Optional[str] = None
login_page_image_filter: Optional[str] = None
login_page_image_dark_filter: Optional[str] = None
custom_meta_url: Optional[str] = None
custom_meta_image_url: Optional[str] = None
logo_file_url: Optional[str] = None
default_avatar_file_url: Optional[str] = None
avatar_size: Optional[int] = None
custom_build: Optional[str] = None
header_links: Optional[List[HeaderLink]] = None
class CodeSettings(BaseModel):
# App action functions
action_callbacks: Dict[str, Callable[["Action"], Any]]
# Module object loaded from the module_name
module: Any = None
# App life cycle callbacks
on_app_startup: Optional[Callable[[], Union[None, Awaitable[None]]]] = None
on_app_shutdown: Optional[Callable[[], Union[None, Awaitable[None]]]] = None
# Session life cycle callbacks
on_logout: Optional[Callable[["Request", "Response"], Any]] = None
on_stop: Optional[Callable[[], Any]] = None
on_chat_start: Optional[Callable[[], Any]] = None
on_chat_end: Optional[Callable[[], Any]] = None
on_chat_resume: Optional[Callable[["ThreadDict"], Any]] = None
on_message: Optional[Callable[["Message"], Any]] = None
on_feedback: Optional[Callable[["Feedback"], Any]] = None
on_slack_reaction_added: Optional[Callable[[Dict[str, Any]], Any]] = None
on_audio_start: Optional[Callable[[], Any]] = None
on_audio_chunk: Optional[Callable[["InputAudioChunk"], Any]] = None
on_audio_end: Optional[Callable[[], Any]] = None
on_mcp_connect: Optional[Callable] = None
on_mcp_disconnect: Optional[Callable] = None
on_settings_edit: Optional[Callable[[Dict[str, Any]], Any]] = None
on_settings_update: Optional[Callable[[Dict[str, Any]], Any]] = None
set_chat_profiles: Optional[
Callable[[Optional["User"], Optional["str"]], Awaitable[List["ChatProfile"]]]
] = None
set_starters: Optional[
Callable[[Optional["User"], Optional["str"]], Awaitable[List["Starter"]]]
] = None
set_starter_categories: Optional[
Callable[
[Optional["User"], Optional["str"], Optional["str"]],
Awaitable[List["StarterCategory"]],
]
] = None
on_shared_thread_view: Optional[
Callable[["ThreadDict", Optional["User"]], Awaitable[bool]]
] = None
# Auth callbacks
password_auth_callback: Optional[
Callable[[str, str], Awaitable[Optional["User"]]]
] = None
header_auth_callback: Optional[Callable[[Headers], Awaitable[Optional["User"]]]] = (
None
)
oauth_callback: Optional[
Callable[[str, str, Dict[str, str], "User"], Awaitable[Optional["User"]]]
] = None
# Helpers
on_window_message: Optional[Callable[[str], Any]] = None
author_rename: Optional[Callable[[str], Awaitable[str]]] = None
data_layer: Optional[Callable[[], BaseDataLayer]] = None
class ProjectSettings(BaseModel):
allow_origins: List[str] = Field(default_factory=lambda: ["*"])
# Socket.io client transports option
transports: Optional[List[str]] = None
# List of environment variables to be provided by each user to use the app. If empty, no environment variables will be asked to the user.
user_env: Optional[List[str]] = None
# Path to the local langchain cache database
lc_cache_path: Optional[str] = None
# Path to the local chat db
# Duration (in seconds) during which the session is saved when the connection is lost
session_timeout: int = 300
# Duration (in seconds) of the user session expiry
user_session_timeout: int = 1296000 # 15 days
# Enable third parties caching (e.g LangChain cache)
cache: bool = False
# Whether to persist user environment variables (API keys) to the database
persist_user_env: Optional[bool] = False
# Whether to mask user environment variables (API keys) in the UI with password type
mask_user_env: Optional[bool] = False
class ChainlitConfigOverrides(BaseModel):
"""Configuration overrides that can be applied to specific chat profiles."""
ui: Optional[UISettings] = None
features: Optional[FeaturesSettings] = None
project: Optional[ProjectSettings] = None
class ChainlitConfig(BaseSettings):
root: str = APP_ROOT
chainlit_server: str = Field(default="")
run: RunSettings = Field(default_factory=RunSettings)
features: FeaturesSettings
ui: UISettings
project: ProjectSettings
code: CodeSettings
def load_translation(self, language: str):
translation = {}
default_language = "en-US"
parent_language = language.split("-")[0]
translation_dir = Path(config_translation_dir)
# 1. Exact match (e.g. "da-DK.json" or "da.json")
translation_lib_file_path = translation_dir / f"{language}.json"
if (
is_path_inside(translation_lib_file_path, translation_dir)
and translation_lib_file_path.is_file()
):
translation = json.loads(
translation_lib_file_path.read_text(encoding="utf-8")
)
return translation
# 2. Parent/base language fallback (e.g. "de-DE" → "de.json")
translation_lib_parent_language_file_path = (
translation_dir / f"{parent_language}.json"
)
if (
is_path_inside(translation_lib_parent_language_file_path, translation_dir)
and translation_lib_parent_language_file_path.is_file()
):
logger.warning(
f"Translation file for {language} not found. Using parent translation {parent_language}."
)
translation = json.loads(
translation_lib_parent_language_file_path.read_text(encoding="utf-8")
)
return translation
# 3. Regional variant lookup (e.g. "da" → "da-DK.json")
if language == parent_language:
for candidate in sorted(translation_dir.glob(f"{parent_language}-*.json")):
if is_path_inside(candidate, translation_dir) and candidate.is_file():
variant = candidate.stem
logger.info(
f"Translation file for {language} not found. Using regional variant {variant}."
)
translation = json.loads(candidate.read_text(encoding="utf-8"))
return translation
# 4. Default fallback
default_translation_lib_file_path = translation_dir / f"{default_language}.json"
if (
is_path_inside(default_translation_lib_file_path, translation_dir)
and default_translation_lib_file_path.is_file()
):
logger.warning(
f"Translation file for {language} not found. Using default translation {default_language}."
)
translation = json.loads(
default_translation_lib_file_path.read_text(encoding="utf-8")
)
return translation
def with_overrides(
self, overrides: "ChainlitConfigOverrides | None"
) -> "ChainlitConfig":
base = self.model_dump()
patch = overrides.model_dump(exclude_unset=True) if overrides else {}
def _merge(a, b):
if isinstance(a, dict) and isinstance(b, dict):
out = dict(a)
for k, v in b.items():
out[k] = _merge(out.get(k), v)
return out
return b
merged = _merge(base, patch) if patch else base
return type(self).model_validate(merged)
def init_config(log: bool = False):
"""Initialize the configuration file if it doesn't exist."""
if not os.path.exists(config_file):
os.makedirs(config_dir, exist_ok=True)
with open(config_file, "w", encoding="utf-8") as f:
f.write(DEFAULT_CONFIG_STR)
logger.info(f"Created default config file at {config_file}")
elif log:
logger.info(f"Config file already exists at {config_file}")
if not os.path.exists(config_translation_dir):
os.makedirs(config_translation_dir, exist_ok=True)
logger.info(
f"Created default translation directory at {config_translation_dir}"
)
for file in os.listdir(TRANSLATIONS_DIR):
if file.endswith(".json"):
dst = os.path.join(config_translation_dir, file)
if not os.path.exists(dst):
src = os.path.join(TRANSLATIONS_DIR, file)
with open(src, encoding="utf-8") as f:
translation = json.load(f)
with open(dst, "w", encoding="utf-8") as f:
json.dump(translation, f, indent=4)
logger.info(f"Created default translation file at {dst}")
def load_module(target: str, force_refresh: bool = False):
"""Load the specified module."""
# Get the target's directory
target_dir = os.path.dirname(os.path.abspath(target))
# Add the target's directory to the Python path
sys.path.insert(0, target_dir)
if force_refresh:
# Get current site packages dirs
site_package_dirs = site.getsitepackages()
# Clear the modules related to the app from sys.modules
for module_name, module in list(sys.modules.items()):
if (
hasattr(module, "__file__")
and module.__file__
and module.__file__.startswith(target_dir)
and not any(module.__file__.startswith(p) for p in site_package_dirs)
):
sys.modules.pop(module_name, None)
spec = util.spec_from_file_location(target, target)
if not spec or not spec.loader:
sys.path.pop(0)
return
module = util.module_from_spec(spec)
if not module:
sys.path.pop(0)
return
spec.loader.exec_module(module)
sys.modules[target] = module
# Remove the target's directory from the Python path
sys.path.pop(0)
def load_settings():
with open(config_file, "rb") as f:
toml_dict = tomli.load(f)
# Load project settings
project_config = toml_dict.get("project", {})
features_settings = toml_dict.get("features", {})
ui_settings = toml_dict.get("UI", {})
meta = toml_dict.get("meta")
if not meta or meta.get("generated_by") <= "0.3.0":
raise ValueError(
f"Your config file '{config_file}' is outdated. Please delete it and restart the app to regenerate it."
)
lc_cache_path = os.path.join(config_dir, ".langchain.db")
project_settings = ProjectSettings(
lc_cache_path=lc_cache_path,
**project_config,
)
features_settings = FeaturesSettings(**features_settings)
ui_settings = UISettings(**ui_settings)
code_settings = CodeSettings(action_callbacks={})
return {
"features": features_settings,
"ui": ui_settings,
"project": project_settings,
"code": code_settings,
}
def reload_config():
"""Reload the configuration from the config file."""
global config
if config is None:
return
# Preserve the module_name during config reload to ensure hot reload works
original_module_name = config.run.module_name if config.run else None
new_cfg = ChainlitConfig(**load_settings())
config.root = new_cfg.root
config.chainlit_server = new_cfg.chainlit_server
config.run = new_cfg.run
config.features = new_cfg.features
config.ui = new_cfg.ui
# Restore the preserved module_name
if original_module_name and config.run:
config.run.module_name = original_module_name
config.project = new_cfg.project
config.code = new_cfg.code
def load_config():
"""Load the configuration from the config file."""
init_config()
settings = load_settings()
return ChainlitConfig(**settings)
def lint_translations():
# Load the ground truth (en-US.json file from chainlit source code)
src = os.path.join(TRANSLATIONS_DIR, "en-US.json")
with open(src, encoding="utf-8") as f:
truth = json.load(f)
# Find the local app translations
for file in os.listdir(config_translation_dir):
if file.endswith(".json"):
# Load the translation file
to_lint = os.path.join(config_translation_dir, file)
with open(to_lint, encoding="utf-8") as f2:
translation = json.load(f2)
# Lint the translation file
lint_translation_json(file, truth, translation)
config = load_config()
+112
View File
@@ -0,0 +1,112 @@
import asyncio
import uuid
from contextvars import ContextVar
from typing import TYPE_CHECKING, Dict, List, Optional, Union
from lazify import LazyProxy
from chainlit.session import ClientType, HTTPSession, WebsocketSession
if TYPE_CHECKING:
from chainlit.emitter import BaseChainlitEmitter
from chainlit.step import Step
from chainlit.user import PersistedUser, User
CL_RUN_NAMES = ["on_chat_start", "on_message", "on_audio_end"]
class ChainlitContextException(Exception):
def __init__(self, msg="Chainlit context not found", *args, **kwargs):
super().__init__(msg, *args, **kwargs)
class ChainlitContext:
loop: asyncio.AbstractEventLoop
emitter: "BaseChainlitEmitter"
session: Union["HTTPSession", "WebsocketSession"]
@property
def current_step(self):
if previous_steps := local_steps.get():
return previous_steps[-1]
@property
def current_run(self):
if previous_steps := local_steps.get():
return next(
(step for step in previous_steps if step.name in CL_RUN_NAMES), None
)
def __init__(
self,
session: Union["HTTPSession", "WebsocketSession"],
emitter: Optional["BaseChainlitEmitter"] = None,
):
from chainlit.emitter import BaseChainlitEmitter, ChainlitEmitter
self.loop = asyncio.get_running_loop()
self.session = session
if emitter:
self.emitter = emitter
elif isinstance(self.session, HTTPSession):
self.emitter = BaseChainlitEmitter(self.session)
elif isinstance(self.session, WebsocketSession):
self.emitter = ChainlitEmitter(self.session)
context_var: ContextVar[ChainlitContext] = ContextVar("chainlit")
local_steps: ContextVar[Optional[List["Step"]]] = ContextVar(
"local_steps", default=None
)
def init_ws_context(session_or_sid: Union[WebsocketSession, str]) -> ChainlitContext:
if not isinstance(session_or_sid, WebsocketSession):
session = WebsocketSession.require(session_or_sid)
else:
session = session_or_sid
context = ChainlitContext(session)
context_var.set(context)
return context
def init_http_context(
thread_id: Optional[str] = None,
user: Optional[Union["User", "PersistedUser"]] = None,
auth_token: Optional[str] = None,
user_env: Optional[Dict[str, str]] = None,
client_type: ClientType = "webapp",
) -> ChainlitContext:
from chainlit.data import get_data_layer
session_id = str(uuid.uuid4())
thread_id = thread_id or str(uuid.uuid4())
session = HTTPSession(
id=session_id,
thread_id=thread_id,
token=auth_token,
user=user,
client_type=client_type,
user_env=user_env,
)
context = ChainlitContext(session)
context_var.set(context)
if data_layer := get_data_layer():
if user_id := getattr(user, "id", None):
asyncio.create_task(
data_layer.update_thread(thread_id=thread_id, user_id=user_id)
)
return context
def get_context() -> ChainlitContext:
try:
return context_var.get()
except LookupError as e:
raise ChainlitContextException from e
context: ChainlitContext = LazyProxy(get_context, enable_cache=False)
+111
View File
@@ -0,0 +1,111 @@
import os
import warnings
from typing import Optional
from .base import BaseDataLayer
from .utils import (
queue_until_user_message as queue_until_user_message, # TODO: Consider deprecating re-export.; Redundant alias tells type checkers to STFU.
)
_data_layer: Optional[BaseDataLayer] = None
_data_layer_initialized = False
def get_data_layer():
global _data_layer, _data_layer_initialized
if not _data_layer_initialized:
if _data_layer:
# Data layer manually set, warn user that this is deprecated.
warnings.warn(
"Setting data layer manually is deprecated. Use @data_layer instead.",
DeprecationWarning,
)
else:
from chainlit.config import config
if config.code.data_layer:
# When @data_layer is configured, call it to get data layer.
_data_layer = config.code.data_layer()
elif database_url := os.environ.get("DATABASE_URL"):
from .chainlit_data_layer import ChainlitDataLayer
if os.environ.get("LITERAL_API_KEY"):
warnings.warn(
"Both LITERAL_API_KEY and DATABASE_URL specified. Ignoring Literal AI data layer and relying on data layer pointing to DATABASE_URL."
)
bucket_name = os.environ.get("BUCKET_NAME")
# AWS S3
aws_region = os.getenv("APP_AWS_REGION")
aws_access_key = os.getenv("APP_AWS_ACCESS_KEY")
aws_secret_key = os.getenv("APP_AWS_SECRET_KEY")
dev_aws_endpoint = os.getenv("DEV_AWS_ENDPOINT")
is_using_s3 = bool(aws_access_key and aws_secret_key and aws_region)
# Google Cloud Storage
gcs_project_id = os.getenv("APP_GCS_PROJECT_ID")
gcs_client_email = os.getenv("APP_GCS_CLIENT_EMAIL")
gcs_private_key = os.getenv("APP_GCS_PRIVATE_KEY")
is_using_gcs = bool(gcs_project_id)
# Azure Storage
azure_storage_account = os.getenv("APP_AZURE_STORAGE_ACCOUNT")
azure_storage_key = os.getenv("APP_AZURE_STORAGE_ACCESS_KEY")
is_using_azure = bool(azure_storage_account and azure_storage_key)
storage_client = None
if sum([is_using_s3, is_using_gcs, is_using_azure]) > 1:
warnings.warn(
"Multiple storage configurations detected. Please use only one."
)
elif is_using_s3:
from chainlit.data.storage_clients.s3 import S3StorageClient
storage_client = S3StorageClient(
bucket=bucket_name,
region_name=aws_region,
aws_access_key_id=aws_access_key,
aws_secret_access_key=aws_secret_key,
endpoint_url=dev_aws_endpoint,
)
elif is_using_gcs:
from chainlit.data.storage_clients.gcs import GCSStorageClient
storage_client = GCSStorageClient(
project_id=gcs_project_id,
client_email=gcs_client_email,
private_key=gcs_private_key,
bucket_name=bucket_name,
)
elif is_using_azure:
from chainlit.data.storage_clients.azure_blob import (
AzureBlobStorageClient,
)
storage_client = AzureBlobStorageClient(
container_name=bucket_name,
storage_account=azure_storage_account,
storage_key=azure_storage_key,
)
_data_layer = ChainlitDataLayer(
database_url=database_url, storage_client=storage_client
)
elif api_key := os.environ.get("LITERAL_API_KEY"):
# When LITERAL_API_KEY is defined, use Literal AI data layer
from .literalai import LiteralDataLayer
# support legacy LITERAL_SERVER variable as fallback
server = os.environ.get("LITERAL_API_URL") or os.environ.get(
"LITERAL_SERVER"
)
_data_layer = LiteralDataLayer(api_key=api_key, server=server)
_data_layer_initialized = True
return _data_layer
+19
View File
@@ -0,0 +1,19 @@
from fastapi import HTTPException
from chainlit.data import get_data_layer
async def is_thread_author(username: str, thread_id: str):
data_layer = get_data_layer()
if not data_layer:
raise HTTPException(status_code=400, detail="Data layer not initialized")
thread_author = await data_layer.get_thread_author(thread_id)
if not thread_author:
raise HTTPException(status_code=404, detail="Thread not found")
if thread_author != username:
raise HTTPException(status_code=401, detail="Unauthorized")
else:
return True
+124
View File
@@ -0,0 +1,124 @@
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Dict, List, Optional
from chainlit.types import (
Feedback,
PaginatedResponse,
Pagination,
ThreadDict,
ThreadFilter,
)
from .utils import queue_until_user_message
if TYPE_CHECKING:
from chainlit.element import Element, ElementDict
from chainlit.step import StepDict
from chainlit.user import PersistedUser, User
class BaseDataLayer(ABC):
"""Base class for data persistence."""
@abstractmethod
async def get_user(self, identifier: str) -> Optional["PersistedUser"]:
pass
@abstractmethod
async def create_user(self, user: "User") -> Optional["PersistedUser"]:
pass
@abstractmethod
async def delete_feedback(
self,
feedback_id: str,
) -> bool:
pass
@abstractmethod
async def upsert_feedback(
self,
feedback: Feedback,
) -> str:
pass
@queue_until_user_message()
@abstractmethod
async def create_element(self, element: "Element"):
pass
@abstractmethod
async def get_element(
self, thread_id: str, element_id: str
) -> Optional["ElementDict"]:
pass
@queue_until_user_message()
@abstractmethod
async def delete_element(self, element_id: str, thread_id: Optional[str] = None):
pass
@queue_until_user_message()
@abstractmethod
async def create_step(self, step_dict: "StepDict"):
pass
@queue_until_user_message()
@abstractmethod
async def update_step(self, step_dict: "StepDict"):
pass
@queue_until_user_message()
@abstractmethod
async def delete_step(self, step_id: str):
pass
@abstractmethod
async def get_thread_author(self, thread_id: str) -> str:
return ""
@abstractmethod
async def delete_thread(self, thread_id: str):
pass
@abstractmethod
async def list_threads(
self, pagination: "Pagination", filters: "ThreadFilter"
) -> "PaginatedResponse[ThreadDict]":
pass
@abstractmethod
async def get_thread(self, thread_id: str) -> "Optional[ThreadDict]":
pass
@abstractmethod
async def update_thread(
self,
thread_id: str,
name: Optional[str] = None,
user_id: Optional[str] = None,
metadata: Optional[Dict] = None,
tags: Optional[List[str]] = None,
):
pass
@abstractmethod
async def build_debug_url(self) -> str:
pass
@abstractmethod
async def close(self) -> None:
pass
@abstractmethod
async def get_favorite_steps(self, user_id: str) -> List["StepDict"]:
pass
async def set_step_favorite(
self, step_dict: "StepDict", favorite: bool
) -> "StepDict":
metadata = step_dict.get("metadata") or {}
metadata["favorite"] = favorite
step_dict["metadata"] = metadata
await self.update_step(step_dict)
return step_dict
@@ -0,0 +1,740 @@
import json
import uuid
from datetime import datetime
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union
import aiofiles
import asyncpg # type: ignore
from chainlit.data.base import BaseDataLayer
from chainlit.data.storage_clients.base import BaseStorageClient
from chainlit.data.utils import queue_until_user_message
from chainlit.element import ElementDict
from chainlit.logger import logger
from chainlit.step import StepDict
from chainlit.types import (
Feedback,
FeedbackDict,
PageInfo,
PaginatedResponse,
Pagination,
ThreadDict,
ThreadFilter,
)
from chainlit.user import PersistedUser, User
# Import for runtime usage (isinstance checks)
try:
from chainlit.data.storage_clients.gcs import GCSStorageClient
except ImportError:
GCSStorageClient = None # type: ignore[assignment,misc]
if TYPE_CHECKING:
from chainlit.data.storage_clients.gcs import GCSStorageClient
from chainlit.element import Element, ElementDict
from chainlit.step import StepDict
ISO_FORMAT = "%Y-%m-%dT%H:%M:%S.%fZ"
class ChainlitDataLayer(BaseDataLayer):
def __init__(
self,
database_url: str,
storage_client: Optional[BaseStorageClient] = None,
show_logger: bool = False,
):
self.database_url = database_url
self.pool: Optional[asyncpg.Pool] = None
self.storage_client = storage_client
self.show_logger = show_logger
async def connect(self):
if not self.pool:
self.pool = await asyncpg.create_pool(self.database_url)
async def get_current_timestamp(self) -> datetime:
return datetime.now()
async def execute_query(
self, query: str, params: Union[Dict, None] = None
) -> List[Dict[str, Any]]:
if not self.pool:
await self.connect()
try:
async with self.pool.acquire() as connection: # type: ignore
try:
if params:
records = await connection.fetch(query, *params.values())
else:
records = await connection.fetch(query)
return [dict(record) for record in records]
except Exception as e:
logger.error(f"Database error: {e!s}")
raise
except (
asyncpg.exceptions.ConnectionDoesNotExistError,
asyncpg.exceptions.InterfaceError,
) as e:
# Handle connection issues by cleaning up and rethrowing
logger.error(f"Connection error: {e!s}")
await self.cleanup()
raise
async def get_user(self, identifier: str) -> Optional[PersistedUser]:
query = """
SELECT * FROM "User"
WHERE identifier = $1
"""
result = await self.execute_query(query, {"identifier": identifier})
if not result or len(result) == 0:
return None
row = result[0]
return PersistedUser(
id=str(row.get("id")),
identifier=str(row.get("identifier")),
createdAt=row.get("createdAt").isoformat(), # type: ignore
metadata=json.loads(row.get("metadata", "{}")),
)
async def create_user(self, user: User) -> Optional[PersistedUser]:
query = """
INSERT INTO "User" (id, identifier, metadata, "createdAt", "updatedAt")
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (identifier) DO UPDATE
SET metadata = $3
RETURNING *
"""
now = await self.get_current_timestamp()
params = {
"id": str(uuid.uuid4()),
"identifier": user.identifier,
"metadata": json.dumps(user.metadata),
"created_at": now,
"updated_at": now,
}
result = await self.execute_query(query, params)
row = result[0]
return PersistedUser(
id=str(row.get("id")),
identifier=str(row.get("identifier")),
createdAt=row.get("createdAt").isoformat(), # type: ignore
metadata=json.loads(row.get("metadata", "{}")),
)
async def delete_feedback(self, feedback_id: str) -> bool:
query = """
DELETE FROM "Feedback" WHERE id = $1
"""
await self.execute_query(query, {"feedback_id": feedback_id})
return True
async def upsert_feedback(self, feedback: Feedback) -> str:
query = """
INSERT INTO "Feedback" (id, "stepId", name, value, comment)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (id) DO UPDATE
SET value = $4, comment = $5
RETURNING id
"""
feedback_id = feedback.id or str(uuid.uuid4())
params = {
"id": feedback_id,
"step_id": feedback.forId,
"name": "user_feedback",
"value": float(feedback.value),
"comment": feedback.comment,
}
results = await self.execute_query(query, params)
return str(results[0]["id"])
@queue_until_user_message()
async def create_element(self, element: "Element"):
if not element.for_id:
return
if element.thread_id:
query = 'SELECT id FROM "Thread" WHERE id = $1'
results = await self.execute_query(query, {"thread_id": element.thread_id})
if not results:
await self.update_thread(thread_id=element.thread_id)
if element.for_id:
query = 'SELECT id FROM "Step" WHERE id = $1'
results = await self.execute_query(query, {"step_id": element.for_id})
if not results:
await self.create_step(
{
"id": element.for_id,
"metadata": {},
"type": "run",
"start_time": await self.get_current_timestamp(),
"end_time": await self.get_current_timestamp(),
}
)
# Handle file uploads only if storage_client is configured
path = None
if self.storage_client:
content: Optional[Union[bytes, str]] = None
if element.path:
async with aiofiles.open(element.path, "rb") as f:
content = await f.read()
elif element.content:
content = element.content
elif not element.url:
raise ValueError("Element url, path or content must be provided")
if content is not None:
if element.thread_id:
path = f"threads/{element.thread_id}/files/{element.id}"
else:
path = f"files/{element.id}"
content_disposition = (
f'attachment; filename="{element.name}"'
if not (
GCSStorageClient is not None
and isinstance(self.storage_client, GCSStorageClient)
)
else None
)
await self.storage_client.upload_file(
object_key=path,
data=content,
mime=element.mime or "application/octet-stream",
overwrite=True,
content_disposition=content_disposition,
)
else:
# Log warning only if element has file content that needs uploading
if element.path or element.url or element.content:
logger.warning(
"Data Layer: No storage client configured. "
"File will not be uploaded."
)
# Always persist element metadata to database
query = """
INSERT INTO "Element" (
id, "threadId", "stepId", metadata, mime, name, "objectKey", url,
"chainlitKey", display, size, language, page, props
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14
)
ON CONFLICT (id) DO UPDATE SET
props = EXCLUDED.props
"""
params = {
"id": element.id,
"thread_id": element.thread_id,
"step_id": element.for_id,
"metadata": json.dumps(
{
"size": element.size,
"language": element.language,
"display": element.display,
"type": element.type,
"page": getattr(element, "page", None),
}
),
"mime": element.mime,
"name": element.name,
"object_key": path,
"url": element.url,
"chainlit_key": element.chainlit_key,
"display": element.display,
"size": element.size,
"language": element.language,
"page": getattr(element, "page", None),
"props": json.dumps(getattr(element, "props", {})),
}
await self.execute_query(query, params)
async def get_element(
self, thread_id: str, element_id: str
) -> Optional[ElementDict]:
query = """
SELECT * FROM "Element"
WHERE id = $1 AND "threadId" = $2
"""
results = await self.execute_query(
query, {"element_id": element_id, "thread_id": thread_id}
)
if not results:
return None
row = results[0]
metadata = json.loads(row.get("metadata", "{}"))
return ElementDict(
id=str(row["id"]),
threadId=str(row["threadId"]),
type=metadata.get("type", "file"),
url=str(row["url"]),
name=str(row["name"]),
mime=str(row["mime"]),
objectKey=str(row["objectKey"]),
forId=str(row["stepId"]),
chainlitKey=row.get("chainlitKey"),
display=row["display"],
size=row["size"],
language=row["language"],
page=row["page"],
autoPlay=row.get("autoPlay"),
playerConfig=row.get("playerConfig"),
props=json.loads(row.get("props", "{}")),
)
@queue_until_user_message()
async def delete_element(self, element_id: str, thread_id: Optional[str] = None):
query = """
SELECT * FROM "Element"
WHERE id = $1
"""
elements = await self.execute_query(query, {"id": element_id})
if self.storage_client is not None and len(elements) > 0:
if elements[0]["objectKey"]:
await self.storage_client.delete_file(
object_key=elements[0]["objectKey"]
)
query = """
DELETE FROM "Element"
WHERE id = $1
"""
params = {"id": element_id}
if thread_id:
query += ' AND "threadId" = $2'
params["thread_id"] = thread_id
await self.execute_query(query, params)
@queue_until_user_message()
async def create_step(self, step_dict: StepDict):
if step_dict.get("threadId"):
thread_query = 'SELECT id FROM "Thread" WHERE id = $1'
thread_results = await self.execute_query(
thread_query, {"thread_id": step_dict["threadId"]}
)
if not thread_results:
await self.update_thread(thread_id=step_dict["threadId"])
if step_dict.get("parentId"):
parent_query = 'SELECT id FROM "Step" WHERE id = $1'
parent_results = await self.execute_query(
parent_query, {"parent_id": step_dict["parentId"]}
)
if not parent_results:
await self.create_step(
{
"id": step_dict["parentId"],
"metadata": {},
"type": "run",
"createdAt": step_dict.get("createdAt"),
}
)
query = """
INSERT INTO "Step" (
id, "threadId", "parentId", input, metadata, name, output,
type, "startTime", "endTime", "showInput", "isError"
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12
)
ON CONFLICT (id) DO UPDATE SET
"parentId" = COALESCE(EXCLUDED."parentId", "Step"."parentId"),
input = COALESCE(NULLIF(EXCLUDED.input, ''), "Step".input),
metadata = CASE
WHEN EXCLUDED.metadata <> '{}' THEN EXCLUDED.metadata
ELSE "Step".metadata
END,
name = COALESCE(EXCLUDED.name, "Step".name),
output = COALESCE(NULLIF(EXCLUDED.output, ''), "Step".output),
type = CASE
WHEN EXCLUDED.type = 'run' THEN "Step".type
ELSE EXCLUDED.type
END,
"threadId" = COALESCE(EXCLUDED."threadId", "Step"."threadId"),
"endTime" = COALESCE(EXCLUDED."endTime", "Step"."endTime"),
"startTime" = LEAST(EXCLUDED."startTime", "Step"."startTime"),
"showInput" = COALESCE(EXCLUDED."showInput", "Step"."showInput"),
"isError" = COALESCE(EXCLUDED."isError", "Step"."isError")
"""
timestamp = await self.get_current_timestamp()
created_at = step_dict.get("createdAt")
if created_at:
timestamp = datetime.strptime(created_at, ISO_FORMAT)
params = {
"id": step_dict["id"],
"thread_id": step_dict.get("threadId"),
"parent_id": step_dict.get("parentId"),
"input": step_dict.get("input"),
"metadata": json.dumps(step_dict.get("metadata", {})),
"name": step_dict.get("name"),
"output": step_dict.get("output"),
"type": step_dict["type"],
"start_time": timestamp,
"end_time": timestamp,
"show_input": str(step_dict.get("showInput", "json")),
"is_error": step_dict.get("isError", False),
}
await self.execute_query(query, params)
@queue_until_user_message()
async def update_step(self, step_dict: StepDict):
await self.create_step(step_dict)
@queue_until_user_message()
async def delete_step(self, step_id: str):
# Delete associated elements and feedbacks first
await self.execute_query(
'DELETE FROM "Element" WHERE "stepId" = $1', {"step_id": step_id}
)
await self.execute_query(
'DELETE FROM "Feedback" WHERE "stepId" = $1', {"step_id": step_id}
)
# Delete the step
await self.execute_query(
'DELETE FROM "Step" WHERE id = $1', {"step_id": step_id}
)
async def get_step(self, step_id: str) -> Optional[StepDict]:
# Get step and related feedback
query = """
SELECT s.*,
f.id feedback_id,
f.value feedback_value,
f."comment" feedback_comment
FROM "Step" s left join "Feedback" f on s.id = f."stepId"
WHERE s.id = $1
"""
result = await self.execute_query(query, {"step_id": step_id})
if not result:
return None
return self._convert_step_row_to_dict(result[0])
async def get_thread_author(self, thread_id: str) -> str:
query = """
SELECT u.identifier
FROM "Thread" t
JOIN "User" u ON t."userId" = u.id
WHERE t.id = $1
"""
results = await self.execute_query(query, {"thread_id": thread_id})
if not results:
raise ValueError(f"Thread {thread_id} not found")
return results[0]["identifier"]
async def delete_thread(self, thread_id: str):
elements_query = """
SELECT * FROM "Element"
WHERE "threadId" = $1
"""
elements_results = await self.execute_query(
elements_query, {"thread_id": thread_id}
)
if self.storage_client is not None:
for elem in elements_results:
if elem["objectKey"]:
await self.storage_client.delete_file(object_key=elem["objectKey"])
await self.execute_query(
'DELETE FROM "Thread" WHERE id = $1', {"thread_id": thread_id}
)
async def list_threads(
self, pagination: Pagination, filters: ThreadFilter
) -> PaginatedResponse[ThreadDict]:
query = """
SELECT
t.*,
u.identifier as user_identifier,
(SELECT COUNT(*) FROM "Thread" WHERE "userId" = t."userId") as total
FROM "Thread" t
LEFT JOIN "User" u ON t."userId" = u.id
WHERE t."deletedAt" IS NULL
"""
params: Dict[str, Any] = {}
param_count = 1
if filters.search:
query += f" AND t.name ILIKE ${param_count}"
params["name"] = f"%{filters.search}%"
param_count += 1
if filters.userId:
query += f' AND t."userId" = ${param_count}'
params["user_id"] = filters.userId
param_count += 1
if pagination.cursor:
query += f' AND t."updatedAt" < (SELECT "updatedAt" FROM "Thread" WHERE id = ${param_count})'
params["cursor"] = pagination.cursor
param_count += 1
query += f' ORDER BY t."updatedAt" DESC LIMIT ${param_count}'
params["limit"] = pagination.first + 1
results = await self.execute_query(query, params)
threads = results
has_next_page = len(threads) > pagination.first
if has_next_page:
threads = threads[:-1]
thread_dicts = []
for thread in threads:
thread_dict = ThreadDict(
id=str(thread["id"]),
createdAt=thread["updatedAt"].isoformat(),
name=thread["name"],
userId=str(thread["userId"]) if thread["userId"] else None,
userIdentifier=thread["user_identifier"],
metadata=json.loads(thread["metadata"]),
steps=[],
elements=[],
tags=[],
)
thread_dicts.append(thread_dict)
return PaginatedResponse(
pageInfo=PageInfo(
hasNextPage=has_next_page,
startCursor=thread_dicts[0]["id"] if thread_dicts else None,
endCursor=thread_dicts[-1]["id"] if thread_dicts else None,
),
data=thread_dicts,
)
async def get_thread(self, thread_id: str) -> Optional[ThreadDict]:
query = """
SELECT t.*, u.identifier as user_identifier
FROM "Thread" t
LEFT JOIN "User" u ON t."userId" = u.id
WHERE t.id = $1 AND t."deletedAt" IS NULL
"""
results = await self.execute_query(query, {"thread_id": thread_id})
if not results:
return None
thread = results[0]
# Get steps and related feedback
steps_query = """
SELECT s.*,
f.id feedback_id,
f.value feedback_value,
f."comment" feedback_comment
FROM "Step" s left join "Feedback" f on s.id = f."stepId"
WHERE s."threadId" = $1
ORDER BY "startTime"
"""
steps_results = await self.execute_query(steps_query, {"thread_id": thread_id})
# Get elements
elements_query = """
SELECT * FROM "Element"
WHERE "threadId" = $1
"""
elements_results = await self.execute_query(
elements_query, {"thread_id": thread_id}
)
if self.storage_client is not None:
for elem in elements_results:
if not elem["url"] and elem["objectKey"]:
elem["url"] = await self.storage_client.get_read_url(
object_key=elem["objectKey"],
)
return ThreadDict(
id=str(thread["id"]),
createdAt=thread["createdAt"].isoformat(),
name=thread["name"],
userId=str(thread["userId"]) if thread["userId"] else None,
userIdentifier=thread["user_identifier"],
metadata=json.loads(thread["metadata"]),
steps=[self._convert_step_row_to_dict(step) for step in steps_results],
elements=[
self._convert_element_row_to_dict(elem) for elem in elements_results
],
tags=[],
)
async def update_thread(
self,
thread_id: str,
name: Optional[str] = None,
user_id: Optional[str] = None,
metadata: Optional[Dict] = None,
tags: Optional[List[str]] = None,
):
if self.show_logger:
logger.info(f"asyncpg: update_thread, thread_id={thread_id}")
has_updates = (
metadata is not None
or name is not None
or user_id is not None
or tags is not None
)
if metadata is None:
metadata = {}
thread_name = truncate(
name
if name is not None
else (metadata.get("name") if metadata and "name" in metadata else None)
)
existing = await self.execute_query(
'SELECT "metadata" FROM "Thread" WHERE id = $1',
{"thread_id": thread_id},
)
thread_exists = isinstance(existing, list) and existing
if thread_exists and not has_updates:
return
base = {}
if thread_exists:
raw = existing[0].get("metadata") or {}
if isinstance(raw, str):
try:
base = json.loads(raw)
except json.JSONDecodeError:
base = {}
elif isinstance(raw, dict):
base = raw
to_delete = {k for k, v in metadata.items() if v is None}
incoming = {k: v for k, v in metadata.items() if v is not None}
base = {k: v for k, v in base.items() if k not in to_delete}
metadata = {**base, **incoming}
data = {
"id": thread_id,
"name": thread_name,
"userId": user_id,
"tags": tags,
"metadata": json.dumps(metadata),
"updatedAt": datetime.now(),
}
# Remove None values
data = {k: v for k, v in data.items() if v is not None}
# Build the query dynamically based on available fields
columns = [f'"{k}"' for k in data.keys()]
placeholders = [f"${i + 1}" for i in range(len(data))]
values = list(data.values())
update_sets = [f'"{k}" = EXCLUDED."{k}"' for k in data.keys() if k != "id"]
if update_sets:
query = f"""
INSERT INTO "Thread" ({", ".join(columns)})
VALUES ({", ".join(placeholders)})
ON CONFLICT (id) DO UPDATE
SET {", ".join(update_sets)};
"""
else:
query = f"""
INSERT INTO "Thread" ({", ".join(columns)})
VALUES ({", ".join(placeholders)})
ON CONFLICT (id) DO NOTHING
"""
await self.execute_query(query, {str(i + 1): v for i, v in enumerate(values)})
async def get_favorite_steps(self, user_id: str) -> List[StepDict]:
query = """
SELECT s.*
FROM "Step" s
JOIN "Thread" t ON s."threadId" = t.id
WHERE t."userId" = $1
AND s.metadata::jsonb->>'favorite' = 'true'
ORDER BY s."createdAt" DESC \
"""
results = await self.execute_query(query, {"user_id": user_id})
return [self._convert_step_row_to_dict(row) for row in results]
def _extract_feedback_dict_from_step_row(self, row: Dict) -> Optional[FeedbackDict]:
if row.get("feedback_id", None) is not None:
return FeedbackDict(
forId=str(row["id"]),
id=str(row["feedback_id"]),
value=row["feedback_value"],
comment=row["feedback_comment"],
)
return None
def _convert_step_row_to_dict(self, row: Dict) -> StepDict:
return StepDict(
id=str(row["id"]),
threadId=str(row["threadId"]) if row.get("threadId") else "",
parentId=str(row["parentId"]) if row.get("parentId") else None,
name=str(row.get("name")),
type=row["type"],
input=row.get("input", {}),
output=row.get("output", {}),
metadata=json.loads(row.get("metadata", "{}")),
createdAt=row["createdAt"].isoformat() if row.get("createdAt") else None,
start=row["startTime"].isoformat() if row.get("startTime") else None,
showInput=row.get("showInput"),
isError=row.get("isError"),
end=row["endTime"].isoformat() if row.get("endTime") else None,
feedback=self._extract_feedback_dict_from_step_row(row),
)
def _convert_element_row_to_dict(self, row: Dict) -> ElementDict:
metadata = json.loads(row.get("metadata", "{}"))
return ElementDict(
id=str(row["id"]),
threadId=str(row["threadId"]) if row.get("threadId") else None,
type=metadata.get("type", "file"),
url=row["url"],
name=row["name"],
mime=row["mime"],
objectKey=row["objectKey"],
forId=str(row["stepId"]),
chainlitKey=row.get("chainlitKey"),
display=row["display"],
size=row["size"],
language=row["language"],
page=row["page"],
autoPlay=row.get("autoPlay"),
playerConfig=row.get("playerConfig"),
props=json.loads(row.get("props") or "{}"),
)
async def build_debug_url(self) -> str:
return ""
async def cleanup(self):
"""Cleanup database connections"""
if self.pool:
logger.debug("Cleaning up connection pool")
await self.pool.close()
self.pool = None
async def close(self) -> None:
if self.storage_client:
await self.storage_client.close()
await self.cleanup()
def truncate(text: Optional[str], max_length: int = 255) -> Optional[str]:
return None if text is None else text[:max_length]
+687
View File
@@ -0,0 +1,687 @@
import asyncio
import json
import logging
import os
import random
from dataclasses import asdict
from datetime import datetime
from decimal import Decimal
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union, cast
import aiofiles
import aiohttp
import boto3 # type: ignore
from boto3.dynamodb.types import TypeDeserializer, TypeSerializer
from chainlit.context import context
from chainlit.data.base import BaseDataLayer
from chainlit.data.storage_clients.base import BaseStorageClient
from chainlit.data.utils import queue_until_user_message
from chainlit.element import ElementDict
from chainlit.logger import logger
from chainlit.step import StepDict
from chainlit.types import (
Feedback,
PageInfo,
PaginatedResponse,
Pagination,
ThreadDict,
ThreadFilter,
)
from chainlit.user import PersistedUser, User
if TYPE_CHECKING:
from mypy_boto3_dynamodb import DynamoDBClient
from chainlit.element import Element
_logger = logger.getChild("DynamoDB")
_logger.setLevel(logging.WARNING)
class DynamoDBDataLayer(BaseDataLayer):
def __init__(
self,
table_name: str,
client: Optional["DynamoDBClient"] = None,
storage_provider: Optional[BaseStorageClient] = None,
user_thread_limit: int = 10,
):
if client:
self.client = client
else:
region_name = os.environ.get("AWS_REGION", "us-east-1")
self.client = boto3.client("dynamodb", region_name=region_name) # type: ignore
self.table_name = table_name
self.storage_provider = storage_provider
self.user_thread_limit = user_thread_limit
self._type_deserializer = TypeDeserializer()
self._type_serializer = TypeSerializer()
def _get_current_timestamp(self) -> str:
return datetime.now().isoformat() + "Z"
def _serialize_item(self, item: dict[str, Any]) -> dict[str, Any]:
def convert_floats(obj):
if isinstance(obj, float):
return Decimal(str(obj))
elif isinstance(obj, dict):
return {k: convert_floats(v) for k, v in obj.items()}
elif isinstance(obj, list):
return [convert_floats(v) for v in obj]
else:
return obj
return {
key: self._type_serializer.serialize(convert_floats(value))
for key, value in item.items()
}
def _deserialize_item(self, item: dict[str, Any]) -> dict[str, Any]:
def convert_decimals(obj):
if isinstance(obj, Decimal):
return float(obj)
elif isinstance(obj, dict):
return {k: convert_decimals(v) for k, v in obj.items()}
elif isinstance(obj, list):
return [convert_decimals(v) for v in obj]
else:
return obj
return {
key: convert_decimals(self._type_deserializer.deserialize(value))
for key, value in item.items()
}
def _update_item(self, key: Dict[str, Any], updates: Dict[str, Any]):
update_expr: List[str] = []
expression_attribute_names = {}
expression_attribute_values = {}
for index, (attr, value) in enumerate(updates.items()):
if value is None:
continue
k, v = f"#{index}", f":{index}"
update_expr.append(f"{k} = {v}")
expression_attribute_names[k] = attr
expression_attribute_values[v] = value
self.client.update_item(
TableName=self.table_name,
Key=self._serialize_item(key),
UpdateExpression="SET " + ", ".join(update_expr),
ExpressionAttributeNames=expression_attribute_names,
ExpressionAttributeValues=self._serialize_item(expression_attribute_values),
)
@property
def context(self):
return context
async def get_user(self, identifier: str) -> Optional["PersistedUser"]:
_logger.info("DynamoDB: get_user identifier=%s", identifier)
response = self.client.get_item(
TableName=self.table_name,
Key={
"PK": {"S": f"USER#{identifier}"},
"SK": {"S": "USER"},
},
)
if "Item" not in response:
return None
user = self._deserialize_item(response["Item"])
return PersistedUser(
id=user["id"],
identifier=user["identifier"],
createdAt=user["createdAt"],
metadata=user["metadata"],
)
async def create_user(self, user: "User") -> Optional["PersistedUser"]:
_logger.info("DynamoDB: create_user user.identifier=%s", user.identifier)
ts = self._get_current_timestamp()
metadata: Dict[Any, Any] = user.metadata # type: ignore
item = {
"PK": f"USER#{user.identifier}",
"SK": "USER",
"id": user.identifier,
"identifier": user.identifier,
"metadata": metadata,
"createdAt": ts,
}
self.client.put_item(
TableName=self.table_name,
Item=self._serialize_item(item),
)
return PersistedUser(
id=user.identifier,
identifier=user.identifier,
createdAt=ts,
metadata=metadata,
)
async def delete_feedback(self, feedback_id: str) -> bool:
_logger.info("DynamoDB: delete_feedback feedback_id=%s", feedback_id)
# feedback id = THREAD#{thread_id}::STEP#{step_id}
thread_id, step_id = feedback_id.split("::")
thread_id = thread_id.strip("THREAD#")
step_id = step_id.strip("STEP#")
self.client.update_item(
TableName=self.table_name,
Key={
"PK": {"S": f"THREAD#{thread_id}"},
"SK": {"S": f"STEP#{step_id}"},
},
UpdateExpression="REMOVE #feedback",
ExpressionAttributeNames={"#feedback": "feedback"},
)
return True
async def upsert_feedback(self, feedback: Feedback) -> str:
_logger.info(
"DynamoDB: upsert_feedback thread=%s step=%s value=%s",
feedback.threadId,
feedback.forId,
feedback.value,
)
if not feedback.forId:
raise ValueError(
"DynamoDB data layer expects value for feedback.threadId got None"
)
feedback.id = f"THREAD#{feedback.threadId}::STEP#{feedback.forId}"
serialized_feedback = self._type_serializer.serialize(asdict(feedback))
self.client.update_item(
TableName=self.table_name,
Key={
"PK": {"S": f"THREAD#{feedback.threadId}"},
"SK": {"S": f"STEP#{feedback.forId}"},
},
UpdateExpression="SET #feedback = :feedback",
ExpressionAttributeNames={"#feedback": "feedback"},
ExpressionAttributeValues={":feedback": serialized_feedback},
)
return feedback.id
@queue_until_user_message()
async def create_element(self, element: "Element"):
_logger.info(
"DynamoDB: create_element thread=%s step=%s type=%s",
element.thread_id,
element.for_id,
element.type,
)
_logger.debug("DynamoDB: create_element: %s", element.to_dict())
if not element.for_id:
return
if not self.storage_provider:
_logger.warning(
"DynamoDB: create_element error. No storage_provider is configured!"
)
return
content: Optional[Union[bytes, str]] = None
if element.content:
content = element.content
elif element.path:
_logger.debug("DynamoDB: create_element reading file %s", element.path)
async with aiofiles.open(element.path, "rb") as f:
content = await f.read()
elif element.url:
_logger.debug("DynamoDB: create_element http %s", element.url)
async with aiohttp.ClientSession() as session:
async with session.get(element.url) as response:
if response.status == 200:
content = await response.read()
else:
raise ValueError(
f"Failed to read content from {element.url} status {response.status}",
)
else:
raise ValueError("Element url, path or content must be provided")
if content is None:
raise ValueError("Content is None, cannot upload file")
if not element.mime:
element.mime = "application/octet-stream"
context_user = self.context.session.user
user_folder = getattr(context_user, "id", "unknown")
file_object_key = f"{user_folder}/{element.thread_id}/{element.id}"
uploaded_file = await self.storage_provider.upload_file(
object_key=file_object_key,
data=content,
mime=element.mime,
overwrite=True,
)
if not uploaded_file:
raise ValueError(
"DynamoDB Error: create_element, Failed to persist data in storage_provider",
)
element_dict: Dict[str, Any] = element.to_dict() # type: ignore
element_dict.update(
{
"PK": f"THREAD#{element.thread_id}",
"SK": f"ELEMENT#{element.id}",
"url": uploaded_file.get("url"),
"objectKey": uploaded_file.get("object_key"),
}
)
self.client.put_item(
TableName=self.table_name,
Item=self._serialize_item(element_dict),
)
async def get_element(
self, thread_id: str, element_id: str
) -> Optional["ElementDict"]:
_logger.info(
"DynamoDB: get_element thread=%s element=%s", thread_id, element_id
)
response = self.client.get_item(
TableName=self.table_name,
Key={
"PK": {"S": f"THREAD#{thread_id}"},
"SK": {"S": f"ELEMENT#{element_id}"},
},
)
if "Item" not in response:
return None
return self._deserialize_item(response["Item"]) # type: ignore
@queue_until_user_message()
async def delete_element(self, element_id: str, thread_id: Optional[str] = None):
thread_id = self.context.session.thread_id
_logger.info(
"DynamoDB: delete_element thread=%s element=%s", thread_id, element_id
)
self.client.delete_item(
TableName=self.table_name,
Key={
"PK": {"S": f"THREAD#{thread_id}"},
"SK": {"S": f"ELEMENT#{element_id}"},
},
)
@queue_until_user_message()
async def create_step(self, step_dict: "StepDict"):
_logger.info(
"DynamoDB: create_step thread=%s step=%s",
step_dict.get("threadId"),
step_dict.get("id"),
)
_logger.debug("DynamoDB: create_step: %s", step_dict)
item = dict(step_dict)
item.update(
{
# ignore type, dynamo needs these so we want to fail if not set
"PK": f"THREAD#{step_dict['threadId']}", # type: ignore
"SK": f"STEP#{step_dict['id']}", # type: ignore
}
)
self.client.put_item(
TableName=self.table_name,
Item=self._serialize_item(item),
)
@queue_until_user_message()
async def update_step(self, step_dict: "StepDict"):
_logger.info(
"DynamoDB: update_step thread=%s step=%s",
step_dict.get("threadId"),
step_dict.get("id"),
)
_logger.debug("DynamoDB: update_step: %s", step_dict)
self._update_item(
key={
# ignore type, dynamo needs these so we want to fail if not set
"PK": f"THREAD#{step_dict['threadId']}", # type: ignore
"SK": f"STEP#{step_dict['id']}", # type: ignore
},
updates=step_dict, # type: ignore
)
@queue_until_user_message()
async def delete_step(self, step_id: str):
thread_id = self.context.session.thread_id
_logger.info("DynamoDB: delete_feedback thread=%s step=%s", thread_id, step_id)
self.client.delete_item(
TableName=self.table_name,
Key={
"PK": {"S": f"THREAD#{thread_id}"},
"SK": {"S": f"STEP#{step_id}"},
},
)
async def get_thread_author(self, thread_id: str) -> str:
_logger.info("DynamoDB: get_thread_author thread=%s", thread_id)
response = self.client.get_item(
TableName=self.table_name,
Key={
"PK": {"S": f"THREAD#{thread_id}"},
"SK": {"S": "THREAD"},
},
ProjectionExpression="userId",
)
if "Item" not in response:
raise ValueError(f"Author not found for thread_id {thread_id}")
item = self._deserialize_item(response["Item"])
return item["userId"]
async def delete_thread(self, thread_id: str):
_logger.info("DynamoDB: delete_thread thread=%s", thread_id)
thread = await self.get_thread(thread_id)
if not thread:
return
items: List[Any] = thread["steps"]
if thread["elements"]:
items.extend(thread["elements"])
delete_requests = []
for item in items:
key = self._serialize_item({"PK": item["PK"], "SK": item["SK"]})
req = {"DeleteRequest": {"Key": key}}
delete_requests.append(req)
BATCH_ITEM_SIZE = 25 # pylint: disable=invalid-name
for i in range(0, len(delete_requests), BATCH_ITEM_SIZE):
chunk = delete_requests[i : i + BATCH_ITEM_SIZE]
response = self.client.batch_write_item(
RequestItems={
self.table_name: chunk, # type: ignore
}
)
backoff_time = 1
while response.get("UnprocessedItems"):
backoff_time *= 2
# Cap the backoff time at 32 seconds & add jitter
delay = min(backoff_time, 32) + random.uniform(0, 1)
await asyncio.sleep(delay)
response = self.client.batch_write_item(
RequestItems=response["UnprocessedItems"]
)
self.client.delete_item(
TableName=self.table_name,
Key={
"PK": {"S": f"THREAD#{thread_id}"},
"SK": {"S": "THREAD"},
},
)
async def list_threads(
self, pagination: "Pagination", filters: "ThreadFilter"
) -> "PaginatedResponse[ThreadDict]":
_logger.info("DynamoDB: list_threads filters.userId=%s", filters.userId)
if filters.feedback:
_logger.warning("DynamoDB: filters on feedback not supported")
paginated_response: PaginatedResponse[ThreadDict] = PaginatedResponse(
data=[],
pageInfo=PageInfo(
hasNextPage=False, startCursor=pagination.cursor, endCursor=None
),
)
query_args: Dict[str, Any] = {
"TableName": self.table_name,
"IndexName": "UserThread",
"ScanIndexForward": False,
"Limit": self.user_thread_limit,
"KeyConditionExpression": "#UserThreadPK = :pk",
"ExpressionAttributeNames": {
"#UserThreadPK": "UserThreadPK",
},
"ExpressionAttributeValues": {
":pk": {"S": f"USER#{filters.userId}"},
},
}
if pagination.cursor:
query_args["ExclusiveStartKey"] = json.loads(pagination.cursor)
if filters.search:
query_args["FilterExpression"] = "contains(#name, :search)"
query_args["ExpressionAttributeNames"]["#name"] = "name"
query_args["ExpressionAttributeValues"][":search"] = {"S": filters.search}
response = self.client.query(**query_args) # type: ignore
if "LastEvaluatedKey" in response:
paginated_response.pageInfo.hasNextPage = True
paginated_response.pageInfo.endCursor = json.dumps(
response["LastEvaluatedKey"]
)
for item in response["Items"]:
deserialized_item: Dict[str, Any] = self._deserialize_item(item)
thread = ThreadDict( # type: ignore
id=deserialized_item["PK"].strip("THREAD#"),
createdAt=deserialized_item["UserThreadSK"].strip("TS#"),
name=deserialized_item["name"],
)
paginated_response.data.append(thread)
return paginated_response
async def get_thread(self, thread_id: str) -> "Optional[ThreadDict]":
_logger.info("DynamoDB: get_thread thread=%s", thread_id)
# Get all thread records
thread_items: List[Any] = []
cursor: Dict[str, Any] = {}
while True:
response = self.client.query(
TableName=self.table_name,
KeyConditionExpression="#pk = :pk",
ExpressionAttributeNames={"#pk": "PK"},
ExpressionAttributeValues={":pk": {"S": f"THREAD#{thread_id}"}},
**cursor,
)
deserialized_items = map(self._deserialize_item, response["Items"])
thread_items.extend(deserialized_items)
if "LastEvaluatedKey" not in response:
break
cursor["ExclusiveStartKey"] = response["LastEvaluatedKey"]
if len(thread_items) == 0:
return None
# process accordingly
thread_dict: Optional[ThreadDict] = None
steps = []
elements = []
for item in thread_items:
if item["SK"] == "THREAD":
thread_dict = item
elif item["SK"].startswith("ELEMENT"):
if self.storage_provider is not None:
item["url"] = await self.storage_provider.get_read_url(
object_key=item["objectKey"],
)
elements.append(item)
elif item["SK"].startswith("STEP"):
if "feedback" in item: # Decimal is not json serializable
item["feedback"]["value"] = int(item["feedback"]["value"])
steps.append(item)
if not thread_dict:
if len(thread_items) > 0:
_logger.warning(
"DynamoDB: found orphaned items for thread=%s", thread_id
)
return None
steps.sort(key=lambda i: i["createdAt"])
thread_dict.update(
{
"steps": steps,
"elements": elements,
}
)
return thread_dict
async def update_thread(
self,
thread_id: str,
name: Optional[str] = None,
user_id: Optional[str] = None,
metadata: Optional[Dict] = None,
tags: Optional[List[str]] = None,
):
_logger.info("DynamoDB: update_thread thread=%s userId=%s", thread_id, user_id)
_logger.debug(
"DynamoDB: update_thread name=%s tags=%s metadata=%s", name, tags, metadata
)
if metadata is None:
metadata = {}
ts = self._get_current_timestamp()
item = {
# GSI: UserThread
"UserThreadSK": f"TS#{ts}",
#
"id": thread_id,
"createdAt": ts,
"name": name,
"userId": user_id,
"userIdentifier": user_id,
"tags": tags,
"metadata": metadata,
}
if user_id:
# user_id may be None on subsequent calls, don't update UserThreadPK to "USER#{None}"
item["UserThreadPK"] = f"USER#{user_id}"
self._update_item(
key={
"PK": f"THREAD#{thread_id}",
"SK": "THREAD",
},
updates=item,
)
async def get_favorite_steps(self, user_id: str) -> List["StepDict"]:
_logger.info("DynamoDB: get_favorite_steps user_id=%s", user_id)
thread_ids = []
query_args: Dict[str, Any] = {
"TableName": self.table_name,
"IndexName": "UserThread",
"KeyConditionExpression": "#UserThreadPK = :pk",
"ExpressionAttributeNames": {"#UserThreadPK": "UserThreadPK"},
"ExpressionAttributeValues": {":pk": {"S": f"USER#{user_id}"}},
}
while True:
response = self.client.query(**query_args) # type: ignore
for item in response.get("Items", []):
pk = item.get("PK", {}).get("S")
if pk:
thread_ids.append(pk.removeprefix("THREAD#"))
if "LastEvaluatedKey" not in response:
break
query_args["ExclusiveStartKey"] = response["LastEvaluatedKey"]
favorite_steps: List[Dict[str, Any]] = []
for thread_id in thread_ids:
t_query_args: Dict[str, Any] = {
"TableName": self.table_name,
"KeyConditionExpression": "#pk = :pk AND begins_with(#sk, :sk_prefix)",
"FilterExpression": "#metadata.#favorite = :true",
"ExpressionAttributeNames": {
"#pk": "PK",
"#sk": "SK",
"#metadata": "metadata",
"#favorite": "favorite",
},
"ExpressionAttributeValues": {
":pk": {"S": f"THREAD#{thread_id}"},
":sk_prefix": {"S": "STEP#"},
":true": {"BOOL": True},
},
}
while True:
response = self.client.query(**t_query_args) # type: ignore
for item in response.get("Items", []):
step = self._deserialize_item(item)
if "PK" in step:
del step["PK"]
if "SK" in step:
del step["SK"]
if "feedback" in step:
del step["feedback"]
favorite_steps.append(step)
if "LastEvaluatedKey" not in response:
break
t_query_args["ExclusiveStartKey"] = response["LastEvaluatedKey"]
favorite_steps.sort(key=lambda x: x.get("createdAt", ""), reverse=True)
return cast(List["StepDict"], favorite_steps)
async def build_debug_url(self) -> str:
return ""
async def close(self) -> None:
if self.storage_provider:
await self.storage_provider.close()
self.client.close()
+524
View File
@@ -0,0 +1,524 @@
import json
# Deprecation warning for users of this provider
import sys
import warnings
from typing import Dict, List, Literal, Optional, Union, cast
import aiofiles
from httpx import HTTPStatusError, RequestError
from literalai import (
Attachment as LiteralAttachment,
Score as LiteralScore,
Step as LiteralStep,
Thread as LiteralThread,
)
from literalai.observability.filter import threads_filters as LiteralThreadsFilters
from literalai.observability.step import StepDict as LiteralStepDict
from chainlit.data.base import BaseDataLayer
from chainlit.data.utils import queue_until_user_message
from chainlit.element import Audio, Element, ElementDict, File, Image, Pdf, Text, Video
from chainlit.logger import logger
from chainlit.step import (
FeedbackDict,
Step,
StepDict,
StepType,
TrueStepType,
check_add_step_in_cot,
stub_step,
)
from chainlit.types import (
Feedback,
PageInfo,
PaginatedResponse,
Pagination,
ThreadDict,
ThreadFilter,
)
from chainlit.user import PersistedUser, User
def _show_deprecation_warning():
message = (
"\n\033[93mWARNING: The LiteralAI data provider is being deprecated and will be turned off on October 31st, 2025.\033[0m\n"
"Please migrate your data layer to another provider as soon as possible.\n"
)
print(message, file=sys.stderr)
warnings.warn(message, DeprecationWarning, stacklevel=2)
_show_deprecation_warning()
class LiteralToChainlitConverter:
@classmethod
def steptype_to_steptype(cls, step_type: Optional[StepType]) -> TrueStepType:
return cast(TrueStepType, step_type or "undefined")
@classmethod
def score_to_feedbackdict(
cls,
score: Optional[LiteralScore],
) -> "Optional[FeedbackDict]":
if not score:
return None
return {
"id": score.id or "",
"forId": score.step_id or "",
"value": cast(Literal[0, 1], score.value),
"comment": score.comment,
}
@classmethod
def step_to_stepdict(cls, step: LiteralStep) -> "StepDict":
metadata = step.metadata or {}
input = (step.input or {}).get("content") or (
json.dumps(step.input) if step.input and step.input != {} else ""
)
output = (step.output or {}).get("content") or (
json.dumps(step.output) if step.output and step.output != {} else ""
)
user_feedback = (
next(
(
s
for s in step.scores
if s.type == "HUMAN" and s.name == "user-feedback"
),
None,
)
if step.scores
else None
)
return {
"createdAt": step.created_at,
"id": step.id or "",
"threadId": step.thread_id or "",
"parentId": step.parent_id,
"feedback": cls.score_to_feedbackdict(user_feedback),
"start": step.start_time,
"end": step.end_time,
"type": step.type or "undefined",
"name": step.name or "",
"generation": step.generation.to_dict() if step.generation else None,
"input": input,
"output": output,
"showInput": metadata.get("showInput", False),
"language": metadata.get("language"),
"isError": bool(step.error),
"waitForAnswer": metadata.get("waitForAnswer", False),
}
@classmethod
def attachment_to_elementdict(cls, attachment: LiteralAttachment) -> ElementDict:
metadata = attachment.metadata or {}
return {
"chainlitKey": None,
"display": metadata.get("display", "side"),
"language": metadata.get("language"),
"autoPlay": metadata.get("autoPlay", None),
"playerConfig": metadata.get("playerConfig", None),
"page": metadata.get("page"),
"props": metadata.get("props"),
"size": metadata.get("size"),
"type": metadata.get("type", "file"),
"forId": attachment.step_id,
"id": attachment.id or "",
"mime": attachment.mime,
"name": attachment.name or "",
"objectKey": attachment.object_key,
"url": attachment.url,
"threadId": attachment.thread_id,
}
@classmethod
def attachment_to_element(
cls, attachment: LiteralAttachment, thread_id: Optional[str] = None
) -> Element:
metadata = attachment.metadata or {}
element_type = metadata.get("type", "file")
element_class = {
"file": File,
"image": Image,
"audio": Audio,
"video": Video,
"text": Text,
"pdf": Pdf,
}.get(element_type, Element)
assert thread_id or attachment.thread_id
element = element_class(
name=attachment.name or "",
display=metadata.get("display", "side"),
language=metadata.get("language"),
size=metadata.get("size"),
url=attachment.url,
mime=attachment.mime,
thread_id=thread_id or attachment.thread_id,
)
element.id = attachment.id or ""
element.for_id = attachment.step_id
element.object_key = attachment.object_key
return element
@classmethod
def step_to_step(cls, step: LiteralStep) -> Step:
chainlit_step = Step(
name=step.name or "",
type=cls.steptype_to_steptype(step.type),
id=step.id,
parent_id=step.parent_id,
thread_id=step.thread_id or None,
)
chainlit_step.start = step.start_time
chainlit_step.end = step.end_time
chainlit_step.created_at = step.created_at
chainlit_step.input = step.input.get("content", "") if step.input else ""
chainlit_step.output = step.output.get("content", "") if step.output else ""
chainlit_step.is_error = bool(step.error)
chainlit_step.metadata = step.metadata or {}
chainlit_step.tags = step.tags
chainlit_step.generation = step.generation
if step.attachments:
chainlit_step.elements = [
cls.attachment_to_element(attachment, chainlit_step.thread_id)
for attachment in step.attachments
]
return chainlit_step
@classmethod
def thread_to_threaddict(cls, thread: LiteralThread) -> ThreadDict:
return {
"id": thread.id,
"createdAt": getattr(thread, "created_at", ""),
"name": thread.name,
"userId": thread.participant_id,
"userIdentifier": thread.participant_identifier,
"tags": thread.tags,
"metadata": thread.metadata,
"steps": [cls.step_to_stepdict(step) for step in thread.steps]
if thread.steps
else [],
"elements": [
cls.attachment_to_elementdict(attachment)
for step in thread.steps
for attachment in step.attachments
]
if thread.steps
else [],
}
class LiteralDataLayer(BaseDataLayer):
def __init__(self, api_key: str, server: Optional[str]):
from literalai import AsyncLiteralClient
self.client = AsyncLiteralClient(api_key=api_key, url=server)
logger.info("Chainlit data layer initialized")
async def build_debug_url(self) -> str:
try:
project_id = await self.client.api.get_my_project_id()
return f"{self.client.api.url}/projects/{project_id}/logs/threads/[thread_id]?currentStepId=[step_id]"
except Exception as e:
logger.error(f"Error building debug url: {e}")
return ""
async def get_user(self, identifier: str) -> Optional[PersistedUser]:
user = await self.client.api.get_user(identifier=identifier)
if not user:
return None
return PersistedUser(
id=user.id or "",
identifier=user.identifier or "",
metadata=user.metadata,
createdAt=user.created_at or "",
)
async def create_user(self, user: User) -> Optional[PersistedUser]:
_user = await self.client.api.get_user(identifier=user.identifier)
if not _user:
_user = await self.client.api.create_user(
identifier=user.identifier, metadata=user.metadata
)
elif _user.id:
await self.client.api.update_user(id=_user.id, metadata=user.metadata)
return PersistedUser(
id=_user.id or "",
identifier=_user.identifier or "",
metadata=user.metadata,
createdAt=_user.created_at or "",
)
async def delete_feedback(
self,
feedback_id: str,
):
if feedback_id:
await self.client.api.delete_score(
id=feedback_id,
)
return True
return False
async def upsert_feedback(
self,
feedback: Feedback,
):
if feedback.id:
await self.client.api.update_score(
id=feedback.id,
update_params={
"comment": feedback.comment,
"value": feedback.value,
},
)
return feedback.id
else:
created = await self.client.api.create_score(
step_id=feedback.forId,
value=feedback.value,
comment=feedback.comment,
name="user-feedback",
type="HUMAN",
)
return created.id or ""
async def safely_send_steps(self, steps):
try:
await self.client.api.send_steps(steps)
except HTTPStatusError as e:
logger.error(f"HTTP Request: error sending steps: {e.response.status_code}")
except RequestError as e:
logger.error(f"HTTP Request: error for {e.request.url!r}.")
@queue_until_user_message()
async def create_element(self, element: "Element"):
metadata = {
"size": element.size,
"language": element.language,
"display": element.display,
"type": element.type,
"page": getattr(element, "page", None),
"props": getattr(element, "props", None),
}
if not element.for_id:
return
object_key = None
if not element.url:
if element.path:
async with aiofiles.open(element.path, "rb") as f:
content: Union[bytes, str] = await f.read()
elif element.content:
content = element.content
else:
raise ValueError("Either path or content must be provided")
uploaded = await self.client.api.upload_file(
content=content, mime=element.mime, thread_id=element.thread_id
)
object_key = uploaded["object_key"]
await self.safely_send_steps(
[
{
"id": element.for_id,
"threadId": element.thread_id,
"attachments": [
{
"id": element.id,
"name": element.name,
"metadata": metadata,
"mime": element.mime,
"url": element.url,
"objectKey": object_key,
}
],
}
]
)
async def get_element(
self, thread_id: str, element_id: str
) -> Optional["ElementDict"]:
attachment = await self.client.api.get_attachment(id=element_id)
if not attachment:
return None
return LiteralToChainlitConverter.attachment_to_elementdict(attachment)
@queue_until_user_message()
async def delete_element(self, element_id: str, thread_id: Optional[str] = None):
await self.client.api.delete_attachment(id=element_id)
@queue_until_user_message()
async def create_step(self, step_dict: "StepDict"):
metadata = dict(
step_dict.get("metadata", {}),
waitForAnswer=step_dict.get("waitForAnswer"),
language=step_dict.get("language"),
showInput=step_dict.get("showInput"),
)
step: LiteralStepDict = {
"createdAt": step_dict.get("createdAt"),
"startTime": step_dict.get("start"),
"endTime": step_dict.get("end"),
"generation": step_dict.get("generation"),
"id": step_dict.get("id"),
"parentId": step_dict.get("parentId"),
"name": step_dict.get("name"),
"threadId": step_dict.get("threadId"),
"type": step_dict.get("type"),
"tags": step_dict.get("tags"),
"metadata": metadata,
}
if step_dict.get("input"):
step["input"] = {"content": step_dict.get("input")}
if step_dict.get("output"):
step["output"] = {"content": step_dict.get("output")}
if step_dict.get("isError"):
step["error"] = step_dict.get("output")
await self.safely_send_steps([step])
@queue_until_user_message()
async def update_step(self, step_dict: "StepDict"):
await self.create_step(step_dict)
@queue_until_user_message()
async def delete_step(self, step_id: str):
await self.client.api.delete_step(id=step_id)
async def get_thread_author(self, thread_id: str) -> str:
thread = await self.get_thread(thread_id)
if not thread:
return ""
user_identifier = thread.get("userIdentifier")
if not user_identifier:
return ""
return user_identifier
async def delete_thread(self, thread_id: str):
await self.client.api.delete_thread(id=thread_id)
async def list_threads(
self, pagination: "Pagination", filters: "ThreadFilter"
) -> "PaginatedResponse[ThreadDict]":
if not filters.userId:
raise ValueError("userId is required")
literal_filters: LiteralThreadsFilters = [
{
"field": "participantId",
"operator": "eq",
"value": filters.userId,
}
]
if filters.search:
literal_filters.append(
{
"field": "stepOutput",
"operator": "ilike",
"value": filters.search,
"path": "content",
}
)
if filters.feedback is not None:
literal_filters.append(
{
"field": "scoreValue",
"operator": "eq",
"value": filters.feedback,
"path": "user-feedback",
}
)
literal_response = await self.client.api.list_threads(
first=pagination.first,
after=pagination.cursor,
filters=literal_filters,
order_by={"column": "createdAt", "direction": "DESC"},
)
chainlit_threads = [
*map(LiteralToChainlitConverter.thread_to_threaddict, literal_response.data)
]
return PaginatedResponse(
pageInfo=PageInfo(
hasNextPage=literal_response.page_info.has_next_page,
startCursor=literal_response.page_info.start_cursor,
endCursor=literal_response.page_info.end_cursor,
),
data=chainlit_threads,
)
async def get_thread(self, thread_id: str) -> Optional[ThreadDict]:
thread = await self.client.api.get_thread(id=thread_id)
if not thread:
return None
elements: List[ElementDict] = []
steps: List[StepDict] = []
if thread.steps:
for step in thread.steps:
for attachment in step.attachments:
elements.append(
LiteralToChainlitConverter.attachment_to_elementdict(attachment)
)
chainlit_step = LiteralToChainlitConverter.step_to_step(step)
if check_add_step_in_cot(chainlit_step):
steps.append(
LiteralToChainlitConverter.step_to_stepdict(step)
) # TODO: chainlit_step.to_dict()
else:
steps.append(stub_step(chainlit_step))
return {
"createdAt": thread.created_at or "",
"id": thread.id,
"name": thread.name or None,
"steps": steps,
"elements": elements,
"metadata": thread.metadata,
"userId": thread.participant_id,
"userIdentifier": thread.participant_identifier,
"tags": thread.tags,
}
async def update_thread(
self,
thread_id: str,
name: Optional[str] = None,
user_id: Optional[str] = None,
metadata: Optional[Dict] = None,
tags: Optional[List[str]] = None,
):
await self.client.api.upsert_thread(
id=thread_id,
name=name,
participant_id=user_id,
metadata=metadata,
tags=tags,
)
async def get_favorite_steps(self, user_id: str) -> List[StepDict]:
"""noop for literalai"""
return []
async def close(self):
self.client.flush_and_stop()
+953
View File
@@ -0,0 +1,953 @@
import json
import ssl
import uuid
from dataclasses import asdict
from datetime import datetime
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union
import aiofiles
import aiohttp
from sqlalchemy import text
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
from chainlit.data.base import BaseDataLayer
from chainlit.data.storage_clients.base import BaseStorageClient
from chainlit.data.utils import queue_until_user_message
from chainlit.element import ElementDict
from chainlit.logger import logger
from chainlit.step import StepDict
from chainlit.types import (
Feedback,
FeedbackDict,
PageInfo,
PaginatedResponse,
Pagination,
ThreadDict,
ThreadFilter,
)
from chainlit.user import PersistedUser, User
if TYPE_CHECKING:
from chainlit.element import Element, ElementDict
from chainlit.step import StepDict
class SQLAlchemyDataLayer(BaseDataLayer):
def __init__(
self,
conninfo: str,
connect_args: Optional[dict[str, Any]] = None,
ssl_require: bool = False,
storage_provider: Optional[BaseStorageClient] = None,
user_thread_limit: Optional[int] = 1000,
show_logger: Optional[bool] = False,
):
self._conninfo = conninfo
self.user_thread_limit = user_thread_limit
self.show_logger = show_logger
if connect_args is None:
connect_args = {}
if ssl_require:
# Create an SSL context to require an SSL connection
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
connect_args["ssl"] = ssl_context
self.engine: AsyncEngine = create_async_engine(
self._conninfo, connect_args=connect_args
)
self.async_session = sessionmaker(
bind=self.engine, expire_on_commit=False, class_=AsyncSession
) # type: ignore
if storage_provider:
self.storage_provider: Optional[BaseStorageClient] = storage_provider
if self.show_logger:
logger.info("SQLAlchemyDataLayer storage client initialized")
else:
self.storage_provider = None
logger.warning(
"SQLAlchemyDataLayer storage client is not initialized and elements will not be persisted!"
)
async def build_debug_url(self) -> str:
return ""
###### SQL Helpers ######
async def execute_sql(
self, query: str, parameters: dict
) -> Union[List[Dict[str, Any]], int, None]:
parameterized_query = text(query)
async with self.async_session() as session:
try:
await session.begin()
result = await session.execute(parameterized_query, parameters)
await session.commit()
if result.returns_rows:
json_result = [dict(row._mapping) for row in result.fetchall()]
clean_json_result = self.clean_result(json_result)
assert isinstance(clean_json_result, list) or isinstance(
clean_json_result, int
)
return clean_json_result
else:
return result.rowcount
except SQLAlchemyError as e:
await session.rollback()
logger.warning(f"An error occurred: {e}")
return None
except Exception as e:
await session.rollback()
logger.warning(f"An unexpected error occurred: {e}")
return None
async def get_current_timestamp(self) -> str:
return datetime.now().isoformat() + "Z"
def clean_result(self, obj):
"""Recursively change UUID -> str and serialize dictionaries"""
if isinstance(obj, dict):
return {k: self.clean_result(v) for k, v in obj.items()}
elif isinstance(obj, list):
return [self.clean_result(item) for item in obj]
elif isinstance(obj, uuid.UUID):
return str(obj)
return obj
###### User ######
async def get_user(self, identifier: str) -> Optional[PersistedUser]:
if self.show_logger:
logger.info(f"SQLAlchemy: get_user, identifier={identifier}")
query = "SELECT * FROM users WHERE identifier = :identifier"
parameters = {"identifier": identifier}
result = await self.execute_sql(query=query, parameters=parameters)
if result and isinstance(result, list):
user_data = result[0]
# SQLite returns JSON as string, we most convert it. (#1137)
metadata = user_data.get("metadata", {})
if isinstance(metadata, str):
metadata = json.loads(metadata)
assert isinstance(metadata, dict)
assert isinstance(user_data["id"], str)
assert isinstance(user_data["identifier"], str)
assert isinstance(user_data["createdAt"], str)
return PersistedUser(
id=user_data["id"],
identifier=user_data["identifier"],
createdAt=user_data["createdAt"],
metadata=metadata,
)
return None
async def _get_user_identifer_by_id(self, user_id: str) -> str:
if self.show_logger:
logger.info(f"SQLAlchemy: _get_user_identifer_by_id, user_id={user_id}")
query = "SELECT identifier FROM users WHERE id = :user_id"
parameters = {"user_id": user_id}
result = await self.execute_sql(query=query, parameters=parameters)
assert result
assert isinstance(result, list)
return result[0]["identifier"]
async def _get_user_id_by_thread(self, thread_id: str) -> Optional[str]:
if self.show_logger:
logger.info(f"SQLAlchemy: _get_user_id_by_thread, thread_id={thread_id}")
query = """SELECT "userId" FROM threads WHERE id = :thread_id"""
parameters = {"thread_id": thread_id}
result = await self.execute_sql(query=query, parameters=parameters)
if result:
assert isinstance(result, list)
return result[0]["userId"]
return None
async def create_user(self, user: User) -> Optional[PersistedUser]:
if self.show_logger:
logger.info(f"SQLAlchemy: create_user, user_identifier={user.identifier}")
existing_user: Optional[PersistedUser] = await self.get_user(user.identifier)
user_dict: Dict[str, Any] = {
"identifier": str(user.identifier),
"metadata": json.dumps(user.metadata) or {},
}
if not existing_user: # create the user
if self.show_logger:
logger.info("SQLAlchemy: create_user, creating the user")
user_dict["id"] = str(uuid.uuid4())
user_dict["createdAt"] = await self.get_current_timestamp()
query = """INSERT INTO users ("id", "identifier", "createdAt", "metadata") VALUES (:id, :identifier, :createdAt, :metadata)"""
await self.execute_sql(query=query, parameters=user_dict)
else: # update the user
if self.show_logger:
logger.info("SQLAlchemy: update user metadata")
query = """UPDATE users SET "metadata" = :metadata WHERE "identifier" = :identifier"""
await self.execute_sql(
query=query, parameters=user_dict
) # We want to update the metadata
return await self.get_user(user.identifier)
###### Threads ######
async def get_thread_author(self, thread_id: str) -> str:
if self.show_logger:
logger.info(f"SQLAlchemy: get_thread_author, thread_id={thread_id}")
query = """SELECT "userIdentifier" FROM threads WHERE "id" = :id"""
parameters = {"id": thread_id}
result = await self.execute_sql(query=query, parameters=parameters)
if isinstance(result, list) and result:
author_identifier = result[0].get("userIdentifier")
if author_identifier is not None:
return author_identifier
raise ValueError(f"Author not found for thread_id {thread_id}")
async def get_thread(self, thread_id: str) -> Optional[ThreadDict]:
if self.show_logger:
logger.info(f"SQLAlchemy: get_thread, thread_id={thread_id}")
user_threads: Optional[List[ThreadDict]] = await self.get_all_user_threads(
thread_id=thread_id
)
if user_threads:
return user_threads[0]
else:
return None
async def update_thread(
self,
thread_id: str,
name: Optional[str] = None,
user_id: Optional[str] = None,
metadata: Optional[Dict] = None,
tags: Optional[List[str]] = None,
):
if self.show_logger:
logger.info(f"SQLAlchemy: update_thread, thread_id={thread_id}")
user_identifier = None
if user_id:
user_identifier = await self._get_user_identifer_by_id(user_id)
has_updates = (
metadata is not None
or name is not None
or user_id is not None
or tags is not None
)
if metadata is None:
metadata = {}
existing = await self.execute_sql(
query='SELECT "metadata" FROM threads WHERE "id" = :id',
parameters={"id": thread_id},
)
thread_exists = isinstance(existing, list) and len(existing) > 0
if thread_exists and not has_updates:
return
base = {}
if isinstance(existing, list) and existing:
raw = existing[0].get("metadata") or {}
if isinstance(raw, str):
try:
base = json.loads(raw)
except json.JSONDecodeError:
base = {}
elif isinstance(raw, dict):
base = raw
to_delete = {k for k, v in metadata.items() if v is None}
incoming = {k: v for k, v in metadata.items() if v is not None}
base = {k: v for k, v in base.items() if k not in to_delete}
metadata = {**base, **incoming}
name_value = name
if name_value is None and metadata:
name_value = metadata.get("name")
is_new_thread = not thread_exists
created_at_value = await self.get_current_timestamp() if is_new_thread else None
data = {
"id": thread_id,
"createdAt": created_at_value,
"name": name_value,
"userId": user_id,
"userIdentifier": user_identifier,
"tags": tags,
"metadata": json.dumps(metadata),
}
parameters = {
key: value for key, value in data.items() if value is not None
} # Remove keys with None values
columns = ", ".join(f'"{key}"' for key in parameters.keys())
values = ", ".join(f":{key}" for key in parameters.keys())
updates = ", ".join(
f'"{key}" = EXCLUDED."{key}"' for key in parameters.keys() if key != "id"
)
query = f"""
INSERT INTO threads ({columns})
VALUES ({values})
ON CONFLICT ("id") DO UPDATE
SET {updates};
"""
await self.execute_sql(query=query, parameters=parameters)
async def delete_thread(self, thread_id: str):
if self.show_logger:
logger.info(f"SQLAlchemy: delete_thread, thread_id={thread_id}")
elements_query = """SELECT * FROM elements WHERE "threadId" = :id"""
elements = await self.execute_sql(elements_query, {"id": thread_id})
if self.storage_provider is not None and isinstance(elements, list):
for elem in filter(lambda x: x["objectKey"], elements):
await self.storage_provider.delete_file(object_key=elem["objectKey"])
# Delete feedbacks/elements/steps/thread
feedbacks_query = """DELETE FROM feedbacks WHERE "forId" IN (SELECT "id" FROM steps WHERE "threadId" = :id)"""
elements_query = """DELETE FROM elements WHERE "threadId" = :id"""
steps_query = """DELETE FROM steps WHERE "threadId" = :id"""
thread_query = """DELETE FROM threads WHERE "id" = :id"""
parameters = {"id": thread_id}
await self.execute_sql(query=feedbacks_query, parameters=parameters)
await self.execute_sql(query=elements_query, parameters=parameters)
await self.execute_sql(query=steps_query, parameters=parameters)
await self.execute_sql(query=thread_query, parameters=parameters)
async def list_threads(
self, pagination: Pagination, filters: ThreadFilter
) -> PaginatedResponse:
if self.show_logger:
logger.info(
f"SQLAlchemy: list_threads, pagination={pagination}, filters={filters}"
)
if not filters.userId:
raise ValueError("userId is required")
all_user_threads: List[ThreadDict] = (
await self.get_all_user_threads(user_id=filters.userId) or []
)
search_keyword = filters.search.lower() if filters.search else None
feedback_value = int(filters.feedback) if filters.feedback else None
filtered_threads = []
for thread in all_user_threads:
keyword_match = True
feedback_match = True
if search_keyword or feedback_value is not None:
if search_keyword:
keyword_match = any(
search_keyword in step["output"].lower()
for step in thread["steps"]
if "output" in step
)
if feedback_value is not None:
feedback_match = False # Assume no match until found
for step in thread["steps"]:
feedback = step.get("feedback")
if feedback and feedback.get("value") == feedback_value:
feedback_match = True
break
if keyword_match and feedback_match:
filtered_threads.append(thread)
start = 0
if pagination.cursor:
for i, thread in enumerate(filtered_threads):
if (
thread["id"] == pagination.cursor
): # Find the start index using pagination.cursor
start = i + 1
break
end = start + pagination.first
paginated_threads = filtered_threads[start:end] or []
has_next_page = len(filtered_threads) > end
start_cursor = paginated_threads[0]["id"] if paginated_threads else None
end_cursor = paginated_threads[-1]["id"] if paginated_threads else None
return PaginatedResponse(
pageInfo=PageInfo(
hasNextPage=has_next_page,
startCursor=start_cursor,
endCursor=end_cursor,
),
data=paginated_threads,
)
###### Steps ######
@queue_until_user_message()
async def create_step(self, step_dict: "StepDict"):
await self.update_thread(step_dict["threadId"])
if self.show_logger:
logger.info(f"SQLAlchemy: create_step, step_id={step_dict.get('id')}")
step_dict["showInput"] = (
str(step_dict.get("showInput", "")).lower()
if "showInput" in step_dict
else None
)
parameters = {
key: value
for key, value in step_dict.items()
if value is not None and not (isinstance(value, dict) and not value)
}
parameters["metadata"] = json.dumps(step_dict.get("metadata", {}))
parameters["generation"] = json.dumps(step_dict.get("generation", {}))
columns = ", ".join(f'"{key}"' for key in parameters.keys())
values = ", ".join(f":{key}" for key in parameters.keys())
updates = ", ".join(
f'"{key}" = :{key}' for key in parameters.keys() if key != "id"
)
query = f"""
INSERT INTO steps ({columns})
VALUES ({values})
ON CONFLICT (id) DO UPDATE
SET {updates};
"""
await self.execute_sql(query=query, parameters=parameters)
@queue_until_user_message()
async def update_step(self, step_dict: "StepDict"):
if self.show_logger:
logger.info(f"SQLAlchemy: update_step, step_id={step_dict.get('id')}")
await self.create_step(step_dict)
@queue_until_user_message()
async def delete_step(self, step_id: str):
if self.show_logger:
logger.info(f"SQLAlchemy: delete_step, step_id={step_id}")
# Delete feedbacks/elements/steps
feedbacks_query = """DELETE FROM feedbacks WHERE "forId" = :id"""
elements_query = """DELETE FROM elements WHERE "forId" = :id"""
steps_query = """DELETE FROM steps WHERE "id" = :id"""
parameters = {"id": step_id}
await self.execute_sql(query=feedbacks_query, parameters=parameters)
await self.execute_sql(query=elements_query, parameters=parameters)
await self.execute_sql(query=steps_query, parameters=parameters)
async def get_step(self, step_id: str) -> Optional["StepDict"]:
if self.show_logger:
logger.info(f"SQLAlchemy: get_step, step_id={step_id}")
steps_feedbacks_query = """
SELECT
s."id" AS step_id,
s."name" AS step_name,
s."type" AS step_type,
s."threadId" AS step_threadid,
s."parentId" AS step_parentid,
s."streaming" AS step_streaming,
s."waitForAnswer" AS step_waitforanswer,
s."isError" AS step_iserror,
s."metadata" AS step_metadata,
s."tags" AS step_tags,
s."input" AS step_input,
s."output" AS step_output,
s."createdAt" AS step_createdat,
s."start" AS step_start,
s."end" AS step_end,
s."generation" AS step_generation,
s."showInput" AS step_showinput,
s."language" AS step_language,
f."value" AS feedback_value,
f."comment" AS feedback_comment,
f."id" AS feedback_id
FROM steps s LEFT JOIN feedbacks f ON s."id" = f."forId"
WHERE s."id" = :step_id
"""
steps_feedbacks = await self.execute_sql(
query=steps_feedbacks_query, parameters={"step_id": step_id}
)
if not isinstance(steps_feedbacks, list) or not steps_feedbacks:
return None
step_feedback = steps_feedbacks[0]
feedback = None
if step_feedback["feedback_value"] is not None:
feedback = FeedbackDict(
forId=step_feedback["step_id"],
id=step_feedback.get("feedback_id"),
value=step_feedback["feedback_value"],
comment=step_feedback.get("feedback_comment"),
)
return StepDict(
id=step_feedback["step_id"],
name=step_feedback["step_name"],
type=step_feedback["step_type"],
threadId=step_feedback.get("step_threadid", ""),
parentId=step_feedback.get("step_parentid"),
streaming=step_feedback.get("step_streaming", False),
waitForAnswer=step_feedback.get("step_waitforanswer"),
isError=step_feedback.get("step_iserror"),
metadata=(
step_feedback["step_metadata"]
if step_feedback.get("step_metadata") is not None
else {}
),
tags=step_feedback.get("step_tags"),
input=(
step_feedback.get("step_input", "")
if step_feedback.get("step_showinput") not in [None, "false"]
else ""
),
output=step_feedback.get("step_output", ""),
createdAt=step_feedback.get("step_createdat"),
start=step_feedback.get("step_start"),
end=step_feedback.get("step_end"),
generation=step_feedback.get("step_generation"),
showInput=step_feedback.get("step_showinput"),
language=step_feedback.get("step_language"),
feedback=feedback,
)
###### Feedback ######
async def upsert_feedback(self, feedback: Feedback) -> str:
if self.show_logger:
logger.info(f"SQLAlchemy: upsert_feedback, feedback_id={feedback.id}")
feedback.id = feedback.id or str(uuid.uuid4())
feedback_dict = asdict(feedback)
parameters = {
key: value for key, value in feedback_dict.items() if value is not None
}
columns = ", ".join(f'"{key}"' for key in parameters.keys())
values = ", ".join(f":{key}" for key in parameters.keys())
updates = ", ".join(
f'"{key}" = :{key}' for key in parameters.keys() if key != "id"
)
query = f"""
INSERT INTO feedbacks ({columns})
VALUES ({values})
ON CONFLICT (id) DO UPDATE
SET {updates};
"""
await self.execute_sql(query=query, parameters=parameters)
return feedback.id
async def delete_feedback(self, feedback_id: str) -> bool:
if self.show_logger:
logger.info(f"SQLAlchemy: delete_feedback, feedback_id={feedback_id}")
query = """DELETE FROM feedbacks WHERE "id" = :feedback_id"""
parameters = {"feedback_id": feedback_id}
await self.execute_sql(query=query, parameters=parameters)
return True
###### Elements ######
async def get_element(
self, thread_id: str, element_id: str
) -> Optional["ElementDict"]:
if self.show_logger:
logger.info(
f"SQLAlchemy: get_element, thread_id={thread_id}, element_id={element_id}"
)
query = """SELECT * FROM elements WHERE "threadId" = :thread_id AND "id" = :element_id"""
parameters = {"thread_id": thread_id, "element_id": element_id}
element: Union[List[Dict[str, Any]], int, None] = await self.execute_sql(
query=query, parameters=parameters
)
if isinstance(element, list) and element:
element_dict: Dict[str, Any] = element[0]
return ElementDict(
id=element_dict["id"],
threadId=element_dict.get("threadId"),
type=element_dict["type"],
chainlitKey=element_dict.get("chainlitKey"),
url=element_dict.get("url"),
objectKey=element_dict.get("objectKey"),
name=element_dict["name"],
props=json.loads(element_dict.get("props", "{}")),
display=element_dict["display"],
size=element_dict.get("size"),
language=element_dict.get("language"),
page=element_dict.get("page"),
autoPlay=element_dict.get("autoPlay"),
playerConfig=element_dict.get("playerConfig"),
forId=element_dict.get("forId"),
mime=element_dict.get("mime"),
)
else:
return None
@queue_until_user_message()
async def create_element(self, element: "Element"):
if self.show_logger:
logger.info(f"SQLAlchemy: create_element, element_id = {element.id}")
if not self.storage_provider:
logger.warning(
"SQLAlchemy: create_element error. No blob_storage_client is configured!"
)
return
if not element.for_id:
return
content: Optional[Union[bytes, str]] = None
if element.path:
async with aiofiles.open(element.path, "rb") as f:
content = await f.read()
elif element.url:
async with aiohttp.ClientSession() as session:
async with session.get(element.url) as response:
if response.status == 200:
content = await response.read()
else:
content = None
elif element.content:
content = element.content
else:
raise ValueError("Element url, path or content must be provided")
if content is None:
raise ValueError("Content is None, cannot upload file")
user_id: str = await self._get_user_id_by_thread(element.thread_id) or "unknown"
file_object_key = f"{user_id}/{element.id}" + (
f"/{element.name}" if element.name else ""
)
if not element.mime:
element.mime = "application/octet-stream"
uploaded_file = await self.storage_provider.upload_file(
object_key=file_object_key, data=content, mime=element.mime, overwrite=True
)
if not uploaded_file:
raise ValueError(
"SQLAlchemy Error: create_element, Failed to persist data in storage_provider"
)
element_dict: ElementDict = element.to_dict()
element_dict["url"] = uploaded_file.get("url")
element_dict["objectKey"] = uploaded_file.get("object_key")
element_dict_cleaned = {k: v for k, v in element_dict.items() if v is not None}
if "props" in element_dict_cleaned:
element_dict_cleaned["props"] = json.dumps(element_dict_cleaned["props"])
columns = ", ".join(f'"{column}"' for column in element_dict_cleaned.keys())
placeholders = ", ".join(f":{column}" for column in element_dict_cleaned.keys())
updates = ", ".join(
f'"{column}" = :{column}'
for column in element_dict_cleaned.keys()
if column != "id"
)
query = f"INSERT INTO elements ({columns}) VALUES ({placeholders}) ON CONFLICT (id) DO UPDATE SET {updates};"
await self.execute_sql(query=query, parameters=element_dict_cleaned)
@queue_until_user_message()
async def delete_element(self, element_id: str, thread_id: Optional[str] = None):
if self.show_logger:
logger.info(f"SQLAlchemy: delete_element, element_id={element_id}")
query = """SELECT * FROM elements WHERE "id" = :id"""
elements = await self.execute_sql(query, {"id": element_id})
if (
self.storage_provider is not None
and isinstance(elements, list)
and len(elements) > 0
and elements[0]["objectKey"]
):
await self.storage_provider.delete_file(object_key=elements[0]["objectKey"])
query = """DELETE FROM elements WHERE "id" = :id"""
parameters = {"id": element_id}
await self.execute_sql(query=query, parameters=parameters)
async def get_all_user_threads(
self, user_id: Optional[str] = None, thread_id: Optional[str] = None
) -> Optional[List[ThreadDict]]:
"""Fetch all user threads up to self.user_thread_limit, or one thread by id if thread_id is provided."""
if self.show_logger:
logger.info("SQLAlchemy: get_all_user_threads")
user_threads_query = """
SELECT
t."id" AS thread_id,
t."createdAt" AS thread_createdat,
t."name" AS thread_name,
t."userId" AS user_id,
t."userIdentifier" AS user_identifier,
t."tags" AS thread_tags,
t."metadata" AS thread_metadata,
MAX(s."createdAt") AS updatedAt
FROM threads t
LEFT JOIN steps s ON t."id" = s."threadId"
WHERE t."userId" = :user_id OR t."id" = :thread_id
GROUP BY
t."id",
t."createdAt",
t."name",
t."userId",
t."userIdentifier",
t."tags",
t."metadata"
ORDER BY updatedAt DESC NULLS LAST
LIMIT :limit
"""
user_threads = await self.execute_sql(
query=user_threads_query,
parameters={
"user_id": user_id,
"limit": self.user_thread_limit,
"thread_id": thread_id,
},
)
if not isinstance(user_threads, list):
return None
if not user_threads:
return []
else:
thread_ids = (
"('"
+ "','".join(map(str, [thread["thread_id"] for thread in user_threads]))
+ "')"
)
steps_feedbacks_query = f"""
SELECT
s."id" AS step_id,
s."name" AS step_name,
s."type" AS step_type,
s."threadId" AS step_threadid,
s."parentId" AS step_parentid,
s."streaming" AS step_streaming,
s."waitForAnswer" AS step_waitforanswer,
s."isError" AS step_iserror,
s."metadata" AS step_metadata,
s."tags" AS step_tags,
s."input" AS step_input,
s."output" AS step_output,
s."createdAt" AS step_createdat,
s."start" AS step_start,
s."end" AS step_end,
s."generation" AS step_generation,
s."showInput" AS step_showinput,
s."language" AS step_language,
f."value" AS feedback_value,
f."comment" AS feedback_comment,
f."id" AS feedback_id
FROM steps s LEFT JOIN feedbacks f ON s."id" = f."forId"
WHERE s."threadId" IN {thread_ids}
ORDER BY s."createdAt" ASC
"""
steps_feedbacks = await self.execute_sql(
query=steps_feedbacks_query, parameters={}
)
elements_query = f"""
SELECT
e."id" AS element_id,
e."threadId" as element_threadid,
e."type" AS element_type,
e."chainlitKey" AS element_chainlitkey,
e."url" AS element_url,
e."objectKey" as element_objectkey,
e."name" AS element_name,
e."display" AS element_display,
e."size" AS element_size,
e."language" AS element_language,
e."page" AS element_page,
e."forId" AS element_forid,
e."mime" AS element_mime,
e."props" AS props
FROM elements e
WHERE e."threadId" IN {thread_ids}
"""
elements = await self.execute_sql(query=elements_query, parameters={})
thread_dicts = {}
for thread in user_threads:
thread_id = thread["thread_id"]
if thread_id is not None:
thread_dicts[thread_id] = ThreadDict(
id=thread_id,
createdAt=thread["thread_createdat"],
name=thread["thread_name"],
userId=thread["user_id"],
userIdentifier=thread["user_identifier"],
tags=thread["thread_tags"],
metadata=thread["thread_metadata"],
steps=[],
elements=[],
)
# Process steps_feedbacks to populate the steps in the corresponding ThreadDict
if isinstance(steps_feedbacks, list):
for step_feedback in steps_feedbacks:
thread_id = step_feedback["step_threadid"]
if thread_id is not None:
feedback = None
if step_feedback["feedback_value"] is not None:
feedback = FeedbackDict(
forId=step_feedback["step_id"],
id=step_feedback.get("feedback_id"),
value=step_feedback["feedback_value"],
comment=step_feedback.get("feedback_comment"),
)
step_dict = StepDict(
id=step_feedback["step_id"],
name=step_feedback["step_name"],
type=step_feedback["step_type"],
threadId=thread_id,
parentId=step_feedback.get("step_parentid"),
streaming=step_feedback.get("step_streaming", False),
waitForAnswer=step_feedback.get("step_waitforanswer"),
isError=step_feedback.get("step_iserror"),
metadata=(
step_feedback["step_metadata"]
if step_feedback.get("step_metadata") is not None
else {}
),
tags=step_feedback.get("step_tags"),
input=(
step_feedback.get("step_input", "")
if step_feedback.get("step_showinput")
not in [None, "false"]
else ""
),
output=step_feedback.get("step_output", ""),
createdAt=step_feedback.get("step_createdat"),
start=step_feedback.get("step_start"),
end=step_feedback.get("step_end"),
generation=step_feedback.get("step_generation"),
showInput=step_feedback.get("step_showinput"),
language=step_feedback.get("step_language"),
feedback=feedback,
)
# Append the step to the steps list of the corresponding ThreadDict
thread_dicts[thread_id]["steps"].append(step_dict)
if isinstance(elements, list):
for element in elements:
thread_id = element["element_threadid"]
if thread_id is not None:
element_url: str | None = None
object_key_val = element.get("element_objectkey")
if (
self.storage_provider is not None
and isinstance(object_key_val, str)
and object_key_val.strip()
):
try:
element_url = await self.storage_provider.get_read_url(
object_key=object_key_val,
)
except Exception as e:
logger.warning(
f"Failed to get read URL for object_key '{object_key_val}': {e}. Falling back to stored URL."
)
element_url = element.get("element_url")
else:
element_url = element.get("element_url")
element_dict = ElementDict(
id=element["element_id"],
threadId=thread_id,
type=element["element_type"],
chainlitKey=element.get("element_chainlitkey"),
url=element_url,
objectKey=element.get("element_objectkey"),
name=element["element_name"],
display=element["element_display"],
size=element.get("element_size"),
language=element.get("element_language"),
autoPlay=element.get("element_autoPlay"),
playerConfig=element.get("element_playerconfig"),
page=element.get("element_page"),
props=element.get("props", "{}"),
forId=element.get("element_forid"),
mime=element.get("element_mime"),
)
thread_dicts[thread_id]["elements"].append(element_dict) # type: ignore
return list(thread_dicts.values())
async def get_favorite_steps(self, user_id: str) -> List[StepDict]:
if self.show_logger:
logger.info(f"SQLAlchemy: get_favorite_steps, user_id={user_id}")
query = """
SELECT
s."id" AS step_id,
s."name" AS step_name,
s."type" AS step_type,
s."threadId" AS step_threadid,
s."parentId" AS step_parentid,
s."streaming" AS step_streaming,
s."waitForAnswer" AS step_waitforanswer,
s."isError" AS step_iserror,
s."metadata" AS step_metadata,
s."tags" AS step_tags,
s."input" AS step_input,
s."output" AS step_output,
s."createdAt" AS step_createdat,
s."start" AS step_start,
s."end" AS step_end,
s."generation" AS step_generation,
s."showInput" AS step_showinput,
s."language" AS step_language
FROM steps s
JOIN threads t ON s."threadId" = t.id
WHERE t."userId" = :user_id
AND s."metadata" LIKE :favorite_pattern
ORDER BY s."createdAt" DESC \
"""
result = await self.execute_sql(
query, {"user_id": user_id, "favorite_pattern": '%"favorite": true%'}
)
steps = []
if isinstance(result, list):
for row in result:
metadata_raw = row["step_metadata"]
meta_dict = {}
if isinstance(metadata_raw, str):
try:
meta_dict = json.loads(metadata_raw)
except Exception:
pass
elif isinstance(metadata_raw, dict):
meta_dict = metadata_raw
if meta_dict.get("favorite"):
steps.append(
StepDict(
id=row["step_id"],
name=row["step_name"],
type=row["step_type"],
threadId=row["step_threadid"],
parentId=row["step_parentid"],
streaming=row.get("step_streaming", False),
waitForAnswer=row.get("step_waitforanswer"),
isError=row.get("step_iserror"),
metadata=meta_dict,
tags=row.get("step_tags"),
input=(
row.get("step_input", "")
if row.get("step_showinput") not in [None, "false"]
else ""
),
output=row.get("step_output", ""),
createdAt=row.get("step_createdat"),
start=row.get("step_start"),
end=row.get("step_end"),
generation=row.get("step_generation"),
showInput=row.get("step_showinput"),
language=row.get("step_language"),
feedback=None,
)
)
return steps
async def close(self) -> None:
if self.storage_provider:
await self.storage_provider.close()
await self.engine.dispose()
@@ -0,0 +1,88 @@
from typing import TYPE_CHECKING, Any, Dict, Optional, Union
from azure.storage.filedatalake import (
ContentSettings,
DataLakeFileClient,
DataLakeServiceClient,
FileSystemClient,
)
from chainlit.data.storage_clients.base import BaseStorageClient
from chainlit.logger import logger
if TYPE_CHECKING:
from azure.core.credentials import (
AzureNamedKeyCredential,
AzureSasCredential,
TokenCredential,
)
class AzureStorageClient(BaseStorageClient):
"""
Class to enable Azure Data Lake Storage (ADLS) Gen2
parms:
account_url: "https://<your_account>.dfs.core.windows.net"
credential: Access credential (AzureKeyCredential)
sas_token: Optionally include SAS token to append to urls
"""
def __init__(
self,
account_url: str,
container: str,
credential: Optional[
Union[
str,
Dict[str, str],
"AzureNamedKeyCredential",
"AzureSasCredential",
"TokenCredential",
]
],
sas_token: Optional[str] = None,
):
try:
self.data_lake_client = DataLakeServiceClient(
account_url=account_url, credential=credential
)
self.container_client: FileSystemClient = (
self.data_lake_client.get_file_system_client(file_system=container)
)
self.sas_token = sas_token
logger.info("AzureStorageClient initialized")
except Exception as e:
logger.warning(f"AzureStorageClient initialization error: {e}")
async def upload_file(
self,
object_key: str,
data: Union[bytes, str],
mime: str = "application/octet-stream",
overwrite: bool = True,
content_disposition: str | None = None,
) -> Dict[str, Any]:
try:
file_client: DataLakeFileClient = self.container_client.get_file_client(
object_key
)
content_settings = ContentSettings(
content_type=mime, content_disposition=content_disposition
)
file_client.upload_data(
data, overwrite=overwrite, content_settings=content_settings
)
url = (
f"{file_client.url}{self.sas_token}"
if self.sas_token
else file_client.url
)
return {"object_key": object_key, "url": url}
except Exception as e:
logger.warning(f"AzureStorageClient, upload_file error: {e}")
return {}
async def close(self) -> None:
self.container_client.close()
self.data_lake_client.close()
@@ -0,0 +1,98 @@
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, Union
from azure.storage.blob import BlobSasPermissions, ContentSettings, generate_blob_sas
from azure.storage.blob.aio import BlobServiceClient as AsyncBlobServiceClient
from chainlit.data.storage_clients.base import BaseStorageClient, storage_expiry_time
from chainlit.logger import logger
class AzureBlobStorageClient(BaseStorageClient):
def __init__(self, container_name: str, storage_account: str, storage_key: str):
self.container_name = container_name
self.storage_account = storage_account
self.storage_key = storage_key
connection_string = (
f"DefaultEndpointsProtocol=https;"
f"AccountName={storage_account};"
f"AccountKey={storage_key};"
f"EndpointSuffix=core.windows.net"
)
self.service_client = AsyncBlobServiceClient.from_connection_string(
connection_string
)
self.container_client = self.service_client.get_container_client(
self.container_name
)
logger.info("AzureBlobStorageClient initialized")
async def get_read_url(self, object_key: str) -> str:
if not self.storage_key:
raise Exception("Not using Azure Storage")
sas_permissions = BlobSasPermissions(read=True)
start_time = datetime.now(tz=timezone.utc)
expiry_time = start_time + timedelta(seconds=storage_expiry_time)
sas_token = generate_blob_sas(
account_name=self.storage_account,
container_name=self.container_name,
blob_name=object_key,
account_key=self.storage_key,
permission=sas_permissions,
start=start_time,
expiry=expiry_time,
)
return f"https://{self.storage_account}.blob.core.windows.net/{self.container_name}/{object_key}?{sas_token}"
async def upload_file(
self,
object_key: str,
data: Union[bytes, str],
mime: str = "application/octet-stream",
overwrite: bool = True,
content_disposition: str | None = None,
) -> Dict[str, Any]:
try:
blob_client = self.container_client.get_blob_client(object_key)
if isinstance(data, str):
data = data.encode("utf-8")
content_settings = ContentSettings(
content_type=mime, content_disposition=content_disposition
)
await blob_client.upload_blob(
data, overwrite=overwrite, content_settings=content_settings
)
properties = await blob_client.get_blob_properties()
return {
"path": object_key,
"object_key": object_key,
"url": await self.get_read_url(object_key),
"size": properties.size,
"last_modified": properties.last_modified,
"etag": properties.etag,
"content_type": properties.content_settings.content_type,
}
except Exception as e:
raise Exception(f"Failed to upload file to Azure Blob Storage: {e!s}")
async def delete_file(self, object_key: str) -> bool:
try:
blob_client = self.container_client.get_blob_client(blob=object_key)
await blob_client.delete_blob()
return True
except Exception as e:
logger.warning(f"AzureBlobStorageClient, delete_file error: {e}")
return False
async def close(self) -> None:
await self.container_client.close()
await self.service_client.close()
@@ -0,0 +1,32 @@
import os
from abc import ABC, abstractmethod
from typing import Any, Dict, Union
storage_expiry_time = int(os.getenv("STORAGE_EXPIRY_TIME", 3600))
class BaseStorageClient(ABC):
"""Base class for non-text data persistence like Azure Data Lake, S3, Google Storage, etc."""
@abstractmethod
async def upload_file(
self,
object_key: str,
data: Union[bytes, str],
mime: str = "application/octet-stream",
overwrite: bool = True,
content_disposition: str | None = None,
) -> Dict[str, Any]:
pass
@abstractmethod
async def delete_file(self, object_key: str) -> bool:
pass
@abstractmethod
async def get_read_url(self, object_key: str) -> str:
pass
@abstractmethod
async def close(self) -> None:
pass
@@ -0,0 +1,104 @@
from typing import Any, Dict, Optional, Union
from google.auth import default
from google.cloud import storage # type: ignore
from google.oauth2 import service_account
from chainlit import make_async
from chainlit.data.storage_clients.base import BaseStorageClient, storage_expiry_time
from chainlit.logger import logger
class GCSStorageClient(BaseStorageClient):
def __init__(
self,
bucket_name: str,
project_id: Optional[str] = None,
client_email: Optional[str] = None,
private_key: Optional[str] = None,
):
if client_email and private_key and project_id:
# Go to IAM & Admin, click on Service Accounts, and generate a new JSON key
logger.info("Using Private Key from Environment Variable")
credentials = service_account.Credentials.from_service_account_info(
{
"type": "service_account",
"project_id": project_id,
"private_key": private_key,
"client_email": client_email,
"token_uri": "https://oauth2.googleapis.com/token",
}
)
else:
# Application Default Credentials (e.g. in Google Cloud Run)
logger.info("Using Application Default Credentials.")
credentials, default_project_id = default()
if not project_id:
project_id = default_project_id
self.client = storage.Client(project=project_id, credentials=credentials)
self.bucket = self.client.bucket(bucket_name)
logger.info("GCSStorageClient initialized")
def sync_get_read_url(self, object_key: str) -> str:
return self.bucket.blob(object_key).generate_signed_url(
version="v4", expiration=storage_expiry_time, method="GET"
)
async def get_read_url(self, object_key: str) -> str:
return await make_async(self.sync_get_read_url)(object_key)
def sync_upload_file(
self,
object_key: str,
data: Union[bytes, str],
mime: str = "application/octet-stream",
overwrite: bool = True,
) -> Dict[str, Any]:
try:
blob = self.bucket.blob(object_key)
if not overwrite and blob.exists():
raise Exception(
f"File {object_key} already exists and overwrite is False"
)
if isinstance(data, str):
data = data.encode("utf-8")
blob.upload_from_string(data, content_type=mime)
# Return signed URL
return {
"object_key": object_key,
"url": self.sync_get_read_url(object_key),
}
except Exception as e:
raise Exception(f"Failed to upload file to GCS: {e!s}")
async def upload_file(
self,
object_key: str,
data: Union[bytes, str],
mime: str = "application/octet-stream",
overwrite: bool = True,
content_disposition: str | None = None,
) -> Dict[str, Any]:
return await make_async(self.sync_upload_file)(
object_key, data, mime, overwrite
)
def sync_delete_file(self, object_key: str) -> bool:
try:
self.bucket.blob(object_key).delete()
return True
except Exception as e:
logger.warning(f"GCSStorageClient, delete_file error: {e}")
return False
async def delete_file(self, object_key: str) -> bool:
return await make_async(self.sync_delete_file)(object_key)
async def close(self) -> None:
self.client.close()
@@ -0,0 +1,91 @@
import os
from typing import Any, Dict, Union
import boto3 # type: ignore
from chainlit import make_async
from chainlit.data.storage_clients.base import BaseStorageClient, storage_expiry_time
from chainlit.logger import logger
class S3StorageClient(BaseStorageClient):
"""
Class to enable Amazon S3 storage provider
"""
def __init__(self, bucket: str, **kwargs: Any):
try:
self.bucket = bucket
self.client = boto3.client("s3", **kwargs)
logger.info("S3StorageClient initialized")
except Exception as e:
logger.warning(f"S3StorageClient initialization error: {e}")
def sync_get_read_url(self, object_key: str) -> str:
try:
url = self.client.generate_presigned_url(
"get_object",
Params={"Bucket": self.bucket, "Key": object_key},
ExpiresIn=storage_expiry_time,
)
return url
except Exception as e:
logger.warning(f"S3StorageClient, get_read_url error: {e}")
return object_key
async def get_read_url(self, object_key: str) -> str:
return await make_async(self.sync_get_read_url)(object_key)
def sync_upload_file(
self,
object_key: str,
data: Union[bytes, str],
mime: str = "application/octet-stream",
overwrite: bool = True,
content_disposition: str | None = None,
) -> Dict[str, Any]:
try:
if content_disposition is not None:
self.client.put_object(
Bucket=self.bucket,
Key=object_key,
Body=data,
ContentType=mime,
ContentDisposition=content_disposition,
)
else:
self.client.put_object(
Bucket=self.bucket, Key=object_key, Body=data, ContentType=mime
)
endpoint = os.environ.get("DEV_AWS_ENDPOINT", "amazonaws.com")
url = f"https://{self.bucket}.s3.{endpoint}/{object_key}"
return {"object_key": object_key, "url": url}
except Exception as e:
logger.warning(f"S3StorageClient, upload_file error: {e}")
return {}
async def upload_file(
self,
object_key: str,
data: Union[bytes, str],
mime: str = "application/octet-stream",
overwrite: bool = True,
content_disposition: str | None = None,
) -> Dict[str, Any]:
return await make_async(self.sync_upload_file)(
object_key, data, mime, overwrite, content_disposition
)
def sync_delete_file(self, object_key: str) -> bool:
try:
self.client.delete_object(Bucket=self.bucket, Key=object_key)
return True
except Exception as e:
logger.warning(f"S3StorageClient, delete_file error: {e}")
return False
async def delete_file(self, object_key: str) -> bool:
return await make_async(self.sync_delete_file)(object_key)
async def close(self) -> None:
await self.client.close()
+29
View File
@@ -0,0 +1,29 @@
import functools
from collections import deque
from chainlit.context import context
from chainlit.session import WebsocketSession
def queue_until_user_message():
def decorator(method):
@functools.wraps(method)
async def wrapper(self, *args, **kwargs):
if (
isinstance(context.session, WebsocketSession)
and not context.session.has_first_interaction
):
# Queue the method invocation waiting for the first user message
queues = context.session.thread_queues
method_name = method.__name__
if method_name not in queues:
queues[method_name] = deque()
queues[method_name].append((method, self, args, kwargs))
else:
# Otherwise, Execute the method immediately
return await method(self, *args, **kwargs)
return wrapper
return decorator
+6
View File
@@ -0,0 +1,6 @@
import importlib.util
if importlib.util.find_spec("discord") is None:
raise ValueError(
"The discord package is required to integrate Chainlit with a Discord app. Run `pip install discord --upgrade`"
)
+364
View File
@@ -0,0 +1,364 @@
import asyncio
import mimetypes
import re
import uuid
from datetime import datetime
from io import BytesIO
from typing import TYPE_CHECKING, Dict, List, Optional, Union
if TYPE_CHECKING:
from discord.abc import MessageableChannel
import discord
import filetype
import httpx
from discord.ui import Button, View
from chainlit.config import config
from chainlit.context import ChainlitContext, HTTPSession, context, context_var
from chainlit.data import get_data_layer
from chainlit.element import Element, ElementDict
from chainlit.emitter import BaseChainlitEmitter
from chainlit.logger import logger
from chainlit.message import Message, StepDict
from chainlit.types import Feedback
from chainlit.user import PersistedUser, User
from chainlit.user_session import user_session
class FeedbackView(View):
def __init__(self, step_id: str):
super().__init__(timeout=None)
self.step_id = step_id
@discord.ui.button(label="👎")
async def thumbs_down(self, interaction: discord.Interaction, button: Button):
if data_layer := get_data_layer():
try:
feedback = Feedback(forId=self.step_id, value=0)
await data_layer.upsert_feedback(feedback)
except Exception as e:
logger.error(f"Error upserting feedback: {e}")
if interaction.message:
await interaction.message.edit(view=None)
await interaction.message.add_reaction("👎")
@discord.ui.button(label="👍")
async def thumbs_up(self, interaction: discord.Interaction, button: Button):
if data_layer := get_data_layer():
try:
feedback = Feedback(forId=self.step_id, value=1)
await data_layer.upsert_feedback(feedback)
except Exception as e:
logger.error(f"Error upserting feedback: {e}")
if interaction.message:
await interaction.message.edit(view=None)
await interaction.message.add_reaction("👍")
class DiscordEmitter(BaseChainlitEmitter):
def __init__(self, session: HTTPSession, channel: "MessageableChannel"):
super().__init__(session)
self.channel = channel
async def send_element(self, element_dict: ElementDict):
if element_dict.get("display") != "inline":
return
persisted_file = self.session.files.get(element_dict.get("chainlitKey") or "")
file: Optional[Union[BytesIO, str]] = None
mime: Optional[str] = None
if persisted_file:
file = str(persisted_file["path"])
mime = element_dict.get("mime")
elif file_url := element_dict.get("url"):
async with httpx.AsyncClient() as client:
response = await client.get(file_url)
if response.status_code == 200:
file = BytesIO(response.content)
mime = filetype.guess_mime(file)
if not file:
return
element_name: str = element_dict.get("name", "Untitled")
if mime:
file_extension = mimetypes.guess_extension(mime)
if file_extension:
element_name += file_extension
file_obj = discord.File(file, filename=element_name)
await self.channel.send(file=file_obj)
async def send_step(self, step_dict: StepDict):
if not step_dict["type"] == "assistant_message":
return
step_type = step_dict.get("type")
is_message = step_type in [
"user_message",
"assistant_message",
]
is_empty_output = not step_dict.get("output")
if is_empty_output or not is_message:
return
else:
enable_feedback = get_data_layer()
message = await self.channel.send(step_dict["output"])
if enable_feedback:
current_run = context.current_run
scorable_id = current_run.id if current_run else step_dict.get("id")
if not scorable_id:
return
view = FeedbackView(scorable_id)
await message.edit(view=view)
async def update_step(self, step_dict: StepDict):
if not step_dict["type"] == "assistant_message":
return
await self.send_step(step_dict)
intents = discord.Intents.default()
intents.message_content = True
client = discord.Client(intents=intents)
def init_discord_context(
session: HTTPSession,
channel: "MessageableChannel",
message: discord.Message,
) -> ChainlitContext:
emitter = DiscordEmitter(session=session, channel=channel)
context = ChainlitContext(session=session, emitter=emitter)
context_var.set(context)
user_session.set("discord_message", message)
user_session.set("discord_channel", channel)
return context
users_by_discord_id: Dict[int, Union[User, PersistedUser]] = {}
USER_PREFIX = "discord_"
async def get_user(discord_user: Union[discord.User, discord.Member]):
if discord_user.id in users_by_discord_id:
return users_by_discord_id[discord_user.id]
metadata = {
"name": discord_user.name,
"id": discord_user.id,
}
user = User(identifier=USER_PREFIX + str(discord_user.name), metadata=metadata)
users_by_discord_id[discord_user.id] = user
if data_layer := get_data_layer():
try:
persisted_user = await data_layer.create_user(user)
if persisted_user:
users_by_discord_id[discord_user.id] = persisted_user
except Exception as e:
logger.error(f"Error creating user: {e}")
return users_by_discord_id[discord_user.id]
async def download_discord_file(url: str):
async with httpx.AsyncClient() as client:
response = await client.get(url)
if response.status_code == 200:
return response.content
else:
return None
async def download_discord_files(
session: HTTPSession, attachments: List[discord.Attachment]
):
download_coros = [
download_discord_file(attachment.url) for attachment in attachments
]
file_bytes_list = await asyncio.gather(*download_coros)
file_refs = []
for idx, file_bytes in enumerate(file_bytes_list):
if file_bytes:
name = attachments[idx].filename
mime_type = attachments[idx].content_type or "application/octet-stream"
file_ref = await session.persist_file(
name=name, mime=mime_type, content=file_bytes
)
file_refs.append(file_ref)
files_dicts = [
session.files[file["id"]] for file in file_refs if file["id"] in session.files
]
elements = [
Element.from_dict(
{
"id": file["id"],
"name": file["name"],
"path": str(file["path"]),
"chainlitKey": file["id"],
"display": "inline",
"type": Element.infer_type_from_mime(file["type"]),
}
)
for file in files_dicts
]
return elements
def clean_content(message: discord.Message):
if not client.user:
return message.content
# Regex to find mentions of the bot
bot_mention = f"<@!?{client.user.id}>"
# Replace the bot's mention with nothing
return re.sub(bot_mention, "", message.content).strip()
async def process_discord_message(
message: discord.Message,
thread_id: str,
thread_name: str,
channel: "MessageableChannel",
bind_thread_to_user=False,
):
user = await get_user(message.author)
text = clean_content(message)
discord_files = message.attachments
session_id = str(uuid.uuid4())
session = HTTPSession(
id=session_id,
thread_id=thread_id,
user=user,
client_type="discord",
)
ctx = init_discord_context(
session=session,
channel=channel,
message=message,
)
file_elements = await download_discord_files(session, discord_files)
if on_chat_start := config.code.on_chat_start:
await on_chat_start()
msg = Message(
content=text,
elements=file_elements,
type="user_message",
author=user.metadata.get("name"),
)
await msg.send()
if on_message := config.code.on_message:
async with channel.typing():
await on_message(msg)
if on_chat_end := config.code.on_chat_end:
await on_chat_end()
if data_layer := get_data_layer():
user_id = None
if isinstance(user, PersistedUser):
user_id = user.id if bind_thread_to_user else None
try:
await data_layer.update_thread(
thread_id=thread_id,
name=thread_name,
metadata=ctx.session.to_persistable(),
user_id=user_id,
)
except Exception as e:
logger.error(f"Error updating thread: {e}")
await ctx.session.delete()
@client.event
async def on_ready():
logger.info(f"Logged in as {client.user}")
@client.event
async def on_message(message: discord.Message):
if not client.user or message.author == client.user:
return
is_dm = isinstance(message.channel, discord.DMChannel)
if not client.user.mentioned_in(message) and not is_dm:
return
thread_name: str = ""
thread_id: str = ""
bind_thread_to_user = False
channel = message.channel
if isinstance(message.channel, discord.Thread):
thread_name = f"{message.channel.name}"
thread_id = str(uuid.uuid5(uuid.NAMESPACE_DNS, str(channel.id)))
elif isinstance(message.channel, discord.ForumChannel):
thread_name = f"{message.channel.name}"
thread_id = str(uuid.uuid5(uuid.NAMESPACE_DNS, str(channel.id)))
elif isinstance(message.channel, discord.DMChannel):
thread_id = str(
uuid.uuid5(
uuid.NAMESPACE_DNS,
str(channel.id) + datetime.today().strftime("%Y-%m-%d"),
)
)
thread_name = (
f"{message.author} Discord DM {datetime.today().strftime('%Y-%m-%d')}"
)
bind_thread_to_user = True
elif isinstance(message.channel, discord.GroupChannel):
thread_id = str(
uuid.uuid5(
uuid.NAMESPACE_DNS,
str(channel.id) + datetime.today().strftime("%Y-%m-%d"),
)
)
thread_name = f"{message.channel.name}"
elif isinstance(message.channel, discord.TextChannel):
# Discord limits thread names to 100 characters and does not create
# threads from empty messages.
thread_id = str(
uuid.uuid5(
uuid.NAMESPACE_DNS,
str(channel.id) + datetime.today().strftime("%Y-%m-%d"),
)
)
discord_thread_name = clean_content(message)[:100] or "Untitled"
channel = await message.channel.create_thread(
name=discord_thread_name, message=message
)
thread_name = f"{channel.name}"
else:
logger.warning(f"Unsupported channel type: {message.channel.type}")
return
await process_discord_message(
message=message,
thread_id=thread_id,
thread_name=thread_name,
channel=channel,
bind_thread_to_user=bind_thread_to_user,
)
+481
View File
@@ -0,0 +1,481 @@
import json
import mimetypes
import uuid
from enum import Enum
from io import BytesIO
from typing import (
Any,
ClassVar,
Dict,
List,
Literal,
Optional,
TypedDict,
TypeVar,
Union,
)
import filetype
from pydantic import Field
from pydantic.dataclasses import dataclass
from syncer import asyncio
from chainlit.context import context
from chainlit.data import get_data_layer
from chainlit.logger import logger
mime_types = {
"text": "text/plain",
"tasklist": "application/json",
"plotly": "application/json",
}
ElementType = Literal[
"image",
"text",
"pdf",
"tasklist",
"audio",
"video",
"file",
"plotly",
"dataframe",
"custom",
]
ElementDisplay = Literal["inline", "side", "page"]
ElementSize = Literal["small", "medium", "large"]
class ElementDict(TypedDict, total=False):
id: str
threadId: Optional[str]
type: ElementType
chainlitKey: Optional[str]
path: Optional[str]
url: Optional[str]
objectKey: Optional[str]
name: str
display: ElementDisplay
size: Optional[ElementSize]
language: Optional[str]
page: Optional[int]
props: Optional[Dict]
autoPlay: Optional[bool]
playerConfig: Optional[dict]
forId: Optional[str]
mime: Optional[str]
@dataclass
class Element:
# Thread id
thread_id: str = Field(default_factory=lambda: context.session.thread_id)
# The type of the element. This will be used to determine how to display the element in the UI.
type: ClassVar[ElementType]
# Name of the element, this will be used to reference the element in the UI.
name: str = ""
# The ID of the element. This is set automatically when the element is sent to the UI.
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
# The key of the element hosted on Chainlit.
chainlit_key: Optional[str] = None
# The URL of the element if already hosted somewhere else.
url: Optional[str] = None
# The S3 object key.
object_key: Optional[str] = None
# The local path of the element.
path: Optional[str] = None
# The byte content of the element.
content: Optional[Union[bytes, str]] = None
# Controls how the image element should be displayed in the UI. Choices are “side” (default), “inline”, or “page”.
display: ElementDisplay = Field(default="inline")
# Controls element size
size: Optional[ElementSize] = None
# The ID of the message this element is associated with.
for_id: Optional[str] = None
# The language, if relevant
language: Optional[str] = None
# Mime type, inferred based on content if not provided
mime: Optional[str] = None
def __post_init__(self) -> None:
self.persisted = False
self.updatable = False
if not self.url and not self.path and not self.content:
raise ValueError("Must provide url, path or content to instantiate element")
def to_dict(self) -> ElementDict:
_dict = ElementDict(
{
"id": self.id,
"threadId": self.thread_id,
"type": self.type,
"url": self.url,
"chainlitKey": self.chainlit_key,
"name": self.name,
"display": self.display,
"objectKey": getattr(self, "object_key", None),
"size": getattr(self, "size", None),
"props": getattr(self, "props", None),
"page": getattr(self, "page", None),
"autoPlay": getattr(self, "auto_play", None),
"playerConfig": getattr(self, "player_config", None),
"language": getattr(self, "language", None),
"forId": getattr(self, "for_id", None),
"mime": getattr(self, "mime", None),
}
)
return _dict
@classmethod
def from_dict(cls, e_dict: ElementDict):
"""
Create an Element instance from a dictionary representation.
Args:
_dict (ElementDict): Dictionary containing element data
Returns:
Element: An instance of the appropriate Element subclass
"""
element_id = e_dict.get("id", str(uuid.uuid4()))
for_id = e_dict.get("forId")
name = e_dict.get("name", "")
type = e_dict.get("type", "file")
path = str(e_dict.get("path")) if e_dict.get("path") else None
url = str(e_dict.get("url")) if e_dict.get("url") else None
content = str(e_dict.get("content")) if e_dict.get("content") else None
object_key = e_dict.get("objectKey")
chainlit_key = e_dict.get("chainlitKey")
display = e_dict.get("display", "inline")
mime_type = e_dict.get("mime", "")
# Common parameters for all element types
common_params = {
"id": element_id,
"for_id": for_id,
"name": name,
"content": content,
"path": path,
"url": url,
"object_key": object_key,
"chainlit_key": chainlit_key,
"display": display,
"mime": mime_type,
}
if type == "image":
return Image(size="medium", **common_params) # type: ignore[arg-type]
elif type == "audio":
return Audio(auto_play=e_dict.get("autoPlay", False), **common_params) # type: ignore[arg-type]
elif type == "video":
return Video(
player_config=e_dict.get("playerConfig"),
**common_params, # type: ignore[arg-type]
)
elif type == "plotly":
return Plotly(size=e_dict.get("size", "medium"), **common_params) # type: ignore[arg-type]
elif type == "custom":
return CustomElement(props=e_dict.get("props", {}), **common_params) # type: ignore[arg-type]
else:
# Default to File for any other type
return File(**common_params) # type: ignore[arg-type]
@classmethod
def infer_type_from_mime(cls, mime_type: str):
"""Infer the element type from a mime type. Useful to know which element to instantiate from a file upload."""
if "image" in mime_type:
return "image"
elif mime_type == "application/pdf":
return "pdf"
elif "audio" in mime_type:
return "audio"
elif "video" in mime_type:
return "video"
else:
return "file"
async def _create(self, persist=True) -> bool:
if self.persisted and not self.updatable:
return True
if (data_layer := get_data_layer()) and persist:
try:
asyncio.create_task(data_layer.create_element(self))
except Exception as e:
logger.error(f"Failed to create element: {e!s}")
if not self.url and (not self.chainlit_key or self.updatable):
file_dict = await context.session.persist_file(
name=self.name,
path=self.path,
content=self.content,
mime=self.mime or "",
)
self.chainlit_key = file_dict["id"]
self.persisted = True
return True
async def remove(self):
data_layer = get_data_layer()
if data_layer:
await data_layer.delete_element(self.id, self.thread_id)
await context.emitter.emit("remove_element", {"id": self.id})
async def send(self, for_id: str, persist=True):
self.for_id = for_id
if not self.mime:
if self.type in mime_types:
self.mime = mime_types[self.type]
elif self.path or isinstance(self.content, (bytes, bytearray)):
file_type = filetype.guess(self.path or self.content)
if file_type:
self.mime = file_type.mime
elif self.url:
self.mime = mimetypes.guess_type(self.url)[0]
await self._create(persist=persist)
if not self.url and not self.chainlit_key:
raise ValueError("Must provide url or chainlit key to send element")
await context.emitter.send_element(self.to_dict())
ElementBased = TypeVar("ElementBased", bound=Element)
@dataclass
class Image(Element):
type: ClassVar[ElementType] = "image"
size: ElementSize = "medium"
@dataclass
class Text(Element):
"""Useful to send a text (not a message) to the UI."""
type: ClassVar[ElementType] = "text"
language: Optional[str] = None
@dataclass
class Pdf(Element):
"""Useful to send a pdf to the UI."""
mime: str = "application/pdf"
page: Optional[int] = None
type: ClassVar[ElementType] = "pdf"
@dataclass
class Pyplot(Element):
"""Useful to send a pyplot to the UI."""
# We reuse the frontend image element to display the chart
type: ClassVar[ElementType] = "image"
size: ElementSize = "medium"
# The type is set to Any because the figure is not serializable
# and its actual type is checked in __post_init__.
figure: Any = None
def __post_init__(self) -> None:
from matplotlib.figure import Figure
if not isinstance(self.figure, Figure):
raise TypeError("figure must be a matplotlib.figure.Figure")
image = BytesIO()
self.figure.savefig(
image, dpi=200, bbox_inches="tight", backend="Agg", format="png"
)
self.content = image.getvalue()
super().__post_init__()
class TaskStatus(Enum):
READY = "ready"
RUNNING = "running"
FAILED = "failed"
DONE = "done"
@dataclass
class Task:
title: str
status: TaskStatus = TaskStatus.READY
forId: Optional[str] = None
def __init__(
self,
title: str,
status: TaskStatus = TaskStatus.READY,
forId: Optional[str] = None,
):
self.title = title
self.status = status
self.forId = forId
@dataclass
class TaskList(Element):
type: ClassVar[ElementType] = "tasklist"
tasks: List[Task] = Field(default_factory=list, exclude=True)
status: str = "Ready"
name: str = "tasklist"
content: str = "dummy content to pass validation"
def __post_init__(self) -> None:
super().__post_init__()
self.updatable = True
async def add_task(self, task: Task):
self.tasks.append(task)
async def update(self):
await self.send()
async def send(self):
await self.preprocess_content()
await super().send(for_id="")
async def preprocess_content(self):
# serialize enum
tasks = [
{"title": task.title, "status": task.status.value, "forId": task.forId}
for task in self.tasks
]
# store stringified json in content so that it's correctly stored in the database
self.content = json.dumps(
{
"status": self.status,
"tasks": tasks,
},
indent=4,
ensure_ascii=False,
)
@dataclass
class Audio(Element):
type: ClassVar[ElementType] = "audio"
auto_play: bool = False
@dataclass
class Video(Element):
type: ClassVar[ElementType] = "video"
size: ElementSize = "medium"
# Override settings for each type of player in ReactPlayer
# https://github.com/cookpete/react-player?tab=readme-ov-file#config-prop
player_config: Optional[dict] = None
@dataclass
class File(Element):
type: ClassVar[ElementType] = "file"
@dataclass
class Plotly(Element):
"""Useful to send a plotly to the UI."""
type: ClassVar[ElementType] = "plotly"
size: ElementSize = "medium"
# The type is set to Any because the figure is not serializable
# and its actual type is checked in __post_init__.
figure: Any = None
content: str = ""
def __post_init__(self) -> None:
from plotly import graph_objects as go, io as pio
if not isinstance(self.figure, go.Figure):
raise TypeError("figure must be a plotly.graph_objects.Figure")
self.figure.layout.autosize = True
self.figure.layout.width = None
self.content = pio.to_json(self.figure, validate=True)
self.mime = "application/json"
super().__post_init__()
@dataclass
class Dataframe(Element):
"""Useful to send a pandas or polars DataFrame to the UI."""
type: ClassVar[ElementType] = "dataframe"
size: ElementSize = "large"
data: Any = None # The type is Any because it is checked in __post_init__.
@staticmethod
def _is_pandas_dataframe(data: Any) -> bool:
"""Check if data is a pandas DataFrame without requiring pandas."""
try:
from pandas import DataFrame as PandasDataFrame
return isinstance(data, PandasDataFrame)
except ImportError:
return False
@staticmethod
def _is_polars_dataframe(data: Any) -> bool:
"""Check if data is a polars DataFrame without requiring polars."""
try:
from polars import DataFrame as PolarsDataFrame
return isinstance(data, PolarsDataFrame)
except ImportError:
return False
def __post_init__(self) -> None:
"""Ensures the data is a pandas or polars DataFrame and converts it to JSON."""
if self._is_pandas_dataframe(self.data):
self.content = self.data.to_json(orient="split", date_format="iso")
elif self._is_polars_dataframe(self.data):
self.content = json.dumps(
{
"columns": self.data.columns,
"index": list(range(len(self.data))),
"data": self.data.rows(),
},
default=str,
)
else:
raise TypeError("data must be a pandas.DataFrame or polars.DataFrame")
super().__post_init__()
@dataclass
class CustomElement(Element):
"""Useful to send a custom element to the UI."""
type: ClassVar[ElementType] = "custom"
mime: str = "application/json"
props: Dict = Field(default_factory=dict)
def __post_init__(self) -> None:
self.content = json.dumps(self.props)
super().__post_init__()
self.updatable = True
async def update(self):
await super().send(self.for_id)
+473
View File
@@ -0,0 +1,473 @@
import asyncio
import uuid
from typing import Any, Dict, List, Literal, Optional, Union, cast, get_args
from socketio.exceptions import TimeoutError
from chainlit.chat_context import chat_context
from chainlit.config import config
from chainlit.data import get_data_layer
from chainlit.element import Element, ElementDict, File
from chainlit.logger import logger
from chainlit.message import Message
from chainlit.mode import Mode
from chainlit.session import BaseSession, WebsocketSession
from chainlit.step import StepDict
from chainlit.types import (
AskActionResponse,
AskElementResponse,
AskFileSpec,
AskSpec,
CommandDict,
FileDict,
FileReference,
MessagePayload,
OutputAudioChunk,
ThreadDict,
ToastType,
)
from chainlit.user import PersistedUser
from chainlit.utils import utc_now
class BaseChainlitEmitter:
"""
Chainlit Emitter Stub class. This class is used for testing purposes.
It stubs the ChainlitEmitter class and does nothing on function calls.
"""
session: BaseSession
enabled: bool = True
def __init__(self, session: BaseSession) -> None:
"""Initialize with the user session."""
self.session = session
async def emit(self, event: str, data: Any):
"""Stub method to get the 'emit' property from the session."""
pass
async def emit_call(self):
"""Stub method to get the 'emit_call' property from the session."""
pass
async def resume_thread(self, thread_dict: ThreadDict):
"""Stub method to resume a thread."""
pass
async def send_resume_thread_error(self, error: str):
"""Stub method to send a resume thread error."""
pass
async def send_element(self, element_dict: ElementDict):
"""Stub method to send an element to the UI."""
pass
async def update_audio_connection(self, state: Literal["on", "off"]):
"""Audio connection signaling."""
pass
async def send_audio_chunk(self, chunk: OutputAudioChunk):
"""Stub method to send an audio chunk to the UI."""
pass
async def send_audio_interrupt(self):
"""Stub method to interrupt the current audio response."""
pass
async def send_step(self, step_dict: StepDict):
"""Stub method to send a message to the UI."""
pass
async def update_step(self, step_dict: StepDict):
"""Stub method to update a message in the UI."""
pass
async def delete_step(self, step_dict: StepDict):
"""Stub method to delete a message in the UI."""
pass
def send_timeout(self, event: Literal["ask_timeout", "call_fn_timeout"]):
"""Stub method to send a timeout to the UI."""
pass
def clear(self, event: Literal["clear_ask", "clear_call_fn"]):
pass
async def init_thread(self, interaction: str):
pass
async def process_message(self, payload: MessagePayload) -> Message:
"""Stub method to process user message."""
return Message(content="")
async def send_ask_user(
self, step_dict: StepDict, spec: AskSpec, raise_on_timeout=False
) -> Optional[
Union["StepDict", "AskActionResponse", "AskElementResponse", List["FileDict"]]
]:
"""Stub method to send a prompt to the UI and wait for a response."""
pass
async def send_call_fn(
self, name: str, args: Dict[str, Any], timeout=300, raise_on_timeout=False
) -> Optional[Dict[str, Any]]:
"""Stub method to send a call function event to the copilot and wait for a response."""
pass
async def update_token_count(self, count: int):
"""Stub method to update the token count for the UI."""
pass
async def task_start(self):
"""Stub method to send a task start signal to the UI."""
pass
async def task_end(self):
"""Stub method to send a task end signal to the UI."""
pass
async def stream_start(self, step_dict: StepDict):
"""Stub method to send a stream start signal to the UI."""
pass
async def send_token(self, id: str, token: str, is_sequence=False, is_input=False):
"""Stub method to send a message token to the UI."""
pass
async def set_chat_settings(self, settings: dict):
"""Stub method to set chat settings."""
pass
async def set_commands(self, commands: List[CommandDict]):
"""Stub method to send the available commands to the UI."""
pass
async def set_modes(self, modes: List[Mode]):
"""Stub method to send the available modes to the UI."""
pass
async def send_window_message(self, data: Any):
"""Stub method to send custom data to the host window."""
pass
async def send_toast(self, message: str, type: Optional[ToastType] = "info"):
"""Stub method to send a toast message to the UI."""
pass
async def set_favorites(self, steps: List[StepDict]):
"""Stub method to send the favorite messages to the UI."""
pass
class ChainlitEmitter(BaseChainlitEmitter):
"""
Chainlit Emitter class. The Emitter is not directly exposed to the developer.
Instead, the developer interacts with the Emitter through the methods and classes exposed in the __init__ file.
"""
session: WebsocketSession
def __init__(self, session: WebsocketSession) -> None:
"""Initialize with the user session."""
self.session = session
def _get_session_property(self, property_name: str, raise_error=True):
"""Helper method to get a property from the session."""
if not hasattr(self, "session") or not hasattr(self.session, property_name):
if raise_error:
raise ValueError(f"Session does not have property '{property_name}'")
else:
return None
return getattr(self.session, property_name)
@property
def emit(self):
"""Get the 'emit' property from the session."""
return self._get_session_property("emit")
@property
def emit_call(self):
"""Get the 'emit_call' property from the session."""
return self._get_session_property("emit_call")
def resume_thread(self, thread_dict: ThreadDict):
"""Send a thread to the UI to resume it"""
return self.emit("resume_thread", thread_dict)
def send_resume_thread_error(self, error: str):
"""Send a thread resume error to the UI"""
return self.emit("resume_thread_error", error)
async def update_audio_connection(self, state: Literal["on", "off"]):
"""Audio connection signaling."""
await self.emit("audio_connection", state)
async def send_audio_chunk(self, chunk: OutputAudioChunk):
"""Send an audio chunk to the UI."""
await self.emit("audio_chunk", chunk)
async def send_audio_interrupt(self):
"""Method to interrupt the current audio response."""
await self.emit("audio_interrupt", {})
async def send_element(self, element_dict: ElementDict):
"""Stub method to send an element to the UI."""
await self.emit("element", element_dict)
def send_step(self, step_dict: StepDict):
"""Send a message to the UI."""
return self.emit("new_message", step_dict)
def update_step(self, step_dict: StepDict):
"""Update a message in the UI."""
return self.emit("update_message", step_dict)
def delete_step(self, step_dict: StepDict):
"""Delete a message in the UI."""
return self.emit("delete_message", step_dict)
def send_timeout(self, event: Literal["ask_timeout", "call_fn_timeout"]):
return self.emit(event, {})
def clear(self, event: Literal["clear_ask", "clear_call_fn"]):
return self.emit(event, {})
async def flush_thread_queues(self, interaction: str):
if data_layer := get_data_layer():
if isinstance(self.session.user, PersistedUser):
user_id = self.session.user.id
else:
user_id = None
try:
should_tag_thread = (
self.session.chat_profile and config.features.auto_tag_thread
)
tags = [self.session.chat_profile] if should_tag_thread else None
await data_layer.update_thread(
thread_id=self.session.thread_id,
name=interaction,
user_id=user_id,
tags=tags,
)
except Exception as e:
logger.error(f"Error updating thread: {e}")
asyncio.create_task(self.session.flush_method_queue())
async def init_thread(self, interaction: str):
await self.flush_thread_queues(interaction)
await self.emit(
"first_interaction",
{
"interaction": interaction,
"thread_id": self.session.thread_id,
},
)
async def process_message(self, payload: MessagePayload):
step_dict = payload["message"]
file_refs = payload.get("fileReferences")
# UUID generated by the frontend should use v4
assert uuid.UUID(step_dict["id"]).version == 4
message = Message.from_dict(step_dict)
# Overwrite the created_at timestamp with the current time
message.created_at = utc_now()
chat_context.add(message)
asyncio.create_task(message._create())
if not self.session.has_first_interaction:
self.session.has_first_interaction = True
asyncio.create_task(self.init_thread(message.content))
if file_refs:
files = [
self.session.files[file["id"]]
for file in file_refs
if file["id"] in self.session.files
]
elements = [
Element.from_dict(
{
"id": file["id"],
"name": file["name"],
"path": str(file["path"]),
"chainlitKey": file["id"],
"display": "inline",
"type": Element.infer_type_from_mime(file["type"]),
"mime": file["type"],
}
)
for file in files
]
message.elements = elements
async def send_elements():
for element in message.elements:
await element.send(for_id=message.id)
asyncio.create_task(send_elements())
return message
async def send_ask_user(
self, step_dict: StepDict, spec: AskSpec, raise_on_timeout=False
):
"""Send a prompt to the UI and wait for a response."""
parent_id = str(step_dict["parentId"])
try:
if spec.type == "file":
self.session.files_spec[parent_id] = cast(AskFileSpec, spec)
# Send the prompt to the UI
user_res = await self.emit_call(
"ask", {"msg": step_dict, "spec": spec.to_dict()}, spec.timeout
) # type: Optional[Union["StepDict", "AskActionResponse", "AskElementResponse", List["FileReference"]]]
# End the task temporarily so that the User can answer the prompt
await self.task_end()
final_res: Optional[
Union[StepDict, AskActionResponse, AskElementResponse, List[FileDict]]
] = None
if user_res:
interaction: Union[str, None] = None
if spec.type == "text":
message_dict_res = cast(StepDict, user_res)
await self.process_message(
{"message": message_dict_res, "fileReferences": None}
)
interaction = message_dict_res["output"]
final_res = message_dict_res
elif spec.type == "file":
file_refs = cast(List[FileReference], user_res)
files = [
self.session.files[file["id"]]
for file in file_refs
if file["id"] in self.session.files
]
final_res = files
interaction = ",".join([file["name"] for file in files])
if get_data_layer():
coros = [
File(
id=file["id"],
name=file["name"],
path=str(file["path"]),
mime=file["type"],
chainlit_key=file["id"],
for_id=step_dict["id"],
)._create()
for file in files
]
await asyncio.gather(*coros)
elif spec.type == "action":
action_res = cast(AskActionResponse, user_res)
final_res = action_res
interaction = action_res["name"]
elif spec.type == "element":
final_res = cast(AskElementResponse, user_res)
interaction = "custom_element"
if not self.session.has_first_interaction and interaction:
self.session.has_first_interaction = True
await self.init_thread(interaction=interaction)
await self.clear("clear_ask")
return final_res
except TimeoutError as e:
await self.send_timeout("ask_timeout")
if raise_on_timeout:
raise e
finally:
if parent_id in self.session.files_spec:
del self.session.files_spec[parent_id]
await self.task_start()
async def send_call_fn(
self, name: str, args: Dict[str, Any], timeout=300, raise_on_timeout=False
) -> Optional[Dict[str, Any]]:
"""Stub method to send a call function event to the copilot and wait for a response."""
try:
call_fn_res = await self.emit_call(
"call_fn", {"name": name, "args": args}, timeout
) # type: Dict
await self.clear("clear_call_fn")
return call_fn_res
except TimeoutError as e:
await self.send_timeout("call_fn_timeout")
if raise_on_timeout:
raise e
return None
def update_token_count(self, count: int):
"""Update the token count for the UI."""
return self.emit("token_usage", count)
def task_start(self):
"""
Send a task start signal to the UI.
"""
return self.emit("task_start", {})
def task_end(self):
"""Send a task end signal to the UI."""
return self.emit("task_end", {})
def stream_start(self, step_dict: StepDict):
"""Send a stream start signal to the UI."""
return self.emit(
"stream_start",
step_dict,
)
def send_token(self, id: str, token: str, is_sequence=False, is_input=False):
"""Send a message token to the UI."""
return self.emit(
"stream_token",
{"id": id, "token": token, "isSequence": is_sequence, "isInput": is_input},
)
def set_chat_settings(self, settings: Dict[str, Any]):
self.session.chat_settings = settings
def set_commands(self, commands: List[CommandDict]):
"""Send the available commands to the UI."""
return self.emit(
"set_commands",
commands,
)
def set_modes(self, modes: List[Mode]):
"""Send the available modes to the UI."""
return self.emit(
"set_modes",
[mode.to_dict() for mode in modes],
)
def set_favorites(self, steps: List[StepDict]):
"""Send the favorite messages to the UI."""
return self.emit(
"set_favorites",
steps,
)
def send_window_message(self, data: Any):
"""Send custom data to the host window."""
return self.emit("window_message", data)
async def send_toast(self, message: str, type: Optional[ToastType] = "info"):
"""Send a toast message to the UI."""
# check that the type is valid using ToastType
if type not in get_args(ToastType):
raise ValueError(f"Invalid toast type: {type}")
await self.emit("toast", {"message": message, "type": type})
+425
View File
@@ -0,0 +1,425 @@
from abc import abstractmethod
from datetime import date
from typing import Any, Dict, List, Literal, Optional
from pydantic import Field
from pydantic.dataclasses import dataclass
from chainlit.types import InputWidgetType
@dataclass
class InputWidget:
id: str
label: str
initial: Any = None
tooltip: Optional[str] = None
description: Optional[str] = None
disabled: Optional[bool] = False
def __post_init__(
self,
) -> None:
if not self.id or not self.label:
raise ValueError("Must provide key and label to load InputWidget")
@abstractmethod
def to_dict(self) -> Dict[str, Any]:
pass
@dataclass
class Switch(InputWidget):
"""Useful to create a switch input."""
type: InputWidgetType = "switch"
initial: bool = False
def to_dict(self) -> Dict[str, Any]:
return {
"type": self.type,
"id": self.id,
"label": self.label,
"initial": self.initial,
"tooltip": self.tooltip,
"description": self.description,
"disabled": self.disabled,
}
@dataclass
class Slider(InputWidget):
"""Useful to create a slider input."""
type: InputWidgetType = "slider"
initial: float = 0
min: float = 0
max: float = 10
step: float = 1
def to_dict(self) -> Dict[str, Any]:
return {
"type": self.type,
"id": self.id,
"label": self.label,
"initial": self.initial,
"min": self.min,
"max": self.max,
"step": self.step,
"tooltip": self.tooltip,
"description": self.description,
"disabled": self.disabled,
}
@dataclass
class Select(InputWidget):
"""Useful to create a select input."""
type: InputWidgetType = "select"
initial: Optional[str] = None
initial_index: Optional[int] = None
initial_value: Optional[str] = None
values: List[str] = Field(default_factory=list)
items: Dict[str, str] = Field(default_factory=dict)
def __post_init__(
self,
) -> None:
super().__post_init__()
if not self.values and not self.items:
raise ValueError("Must provide values or items to create a Select")
if self.values and self.items:
raise ValueError(
"You can only provide either values or items to create a Select"
)
if not self.values and self.initial_index is not None:
raise ValueError(
"Initial_index can only be used in combination with values to create a Select"
)
if self.items:
self.initial = self.initial_value
elif self.values:
self.items = {value: value for value in self.values}
self.initial = (
self.values[self.initial_index]
if self.initial_index is not None
else self.initial_value
)
def to_dict(self) -> Dict[str, Any]:
return {
"type": self.type,
"id": self.id,
"label": self.label,
"initial": self.initial,
"items": [
{"label": id, "value": value} for id, value in self.items.items()
],
"tooltip": self.tooltip,
"description": self.description,
"disabled": self.disabled,
}
@dataclass
class TextInput(InputWidget):
"""Useful to create a text input."""
type: InputWidgetType = "textinput"
initial: Optional[str] = None
placeholder: Optional[str] = None
multiline: bool = False
def to_dict(self) -> Dict[str, Any]:
return {
"type": self.type,
"id": self.id,
"label": self.label,
"initial": self.initial,
"placeholder": self.placeholder,
"tooltip": self.tooltip,
"description": self.description,
"multiline": self.multiline,
"disabled": self.disabled,
}
@dataclass
class NumberInput(InputWidget):
"""Useful to create a number input."""
type: InputWidgetType = "numberinput"
initial: Optional[float] = None
placeholder: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
return {
"type": self.type,
"id": self.id,
"label": self.label,
"initial": self.initial,
"placeholder": self.placeholder,
"tooltip": self.tooltip,
"description": self.description,
"disabled": self.disabled,
}
@dataclass
class Tags(InputWidget):
"""Useful to create an input for an array of strings."""
type: InputWidgetType = "tags"
initial: List[str] = Field(default_factory=list)
values: List[str] = Field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
return {
"type": self.type,
"id": self.id,
"label": self.label,
"initial": self.initial,
"tooltip": self.tooltip,
"description": self.description,
"disabled": self.disabled,
}
@dataclass
class MultiSelect(InputWidget):
"""Useful to create a multi-select input."""
type: InputWidgetType = "multiselect"
initial: List[str] = Field(default_factory=list)
values: List[str] = Field(default_factory=list)
items: Dict[str, str] = Field(default_factory=dict)
def __post_init__(
self,
) -> None:
super().__post_init__()
if not self.values and not self.items:
raise ValueError("Must provide values or items to create a MultiSelect")
if self.values and self.items:
raise ValueError(
"You can only provide either values or items to create a MultiSelect"
)
if self.values:
self.items = {value: value for value in self.values}
def to_dict(self) -> Dict[str, Any]:
return {
"type": self.type,
"id": self.id,
"label": self.label,
"initial": self.initial,
"items": [
{"label": id, "value": value} for id, value in self.items.items()
],
"tooltip": self.tooltip,
"description": self.description,
"disabled": self.disabled,
}
@dataclass
class Checkbox(InputWidget):
"""Useful to create a checkbox input."""
type: InputWidgetType = "checkbox"
initial: bool = False
def to_dict(self) -> Dict[str, Any]:
return {
"type": self.type,
"id": self.id,
"label": self.label,
"initial": self.initial,
"tooltip": self.tooltip,
"description": self.description,
"disabled": self.disabled,
}
@dataclass
class RadioGroup(InputWidget):
"""Useful to create a radio button input."""
type: InputWidgetType = "radio"
initial: Optional[str] = None
initial_index: Optional[int] = None
initial_value: Optional[str] = None
values: List[str] = Field(default_factory=list)
items: Dict[str, str] = Field(default_factory=dict)
def __post_init__(
self,
) -> None:
super().__post_init__()
if not self.values and not self.items:
raise ValueError("Must provide values or items to create a RadioButton")
if self.values and self.items:
raise ValueError(
"You can only provide either values or items to create a RadioButton"
)
if not self.values and self.initial_index is not None:
raise ValueError(
"Initial_index can only be used in combination with values to create a RadioButton"
)
if self.items:
self.initial = self.initial_value
elif self.values:
self.items = {value: value for value in self.values}
self.initial = (
self.values[self.initial_index]
if self.initial_index is not None
else self.initial_value
)
def to_dict(self) -> Dict[str, Any]:
return {
"type": self.type,
"id": self.id,
"label": self.label,
"initial": self.initial,
"items": [
{"label": id, "value": value} for id, value in self.items.items()
],
"tooltip": self.tooltip,
"description": self.description,
"disabled": self.disabled,
}
@dataclass
class Tab:
id: str
label: str
inputs: list[InputWidget] = Field(default_factory=list, exclude=True)
def to_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"label": self.label,
"inputs": [input.to_dict() for input in self.inputs],
}
@dataclass
class DatePicker(InputWidget):
"""
Datepicker input widget.
Supports both single date and date range selection.
"""
type: InputWidgetType = "datepicker"
mode: Literal["single", "range"] = "single"
initial: str | date | tuple[str | date, str | date] | None = None
min_date: str | date | None = None
max_date: str | date | None = None
format: str | None = None
"""date-fns format string"""
placeholder: str | None = None
"""Placeholder to use when no date is selected"""
def __post_init__(self) -> None:
super().__post_init__()
if self.mode not in ("single", "range"):
raise ValueError("mode must be 'single' or 'range'")
if (
self.mode == "range"
and self.initial is not None
and not isinstance(self.initial, tuple)
):
raise ValueError("'initial' must be a tuple for range mode")
(initial_start, initial_end), min_date, max_date = (
[
DatePicker._validate_iso_format(date, "initial")
for date in (
self.initial
if isinstance(self.initial, tuple)
else [self.initial, None]
)
],
DatePicker._validate_iso_format(self.min_date, "min_date"),
DatePicker._validate_iso_format(self.max_date, "max_date"),
)
if self.mode == "range":
self._validate_range(initial_start, initial_end, "initial")
self._validate_range(min_date, max_date, "[min_date, max_date]")
# Validate that initial value(s) are within min_date and max_date bounds
for d in [initial_start, initial_end]:
if d is not None and (
(min_date is not None and d < min_date)
or (max_date is not None and d > max_date)
):
raise ValueError(
"'initial' must be within 'min_date' and 'max_date' bounds"
)
@staticmethod
def _validate_range(
start: date | None,
end: date | None,
field_name: str,
) -> None:
if start is not None and end is not None and start > end:
raise ValueError(
f"'{field_name}' range is invalid, start must be before end."
)
@staticmethod
def _validate_iso_format(
date_value: str | date | None, field_name: str
) -> date | None:
if isinstance(date_value, str):
try:
return date.fromisoformat(date_value)
except ValueError as e:
raise ValueError(f"'{field_name}' must be in ISO format") from e
return date_value
@staticmethod
def _format_date(date_value: str | date | None) -> str | None:
if isinstance(date_value, date):
return date_value.isoformat()
return date_value
def to_dict(self) -> dict[str, Any]:
return {
"type": self.type,
"id": self.id,
"label": self.label,
"tooltip": self.tooltip,
"description": self.description,
"mode": self.mode,
"initial": (
self._format_date(self.initial[0]),
self._format_date(self.initial[1]),
)
if isinstance(self.initial, tuple)
else DatePicker._format_date(self.initial),
"min_date": DatePicker._format_date(self.min_date),
"max_date": DatePicker._format_date(self.max_date),
"format": self.format,
"placeholder": self.placeholder,
"disabled": self.disabled,
}
+6
View File
@@ -0,0 +1,6 @@
from chainlit.utils import check_module_version
if not check_module_version("langchain_core", "0.2.5"):
raise ValueError(
"Expected langchain-core version >= 0.2.5. Run `pip install langchain-core --upgrade`"
)
+682
View File
@@ -0,0 +1,682 @@
import time
from typing import Any, Dict, List, Optional, Tuple, TypedDict, Union
from uuid import UUID
import pydantic
from langchain_core.load import dumps
from langchain_core.messages import BaseMessage
from langchain_core.outputs import ChatGenerationChunk, GenerationChunk
from langchain_core.tracers.base import AsyncBaseTracer
from langchain_core.tracers.schemas import Run
from literalai import ChatGeneration, CompletionGeneration, GenerationMessage
from literalai.observability.step import TrueStepType
from chainlit.context import context_var
from chainlit.message import Message
from chainlit.step import Step
from chainlit.utils import utc_now
DEFAULT_ANSWER_PREFIX_TOKENS = ["Final", "Answer", ":"]
class FinalStreamHelper:
# The stream we can use to stream the final answer from a chain
final_stream: Union[Message, None]
# Should we stream the final answer?
stream_final_answer: bool = False
# Token sequence that prefixes the answer
answer_prefix_tokens: List[str]
# Ignore white spaces and new lines when comparing answer_prefix_tokens to last tokens? (to determine if answer has been reached)
strip_tokens: bool
answer_reached: bool
def __init__(
self,
answer_prefix_tokens: Optional[List[str]] = None,
stream_final_answer: bool = False,
force_stream_final_answer: bool = False,
strip_tokens: bool = True,
) -> None:
# Langchain final answer streaming logic
if answer_prefix_tokens is None:
self.answer_prefix_tokens = DEFAULT_ANSWER_PREFIX_TOKENS
else:
self.answer_prefix_tokens = answer_prefix_tokens
if strip_tokens:
self.answer_prefix_tokens_stripped = [
token.strip() for token in self.answer_prefix_tokens
]
else:
self.answer_prefix_tokens_stripped = self.answer_prefix_tokens
self.last_tokens = [""] * len(self.answer_prefix_tokens)
self.last_tokens_stripped = [""] * len(self.answer_prefix_tokens)
self.strip_tokens = strip_tokens
self.answer_reached = force_stream_final_answer
# Our own final answer streaming logic
self.stream_final_answer = stream_final_answer
self.final_stream = None
self.has_streamed_final_answer = False
def _check_if_answer_reached(self) -> bool:
if self.strip_tokens:
return self._compare_last_tokens(self.last_tokens_stripped)
else:
return self._compare_last_tokens(self.last_tokens)
def _compare_last_tokens(self, last_tokens: List[str]):
if last_tokens == self.answer_prefix_tokens_stripped:
# If tokens match perfectly we are done
return True
else:
# Some LLMs will consider all the tokens of the final answer as one token
# so we check if any last token contains all answer tokens
return any(
[
all(
answer_token in last_token
for answer_token in self.answer_prefix_tokens_stripped
)
for last_token in last_tokens
]
)
def _append_to_last_tokens(self, token: str) -> None:
self.last_tokens.append(token)
self.last_tokens_stripped.append(token.strip())
if len(self.last_tokens) > len(self.answer_prefix_tokens):
self.last_tokens.pop(0)
self.last_tokens_stripped.pop(0)
class ChatGenerationStart(TypedDict):
input_messages: List[BaseMessage]
start: float
token_count: int
tt_first_token: Optional[float]
class CompletionGenerationStart(TypedDict):
prompt: str
start: float
token_count: int
tt_first_token: Optional[float]
class GenerationHelper:
chat_generations: Dict[str, ChatGenerationStart]
completion_generations: Dict[str, CompletionGenerationStart]
generation_inputs: Dict[str, Dict]
def __init__(self) -> None:
self.chat_generations = {}
self.completion_generations = {}
self.generation_inputs = {}
def ensure_values_serializable(self, data):
"""
Recursively ensures that all values in the input (dict or list) are JSON serializable.
"""
if isinstance(data, dict):
return {
key: self.ensure_values_serializable(value)
for key, value in data.items()
}
elif isinstance(data, pydantic.BaseModel):
# Fallback to support pydantic v1
# https://docs.pydantic.dev/latest/migration/#changes-to-pydanticbasemodel
if pydantic.VERSION.startswith("1"):
return data.dict()
# pydantic v2
return data.model_dump() # pyright: ignore reportAttributeAccessIssue
elif isinstance(data, list):
return [self.ensure_values_serializable(item) for item in data]
elif isinstance(data, (str, int, float, bool, type(None))):
return data
elif isinstance(data, (tuple, set)):
return list(data) # Convert tuples and sets to lists
else:
return str(data) # Fallback: convert other types to string
def _convert_message_role(self, role: str):
if "human" in role.lower():
return "user"
elif "system" in role.lower():
return "system"
elif "function" in role.lower():
return "function"
elif "tool" in role.lower():
return "tool"
else:
return "assistant"
def _convert_message_dict(
self,
message: Dict,
):
class_name = message["id"][-1]
kwargs = message.get("kwargs", {})
function_call = kwargs.get("additional_kwargs", {}).get("function_call")
msg = GenerationMessage(
role=self._convert_message_role(class_name),
content="",
)
if name := kwargs.get("name"):
msg["name"] = name
if function_call:
msg["function_call"] = function_call
else:
content = kwargs.get("content")
if isinstance(content, list):
tool_calls = []
content_parts = []
for item in content:
if item.get("type") == "tool_use":
tool_calls.append(
{
"id": item.get("id"),
"type": "function",
"function": {
"name": item.get("name"),
"arguments": item.get("input"),
},
}
)
elif item.get("type") == "text":
content_parts.append({"type": "text", "text": item.get("text")})
if tool_calls:
msg["tool_calls"] = tool_calls
if content_parts:
msg["content"] = content_parts # type: ignore
else:
msg["content"] = content # type: ignore
return msg
def _convert_message(
self,
message: Union[Dict, BaseMessage],
):
if isinstance(message, dict):
return self._convert_message_dict(
message,
)
function_call = message.additional_kwargs.get("function_call")
msg = GenerationMessage(
role=self._convert_message_role(message.type),
content="",
)
if literal_uuid := message.additional_kwargs.get("uuid"):
msg["uuid"] = literal_uuid
msg["templated"] = True
if name := getattr(message, "name", None):
msg["name"] = name
if function_call:
msg["function_call"] = function_call
else:
if isinstance(message.content, list):
tool_calls = []
content_parts = []
for item in message.content:
if isinstance(item, str):
continue
if item.get("type") == "tool_use":
tool_calls.append(
{
"id": item.get("id"),
"type": "function",
"function": {
"name": item.get("name"),
"arguments": item.get("input"),
},
}
)
elif item.get("type") == "text":
content_parts.append({"type": "text", "text": item.get("text")})
if tool_calls:
msg["tool_calls"] = tool_calls
if content_parts:
msg["content"] = content_parts # type: ignore
else:
msg["content"] = message.content # type: ignore
return msg
def _build_llm_settings(
self,
serialized: Dict,
invocation_params: Optional[Dict] = None,
):
# invocation_params = run.extra.get("invocation_params")
if invocation_params is None:
return None, None
provider = invocation_params.pop("_type", "") # type: str
model_kwargs = invocation_params.pop("model_kwargs", {})
if model_kwargs is None:
model_kwargs = {}
merged = {
**invocation_params,
**model_kwargs,
**serialized.get("kwargs", {}),
}
# make sure there is no api key specification
settings = {k: v for k, v in merged.items() if not k.endswith("_api_key")}
model_keys = ["azure_deployment", "deployment_name", "model", "model_name"]
model = next((settings[k] for k in model_keys if k in settings), None)
if isinstance(model, str):
model = model.replace("models/", "")
tools = None
if "functions" in settings:
tools = [{"type": "function", "function": f} for f in settings["functions"]]
if "tools" in settings:
tools = [
{"type": "function", "function": t}
if t.get("type") != "function"
else t
for t in settings["tools"]
]
return provider, model, tools, settings
def process_content(content: Any) -> Tuple[Dict | str, Optional[str]]:
if content is None:
return {}, None
if isinstance(content, str):
return {"content": content}, "text"
else:
return dumps(content), "json"
DEFAULT_TO_IGNORE = [
"RunnableSequence",
"RunnableParallel",
"RunnableAssign",
"RunnableLambda",
"<lambda>",
]
DEFAULT_TO_KEEP = ["retriever", "llm", "agent", "chain", "tool"]
class LangchainTracer(AsyncBaseTracer, GenerationHelper, FinalStreamHelper):
steps: Dict[str, Step]
parent_id_map: Dict[str, str]
ignored_runs: set
def __init__(
self,
# Token sequence that prefixes the answer
answer_prefix_tokens: Optional[List[str]] = None,
# Should we stream the final answer?
stream_final_answer: bool = False,
# Should force stream the first response?
force_stream_final_answer: bool = False,
# Runs to ignore to enhance readability
to_ignore: Optional[List[str]] = None,
# Runs to keep within ignored runs
to_keep: Optional[List[str]] = None,
**kwargs: Any,
) -> None:
AsyncBaseTracer.__init__(self, **kwargs)
GenerationHelper.__init__(self)
FinalStreamHelper.__init__(
self,
answer_prefix_tokens=answer_prefix_tokens,
stream_final_answer=stream_final_answer,
force_stream_final_answer=force_stream_final_answer,
)
self.context = context_var.get()
self.steps = {}
self.parent_id_map = {}
self.ignored_runs = set()
if self.context.current_step:
self.root_parent_id = self.context.current_step.id
else:
self.root_parent_id = None
if to_ignore is None:
self.to_ignore = DEFAULT_TO_IGNORE
else:
self.to_ignore = to_ignore
if to_keep is None:
self.to_keep = DEFAULT_TO_KEEP
else:
self.to_keep = to_keep
async def on_chat_model_start(
self,
serialized: Dict[str, Any],
messages: List[List[BaseMessage]],
*,
run_id: "UUID",
parent_run_id: Optional["UUID"] = None,
tags: Optional[List[str]] = None,
metadata: Optional[Dict[str, Any]] = None,
name: Optional[str] = None,
**kwargs: Any,
) -> Run:
lc_messages = messages[0]
self.chat_generations[str(run_id)] = {
"input_messages": lc_messages,
"start": time.time(),
"token_count": 0,
"tt_first_token": None,
}
return await super().on_chat_model_start(
serialized,
messages,
run_id=run_id,
parent_run_id=parent_run_id,
tags=tags,
metadata=metadata,
name=name,
**kwargs,
)
async def on_llm_start(
self,
serialized: Dict[str, Any],
prompts: List[str],
*,
run_id: "UUID",
parent_run_id: Optional[UUID] = None,
tags: Optional[List[str]] = None,
metadata: Optional[Dict[str, Any]] = None,
**kwargs: Any,
) -> None:
await super().on_llm_start(
serialized,
prompts,
run_id=run_id,
parent_run_id=parent_run_id,
tags=tags,
metadata=metadata,
**kwargs,
)
self.completion_generations[str(run_id)] = {
"prompt": prompts[0],
"start": time.time(),
"token_count": 0,
"tt_first_token": None,
}
return None
async def on_llm_new_token(
self,
token: str,
*,
chunk: Optional[Union[GenerationChunk, ChatGenerationChunk]] = None,
run_id: "UUID",
parent_run_id: Optional["UUID"] = None,
**kwargs: Any,
) -> None:
await super().on_llm_new_token(
token=token,
chunk=chunk,
run_id=run_id,
parent_run_id=parent_run_id,
**kwargs,
)
if isinstance(chunk, ChatGenerationChunk):
start = self.chat_generations[str(run_id)]
else:
start = self.completion_generations[str(run_id)] # type: ignore
start["token_count"] += 1
if start["tt_first_token"] is None:
start["tt_first_token"] = (time.time() - start["start"]) * 1000
# Process token to ensure it's a string, as strip() will be called on it.
processed_token: str
# Handle case where token is a list (can occur with some model outputs).
# Join all elements into a single string to maintain compatibility with downstream processing.
if isinstance(token, list):
# If token is a list, join its elements (converted to strings) into a single string.
processed_token = "".join(map(str, token))
elif not isinstance(token, str):
# If token is neither a list nor a string, convert it to a string.
processed_token = str(token)
else:
# If token is already a string, use it as is.
processed_token = token
if self.stream_final_answer:
self._append_to_last_tokens(processed_token)
if self.answer_reached:
if not self.final_stream:
self.final_stream = Message(content="")
await self.final_stream.send()
await self.final_stream.stream_token(processed_token)
self.has_streamed_final_answer = True
else:
self.answer_reached = self._check_if_answer_reached()
async def _persist_run(self, run: Run) -> None:
pass
def _get_run_parent_id(self, run: Run):
parent_id = str(run.parent_run_id) if run.parent_run_id else self.root_parent_id
return parent_id
def _get_non_ignored_parent_id(self, current_parent_id: Optional[str] = None):
if not current_parent_id:
return self.root_parent_id
if current_parent_id not in self.parent_id_map:
return None
while current_parent_id in self.parent_id_map:
# If the parent id is in the ignored runs, we need to get the parent id of the ignored run
if current_parent_id in self.ignored_runs:
current_parent_id = self.parent_id_map[current_parent_id]
else:
return current_parent_id
return self.root_parent_id
def _should_ignore_run(self, run: Run):
parent_id = self._get_run_parent_id(run)
if parent_id:
# Add the parent id of the ignored run in the mapping
# so we can re-attach a kept child to the right parent id
self.parent_id_map[str(run.id)] = parent_id
ignore_by_name = False
ignore_by_parent = parent_id in self.ignored_runs
for filter in self.to_ignore:
if filter in run.name:
ignore_by_name = True
break
ignore = ignore_by_name or ignore_by_parent
# If the ignore cause is the parent being ignored, check if we should nonetheless keep the child
if ignore_by_parent and not ignore_by_name and run.run_type in self.to_keep:
return False, self._get_non_ignored_parent_id(parent_id)
else:
if ignore:
# Tag the run as ignored
self.ignored_runs.add(str(run.id))
return ignore, parent_id
async def _start_trace(self, run: Run) -> None:
await super()._start_trace(run)
context_var.set(self.context)
ignore, parent_id = self._should_ignore_run(run)
if run.run_type in ["chain", "prompt"]:
self.generation_inputs[str(run.id)] = self.ensure_values_serializable(
run.inputs
)
if ignore:
return
step_type: TrueStepType = "undefined"
if run.run_type == "agent":
step_type = "run"
elif run.run_type == "chain":
if not self.steps:
step_type = "run"
elif run.run_type == "llm":
step_type = "llm"
elif run.run_type == "retriever":
step_type = "tool"
elif run.run_type == "tool":
step_type = "tool"
elif run.run_type == "embedding":
step_type = "embedding"
step = Step(
id=str(run.id),
name=run.name,
type=step_type,
parent_id=parent_id,
)
step.start = utc_now()
if step_type != "llm":
step.input, language = process_content(run.inputs)
step.show_input = language or False
step.tags = run.tags
self.steps[str(run.id)] = step
await step.send()
async def _on_run_update(self, run: Run) -> None:
"""Process a run upon update."""
context_var.set(self.context)
ignore, _parent_id = self._should_ignore_run(run)
if ignore:
return
current_step = self.steps.get(str(run.id), None)
if run.run_type == "llm" and current_step:
provider, model, tools, llm_settings = self._build_llm_settings(
(run.serialized or {}), (run.extra or {}).get("invocation_params")
)
generations = (run.outputs or {}).get("generations", [])
generation = generations[0][0]
variables = self.generation_inputs.get(str(run.parent_run_id), {})
variables = {k: str(v) for k, v in variables.items() if v is not None}
if message := generation.get("message"):
chat_start = self.chat_generations[str(run.id)]
duration = time.time() - chat_start["start"]
if duration and chat_start["token_count"]:
throughput = chat_start["token_count"] / duration
else:
throughput = None
message_completion = self._convert_message(message)
current_step.generation = ChatGeneration(
provider=provider,
model=model,
tools=tools,
variables=variables,
settings=llm_settings,
duration=duration,
token_throughput_in_s=throughput,
tt_first_token=chat_start.get("tt_first_token"),
messages=[
self._convert_message(m) for m in chat_start["input_messages"]
],
message_completion=message_completion,
)
# find first message with prompt_id
for m in chat_start["input_messages"]:
if m.additional_kwargs.get("prompt_id"):
current_step.generation.prompt_id = m.additional_kwargs[
"prompt_id"
]
if custom_variables := m.additional_kwargs.get("variables"):
current_step.generation.variables = {
k: str(v)
for k, v in custom_variables.items()
if v is not None
}
break
current_step.language = "json"
else:
completion_start = self.completion_generations[str(run.id)]
completion = generation.get("text", "")
duration = time.time() - completion_start["start"]
if duration and completion_start["token_count"]:
throughput = completion_start["token_count"] / duration
else:
throughput = None
current_step.generation = CompletionGeneration(
provider=provider,
model=model,
settings=llm_settings,
variables=variables,
duration=duration,
token_throughput_in_s=throughput,
tt_first_token=completion_start.get("tt_first_token"),
prompt=completion_start["prompt"],
completion=completion,
)
current_step.output = completion
if current_step:
current_step.end = utc_now()
await current_step.update()
if self.final_stream and self.has_streamed_final_answer:
await self.final_stream.update()
return
if current_step:
if current_step.type != "llm":
current_step.output, current_step.language = process_content(
run.outputs
)
current_step.end = utc_now()
await current_step.update()
async def _on_error(self, error: BaseException, *, run_id: UUID, **kwargs: Any):
context_var.set(self.context)
if current_step := self.steps.get(str(run_id), None):
current_step.is_error = True
current_step.output = str(error)
current_step.end = utc_now()
await current_step.update()
on_llm_error = _on_error
on_chain_error = _on_error
on_tool_error = _on_error
on_retriever_error = _on_error
LangchainCallbackHandler = LangchainTracer
AsyncLangchainCallbackHandler = LangchainTracer
+25
View File
@@ -0,0 +1,25 @@
from chainlit.utils import check_module_version
if not check_module_version("langflow", "0.1.4"):
raise ValueError(
"Expected Langflow version >= 0.1.4. Run `pip install langflow --upgrade`"
)
from typing import Dict, Optional, Union
import httpx
async def load_flow(schema: Union[Dict, str], tweaks: Optional[Dict] = None):
from langflow import load_flow_from_json
if isinstance(schema, str):
async with httpx.AsyncClient() as client:
response = await client.get(schema)
if response.status_code != 200:
raise ValueError(f"Error: {response.text}")
schema = response.json()
flow = load_flow_from_json(flow=schema, tweaks=tweaks)
return flow
+6
View File
@@ -0,0 +1,6 @@
from chainlit.utils import check_module_version
if not check_module_version("llama_index.core", "0.10.15"):
raise ValueError(
"Expected LlamaIndex version >= 0.10.15. Run `pip install llama_index --upgrade`"
)
+206
View File
@@ -0,0 +1,206 @@
from typing import Any, Dict, List, Optional
from literalai import ChatGeneration, CompletionGeneration, GenerationMessage
from llama_index.core.callbacks import TokenCountingHandler
from llama_index.core.callbacks.schema import CBEventType, EventPayload
from llama_index.core.llms import ChatMessage, ChatResponse, CompletionResponse
from llama_index.core.tools.types import ToolMetadata
from chainlit.context import context_var
from chainlit.element import Text
from chainlit.step import Step, StepType
from chainlit.utils import utc_now
DEFAULT_IGNORE = [
CBEventType.CHUNKING,
CBEventType.SYNTHESIZE,
CBEventType.EMBEDDING,
CBEventType.NODE_PARSING,
CBEventType.TREE,
]
class LlamaIndexCallbackHandler(TokenCountingHandler):
"""Base callback handler that can be used to track event starts and ends."""
steps: Dict[str, Step]
def __init__(
self,
event_starts_to_ignore: List[CBEventType] = DEFAULT_IGNORE,
event_ends_to_ignore: List[CBEventType] = DEFAULT_IGNORE,
) -> None:
"""Initialize the base callback handler."""
super().__init__(
event_starts_to_ignore=event_starts_to_ignore,
event_ends_to_ignore=event_ends_to_ignore,
)
self.steps = {}
def _get_parent_id(self, event_parent_id: Optional[str] = None) -> Optional[str]:
if event_parent_id and event_parent_id in self.steps:
return event_parent_id
elif context_var.get().current_step:
return context_var.get().current_step.id
else:
return None
def on_event_start(
self,
event_type: CBEventType,
payload: Optional[Dict[str, Any]] = None,
event_id: str = "",
parent_id: str = "",
**kwargs: Any,
) -> str:
"""Run when an event starts and return id of event."""
step_type: StepType = "undefined"
step_name: str = event_type.value
step_input: Optional[Dict[str, Any]] = payload
if event_type == CBEventType.FUNCTION_CALL:
step_type = "tool"
if payload:
metadata: Optional[ToolMetadata] = payload.get(EventPayload.TOOL)
if metadata:
step_name = getattr(metadata, "name", step_name)
step_input = payload.get(EventPayload.FUNCTION_CALL)
elif event_type == CBEventType.RETRIEVE:
step_type = "tool"
elif event_type == CBEventType.QUERY:
step_type = "tool"
elif event_type == CBEventType.LLM:
step_type = "llm"
else:
return event_id
step = Step(
name=step_name,
type=step_type,
parent_id=self._get_parent_id(parent_id),
id=event_id,
)
self.steps[event_id] = step
step.start = utc_now()
step.input = step_input or {}
context_var.get().loop.create_task(step.send())
return event_id
def on_event_end(
self,
event_type: CBEventType,
payload: Optional[Dict[str, Any]] = None,
event_id: str = "",
**kwargs: Any,
) -> None:
"""Run when an event ends."""
step = self.steps.get(event_id, None)
if payload is None or step is None:
return
step.end = utc_now()
if event_type == CBEventType.FUNCTION_CALL:
response = payload.get(EventPayload.FUNCTION_OUTPUT)
if response:
step.output = f"{response}"
context_var.get().loop.create_task(step.update())
elif event_type == CBEventType.QUERY:
response = payload.get(EventPayload.RESPONSE)
source_nodes = getattr(response, "source_nodes", None)
if source_nodes:
source_refs = ", ".join(
[f"Source {idx}" for idx, _ in enumerate(source_nodes)]
)
step.elements = [
Text(
name=f"Source {idx}",
content=source.text or "Empty node",
display="side",
)
for idx, source in enumerate(source_nodes)
]
step.output = f"Retrieved the following sources: {source_refs}"
context_var.get().loop.create_task(step.update())
elif event_type == CBEventType.RETRIEVE:
sources = payload.get(EventPayload.NODES)
if sources:
source_refs = ", ".join(
[f"Source {idx}" for idx, _ in enumerate(sources)]
)
step.elements = [
Text(
name=f"Source {idx}",
display="side",
content=source.node.get_text() or "Empty node",
)
for idx, source in enumerate(sources)
]
step.output = f"Retrieved the following sources: {source_refs}"
context_var.get().loop.create_task(step.update())
elif event_type == CBEventType.LLM:
formatted_messages = payload.get(EventPayload.MESSAGES) # type: Optional[List[ChatMessage]]
formatted_prompt = payload.get(EventPayload.PROMPT)
response = payload.get(EventPayload.RESPONSE)
if formatted_messages:
messages = [
GenerationMessage(
role=m.role.value, # type: ignore
content=m.content or "",
)
for m in formatted_messages
]
else:
messages = None
if isinstance(response, ChatResponse):
content = response.message.content or ""
elif isinstance(response, CompletionResponse):
content = response.text
else:
content = ""
step.output = content
token_count = self.total_llm_token_count or None
raw_response = response.raw if response else None
model = getattr(raw_response, "model", None)
if messages and isinstance(response, ChatResponse):
msg: ChatMessage = response.message
step.generation = ChatGeneration(
model=model,
messages=messages,
message_completion=GenerationMessage(
role=msg.role.value, # type: ignore
content=content,
),
token_count=token_count,
)
elif formatted_prompt:
step.generation = CompletionGeneration(
model=model,
prompt=formatted_prompt,
completion=content,
token_count=token_count,
)
context_var.get().loop.create_task(step.update())
else:
step.output = payload
context_var.get().loop.create_task(step.update())
self.steps.pop(event_id, None)
def _noop(self, *args, **kwargs):
pass
start_trace = _noop
end_trace = _noop
+8
View File
@@ -0,0 +1,8 @@
import logging
logging.getLogger("socketio").setLevel(logging.ERROR)
logging.getLogger("engineio").setLevel(logging.ERROR)
logging.getLogger("numexpr").setLevel(logging.ERROR)
logger = logging.getLogger("chainlit")
+57
View File
@@ -0,0 +1,57 @@
import os
from pathlib import Path
from typing import Optional
from chainlit.logger import logger
from ._utils import is_path_inside
# Default chainlit.md file created if none exists
DEFAULT_MARKDOWN_STR = """# Welcome to Chainlit! 🚀🤖
Hi there, Developer! 👋 We're excited to have you on board. Chainlit is a powerful tool designed to help you prototype, debug and share applications built on top of LLMs.
## Useful Links 🔗
- **Documentation:** Get started with our comprehensive [Chainlit Documentation](https://docs.chainlit.io) 📚
- **Discord Community:** Join our friendly [Chainlit Discord](https://discord.gg/k73SQ3FyUh) to ask questions, share your projects, and connect with other developers! 💬
We can't wait to see what you create with Chainlit! Happy coding! 💻😊
## Welcome screen
To modify the welcome screen, edit the `chainlit.md` file at the root of your project. If you do not want a welcome screen, just leave this file empty.
"""
def init_markdown(root: str):
"""Initialize the chainlit.md file if it doesn't exist."""
chainlit_md_file = os.path.join(root, "chainlit.md")
if not os.path.exists(chainlit_md_file):
with open(chainlit_md_file, "w", encoding="utf-8") as f:
f.write(DEFAULT_MARKDOWN_STR)
logger.info(f"Created default chainlit markdown file at {chainlit_md_file}")
def get_markdown_str(root: str, language: str) -> Optional[str]:
"""Get the chainlit.md file as a string."""
root_path = Path(root)
translated_chainlit_md_path = root_path / f"chainlit_{language}.md"
default_chainlit_md_path = root_path / "chainlit.md"
if (
is_path_inside(translated_chainlit_md_path, root_path)
and translated_chainlit_md_path.is_file()
):
chainlit_md_path = translated_chainlit_md_path
else:
chainlit_md_path = default_chainlit_md_path
logger.warning(
f"Translated markdown file for {language} not found. Defaulting to chainlit.md."
)
if chainlit_md_path.is_file():
return chainlit_md_path.read_text(encoding="utf-8")
else:
return None
+99
View File
@@ -0,0 +1,99 @@
import shlex
from typing import Dict, Literal, Optional, Union
from pydantic import BaseModel
from chainlit.config import config
class StdioMcpConnection(BaseModel):
name: str
command: str
args: list[str]
clientType: Literal["stdio"] = "stdio"
class SseMcpConnection(BaseModel):
name: str
url: str
headers: Optional[Dict[str, str]] = None
clientType: Literal["sse"] = "sse"
class HttpMcpConnection(BaseModel):
name: str
url: str
headers: Optional[Dict[str, str]] = None
clientType: Literal["streamable-http"] = "streamable-http"
McpConnection = Union[StdioMcpConnection, SseMcpConnection, HttpMcpConnection]
def validate_mcp_command(command_string: str):
"""
Validates that a command string uses command in the allowed list as the executable and returns
the executable and list of arguments suitable for subprocess calls.
This function handles potential command prefixes, flags, and options
to ensure only commands in allowed list are allowed.
Args:
command_string (str): The full command string to validate
Returns:
tuple: (env, executable, args_list) where:
- env (dict): Environment variables as a dictionary
- executable (str): The executable name or path
- args_list (list): List of command arguments
Raises:
ValueError: If the command doesn't use an allowed executable
"""
# Split the command string into parts while respecting quotes and escapes
# Using shlex.split provides POSIX-compatible parsing so that arguments
# wrapped in quotes (e.g. "--header \"Authorization: Bearer TOKEN\"")
# or environment variable assignments such as
# MY_VAR="value with spaces" are preserved as single list items.
# On Windows, shlex also works as long as posix=False is not required for
# our use-case (Chainlit targets POSIX-style shells for the MCP command).
try:
parts = shlex.split(command_string, posix=True)
except ValueError as exc:
# Provide a clearer error message when the command cannot be parsed
raise ValueError(f"Invalid command string: {exc}") from exc
if not parts:
raise ValueError("Empty command string")
# Look for the actual executable in the command
executable = None
executable_index = None
allowed_executables = config.features.mcp.stdio.allowed_executables
for i, part in enumerate(parts):
# Remove any path components to get the base executable name
base_exec = part.split("/")[-1].split("\\")[-1]
if allowed_executables is None or base_exec in allowed_executables:
executable = part
executable_index = i
break
if executable is None or executable_index is None:
raise ValueError(
f"Only commands in ({', '.join(allowed_executables)}) are allowed"
if allowed_executables
else "No allowed executables found"
)
# Return `executable` as the executable and everything after it as args
args_list = parts[executable_index + 1 :]
env_list = parts[:executable_index]
env = {}
for env_var in env_list:
if "=" in env_var:
key, value = env_var.split("=", 1)
env[key] = value
else:
raise ValueError(f"Invalid environment variable format: {env_var}")
return env, executable, args_list
+626
View File
@@ -0,0 +1,626 @@
import asyncio
import json
import time
import uuid
from abc import ABC
from typing import Dict, List, Optional, Union, cast
from literalai.observability.step import MessageStepType
from chainlit.action import Action
from chainlit.chat_context import chat_context
from chainlit.config import config
from chainlit.context import context, local_steps
from chainlit.data import get_data_layer
from chainlit.element import CustomElement, ElementBased
from chainlit.logger import logger
from chainlit.step import StepDict
from chainlit.types import (
AskActionResponse,
AskActionSpec,
AskElementResponse,
AskElementSpec,
AskFileResponse,
AskFileSpec,
AskSpec,
FileDict,
)
from chainlit.utils import utc_now
class MessageBase(ABC):
id: str
thread_id: str
author: str
content: str = ""
type: MessageStepType = "assistant_message"
streaming = False
created_at: Union[str, None] = None
fail_on_persist_error: bool = False
persisted = False
is_error = False
command: Optional[str] = None
modes: Optional[Dict[str, str]] = None
parent_id: Optional[str] = None
language: Optional[str] = None
metadata: Optional[Dict] = None
tags: Optional[List[str]] = None
wait_for_answer = False
def __post_init__(self) -> None:
self.thread_id = context.session.thread_id
previous_steps = local_steps.get() or []
parent_step = previous_steps[-1] if previous_steps else None
if parent_step:
self.parent_id = parent_step.id
if not getattr(self, "id", None):
self.id = str(uuid.uuid4())
@classmethod
def from_dict(self, _dict: StepDict):
type = _dict.get("type", "assistant_message")
return Message(
id=_dict["id"],
parent_id=_dict.get("parentId"),
created_at=_dict["createdAt"],
content=_dict["output"],
author=_dict.get("name", config.ui.name),
command=_dict.get("command"),
modes=_dict.get("modes"),
type=type, # type: ignore
language=_dict.get("language"),
metadata=_dict.get("metadata", {}),
)
def to_dict(self) -> StepDict:
_dict: StepDict = {
"id": self.id,
"threadId": self.thread_id,
"parentId": self.parent_id,
"createdAt": self.created_at,
"command": self.command,
"modes": self.modes,
"start": self.created_at,
"end": self.created_at,
"output": self.content,
"name": self.author,
"type": self.type,
"language": self.language,
"streaming": self.streaming,
"isError": self.is_error,
"waitForAnswer": self.wait_for_answer,
"metadata": self.metadata or {},
"tags": self.tags,
}
return _dict
async def update(
self,
):
"""
Update a message already sent to the UI.
"""
if self.streaming:
self.streaming = False
step_dict = self.to_dict()
chat_context.add(self)
data_layer = get_data_layer()
if data_layer:
try:
asyncio.create_task(data_layer.update_step(step_dict))
except Exception as e:
if self.fail_on_persist_error:
raise e
logger.error(f"Failed to persist message update: {e!s}")
await context.emitter.update_step(step_dict)
return True
async def remove(self):
"""
Remove a message already sent to the UI.
"""
chat_context.remove(self)
step_dict = self.to_dict()
data_layer = get_data_layer()
if data_layer:
try:
asyncio.create_task(data_layer.delete_step(step_dict["id"]))
except Exception as e:
if self.fail_on_persist_error:
raise e
logger.error(f"Failed to persist message deletion: {e!s}")
await context.emitter.delete_step(step_dict)
return True
async def _create(self):
step_dict = self.to_dict()
data_layer = get_data_layer()
if data_layer and not self.persisted:
try:
asyncio.create_task(data_layer.create_step(step_dict))
self.persisted = True
except Exception as e:
if self.fail_on_persist_error:
raise e
logger.error(f"Failed to persist message creation: {e!s}")
return step_dict
async def send(self):
if not self.created_at:
self.created_at = utc_now()
if self.content is None:
self.content = ""
if config.code.author_rename:
self.author = await config.code.author_rename(self.author)
if self.streaming:
self.streaming = False
step_dict = await self._create()
chat_context.add(self)
await context.emitter.send_step(step_dict)
return self
async def stream_token(self, token: str, is_sequence=False):
"""
Sends a token to the UI. This is useful for streaming messages.
Once all tokens have been streamed, call .send() to end the stream and persist the message if persistence is enabled.
"""
if not token:
return
if is_sequence:
self.content = token
else:
self.content += token
assert self.id
if not self.streaming:
self.streaming = True
step_dict = self.to_dict()
await context.emitter.stream_start(step_dict)
else:
await context.emitter.send_token(
id=self.id, token=token, is_sequence=is_sequence
)
class Message(MessageBase):
"""
Send a message to the UI
Args:
content (Union[str, Dict]): The content of the message.
author (str, optional): The author of the message, this will be used in the UI. Defaults to the assistant name (see config).
language (str, optional): Language of the code is the content is code. See https://react-code-blocks-rajinwonderland.vercel.app/?path=/story/codeblock--supported-languages for a list of supported languages.
actions (List[Action], optional): A list of actions to send with the message.
elements (List[ElementBased], optional): A list of elements to send with the message.
"""
def __init__(
self,
content: Union[str, Dict],
author: Optional[str] = None,
language: Optional[str] = None,
actions: Optional[List[Action]] = None,
elements: Optional[List[ElementBased]] = None,
type: MessageStepType = "assistant_message",
metadata: Optional[Dict] = None,
tags: Optional[List[str]] = None,
id: Optional[str] = None,
parent_id: Optional[str] = None,
command: Optional[str] = None,
modes: Optional[Dict[str, str]] = None,
created_at: Union[str, None] = None,
):
time.sleep(0.001)
self.language = language
if isinstance(content, dict):
try:
self.content = json.dumps(content, indent=4, ensure_ascii=False)
self.language = "json"
except TypeError:
self.content = str(content)
self.language = "text"
elif isinstance(content, str):
self.content = content
else:
self.content = str(content)
self.language = "text"
if id:
self.id = str(id)
if parent_id:
self.parent_id = str(parent_id)
if command:
self.command = str(command)
if modes:
self.modes = modes
if created_at:
self.created_at = created_at
self.metadata = metadata
self.tags = tags
self.author = author or config.ui.name
self.type = type
self.actions = actions if actions is not None else []
self.elements = elements if elements is not None else []
super().__post_init__()
async def send(self):
"""
Send the message to the UI and persist it in the cloud if a project ID is configured.
Return the ID of the message.
"""
await super().send()
# Create tasks for all actions and elements
tasks = [action.send(for_id=self.id) for action in self.actions]
tasks.extend(element.send(for_id=self.id) for element in self.elements)
# Run all tasks concurrently
await asyncio.gather(*tasks)
return self
async def update(self):
"""
Send the message to the UI and persist it in the cloud if a project ID is configured.
Return the ID of the message.
"""
await super().update()
# Update tasks for all actions and elements
tasks = [
action.send(for_id=self.id)
for action in self.actions
if action.forId is None
]
tasks.extend(element.send(for_id=self.id) for element in self.elements)
# Run all tasks concurrently
await asyncio.gather(*tasks)
return True
async def remove_actions(self):
for action in self.actions:
await action.remove()
class ErrorMessage(MessageBase):
"""
Send an error message to the UI
If a project ID is configured, the message will be persisted in the cloud.
Args:
content (str): Text displayed above the upload button.
author (str, optional): The author of the message, this will be used in the UI. Defaults to the assistant name (see config).
"""
def __init__(
self,
content: str,
author: str = config.ui.name,
fail_on_persist_error: bool = False,
):
self.content = content
self.author = author
self.type = "assistant_message"
self.is_error = True
self.fail_on_persist_error = fail_on_persist_error
super().__post_init__()
async def send(self):
"""
Send the error message to the UI and persist it in the cloud if a project ID is configured.
Return the ID of the message.
"""
return await super().send()
class AskMessageBase(MessageBase):
async def remove(self):
removed = await super().remove()
if removed:
await context.emitter.clear("clear_ask")
class AskUserMessage(AskMessageBase):
"""
Ask for the user input before continuing.
If the user does not answer in time (see timeout), a TimeoutError will be raised or None will be returned depending on raise_on_timeout.
If a project ID is configured, the message will be uploaded to the cloud storage.
Args:
content (str): The content of the prompt.
author (str, optional): The author of the message, this will be used in the UI. Defaults to the assistant name (see config).
timeout (int, optional): The number of seconds to wait for an answer before raising a TimeoutError.
raise_on_timeout (bool, optional): Whether to raise a socketio TimeoutError if the user does not answer in time.
"""
def __init__(
self,
content: str,
author: str = config.ui.name,
type: MessageStepType = "assistant_message",
timeout: int = 60,
raise_on_timeout: bool = False,
):
self.content = content
self.author = author
self.timeout = timeout
self.type = type
self.raise_on_timeout = raise_on_timeout
super().__post_init__()
async def send(self) -> Union[StepDict, None]:
"""
Sends the question to ask to the UI and waits for the reply.
"""
if not self.created_at:
self.created_at = utc_now()
if config.code.author_rename:
self.author = await config.code.author_rename(self.author)
if self.streaming:
self.streaming = False
self.wait_for_answer = True
step_dict = await self._create()
spec = AskSpec(type="text", step_id=step_dict["id"], timeout=self.timeout)
res = cast(
Union[None, StepDict],
await context.emitter.send_ask_user(step_dict, spec, self.raise_on_timeout),
)
self.wait_for_answer = False
return res
class AskFileMessage(AskMessageBase):
"""
Ask the user to upload a file before continuing.
If the user does not answer in time (see timeout), a TimeoutError will be raised or None will be returned depending on raise_on_timeout.
If a project ID is configured, the file will be uploaded to the cloud storage.
Args:
content (str): Text displayed above the upload button.
accept (Union[List[str], Dict[str, List[str]]]): List of mime type to accept like ["text/csv", "application/pdf"] or a dict like {"text/plain": [".txt", ".py"]}.
max_size_mb (int, optional): Maximum size per file in MB. Maximum value is 100.
max_files (int, optional): Maximum number of files to upload. Maximum value is 10.
author (str, optional): The author of the message, this will be used in the UI. Defaults to the assistant name (see config).
timeout (int, optional): The number of seconds to wait for an answer before raising a TimeoutError.
raise_on_timeout (bool, optional): Whether to raise a socketio TimeoutError if the user does not answer in time.
"""
def __init__(
self,
content: str,
accept: Union[List[str], Dict[str, List[str]]],
max_size_mb=2,
max_files=1,
author=config.ui.name,
type: MessageStepType = "assistant_message",
timeout=90,
raise_on_timeout=False,
):
self.content = content
self.max_size_mb = max_size_mb
self.max_files = max_files
self.accept = accept
self.type = type
self.author = author
self.timeout = timeout
self.raise_on_timeout = raise_on_timeout
super().__post_init__()
async def send(self) -> Union[List[AskFileResponse], None]:
"""
Sends the message to request a file from the user to the UI and waits for the reply.
"""
if not self.created_at:
self.created_at = utc_now()
if self.streaming:
self.streaming = False
if config.code.author_rename:
self.author = await config.code.author_rename(self.author)
self.wait_for_answer = True
step_dict = await self._create()
spec = AskFileSpec(
type="file",
step_id=step_dict["id"],
accept=self.accept,
max_size_mb=self.max_size_mb,
max_files=self.max_files,
timeout=self.timeout,
)
res = cast(
Union[None, List[FileDict]],
await context.emitter.send_ask_user(step_dict, spec, self.raise_on_timeout),
)
self.wait_for_answer = False
if res:
return [
AskFileResponse(
id=r["id"],
name=r["name"],
path=str(r["path"]),
size=r["size"],
type=r["type"],
)
for r in res
]
else:
return None
class AskActionMessage(AskMessageBase):
"""
Ask the user to select an action before continuing.
If the user does not answer in time (see timeout), a TimeoutError will be raised or None will be returned depending on raise_on_timeout.
"""
def __init__(
self,
content: str,
actions: List[Action],
author=config.ui.name,
timeout=90,
raise_on_timeout=False,
):
self.content = content
self.actions = actions
self.author = author
self.timeout = timeout
self.raise_on_timeout = raise_on_timeout
super().__post_init__()
async def send(self) -> Union[AskActionResponse, None]:
"""
Sends the question to ask to the UI and waits for the reply
"""
if not self.created_at:
self.created_at = utc_now()
if self.streaming:
self.streaming = False
if config.code.author_rename:
self.author = await config.code.author_rename(self.author)
self.wait_for_answer = True
step_dict = await self._create()
action_keys = []
for action in self.actions:
action_keys.append(action.id)
await action.send(for_id=str(step_dict["id"]))
spec = AskActionSpec(
type="action",
step_id=step_dict["id"],
timeout=self.timeout,
keys=action_keys,
)
res = cast(
Union[AskActionResponse, None],
await context.emitter.send_ask_user(step_dict, spec, self.raise_on_timeout),
)
for action in self.actions:
await action.remove()
if res is None:
self.content = "Timed out: no action was taken"
else:
self.content = f"**Selected:** {res['label']}"
self.wait_for_answer = False
await self.update()
return res
class AskElementMessage(AskMessageBase):
"""Ask the user to submit a custom element."""
def __init__(
self,
content: str,
element: CustomElement,
author=config.ui.name,
timeout=90,
raise_on_timeout=False,
):
self.content = content
self.element = element
self.author = author
self.timeout = timeout
self.raise_on_timeout = raise_on_timeout
super().__post_init__()
async def send(self) -> Union[AskElementResponse, None]:
"""Send the custom element to the UI and wait for the reply."""
if not self.created_at:
self.created_at = utc_now()
if self.streaming:
self.streaming = False
if config.code.author_rename:
self.author = await config.code.author_rename(self.author)
self.wait_for_answer = True
step_dict = await self._create()
await self.element.send(for_id=str(step_dict["id"]))
spec = AskElementSpec(
type="element",
step_id=step_dict["id"],
timeout=self.timeout,
element_id=self.element.id,
)
res = cast(
Union[AskElementResponse, None],
await context.emitter.send_ask_user(step_dict, spec, self.raise_on_timeout),
)
await self.element.remove()
if res is None:
self.content = "Timed out"
elif res.get("submitted"):
self.content = "Thanks for submitting"
else:
self.content = "Cancelled"
self.wait_for_answer = False
await self.update()
return res
+50
View File
@@ -0,0 +1,50 @@
import asyncio
from typing import Union
from literalai import ChatGeneration, CompletionGeneration
from chainlit.context import get_context
from chainlit.step import Step
from chainlit.utils import timestamp_utc
def instrument_mistralai():
from literalai.instrumentation.mistralai import instrument_mistralai
def on_new_generation(
generation: Union["ChatGeneration", "CompletionGeneration"], timing
):
context = get_context()
parent_id = None
if context.current_step:
parent_id = context.current_step.id
step = Step(
name=generation.model or generation.provider,
type="llm",
parent_id=parent_id,
)
step.generation = generation
# Convert start/end time from seconds to milliseconds
step.start = (
timestamp_utc(timing.get("start"))
if timing.get("start", None) is not None
else None
)
step.end = (
timestamp_utc(timing.get("end"))
if timing.get("end", None) is not None
else None
)
if isinstance(generation, ChatGeneration):
step.input = generation.messages # type: ignore
step.output = generation.message_completion # type: ignore
else:
step.input = generation.prompt # type: ignore
step.output = generation.completion # type: ignore
asyncio.create_task(step.send())
instrument_mistralai(None, on_new_generation)
+62
View File
@@ -0,0 +1,62 @@
"""Mode and ModeOption dataclasses for the Modes system.
The Modes system allows developers to define multiple picker categories
(e.g., Model, Approach, Reasoning Effort) that users can select from
in the chat composer.
"""
from dataclasses import dataclass, field
from typing import List, Optional
from dataclasses_json import DataClassJsonMixin
@dataclass
class ModeOption(DataClassJsonMixin):
"""A single selectable option within a Mode.
Attributes:
id: Unique identifier for this option (e.g., "gpt-5", "planning")
name: Display name shown in the UI (e.g., "GPT-5", "Planning")
description: Optional description shown in the dropdown
icon: Optional icon - can be a Lucide icon name, local path, or URL
default: Whether this is the default selected option for its mode
"""
id: str
name: str
description: Optional[str] = None
icon: Optional[str] = None
default: bool = False
@dataclass
class Mode(DataClassJsonMixin):
"""A category of options the user can select from.
Each Mode represents a picker dropdown in the chat composer.
Users select exactly one option per mode.
Attributes:
id: Unique identifier for this mode (e.g., "llm", "approach")
name: Display name shown in the UI (e.g., "Model", "Approach")
options: List of available options for this mode
"""
id: str
name: str
options: List[ModeOption] = field(default_factory=list)
def get_default_option(self) -> Optional[ModeOption]:
"""Get the default option for this mode, or the first option if none is default."""
for option in self.options:
if option.default:
return option
return self.options[0] if self.options else None
def get_option_by_id(self, option_id: str) -> Optional[ModeOption]:
"""Get an option by its ID."""
for option in self.options:
if option.id == option_id:
return option
return None
+856
View File
@@ -0,0 +1,856 @@
import base64
import os
import urllib.parse
from typing import Dict, List, Optional, Tuple
import httpx
from fastapi import HTTPException
from chainlit.secret import random_secret
from chainlit.user import User
ACCESS_TOKEN_MISSING = "Access token missing in the response"
class OAuthProvider:
id: str
env: List[str]
client_id: str
client_secret: str
authorize_url: str
authorize_params: Dict[str, str]
default_prompt: Optional[str] = None
def is_configured(self):
return all([os.environ.get(env) for env in self.env])
async def get_raw_token_response(self, code: str, url: str) -> dict:
raise NotImplementedError
async def get_token(self, code: str, url: str) -> str:
raise NotImplementedError
async def get_user_info(self, token: str) -> Tuple[Dict[str, str], User]:
raise NotImplementedError
def get_env_prefix(self) -> str:
"""Return environment prefix, like AZURE_AD."""
return self.id.replace("-", "_").upper()
def get_prompt(self) -> Optional[str]:
"""Return OAuth prompt param."""
if prompt := os.environ.get(f"OAUTH_{self.get_env_prefix()}_PROMPT"):
return prompt
if prompt := os.environ.get("OAUTH_PROMPT"):
return prompt
return self.default_prompt
class GithubOAuthProvider(OAuthProvider):
id = "github"
env = ["OAUTH_GITHUB_CLIENT_ID", "OAUTH_GITHUB_CLIENT_SECRET"]
authorize_url = os.environ.get(
"OAUTH_GITHUB_AUTH_URL", "https://github.com/login/oauth/authorize"
)
token_url = os.environ.get(
"OAUTH_GITHUB_TOKEN_URL", "https://github.com/login/oauth/access_token"
)
user_info_url = os.environ.get(
"OAUTH_GITHUB_USER_INFO_URL", "https://api.github.com/user"
)
def __init__(self):
self.client_id = os.environ.get("OAUTH_GITHUB_CLIENT_ID")
self.client_secret = os.environ.get("OAUTH_GITHUB_CLIENT_SECRET")
self.authorize_params = {
"scope": "user:email",
}
if prompt := self.get_prompt():
self.authorize_params["prompt"] = prompt
async def get_raw_token_response(self, code: str, url: str) -> Dict[str, List[str]]:
payload = {
"client_id": self.client_id,
"client_secret": self.client_secret,
"code": code,
}
async with httpx.AsyncClient() as client:
response = await client.post(
self.token_url,
data=payload,
)
response.raise_for_status()
return urllib.parse.parse_qs(response.text)
async def get_token(self, code: str, url: str):
content = await self.get_raw_token_response(code, url)
token = content.get("access_token", [""])[0]
if not token:
raise HTTPException(status_code=400, detail=ACCESS_TOKEN_MISSING)
return token
async def get_user_info(self, token: str):
async with httpx.AsyncClient() as client:
user_response = await client.get(
self.user_info_url,
headers={"Authorization": f"token {token}"},
)
user_response.raise_for_status()
github_user = user_response.json()
emails_response = await client.get(
urllib.parse.urljoin(self.user_info_url + "/", "emails"),
headers={"Authorization": f"token {token}"},
)
emails_response.raise_for_status()
emails = emails_response.json()
github_user.update({"emails": emails})
user = User(
identifier=github_user["login"],
metadata={"image": github_user["avatar_url"], "provider": "github"},
)
return (github_user, user)
class GoogleOAuthProvider(OAuthProvider):
id = "google"
env = ["OAUTH_GOOGLE_CLIENT_ID", "OAUTH_GOOGLE_CLIENT_SECRET"]
authorize_url = "https://accounts.google.com/o/oauth2/v2/auth"
def __init__(self):
self.client_id = os.environ.get("OAUTH_GOOGLE_CLIENT_ID")
self.client_secret = os.environ.get("OAUTH_GOOGLE_CLIENT_SECRET")
self.authorize_params = {
"scope": "https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email",
"response_type": "code",
"access_type": "offline",
}
if prompt := self.get_prompt():
self.authorize_params["prompt"] = prompt
async def get_raw_token_response(self, code: str, url: str) -> dict:
payload = {
"client_id": self.client_id,
"client_secret": self.client_secret,
"code": code,
"grant_type": "authorization_code",
"redirect_uri": url,
}
async with httpx.AsyncClient() as client:
response = await client.post(
"https://oauth2.googleapis.com/token",
data=payload,
)
response.raise_for_status()
return response.json()
async def get_token(self, code: str, url: str):
json = await self.get_raw_token_response(code, url)
token = json.get("access_token")
if not token:
raise HTTPException(status_code=400, detail=ACCESS_TOKEN_MISSING)
return token
async def get_user_info(self, token: str):
async with httpx.AsyncClient() as client:
response = await client.get(
"https://www.googleapis.com/userinfo/v2/me",
headers={"Authorization": f"Bearer {token}"},
)
response.raise_for_status()
google_user = response.json()
user = User(
identifier=google_user["email"],
metadata={"image": google_user["picture"], "provider": "google"},
)
return (google_user, user)
class AzureADOAuthProvider(OAuthProvider):
id = "azure-ad"
env = [
"OAUTH_AZURE_AD_CLIENT_ID",
"OAUTH_AZURE_AD_CLIENT_SECRET",
"OAUTH_AZURE_AD_TENANT_ID",
]
authorize_url = (
f"https://login.microsoftonline.com/{os.environ.get('OAUTH_AZURE_AD_TENANT_ID', '')}/oauth2/v2.0/authorize"
if os.environ.get("OAUTH_AZURE_AD_ENABLE_SINGLE_TENANT")
else "https://login.microsoftonline.com/common/oauth2/v2.0/authorize"
)
token_url = (
f"https://login.microsoftonline.com/{os.environ.get('OAUTH_AZURE_AD_TENANT_ID', '')}/oauth2/v2.0/token"
if os.environ.get("OAUTH_AZURE_AD_ENABLE_SINGLE_TENANT")
else "https://login.microsoftonline.com/common/oauth2/v2.0/token"
)
def __init__(self):
self.client_id = os.environ.get("OAUTH_AZURE_AD_CLIENT_ID")
self.client_secret = os.environ.get("OAUTH_AZURE_AD_CLIENT_SECRET")
self.authorize_params = {
"tenant": os.environ.get("OAUTH_AZURE_AD_TENANT_ID"),
"response_type": "code",
"scope": os.environ.get(
"OAUTH_AZURE_AD_SCOPES",
"https://graph.microsoft.com/User.Read offline_access",
),
"response_mode": "query",
}
if prompt := self.get_prompt():
self.authorize_params["prompt"] = prompt
async def get_raw_token_response(self, code: str, url: str) -> dict:
payload = {
"client_id": self.client_id,
"client_secret": self.client_secret,
"code": code,
"grant_type": "authorization_code",
"redirect_uri": url,
}
async with httpx.AsyncClient() as client:
response = await client.post(
self.token_url,
data=payload,
)
response.raise_for_status()
return response.json()
async def get_token(self, code: str, url: str):
json = await self.get_raw_token_response(code, url)
token = json["access_token"]
refresh_token = json.get("refresh_token")
if not token:
raise HTTPException(status_code=400, detail=ACCESS_TOKEN_MISSING)
self._refresh_token = refresh_token
return token
async def get_user_info(self, token: str):
async with httpx.AsyncClient() as client:
response = await client.get(
"https://graph.microsoft.com/v1.0/me",
headers={"Authorization": f"Bearer {token}"},
)
response.raise_for_status()
azure_user = response.json()
try:
photo_response = await client.get(
"https://graph.microsoft.com/v1.0/me/photos/48x48/$value",
headers={"Authorization": f"Bearer {token}"},
)
photo_data = await photo_response.aread()
base64_image = base64.b64encode(photo_data)
azure_user["image"] = (
f"data:{photo_response.headers['Content-Type']};base64,{base64_image.decode('utf-8')}"
)
except Exception:
# Ignore errors getting the photo
pass
user = User(
identifier=azure_user["userPrincipalName"],
metadata={
"image": azure_user.get("image"),
"provider": "azure-ad",
"refresh_token": getattr(self, "_refresh_token", None),
},
)
return (azure_user, user)
class AzureADHybridOAuthProvider(OAuthProvider):
id = "azure-ad-hybrid"
env = [
"OAUTH_AZURE_AD_HYBRID_CLIENT_ID",
"OAUTH_AZURE_AD_HYBRID_CLIENT_SECRET",
"OAUTH_AZURE_AD_HYBRID_TENANT_ID",
]
authorize_url = (
f"https://login.microsoftonline.com/{os.environ.get('OAUTH_AZURE_AD_HYBRID_TENANT_ID', '')}/oauth2/v2.0/authorize"
if os.environ.get("OAUTH_AZURE_AD_HYBRID_ENABLE_SINGLE_TENANT")
else "https://login.microsoftonline.com/common/oauth2/v2.0/authorize"
)
token_url = (
f"https://login.microsoftonline.com/{os.environ.get('OAUTH_AZURE_AD_HYBRID_TENANT_ID', '')}/oauth2/v2.0/token"
if os.environ.get("OAUTH_AZURE_AD_HYBRID_ENABLE_SINGLE_TENANT")
else "https://login.microsoftonline.com/common/oauth2/v2.0/token"
)
def __init__(self):
self.client_id = os.environ.get("OAUTH_AZURE_AD_HYBRID_CLIENT_ID")
self.client_secret = os.environ.get("OAUTH_AZURE_AD_HYBRID_CLIENT_SECRET")
nonce = random_secret(16)
self.authorize_params = {
"tenant": os.environ.get("OAUTH_AZURE_AD_HYBRID_TENANT_ID"),
"response_type": "code id_token",
"scope": os.environ.get(
"OAUTH_AZURE_AD_HYBRID_SCOPES",
"https://graph.microsoft.com/User.Read https://graph.microsoft.com/openid offline_access",
),
"response_mode": "form_post",
"nonce": nonce,
}
if prompt := self.get_prompt():
self.authorize_params["prompt"] = prompt
async def get_raw_token_response(self, code: str, url: str) -> dict:
payload = {
"client_id": self.client_id,
"client_secret": self.client_secret,
"code": code,
"grant_type": "authorization_code",
"redirect_uri": url,
}
async with httpx.AsyncClient() as client:
response = await client.post(
self.token_url,
data=payload,
)
response.raise_for_status()
return response.json()
async def get_token(self, code: str, url: str):
json = await self.get_raw_token_response(code, url)
token = json["access_token"]
refresh_token = json.get("refresh_token")
if not token:
raise HTTPException(status_code=400, detail=ACCESS_TOKEN_MISSING)
self._refresh_token = refresh_token
return token
async def get_user_info(self, token: str):
async with httpx.AsyncClient() as client:
response = await client.get(
"https://graph.microsoft.com/v1.0/me",
headers={"Authorization": f"Bearer {token}"},
)
response.raise_for_status()
azure_user = response.json()
try:
photo_response = await client.get(
"https://graph.microsoft.com/v1.0/me/photos/48x48/$value",
headers={"Authorization": f"Bearer {token}"},
)
photo_data = await photo_response.aread()
base64_image = base64.b64encode(photo_data)
azure_user["image"] = (
f"data:{photo_response.headers['Content-Type']};base64,{base64_image.decode('utf-8')}"
)
except Exception:
# Ignore errors getting the photo
pass
user = User(
identifier=azure_user["userPrincipalName"],
metadata={
"image": azure_user.get("image"),
"provider": "azure-ad",
"refresh_token": getattr(self, "_refresh_token", None),
},
)
return (azure_user, user)
class OktaOAuthProvider(OAuthProvider):
id = "okta"
env = [
"OAUTH_OKTA_CLIENT_ID",
"OAUTH_OKTA_CLIENT_SECRET",
"OAUTH_OKTA_DOMAIN",
]
# Avoid trailing slash in domain if supplied
domain = f"https://{os.environ.get('OAUTH_OKTA_DOMAIN', '').rstrip('/')}"
def __init__(self):
self.client_id = os.environ.get("OAUTH_OKTA_CLIENT_ID")
self.client_secret = os.environ.get("OAUTH_OKTA_CLIENT_SECRET")
self.authorization_server_id = os.environ.get(
"OAUTH_OKTA_AUTHORIZATION_SERVER_ID", ""
)
self.authorize_url = (
f"{self.domain}/oauth2{self.get_authorization_server_path()}/v1/authorize"
)
self.authorize_params = {
"response_type": "code",
"scope": "openid profile email",
"response_mode": "query",
}
if prompt := self.get_prompt():
self.authorize_params["prompt"] = prompt
def get_authorization_server_path(self):
if not self.authorization_server_id:
return "/default"
if self.authorization_server_id == "false":
return ""
return f"/{self.authorization_server_id}"
async def get_raw_token_response(self, code: str, url: str) -> dict:
payload = {
"client_id": self.client_id,
"client_secret": self.client_secret,
"code": code,
"grant_type": "authorization_code",
"redirect_uri": url,
}
async with httpx.AsyncClient() as client:
response = await client.post(
f"{self.domain}/oauth2{self.get_authorization_server_path()}/v1/token",
data=payload,
)
response.raise_for_status()
return response.json()
async def get_token(self, code: str, url: str):
json_data = await self.get_raw_token_response(code, url)
token = json_data.get("access_token")
if not token:
raise HTTPException(status_code=400, detail=ACCESS_TOKEN_MISSING)
return token
async def get_user_info(self, token: str):
async with httpx.AsyncClient() as client:
response = await client.get(
f"{self.domain}/oauth2{self.get_authorization_server_path()}/v1/userinfo",
headers={"Authorization": f"Bearer {token}"},
)
response.raise_for_status()
okta_user = response.json()
user = User(
identifier=okta_user.get("email"),
metadata={"image": "", "provider": "okta"},
)
return (okta_user, user)
class Auth0OAuthProvider(OAuthProvider):
id = "auth0"
env = ["OAUTH_AUTH0_CLIENT_ID", "OAUTH_AUTH0_CLIENT_SECRET", "OAUTH_AUTH0_DOMAIN"]
def __init__(self):
self.client_id = os.environ.get("OAUTH_AUTH0_CLIENT_ID")
self.client_secret = os.environ.get("OAUTH_AUTH0_CLIENT_SECRET")
# Ensure that the domain does not have a trailing slash
self.domain = f"https://{os.environ.get('OAUTH_AUTH0_DOMAIN', '').rstrip('/')}"
self.original_domain = (
f"https://{os.environ.get('OAUTH_AUTH0_ORIGINAL_DOMAIN').rstrip('/')}"
if os.environ.get("OAUTH_AUTH0_ORIGINAL_DOMAIN")
else self.domain
)
self.authorize_url = f"{self.domain}/authorize"
self.authorize_params = {
"response_type": "code",
"scope": "openid profile email",
"audience": f"{self.original_domain}/userinfo",
}
if prompt := self.get_prompt():
self.authorize_params["prompt"] = prompt
async def get_raw_token_response(self, code: str, url: str) -> dict:
payload = {
"client_id": self.client_id,
"client_secret": self.client_secret,
"code": code,
"grant_type": "authorization_code",
"redirect_uri": url,
}
async with httpx.AsyncClient() as client:
response = await client.post(
f"{self.domain}/oauth/token",
data=payload,
)
response.raise_for_status()
return response.json()
async def get_token(self, code: str, url: str):
json_content = await self.get_raw_token_response(code, url)
token = json_content.get("access_token")
if not token:
raise HTTPException(status_code=400, detail=ACCESS_TOKEN_MISSING)
return token
async def get_user_info(self, token: str):
async with httpx.AsyncClient() as client:
response = await client.get(
f"{self.original_domain}/userinfo",
headers={"Authorization": f"Bearer {token}"},
)
response.raise_for_status()
auth0_user = response.json()
user = User(
identifier=auth0_user.get("email"),
metadata={
"image": auth0_user.get("picture", ""),
"provider": "auth0",
},
)
return (auth0_user, user)
class DescopeOAuthProvider(OAuthProvider):
id = "descope"
env = ["OAUTH_DESCOPE_CLIENT_ID", "OAUTH_DESCOPE_CLIENT_SECRET"]
# Ensure that the domain does not have a trailing slash
domain = "https://api.descope.com/oauth2/v1"
authorize_url = f"{domain}/authorize"
def __init__(self):
self.client_id = os.environ.get("OAUTH_DESCOPE_CLIENT_ID")
self.client_secret = os.environ.get("OAUTH_DESCOPE_CLIENT_SECRET")
self.authorize_params = {
"response_type": "code",
"scope": "openid profile email",
"audience": f"{self.domain}/userinfo",
}
if prompt := self.get_prompt():
self.authorize_params["prompt"] = prompt
async def get_raw_token_response(self, code: str, url: str) -> dict:
payload = {
"client_id": self.client_id,
"client_secret": self.client_secret,
"code": code,
"grant_type": "authorization_code",
"redirect_uri": url,
}
async with httpx.AsyncClient() as client:
response = await client.post(
f"{self.domain}/token",
data=payload,
)
response.raise_for_status()
return response.json()
async def get_token(self, code: str, url: str):
json_content = await self.get_raw_token_response(code, url)
token = json_content.get("access_token")
if not token:
raise HTTPException(status_code=400, detail=ACCESS_TOKEN_MISSING)
return token
async def get_user_info(self, token: str):
async with httpx.AsyncClient() as client:
response = await client.get(
f"{self.domain}/userinfo", headers={"Authorization": f"Bearer {token}"}
)
response.raise_for_status() # This will raise an exception for 4xx/5xx responses
descope_user = response.json()
user = User(
identifier=descope_user.get("email"),
metadata={"image": "", "provider": "descope"},
)
return (descope_user, user)
class AWSCognitoOAuthProvider(OAuthProvider):
id = "aws-cognito"
env = [
"OAUTH_COGNITO_CLIENT_ID",
"OAUTH_COGNITO_CLIENT_SECRET",
"OAUTH_COGNITO_DOMAIN",
]
authorize_url = f"https://{os.environ.get('OAUTH_COGNITO_DOMAIN')}/login"
token_url = f"https://{os.environ.get('OAUTH_COGNITO_DOMAIN')}/oauth2/token"
def __init__(self):
self.client_id = os.environ.get("OAUTH_COGNITO_CLIENT_ID")
self.client_secret = os.environ.get("OAUTH_COGNITO_CLIENT_SECRET")
self.scopes = os.environ.get("OAUTH_COGNITO_SCOPE", "openid profile email")
self.authorize_params = {
"response_type": "code",
"client_id": self.client_id,
"scope": self.scopes,
}
if prompt := self.get_prompt():
self.authorize_params["prompt"] = prompt
async def get_raw_token_response(self, code: str, url: str) -> dict:
payload = {
"client_id": self.client_id,
"client_secret": self.client_secret,
"code": code,
"grant_type": "authorization_code",
"redirect_uri": url,
}
async with httpx.AsyncClient() as client:
response = await client.post(
self.token_url,
data=payload,
)
response.raise_for_status()
return response.json()
async def get_token(self, code: str, url: str):
json = await self.get_raw_token_response(code, url)
token = json.get("access_token")
if not token:
raise HTTPException(status_code=400, detail=ACCESS_TOKEN_MISSING)
return token
async def get_user_info(self, token: str):
user_info_url = (
f"https://{os.environ.get('OAUTH_COGNITO_DOMAIN')}/oauth2/userInfo"
)
async with httpx.AsyncClient() as client:
response = await client.get(
user_info_url,
headers={"Authorization": f"Bearer {token}"},
)
response.raise_for_status()
cognito_user = response.json()
# Customize user metadata as needed
user = User(
identifier=cognito_user["email"],
metadata={
"image": cognito_user.get("picture", ""),
"provider": "aws-cognito",
},
)
return (cognito_user, user)
class GitlabOAuthProvider(OAuthProvider):
id = "gitlab"
env = [
"OAUTH_GITLAB_CLIENT_ID",
"OAUTH_GITLAB_CLIENT_SECRET",
"OAUTH_GITLAB_DOMAIN",
]
def __init__(self):
self.client_id = os.environ.get("OAUTH_GITLAB_CLIENT_ID")
self.client_secret = os.environ.get("OAUTH_GITLAB_CLIENT_SECRET")
# Ensure that the domain does not have a trailing slash
self.domain = f"https://{os.environ.get('OAUTH_GITLAB_DOMAIN', '').rstrip('/')}"
self.authorize_url = f"{self.domain}/oauth/authorize"
self.authorize_params = {
"scope": "openid profile email",
"response_type": "code",
}
if prompt := self.get_prompt():
self.authorize_params["prompt"] = prompt
async def get_raw_token_response(self, code: str, url: str) -> dict:
payload = {
"client_id": self.client_id,
"client_secret": self.client_secret,
"code": code,
"grant_type": "authorization_code",
"redirect_uri": url,
}
async with httpx.AsyncClient() as client:
response = await client.post(
f"{self.domain}/oauth/token",
data=payload,
)
response.raise_for_status()
return response.json()
async def get_token(self, code: str, url: str):
json_content = await self.get_raw_token_response(code, url)
token = json_content.get("access_token")
if not token:
raise HTTPException(status_code=400, detail=ACCESS_TOKEN_MISSING)
return token
async def get_user_info(self, token: str):
async with httpx.AsyncClient() as client:
response = await client.get(
f"{self.domain}/oauth/userinfo",
headers={"Authorization": f"Bearer {token}"},
)
response.raise_for_status()
gitlab_user = response.json()
user = User(
identifier=gitlab_user.get("email"),
metadata={
"image": gitlab_user.get("picture", ""),
"provider": "gitlab",
},
)
return (gitlab_user, user)
class KeycloakOAuthProvider(OAuthProvider):
env = [
"OAUTH_KEYCLOAK_CLIENT_ID",
"OAUTH_KEYCLOAK_CLIENT_SECRET",
"OAUTH_KEYCLOAK_REALM",
"OAUTH_KEYCLOAK_BASE_URL",
]
id = os.environ.get("OAUTH_KEYCLOAK_NAME", "keycloak")
def __init__(self):
self.refresh_token = None
self.client_id = os.environ.get("OAUTH_KEYCLOAK_CLIENT_ID")
self.client_secret = os.environ.get("OAUTH_KEYCLOAK_CLIENT_SECRET")
self.realm = os.environ.get("OAUTH_KEYCLOAK_REALM")
self.base_url = os.environ.get("OAUTH_KEYCLOAK_BASE_URL")
self.authorize_url = (
f"{self.base_url}/realms/{self.realm}/protocol/openid-connect/auth"
)
self.authorize_params = {
"scope": "profile email openid",
"response_type": "code",
}
if prompt := self.get_prompt():
self.authorize_params["prompt"] = prompt
async def get_raw_token_response(self, code: str, url: str) -> dict:
payload = {
"client_id": self.client_id,
"client_secret": self.client_secret,
"code": code,
"grant_type": "authorization_code",
"redirect_uri": url,
}
async with httpx.AsyncClient() as client:
response = await client.post(
f"{self.base_url}/realms/{self.realm}/protocol/openid-connect/token",
data=payload,
)
response.raise_for_status()
return response.json()
async def get_token(self, code: str, url: str):
json = await self.get_raw_token_response(code, url)
token = json.get("access_token")
refresh_token = json.get("refresh_token")
if not token:
raise HTTPException(status_code=400, detail=ACCESS_TOKEN_MISSING)
self.refresh_token = refresh_token
return token
async def get_user_info(self, token: str):
async with httpx.AsyncClient() as client:
response = await client.get(
f"{self.base_url}/realms/{self.realm}/protocol/openid-connect/userinfo",
headers={"Authorization": f"Bearer {token}"},
)
response.raise_for_status()
kc_user = response.json()
user = User(
identifier=kc_user["email"],
metadata={"provider": "keycloak"},
)
return (kc_user, user)
class GenericOAuthProvider(OAuthProvider):
env = [
"OAUTH_GENERIC_CLIENT_ID",
"OAUTH_GENERIC_CLIENT_SECRET",
"OAUTH_GENERIC_AUTH_URL",
"OAUTH_GENERIC_TOKEN_URL",
"OAUTH_GENERIC_USER_INFO_URL",
"OAUTH_GENERIC_SCOPES",
]
id = os.environ.get("OAUTH_GENERIC_NAME", "generic")
def __init__(self):
self.client_id = os.environ.get("OAUTH_GENERIC_CLIENT_ID")
self.client_secret = os.environ.get("OAUTH_GENERIC_CLIENT_SECRET")
self.authorize_url = os.environ.get("OAUTH_GENERIC_AUTH_URL")
self.token_url = os.environ.get("OAUTH_GENERIC_TOKEN_URL")
self.user_info_url = os.environ.get("OAUTH_GENERIC_USER_INFO_URL")
self.scopes = os.environ.get("OAUTH_GENERIC_SCOPES")
self.user_identifier = os.environ.get("OAUTH_GENERIC_USER_IDENTIFIER", "email")
self.authorize_params = {
"scope": self.scopes,
"response_type": "code",
}
if prompt := self.get_prompt():
self.authorize_params["prompt"] = prompt
async def get_raw_token_response(self, code: str, url: str) -> dict:
payload = {
"client_id": self.client_id,
"client_secret": self.client_secret,
"code": code,
"grant_type": "authorization_code",
"redirect_uri": url,
}
async with httpx.AsyncClient() as client:
response = await client.post(self.token_url, data=payload)
response.raise_for_status()
return response.json()
async def get_token(self, code: str, url: str) -> str:
json = await self.get_raw_token_response(code, url)
token = json.get("access_token")
if not token:
raise HTTPException(status_code=400, detail=ACCESS_TOKEN_MISSING)
return token
async def get_user_info(self, token: str):
async with httpx.AsyncClient() as client:
response = await client.get(
self.user_info_url,
headers={"Authorization": f"Bearer {token}"},
)
response.raise_for_status()
server_user = response.json()
user = User(
identifier=server_user.get(self.user_identifier),
metadata={
"provider": self.id,
},
)
return (server_user, user)
providers = [
GithubOAuthProvider(),
GoogleOAuthProvider(),
AzureADOAuthProvider(),
AzureADHybridOAuthProvider(),
OktaOAuthProvider(),
Auth0OAuthProvider(),
DescopeOAuthProvider(),
AWSCognitoOAuthProvider(),
GitlabOAuthProvider(),
KeycloakOAuthProvider(),
GenericOAuthProvider(),
]
def get_oauth_provider(provider: str) -> Optional[OAuthProvider]:
for p in providers:
if p.id == provider:
return p
return None
def get_configured_oauth_providers():
return [p.id for p in providers if p.is_configured()]
+53
View File
@@ -0,0 +1,53 @@
import asyncio
from typing import Union
from literalai import ChatGeneration, CompletionGeneration
from chainlit.context import local_steps
from chainlit.step import Step
from chainlit.utils import check_module_version, timestamp_utc
def instrument_openai():
if not check_module_version("openai", "1.0.0"):
raise ValueError(
"Expected OpenAI version >= 1.0.0. Run `pip install openai --upgrade`"
)
from literalai.instrumentation.openai import instrument_openai
def on_new_generation(
generation: Union["ChatGeneration", "CompletionGeneration"], timing
):
previous_steps = local_steps.get()
parent_id = previous_steps[-1].id if previous_steps else None
step = Step(
name=generation.model or generation.provider,
type="llm",
parent_id=parent_id,
)
step.generation = generation
# Convert start/end time from seconds to milliseconds
step.start = (
timestamp_utc(timing.get("start"))
if timing.get("start", None) is not None
else None
)
step.end = (
timestamp_utc(timing.get("end"))
if timing.get("end", None) is not None
else None
)
if isinstance(generation, ChatGeneration):
step.input = generation.messages # type: ignore
step.output = generation.message_completion # type: ignore
else:
step.input = generation.prompt # type: ignore
step.output = generation.completion # type: ignore
asyncio.create_task(step.send())
instrument_openai(None, on_new_generation)
View File
+12
View File
@@ -0,0 +1,12 @@
# This is a simple example of a chainlit app.
from chainlit import AskUserMessage, Message, on_chat_start
@on_chat_start
async def main():
res = await AskUserMessage(content="What is your name?", timeout=30).send()
if res:
await Message(
content=f"Your name is: {res['output']}.\nChainlit installation is working!\nYou can now start building your own chainlit apps!",
).send()
+59
View File
@@ -0,0 +1,59 @@
from typing import Optional
import chainlit as cl
@cl.set_starter_categories
async def starter_categories(user: Optional[cl.User] = None):
return [
cl.StarterCategory(
label="Creative",
icon="https://cdn-icons-png.flaticon.com/512/3094/3094837.png",
starters=[
cl.Starter(
label="Write a poem about nature",
message="Write a poem about nature",
),
cl.Starter(
label="Create a short story",
message="Create a short story about adventure",
),
cl.Starter(
label="Generate a creative name",
message="Generate creative names for a tech startup",
),
],
),
cl.StarterCategory(
label="Learning",
icon="https://cdn-icons-png.flaticon.com/512/3976/3976625.png",
starters=[
cl.Starter(
label="Explain a complex topic",
message="Explain quantum computing in simple terms",
),
cl.Starter(
label="Help me learn a language",
message="Teach me basic French phrases",
),
],
),
cl.StarterCategory(
label="Productivity",
icon="https://cdn-icons-png.flaticon.com/512/1055/1055646.png",
starters=[
cl.Starter(
label="Summarize a topic",
message="Summarize the key points of machine learning",
),
cl.Starter(
label="Create a plan", message="Help me create a weekly study plan"
),
],
),
]
@cl.on_message
async def on_message(msg: cl.Message):
await cl.Message(f"You said: {msg.content}").send()
+9
View File
@@ -0,0 +1,9 @@
import secrets
import string
# Using punctuation, without chars that can break in the cli (quotes, backslash, backtick...)
chars = string.ascii_letters + string.digits + "$%*,-./:=>?@^_~"
def random_secret(length: int = 64):
return "".join(secrets.choice(chars) for i in range(length))

Some files were not shown because too many files have changed in this diff Show More