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
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:
Symlink
+1
@@ -0,0 +1 @@
|
||||
../.shared/agents
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../.shared/commands
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../.shared/rules
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../.shared/skills
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../.shared/agents
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../.shared/commands
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../.shared/rules
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"plugins": {
|
||||
"superpowers": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../.shared/skills
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
* text=auto
|
||||
@@ -0,0 +1 @@
|
||||
* @hayescode @asvishnyakov @sandangel
|
||||
@@ -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.
|
||||
@@ -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 }}
|
||||
@@ -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)
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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
|
||||
@@ -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'
|
||||
@@ -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
|
||||
@@ -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' || '' }}
|
||||
@@ -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 }}
|
||||
@@ -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
@@ -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
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
pnpm lint-staged
|
||||
@@ -0,0 +1,5 @@
|
||||
shared-workspace-lockfile=false
|
||||
public-hoist-pattern[]=*eslint*
|
||||
public-hoist-pattern[]=*prettier*
|
||||
public-hoist-pattern[]=@types*
|
||||
side-effects-cache=false
|
||||
@@ -0,0 +1 @@
|
||||
pnpm-lock.yaml
|
||||
+19
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/cache
|
||||
/project.local.yml
|
||||
@@ -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 read‑only.
|
||||
# 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: []
|
||||
@@ -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.
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,3 @@
|
||||
# Privacy Policy
|
||||
|
||||
Chainlit doesn't collect any data from its users after 2.6.1 release.
|
||||
@@ -0,0 +1,7 @@
|
||||
# WeHub 来源说明
|
||||
|
||||
- 原始项目:`Chainlit/chainlit`
|
||||
- 原始仓库:https://github.com/Chainlit/chainlit
|
||||
- 导入方式:上游默认分支的最新快照
|
||||
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
|
||||
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
|
||||
@@ -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
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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()
|
||||
@@ -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__
|
||||
@@ -0,0 +1,4 @@
|
||||
from chainlit.cli import cli
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli(prog_name="chainlit")
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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?
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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`"
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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})
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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`"
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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`"
|
||||
)
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()]
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
Reference in New Issue
Block a user