commit b7f52be4c9323bfb6554eebbbf575ecb08a85240 Author: wehub-resource-sync Date: Mon Jul 13 12:48:47 2026 +0800 chore: import upstream snapshot with attribution diff --git a/.claude/agents b/.claude/agents new file mode 120000 index 0000000..4149168 --- /dev/null +++ b/.claude/agents @@ -0,0 +1 @@ +../.shared/agents \ No newline at end of file diff --git a/.claude/commands b/.claude/commands new file mode 120000 index 0000000..f05ce05 --- /dev/null +++ b/.claude/commands @@ -0,0 +1 @@ +../.shared/commands \ No newline at end of file diff --git a/.claude/rules b/.claude/rules new file mode 120000 index 0000000..4806968 --- /dev/null +++ b/.claude/rules @@ -0,0 +1 @@ +../.shared/rules \ No newline at end of file diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..4b2c156 --- /dev/null +++ b/.claude/settings.json @@ -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 + } +} diff --git a/.claude/skills b/.claude/skills new file mode 120000 index 0000000..73d4acd --- /dev/null +++ b/.claude/skills @@ -0,0 +1 @@ +../.shared/skills \ No newline at end of file diff --git a/.cursor/agents b/.cursor/agents new file mode 120000 index 0000000..4149168 --- /dev/null +++ b/.cursor/agents @@ -0,0 +1 @@ +../.shared/agents \ No newline at end of file diff --git a/.cursor/commands b/.cursor/commands new file mode 120000 index 0000000..f05ce05 --- /dev/null +++ b/.cursor/commands @@ -0,0 +1 @@ +../.shared/commands \ No newline at end of file diff --git a/.cursor/rules b/.cursor/rules new file mode 120000 index 0000000..4806968 --- /dev/null +++ b/.cursor/rules @@ -0,0 +1 @@ +../.shared/rules \ No newline at end of file diff --git a/.cursor/settings.json b/.cursor/settings.json new file mode 100644 index 0000000..36b564b --- /dev/null +++ b/.cursor/settings.json @@ -0,0 +1,7 @@ +{ + "plugins": { + "superpowers": { + "enabled": true + } + } +} diff --git a/.cursor/skills b/.cursor/skills new file mode 120000 index 0000000..73d4acd --- /dev/null +++ b/.cursor/skills @@ -0,0 +1 @@ +../.shared/skills \ No newline at end of file diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..506ea97 --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..176a458 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..1e78668 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @hayescode @asvishnyakov @sandangel \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..9778b62 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -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. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..8b8c3fa --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -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. diff --git a/.github/actions/pnpm-node-install/action.yaml b/.github/actions/pnpm-node-install/action.yaml new file mode 100644 index 0000000..413fe1f --- /dev/null +++ b/.github/actions/pnpm-node-install/action.yaml @@ -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 diff --git a/.github/actions/uv-python-install/action.yaml b/.github/actions/uv-python-install/action.yaml new file mode 100644 index 0000000..c276f10 --- /dev/null +++ b/.github/actions/uv-python-install/action.yaml @@ -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 }} diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..3d8e359 --- /dev/null +++ b/.github/copilot-instructions.md @@ -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) diff --git a/.github/workflows/check-backend.yaml b/.github/workflows/check-backend.yaml new file mode 100644 index 0000000..aba32d4 --- /dev/null +++ b/.github/workflows/check-backend.yaml @@ -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 }} diff --git a/.github/workflows/check-frontend.yaml b/.github/workflows/check-frontend.yaml new file mode 100644 index 0000000..90fbefb --- /dev/null +++ b/.github/workflows/check-frontend.yaml @@ -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 }} diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..199a730 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -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 diff --git a/.github/workflows/close_stale.yml b/.github/workflows/close_stale.yml new file mode 100644 index 0000000..d87c595 --- /dev/null +++ b/.github/workflows/close_stale.yml @@ -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 }} diff --git a/.github/workflows/copilot-setup-steps.yaml b/.github/workflows/copilot-setup-steps.yaml new file mode 100644 index 0000000..dfd8f63 --- /dev/null +++ b/.github/workflows/copilot-setup-steps.yaml @@ -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' diff --git a/.github/workflows/e2e-tests.yaml b/.github/workflows/e2e-tests.yaml new file mode 100644 index 0000000..d6ad783 --- /dev/null +++ b/.github/workflows/e2e-tests.yaml @@ -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 diff --git a/.github/workflows/publish-libs.yaml b/.github/workflows/publish-libs.yaml new file mode 100644 index 0000000..de1a646 --- /dev/null +++ b/.github/workflows/publish-libs.yaml @@ -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' || '' }} diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml new file mode 100644 index 0000000..a036872 --- /dev/null +++ b/.github/workflows/publish.yaml @@ -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 }} diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml new file mode 100644 index 0000000..826bcec --- /dev/null +++ b/.github/workflows/tests.yaml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fe6668e --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000..cb2c84d --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +pnpm lint-staged diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..0c34973 --- /dev/null +++ b/.npmrc @@ -0,0 +1,5 @@ +shared-workspace-lockfile=false +public-hoist-pattern[]=*eslint* +public-hoist-pattern[]=*prettier* +public-hoist-pattern[]=@types* +side-effects-cache=false diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..bd5535a --- /dev/null +++ b/.prettierignore @@ -0,0 +1 @@ +pnpm-lock.yaml diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..a1e8d46 --- /dev/null +++ b/.prettierrc @@ -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 +} diff --git a/.serena/.gitignore b/.serena/.gitignore new file mode 100644 index 0000000..2e510af --- /dev/null +++ b/.serena/.gitignore @@ -0,0 +1,2 @@ +/cache +/project.local.yml diff --git a/.serena/project.yml b/.serena/project.yml new file mode 100644 index 0000000..a7cb1e6 --- /dev/null +++ b/.serena/project.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: [] diff --git a/.shared/agents/.keep b/.shared/agents/.keep new file mode 100644 index 0000000..e69de29 diff --git a/.shared/commands/.keep b/.shared/commands/.keep new file mode 100644 index 0000000..e69de29 diff --git a/.shared/rules/.keep b/.shared/rules/.keep new file mode 100644 index 0000000..e69de29 diff --git a/.shared/skills/karpathy-guidelines/SKILL.md b/.shared/skills/karpathy-guidelines/SKILL.md new file mode 100644 index 0000000..029e4d5 --- /dev/null +++ b/.shared/skills/karpathy-guidelines/SKILL.md @@ -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. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..1f712fb --- /dev/null +++ b/AGENTS.md @@ -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: `(): `. + +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: +``` + +Examples: + +- `Co-Authored-By: Claude Opus 4.6 ` +- `Co-Authored-By: GitHub Copilot ` +- `Co-Authored-By: Gemini CLI ` + +## 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 `` 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. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d561141 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,1859 @@ +# Changelog + +All notable changes to Chainlit will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). + +## [2.11.1] - 2026-04-22 + +### Added + +- Pass `chat_profile` to `set_starter_categories` callback so starters can vary by profile + +### Fixed + +- Guard `on_chat_start` against duplicate dispatch on WebSocket reconnect +- Check `langchain-core` version instead of `langchain` for callback compatibility +- Resolve base locale codes to regional translation files (e.g. `da` → `da-DK`) + +### Changed + +- Refactor dev scripts for streamlined local development +- Upgrade Prettier and Ruff and auto-format missed files + +## [2.11.0] - 2026-04-07 + +### Added + +- Add copilot sidebar mode with drag-to-resize and persisted display preferences +- Add `polars.DataFrame` support for `Dataframe` elements +- Replace the iframe PDF preview with an improved built-in PDF viewer +- Allow `Step` messages to use Lucide icons instead of the default avatar +- Make the stop icon and loading cursor customizable via CSS variables +- Add Portuguese (`pt-PT`) translations +- Make Azure AD OAuth scopes configurable via environment variables +- Add `ChatSettings.refresh()` so `on_settings_edit` can refresh widgets without committing session settings + +### Fixed + +- Sort thread history month categories by actual date and locale +- Clear pending attachments when switching chat profiles +- Handle Slack workflow and bot `app_mention` events correctly +- Show error toasts when Streamable HTTP MCP connections fail +- Harden MCP cleanup to avoid cross-task cancel-scope errors during connect, disconnect, and reconnect +- Respect disabled state for date picker, multiselect, tags, and radio chat settings widgets +- Restore chat settings values and schema correctly after live edits, reset, or cancel +- Send slash-command input on Enter when no registered command matches +- Preserve live favorite message state and fix accordion closing behavior +- Respect explicit Plotly figure heights in rendered elements +- Make `send_toast()` asynchronous and improve LangChain 1.x cache compatibility +- Prevent thread metadata `NULL` violations while preserving metadata merges in built-in data layers +- Treat an empty `custom_fonts` array as removing default fonts +- Use the correct logging format specifier during OAuth state validation + +### Changed + +- Widen dependency compatibility for OpenAI 2.x, LangChain 1.x, Transformers 5.x, Plotly 6.x, and related packages +- Upgrade repo linting to ESLint 10 and make `pdfjs-dist` an explicit frontend dependency + +## [2.10.1] - 2026-03-27 + +### Fixed + +- Security vulnerability in Chainlit: validate WebSocket session restore against the authenticated user + +### Changed + +- Move npm library publishing to OIDC Trusted Publishing + +## [2.10.0] - 2026-03-05 + +### Added + +- Add starter categories for grouped starters +- Always show the favorite messages button with an empty state +- Add option to disable rendering markdown in user messages +- Allow easy deletion of favorites +- Make state cookie lifetime configurable via env var +- Add Arabic translation +- Add Danish translation +- Add settings change listener +- Add image preview +- Add selected option for command pre-selection +- Add `auto_collapse` parameter to `Step` +- Add `/health` endpoint for container orchestration +- Add `hidden` option for `default_sidebar_state` +- Make avatar size configurable via `config.toml` + +### Fixed + +- Reorder chat history sidebar after messages in existing chats +- Use login error detail for credential failures +- Convert UUID fields to strings in feedback extraction +- Preserve thread metadata when updated without metadata +- Reset audio UI when microphone permission is denied +- Fix sidebar inset overflow causing horizontal scroll +- Prevent empty strings from overwriting step content on upsert +- Use correct URL scheme when SSL is configured + +## [2.9.6] - 2026-01-20 + +### Added + +- Allow skip new chat creation +- Add data picker input widget +- Toggle chat settings in sidebar instead of composer + +### Fixed + +- Fix: Starters now correctly use the selected/default mode if configured + +## [2.9.5] - 2026-01-08 + +### Added + +- Add favorite messages (prompt templates) + +### Fixed + +- Fix: Starters now correctly use the selected/default mode if configured + +## [2.9.4] - 2025-12-24 + +### Added + +- Add an icon for shared thread +- New option to allow disabling auto scroll of assistant messages +- Add modes: you may allow users to select an LLM model, a mode (for example, planning), allow to enable reasoning etc. + - Breaking change: you need to run `ALTER TABLE steps ADD COLUMN IF NOT EXISTS modes JSONB;` for migration + +### Fixed + +- Fix tiny avatar for long messages +- Security vulnerability in Chainlit: added missed sanitization to custom elements update endpoint + +### Changed + +- Bumped watchfiles version + +## [2.9.3] - 2025-12-04 + +### Added + +- Add tests for oauth providers and messages +- Merge metadata in chainlit data layer +- Add native video support in markdown rendering +- Optimize chat message rendering +- Add language configuration option to config.toml +- Upgrade langchain imports for v1 compatibility +- Improve icon name formatting issues + +### Fixed + +- Fixed page blinking issue with header_auth +- Set environ when restoring websocket session +- Move hello.py to avoid import issues +- Fix issue showing thread sharing when disabled +- Disable Chainlit from setting logging globally + +## [2.9.2] - 2025-11-22 + +### Added + +- Add tests for socket, chat context, cache, translations & oauth providers + +### Fixed + +- Fix copilot breaking change introduced in 2.8.5 + +## [2.9.1] - 2025-11-20 + +### Added + +- Add support for tabs in chat settings +- Support markdown in watermark +- Add italian translation to translations folder +- Add query param prefill for chat +- Add tests for utils, markdown, sidebar, chat settings, mcp, input widget, langchain, elements, steps, and actions + +## [2.9.0] - 2025-11-06 + +### Added + +- Add better support for Multi-Agent implementations + - Nested steps are now step.input -> child step -> step.output + - Improved formatting and styling of Tasklist + +## [2.8.5] - 2025-11-07 + +### Added + +- Add display_name to ChatProfile +- Add slack reaction event callback +- Add raw response from OAuth providers + +### Fixed + +- Security vulnerability in Chainlint: added missed ACL check for session initialization + +### Changed + +- Remove FastAPI version restrictions + +## [2.8.4] - 2025-10-29 + +### Added + +- Add support for GitHub Enterprise OAuth provider +- Explicit disable on input widgets + +### Fixed + +- Tasklist tasks are now properly reconnected to their steps/messages +- ci: fix pnpm publish checks +- fix: missing / in url with base path when connecting Streamable HTTP MCP +- fix - persist custom_elements to data layer without cloud storage +- fix: propagate IME composition events in AutoResizeTextarea +- fix: confirm when enter +- Fix(translation): correct French translation of chat watermark +- fix(ui): add fallback logo if custom logo is missing + +## [2.8.3] - 2025-10-06 + +### Added + +- Support for the `target` attribute in header links, which can be configured through the configuration options + +### Changed + +- `@chainlit/react-client` automatic publishing + +## [2.8.2] - 2025-10-01 + +### Changed + +- Remove autofocus in mobile message composer +- Improve error handling in sqlalchemy data layer `get_read_url()` + +### Fixed + +- Fix voice hotkey (P) triggering when typing in chat input +- Properly finalize data layers +- Fix `on_chat_start` not always firing + +## [2.8.1] - 2025-09-24 + +### Added + +- Add German and Korean translations +- Add support for custom_meta_url in config.toml + +### Changed + +- `cl.on_thread_share_view` will allow shared thread viewing if it returns `True` to enable custom/admin viewing. + +### Fixed + +- Removed redundant message sending in Slack when images are present. +- Generate signed url when loading elements using SQLAlchemy data layer. + +## [2.8.0] - 2025-09-12 + +### Added + +- Add ability to share threads. See documentation for how to enable it. + - https://docs.chainlit.io/api-reference/lifecycle-hooks/on-shared-thread-view +- Add new chat settings: multi-select, radio-group, and checkbox +- Add optional language parameter to set_starters +- Add neutral Spanish translation +- Allow sending commands from custom elements + +### Changed + +- Reordered message composer elements + +### Fixed + +- Default to plaintext code blocks for unsupported languages like CSV +- Sort threads by updated_at field +- Replace hardcoded strings with translation keys +- GCP storage provider dependency is now optional +- CI/CD fixes +- Fixed issues with hot-reloading in dev mode (`-w` flag) +- Take overridden config into account in audio handlers + +## [2.7.2] - 2025-08-26 + +### Added + +- Added LiteralAI data layer deprecation warning +- Added context to `@cl.on_feedback` callback +- Added Traditional Chinese (Taiwan) translations +- Added configurable user_env persistence to database + - New `persist_user_env` and `mask_user_env` field in `config.toml` +- Added new command translations to all languages +- Added CODEOWNERS + +### Fixed + +- Improved dynamic config overrides for chat profiles +- Import GCSStorageClient only when needed to avoid requiring optional dependencies +- Updated CONTRIBUTING.md for `uv` usage + +## [2.7.1.1] - 2025-08-21 + +- Fix publishing to include frontend and copilot folders + +## [2.7.1] - 2025-08-20 + +- Fix publishing to work with uv + +## [2.7.0] - 2025-08-20 + +### Added + +- New ChatGPT-style command selection and improve message input handling +- Added the ability to override certain config.toml settings for Chat Profiles, so some profiles can have MCP and some can't for example. [Documentation Updated](https://docs.chainlit.io/api-reference/chat-profiles#dynamic-configuration). + - You must now explicity enable audio and MCP as these are no longer inferred by the presence of `on_audio_start` or `on_mcp_connect` callbacks + - Delete your `config.toml`, run `chainlit init`, and update your settings +- Added copilot setup instructions for GitHub Copilot SWE Agent +- Added Slack socket mode support +- AskFileButton can now upload file with proper checking and it's own limits +- Added content-disposition metadata to azure blob uploads to persist download file name +- Migrated from poetry to uv + +### Fixed + +- Changed thread sorting to use updated time instead of creation time +- Add missing headers when connecting Streamable HTTP MCP +- Remove undocumented `CHAINLIT_CUSTOM_AUTH` environment variable used in Copilot + +## [2.6.9] - 2025-08-14 + +### Added + +- Add GitHub Copilot instructions for automated PRs +- (Slack) Add threadId for user feedback +- (Copilot) Add new optional opened property has been added to the widget config + +### Fixed + +- Fix blinking cursor indicator +- (Copilot) Rename copilot inner div id `chainlit-copilot` to `chainlit-copilot-chat` due to naming conflict with the outer div +- Disable gzip for websocket-relaed http endpoint (Safari compatibility) +- Prevent constant refresh on the login screen when using custom authenication +- Fix MCP type hints + +## [2.6.8] - 2025-08-08 + +### Other + +- Reverted PR with newline preservation in messages due to incorrect rendering in child components like lists + +## [2.6.7] - 2025-08-07 + +### Fixed + +- Formatting when pasting HTML code and newlines in received messages + +## [2.6.6] - 2025-08-05 + +### Added + +- Add support for emoji reaction on message received in Slack +- Add Greek translation +- Copy both plain text and rich text to clipboard, if available (rich text pasting to editors like Word) +- Rename `CHAINLIT_COOKIE_PATH` to `CHAINLIT_AUTH_COOKIE_PATH` and now espect CHAINLIT_ROOT_PATH +- Add language parameter to Copilot widget configuration + +### Fixed + +- Prevent HTML code in user message to be rendered as HTML instead of displaying as code +- Properly parse `user_env` when `config.project.user_env` is empty + +## [2.6.5] - 2025-08-02 + +### Fixed + +- Properly escape HTML on paste +- Enable gzip compression for frontend +- Address security vulnerabilities in dependencies by upgrading them to the closest safe versions +- CI e2e tests and pnpm cache issues + +## [2.6.4] - 2025-08-01 + +### Added + +- Add streamable HTTP MCP support +- Improve e2e test stability and performance +- Add configuration for expanded copilot mode +- Add French translation + +### Fixed + +- Fix inputs/outputs for langchain callbacks +- Fix blinking indicator for in-progress steps +- Avoid unnecessary logo fetching when supplied in config.toml + +### Other + +- Bump dependencies + +## [2.6.3] - 2025-07-25 + +### Added + +- Ability to send empty commands +- Wider element view in copilot and improved styling +- Support signed urls for elements using dynamoDB persistence +- Support additional connection arguments in SQLAlchemy data layer +- Added `CHAINLIT_COOKIE_PATH` environment variable to set the cookie path + +### Fixed + +- Message inputs formatting +- Language pattern to allow `tzm-Latn-DZ` +- Properly encode parentheses in markdown links +- Fix chainlit data layer metadata upserts +- Improve database connection handling +- Fixed cookie path +- Improve lanchain callbacks + +### Other + +- Improve robustness of E2E tests +- Removed watermark "Built with Chainlit" + +## [2.6.2] - 2025-07-16 + +Technical release due to missed `frontend` and `copilot` folders in previous one. + +## [2.6.1] - 2025-07-15 + +### Added + +- New `on_feedback` callback +- Relaxed restriction on number of starters (now more than 4 can be displayed) + +### Fixed + +- Command persistence when `"button": True` is missing from command definition +- `openai` and `mistralai` sub-modules fail due to incorrect `timestamp_utc` import +- Temporarily reverted fix caused the following issues with Chainlit data layer: + - `null value in column "metadata" of relation "Thread"` + - `syntax error at or near ";"` +- Google Cloud Storage private bucket support in Chainlit data layer +- Portals (popups, dialogs, etc.) now render correctly inside Copilot's shadow DOM + +### Other + +- Removed telemetry +- Updated versions for Node.js, Poetry, and pnpm; added Corepack support + +## [2.6.0] - 2025-07-01 + +### Added + +- Add commands to starters +- Collapse command buttons to icons for small screens +- Add timegated custom elements +- Added ADC support for google cloud storage adapter +- Added scope as env variable (`OAUTH_COGNITO_SCOPE`) to Cognito auth provider +- Add MarkdownAlert Style Switcher. Control via `alert_style` in `config.toml`. +- Allow custom s3 endpoint for the official data layer +- Added container prop to dialog portal in Copilot shadow DOM +- Bump dependencies +- Add python 3.13 support + +### Fixed + +- Fix chat input double-spacing issue +- Resolve python deprecation warning for utc_now() and logger.warn +- Fixed an issue where the portal for the ChatProfiles selector was being rendered outside the Copilot shadow DOM +- Add mime type to element emitter +- Handle float/Decimal conversion for DynamoDB persistence +- Fix cancel button in Chat settings +- Only update thread metadata when not empty + +### Breaking + +- **LiteralAI** is being sunset and will be removed in one of the next releases. Please migrate to the official data layer instead. +- Telemetry is now opt-in by default and will be removed in the next release. + +## [2.5.5] - 2025-04-14 + +### Added + +- Avatars now support `.` in their name (will be replaced with `_`). +- Typed session accessors for user session +- Allow set attributes for the tags of the custom_js or custom_css +- Hovering a past chat in the sidebar will display the full title of the chat in a tooltip +- The `X-Chainlit-Session-id` header is now automatically set to facilitate sticky sessions with websockets +- `cl.ErrorMessage` now have a different avatar +- The copy button is now only displayed on the final message of a run, like feedback buttons +- CopilotFunction is now usable in custom JS +- Header link now have an optional `display_name` to display text next to the icon +- The default .env file loaded by chainlit is now configurable with `CHAINLIT_ENV_FILE` + +### Changed + +- **[breaking]**: `http_referer`, `http_cookie` and `languages` are no longer directly available in the session object. Instead, `environ` is available containing all of those plus other HTTP headers +- The scroll to the bottom animation is now smooth + +## [2.4.400] - 2025-03-29 + +### Added + +- `@cl.on_app_startup` and `@cl.on_app_shutdown` +- Configuration option for chat history default open state +- Configuration option for login page background image and filter +- Most commonly customized ui elements now have specific IDs + +### Fixed + +- App should no longer flicker on load +- Attachments icons for microsoft files should now correctly display +- Pasting should no longer be duplicated + +## [2.4.302] - 2025-03-26 + +### Added + +- Add thinking token support to langchain callback handler + +### Fixed + +- Pasting issues in the chat input +- Rename nl-NL.json to nl.json + +## [2.4.301] - 2025-03-24 + +### Fixed + +- Mcp button should not be displayed if `@on_mcp_connect` is not defined + +## [2.4.3] - 2025-03-23 + +### Added + +- Canvas mode for the element side bar if title == `canvas` +- Allow list for MCP stdio commands +- `key` parameter to `ElementSidebar.set_elements` method + +### Fixed + +- Literal AI should now correctly store custom elements props +- Element should correctly load from azure storage +- Plotly elements should now take full width + +## [2.4.2] - 2025-03-19 + +### Added + +- Hide commands button if all commands are specified as button. + +### Fixed + +- Chat profiles tooltip should no longer freeze is hover rapidly + +## [2.4.1] - 2025-03-13 + +### Added + +- The user message auto scroll behavior is now a feature `config.features.user_message_autoscroll` +- Stdio MCP commands now support environment variables + +### Fixed + +- Submounting a Chainlit app to a FastAPI app with a root path should now work + +## [2.4.0] - 2025-03-11 + +### Changed + +- Chainlit now requires python `>=3.10` + +### Added + +- MCP support through `@cl.on_mcp_connect` and `@cl.on_mcp_disconnect` + +### Fixed + +- Pasting text/images into Chainlit Copilot should now work +- OAuth redirection should work when submounting Chainlit with root path `/` +- Successive AskUser messages should no longer collide + +### Removed + +- Outdated Haystack integration + +## [2.3.0] - 2025-03-09 + +### Added + +- New user messages are now placed/scrolled to the top of the chat to enhance readability +- Commands have a new optional boolean field `button` to turn them into buttons +- Custom elements have access to a new API `sendUserMessage` + +### Fixed + +- Chainlit app using a custom root path should now work correctly when running in docker containers +- Chat history time groups should now be sorted properly + +## [2.2.1] - 2025-02-14 + +### Added + +- `default_open` parameter to the step decorator/class + +### Fixed + +- Input should not replace <,>,& +- Starters should be disabled if no ws connection +- Prevent orphaned thread record when deleting active conversation + +## [2.2.0] - 2025-02-08 + +### Added + +- You can now add custom buttons in the header + +### Fixed + +- Step open/close is now animated +- prevent unstyled flash when streaming code blocks +- Docking/undocking scroll while streaming show now work better + +## [2.1.2] - 2025-02-05 + +### Fixed + +- The default loader should now be displayed if the chat is running and no response is yet sent +- Pasting HTML in the chat input show now work +- React warnings and accessibility issues +- Command filtering now works with `includes` instead of `startWith` +- The submit button should be disabled in the chat input is empty + +## [2.1.1] - 2025-02-03 + +### Fixed + +- Reintroduce including URL location after UI refactor +- Ensure SAS token start time is set to UTC +- Prevent showing 0's on resumed thread if AskAction/File was used +- Remove 22px element ref height +- Update Microsoft OAuth offline_access scope to be fully qualified with the prefix + +## [2.1.0] - 2025-01-30 + +### Added + +- You can now send toasts with `cl.context.emitter.send_toast` +- Markdown now supports alerts +- Theme options are now translatable +- Copilot can now load custom css + +### Fixed + +- Mounting Chainlit as a sub app should no longer break the parent's app endpoints +- Pasting text in the chat input should now remove extra formatting and preserve new lines + +## [2.0.603] - 2025-01-28 + +### Added + +- Data layer initialization to the telemetry + +### Fixed + +- Gap between the word `Used` and tool name in step name + +## [2.0.602] - 2025-01-27 + +### Fixed + +- Chat input should now auto focus +- When unfolding a step, the `Output` title should only show if there is an input to display + +## [2.0.601] - 2025-01-25 + +### Fixed + +- Element sidebar should take full height + +## [2.0.6] - 2025-01-24 + +### Added + +- The element sidebar is now controllable from the python code + +### Fixed + +- The auth cookie no longer has a maximal size +- Pasting text in the chat input should now work +- Long text in AskAction buttons are now gracefully displayed +- Server connection error translation path + +## [2.0.5] - 2025-01-21 + +### Added + +- Chat GPT like commands +- Translation options. The translation schema has been simplified + +### Fixed + +- Warnings around file upload mime types +- `uvicorn` and `packaging` version requirement have been relaxed + +## [2.0.4] - 2025-01-17 + +### Added + +- Overhaul element reference link styling +- Japanese translations +- Improved Chinese translations +- Translations for feedback buttons + +### Fixed + +- Cookie max age should now correctly use the config `user_session_timeout` field +- Thread grouping in the chat history should now correctly handle timezones +- File from `AskFileMessage` should now share ID with the data layer +- Data layer boolean casting issues +- Chat settings modal scrolling issue + +## [2.0.3] - 2025-01-14 + +### Added + +- `CustomElement.update()` to update a custom element props server side +- Translation for the copy button + +### Fixed + +- The official data layer should not overwrite elements anymore +- A bug where resuming a thread would not load the thread +- Prevent authentication before the app is fully loaded +- Installing Chainlit from github should work again +- `tool` steps should count as a thread start + +## [2.0.2] - 2025-01-10 + +### Added + +- `http_cookie` is now available in the user session and websocket session + +### Fixed + +- Chat profile description on the welcome screen now supports custom html and latex +- Thread history batch size has been increased to 35 to ensure scroll on a taller screens +- Chat settings modal should now scroll if too tall +- Errors in thread resume (like thread not found) now properly redirects to the the home page +- Elements like Dataframe, Plotly or text should now load correctly from cloud storages +- AskFileMessage is now usable even if spontaneous uploads are disabled +- Remove element objects from cloud storage on thread removal (Official & SQLAlchemy data layers) +- Fix custom element `props` storage for SQL Alchemy data layer + +## [2.0.1] - 2025-01-09 + +### Added + +- `window.toggleChainlitCopilot()` to toggle the copilot + +### Fixed + +- Chat profiles icon and description should now be displayed on the welcome screen +- Action should be able to trigger the first interaction +- Raw code blocks should now be displayed correctly +- TextInput for chat settings should now work +- Upload attachement button should not be displayed when upload is disabled +- Removed unused numpy dependency + +## [2.0.0] - 2025-01-06 + +The Chainlit UI (including the copilot) has been completely re-written with Shadcn/Tailwind. This brings several advantages: + +1. The codebase is simpler and more contribution friendly. +2. It enabled the new custom element feature. +3. The theme customisation is more powerful. + +### Added + +- Custom Elements (code your own elements) +- `Cmd+k` thread search +- Thread rename +- Official PostGres open source data layer +- New `@data_layer` decorator for configuring custom data layers declaratively + +### Changed + +- Authentication is now based on cookies. Cross Origins are disallowed unless added in `allow_origins` in the `config.toml` file +- No longer need to click on `resume` to resume a thread +- **[breaking]**: Theme customisation is now handled in `public/theme.json` instead of `config.toml`. +- **[breaking]**: Changed fields on the `Action` class: + - The `value` field has replaced with `payload` which accepts a Python dict + - The `description` field has been renamed `tooltip` + - The field `icon` has been added + - The `collapsed` field has been removed. +- **[breaking]**: Completely revamped audio implementation (#1401, #1410): + - Replaced `AudioChunk` with `InputAudioChunk` and `OutputAudioChunk` + - Changed default audio sampling rate from 44100 to 24000 + - Removed several audio configuration options (`min_decibels`, `initial_silence_timeout`, `silence_timeout`, `chunk_duration`, `max_duration`) + +### Fixed + +- Autoscaling of Chainlit app behind a load balancer should now work. Don't forget to enable sticky sessions + +## [2.1.dev0] - 2024-11-14 + +Pre-release: developer preview. + +### Added + +- New `@data_layer` decorator for configuring custom data layers declaratively +- Unit tests for `get_data_layer()` and `@data_layer` functionality + +### Changed + +- Data layer configuration system now prioritizes `@data_layer` decorator over environment variables +- Data layer initialization is now more explicit and testable through the decorator pattern +- Updated example code in `/cypress/e2e/custom_data_layer` and `/cypress/e2e/data_layer` to use the new decorator + +### Developer Experience + +- Improved test infrastructure with new fixtures for data layer mocking +- Added comprehensive tests for data layer configuration scenarios + +## [1.3.2] - 2024-11-08 + +### Security Advisory + +**IMPORTANT**: + +- This release drops support for FastAPI versions before 0.115.3 and Starlette versions before 0.41.2 due to a severe security vulnerability (CVE-2024-47874). We strongly encourage all downstream dependencies to upgrade as well. +- This release still contains a known security vulnerability in the element feature that could allow unauthorized file access. We strongly recommend against using elements in production environments until a comprehensive fix is implemented in an upcoming release. + +### Security + +- **[breaking]** Updated dependencies to address critical issues (#1493): + - Upgraded fastapi to 0.115.3 to address CVE-2024-47874 in Starlette + - Upgraded starlette to 0.41.2 (required for security fix) + - Upgraded werkzeug to 3.0.6 + +Note: This is a breaking change as older FastAPI versions are no longer supported. +To prioritize security, we opted to break with semver on this particular occasion. + +### Fixed + +- Resolved incorrect message ordering in UI (#1501) + +## [2.0rc0] - 2024-11-08 + +### Security Advisory + +**IMPORTANT**: + +- The element feature currently contains a known security vulnerability that could allow unauthorized file access. We strongly recommend against using elements in production environments until a comprehensive fix is implemented in an upcoming release. + +### Changed + +- **[breaking]**: Completely revamped audio implementation (#1401, #1410): + - Replaced `AudioChunk` with `InputAudioChunk` and `OutputAudioChunk` + - Changed default audio sampling rate from 44100 to 24000 + - Removed several audio configuration options (`min_decibels`, `initial_silence_timeout`, `silence_timeout`, `chunk_duration`, `max_duration`) + - Removed `RecordScreen` component +- Factored storage clients into separate modules (#1363) + +### Added + +- Realtime audio streaming and processing (#1401, #1406, #1410): + - New `AudioPresence` component for visual representation + - Implemented `WavRecorder` and `WavStreamPlayer` classes + - Introduced new `on_audio_start` callback + - Added audio interruption functionality + - New audio connection signaling with `on` and `off` states +- Interactive DataFrame display with auto-fit content using MUI Data Grid (#1373, #1467) +- Optional websocket connection in react-client (#1379) +- Enhanced image interaction with popup view and download option (#1402) +- Current URL included in message payload (#1403) +- Allow empty chat input when submitting attachments (#1261) + +### Fixes + +- Various backend fixes and cleanup (#1432): + - Use importlib.util.find_spec to check if a package is installed + - Use `raise... from` to wrap exceptions + - Fix error message in Discord integration + - Several minor fixups/cleanup + +### Development + +- Implemented ruff for linting and formatting (#1495) +- Added mypy daemon for faster type-checking (#1495) +- Added GitHub Actions linting (#1445) +- Enabled direct installation from GitHub (#1423) +- Various build script improvements (#1462) + +## [1.3.1] - 2024-10-25 + +### Security Advisory + +- **IMPORTANT**: This release temporarily reverts the file access security improvements from 1.3.0 to restore element functionality. The element feature currently has a known security vulnerability that could allow unauthorized access to files. We strongly recommend against using elements in production environments until the next release. +- A comprehensive security fix will be implemented in an upcoming release. + +### Changed + +- Reverted authentication requirements for file access endpoints to restore element functionality (#1474) + +### Development + +- Work in progress on implementing HTTP-only cookie authentication for proper security (#1472) + +## [1.3.0] - 2024-10-22 + +### Security + +- Fixed critical endpoint security vulnerabilities (#1441) +- Enhanced authentication for file-related endpoints (#1431) +- Upgraded frontend and backend dependencies to address security issues (#1431) + +### Added + +- SQLite support in SQLAlchemy integration (#1319) +- Support for IETF BCP 47 language tags, enabling localized languages like es-419 (#1399) +- Environment variables `OAUTH__PROMPT` and `OAUTH_PROMPT` to + override oauth prompt parameter. Enabling users to explicitly enable login/consent prompts for oauth, e.g. `OAUTH_PROMPT=consent` to prevent automatic re-login. (#1362, #1456). +- Added `get_element()` method to SQLAlchemyDataLayer (#1346) + +### Changed + +- Bumped LiteralAI dependency to version 0.0.625 (#1376) +- Optimized LiteralDataLayer for improved performance and consistency (#1376) +- Refactored context handling in SQLAlchemy data layer (#1319) +- Updated package metadata with correct authors, license, and documentation links (#1413) +- Enhanced GitHub Actions workflow with restricted permissions (#1349) + +### Fixed + +- Resolved dialog boxes extending beyond window bounds (#1446) +- Fixed tasklist functionality when Chainlit is submounted (#1433) +- Corrected handling of `display_name` in PersistentUser during authentication (#1425) +- Fixed SQLAlchemy identifier quoting (#1395) +- Improved spaces handling in avatar filenames (#1418) + +### Development + +- Implemented extensive test coverage for LiteralDataLayer and SQLAlchemyDataLayer +- Added comprehensive unit tests for file-related endpoints +- Enhanced code organization and import structure +- Improved Python code style and linting (#1353) +- Resolved various small text and documentation issues (#1347, #1348) + +## [1.2.0] - 2024-09-16 + +### Security + +- Fixed critical vulnerabilities allowing arbitrary file read access (#1326) +- Improved path traversal protection in various endpoints (#1326) + +### Added + +- Hebrew translation JSON (#1322) +- Translation files for Indian languages (#1321) +- Support for displaying function calls as tools in Chain of Thought for LlamaIndexCallbackHandler (#1285) +- Improved feedback UI with refined type handling (#1325) + +### Changed + +- Upgraded cryptography from 43.0.0 to 43.0.1 in backend dependencies (#1298) +- Improved GitHub Actions workflow (#1301) +- Enhanced data layer cleanup for better performance (#1288) +- Factored out callbacks with extensive test coverage (#1292) +- Adopted strict adherence to Semantic Versioning (SemVer) + +### Fixed + +- Websocket connection issues when submounting Chainlit (#1337) +- Show_input functionality on chat resume for SQLAlchemy (#1221) +- Negative feedback class incorrectness (#1332) +- Interaction issues with Chat Profile Description Popover (#1276) +- Centered steps within assistant messages (#1324) +- Minor spelling errors (#1341) + +### Development + +- Added documentation for release engineering process (#1293) +- Implemented testing for FastAPI version matrix (#1306) +- Removed wait statements from E2E tests for improved performance (#1270) +- Bumped dataclasses to latest version (#1291) +- Ensured environment loading before other imports (#1328) + +## [1.1.404] - 2024-09-04 + +### Security + +- **[breaking]**: Listen to 127.0.0.1 (localhost) instead on 0.0.0.0 (public) (#861). +- **[breaking]**: Dropped support for Python 3.8, solving dependency resolution, addressing vulnerable dependencies (#1192, #1236, #1250). + +### Fixed + +- Frontend connection resuming after connection loss (#828). +- Gracefully handle HTTP errors in data layers (#1232). +- AttributeError: 'ChatCompletionChunk' object has no attribute 'get' in llama_index (#1229). +- `edit_message` in correct place in default config, allowing users to edit messages (#1218). + +### Added + +- `CHAINLIT_APP_ROOT` environment variable to modify `APP_ROOT`, enabling the ability to set the location of `config.toml` and other setting files (#1259). +- Poetry lockfile in GIT repository for reproducible builds (#1191). +- pytest-based testing infrastructure, first unit tests of backend and testing on all supported Python versions (#1245 and #1271). +- Black and isort added to dev dependencies group (#1217). + +## [1.1.403rc0] - 2024-08-13 + +### Fixed + +- Langchain Callback handler IndexError +- Attempt to fix websocket issues + +## [1.1.402] - 2024-08-07 + +### Added + +- The `User` class now has a `display_name` field. It will not be persisted by the data layer. +- The logout button will now reload the page (needed for custom auth providers) + +## [1.1.401] - 2024-08-02 + +### Changed + +- Directly log step input args by name instead of wrapping them in "args" for readability. + +### Fixed + +- Langchain Callback handler ValueError('not enough values to unpack (expected 2, got 0)') + +## [1.1.400] - 2024-07-29 + +### Changed + +- hide_cot becomes cot and has three possible values: hidden, tool_call, full +- User feedback are now scoring an entire run instead of a specific message +- Slack/Teams/Discord DM threads are now split by day +- Slack DM now also use threads +- Avatars are always displayed at the root level of the conversation + +### Removed + +- disable_feedback has been removed +- root_message has been removed + +## [1.1.306] - 2024-07-03 + +### Added + +- Messages are now editable. You can disable this feature with `config.features.edit_message = false` +- `cl.chat_context` to help keeping track of the messages of the current thread +- You can now enable debug_mode when mounting Chainlit as a sub app by setting the `CHAINLIT_DEBUG` to `true`. + +### Fixed + +- Message are now collapsible if too long +- Only first level tool calls are displayed +- OAuth redirection when mounting Chainlit on a FastAPI app should now work +- The Langchain callback handler should better capture chain runs +- The Llama Index callback handler should now work with other decorators + +## [1.1.305] - 2024-06-26 + +### Added + +- Mistral AI instrumentation + +## [1.1.304] - 2024-06-21 + +### Fixed + +- OAuth final redirection should account for root path if provided + +## [1.1.303] - 2024-06-20 + +### Fixed + +- OAuth URL redirection should be correctly formed when using CHAINLIT_URL + submounted chainlit app + +## [1.1.302] - 2024-06-16 + +### Added + +- Width and height option for the copilot bubble + +### Fixed + +- Chat profile icon in copilot should load +- Theme should work with Copilot + +### Removed + +- Running toast when an action is running + +## [1.1.301] - 2024-06-14 + +### Fixed + +- Azure AD oauth get_user_info not implemented error + +## [1.1.300] - 2024-06-13 + +### Added + +- `@cl.set_starters` and `cl.Starter` to suggest conversation starters to the user +- Teams integration +- Expand copilot button +- Debug mode when starting with `-d`. Only available if the data layer supports it. This replaces the Prompt Playground. +- `default` theme config in `config.toml` +- If only one OAuth provider is set, automatically redirect the user to it +- Input streaming for tool calls + +### Changed + +- **[BREAKING]** Custom endpoints have been reworked. You should now mount your Chainlit app as a FastAPI subapp. +- **[BREAKING]** Avatars have been reworked. `cl.Avatar` has been removed, instead place your avatars by name in `/public/avatars/*` +- **[BREAKING]** The `running`, `took_one` and `took_other` translations have been replaced by `used`. +- **[BREAKING]** `root` attribute of `cl.Step` has been removed. Use `cl.Message` to send root level messages. +- Chain of Thought has been reworked. Only steps of type `tool` will be displayed if `hide_cot` is false +- The `show_readme_as_default` config has been removed +- No longer collapse root level messages +- The blue alert "Continuing chat" has been removed. + +### Fix + +- The Chat Profile description should now disappear when not hovered. +- Error handling of steps has been improved +- No longer stream the first token twice +- Copilot should now work as expected even if the user is closing/reopening it +- Copilot CSS should no longer leak/be impacted by the host website CSS +- Fix various `cl.Context` errors +- Reworked message padding and spacing +- Chat profile should now support non-ASCII characters (like chinese) + +## [1.1.202] - 2024-05-22 + +### Added + +- Support for video players like youtube or vimeo + +### Fixed + +- Fix audio capture on windows browsers + +## [1.1.201] - 2024-05-21 + +### Fixed + +- Intermediary steps button placement + +## [1.1.200] - 2024-05-21 + +### Changed + +- User message UI has been updated +- Loading indicator has been improved and visually updated +- Icons have been updated +- Dark theme is now the default + +### Fixed + +- Scroll issues on mobile browsers +- Github button now showing + +## [1.1.101] - 2024-05-14 + +### Added + +- The discord bot now shows "typing" while responding + +### Fixed + +- Discord and Slack bots should no longer fail to respond if the data layer fails + +## [1.1.0] - 2024-05-13 + +### Added + +- You can know serve your Chainlit app as a Slack bot +- You can know serve your Chainlit app as a Discord bot +- `cl.on_audio_chunk` decorator to process incoming the user incoming audio stream +- `cl.on_audio_end` decorator to react to the end of the user audio stream +- The `cl.Audio` element now has an `auto_play` property +- `layout` theme config, wide or default +- `http_referer` is now available in `cl.user_session` + +### Changed + +- The UI has been revamped, especially the navigation +- The arrow up button has been removed from the input bar, however pressing the arrow up key still opens the last inputs menu +- The user session will no longer be persisted as metadata if > 1mb +- **[breaking]** the `send()` method on `cl.Message` now returns the message instead of the message id +- **[breaking]** The `multi_modal` feature has been renamed `spontaneous_file_upload` in the config +- Element display property now defaults to `inline` instead of `side` +- The SQL Alchemy data layer logging has been improved + +### Fixed + +- Fixed a bug disconnecting the user when loading the chat history +- Elements based on an URL should now have a mime type +- Stopping a task should now work better (using asyncio task.cancel) + +## [1.0.506] - 2024-04-30 + +### Added + +- add support for multiline option in TextInput chat settings field - @kevinwmerritt + +### Changed + +- disable gzip middleware to prevent a compression issue on safari + +### Fixed + +- pasting from microsoft products generates text instead of an image +- do not prevent thread history revalidation - @kevinwmerritt +- display the label instead of the value for menu item - @kevinwmerritt + +### Added + +## [1.0.505] - 2024-04-23 + +### Added + +- The user's browser language configuration is available in `cl.user_session.get("languages")` +- Allow html in text elements - @jdb78 +- Allow for setting a ChatProfile default - @kevinwmerritt + +### Changed + +- The thread history refreshes right after a new thread is created. +- The thread auto-tagging feature is now opt-in using `auto_tag_thread` in the config.toml file + +### Fixed + +- Fixed incorrect step ancestor in the OpenAI instrumentation +- Enabled having a `storage_provider` set to `None` in SQLAlchemyDataLayer - @mohamedalani +- Correctly serialize `generation` in SQLAlchemyDataLayer - @mohamedalani + +## [1.0.504] - 2024-04-16 + +### Changed + +- Chainlit apps should function correctly even if the data layer is down + +## [1.0.503] - 2024-04-15 + +### Added + +- Enable persisting threads using a Custom Data Layer (through SQLAlchemy) - @hayescode + +### Changed + +- React-client: Expose `sessionId` in `useChatSession` +- Add chat profile as thread tag metadata + +### Fixed + +- Add quotes around the chainlit create-secret CLI output to avoid any issues with special characters + +## [1.0.502] - 2024-04-08 + +### Added + +- Actions now trigger conversation persistence + +## [1.0.501] - 2024-04-08 + +### Added + +- Messages and steps now accept tags and metadata (useful for the data layer) + +### Changed + +- The LLama Index callback handler should now show retrieved chunks in the intermadiary steps +- Renamed the Literal environment variable to `LITERAL_API_URL` (it used to be `LITERAL_SERVER`) + +### Fixed + +- Starting a new conversation should close the element side bar +- Resolved security issues by upgrading starlette dependency + +## [1.0.500] - 2024-04-02 + +### Added + +- Added a new command `chainlit lint-translations` to check that translations file are OK +- Added new sections to the translations, like signin page +- chainlit.md now supports translations based on the browser's language. Like chainlit_pt-BR.md +- A health check endpoint is now available through a HEAD http call at root +- You can now specify a custom frontend build path + +### Fixed + +- Translated will no longer flash at app load +- Llama Index callback handler has been updated +- File watcher should now properly refresh the app when the code changes +- Markdown titles should now have the correct line height + +### Changed + +- `multi_modal` is now under feature in the config.toml and has more granularity +- Feedback no longer has a -1 value. Instead a delete_feedback method has been added to the data layer +- ThreadDict no longer has the full User object. Instead it has user_id and user_identifier fields + +## [1.0.400] - 2024-03-06 + +### Added + +- OpenAI integration + +### Fixed + +- Langchain final answer streaming should work again +- Elements with public URLs should be correctly persisted by the data layer + +### Changed + +- Enforce UTC DateTimes + +## [1.0.300] - 2024-02-19 + +### Added + +- Custom js script injection +- First token and token throughput per second metrics + +### Changed + +- The `ChatGeneration` and `CompletionGeneration` has been reworked to better match the OpenAI semantics + +## [1.0.200] - 2024-01-22 + +### Added + +- Chainlit Copilot +- Translations +- Custom font + +### Fixed + +- Tasklist flickering + +## [1.0.101] - 2024-01-12 + +### Fixed + +- Llama index callback handler should now correctly nest the intermediary steps +- Toggling hide_cot parameter in the UI should correctly hide the `took n steps` buttons +- `running` loading button should only be displayed once when `hide_cot` is true and a message is being streamed + +## [1.0.100] - 2024-01-10 + +### Added + +- `on_logout` hook allowing to clear cookies when a user logs out + +### Changed + +- Chainlit apps won't crash anymore if the data layer is not reachable + +### Fixed + +- File upload now works when switching chat profiles +- Avatar with an image no longer have a background color +- If `hide_cot` is set to `true`, the UI will never get the intermediary steps (but they will still be persisted) +- Fixed a bug preventing to open past chats + +## [1.0.0] - 2024-01-08 + +### Added + +- Scroll down button +- If `hide_cot` is set to `true`, a `running` loader is displayed by default under the last message when a task is running. + +### Changed + +- Avatars are now always displayed +- Chat history sidebar has been revamped +- Stop task button has been moved to the input bar + +### Fixed + +- If `hide_cot` is set to `true`, the UI will never get the intermediary steps (but they will still be persisted) + +## [1.0.0rc3] - 2023-12-21 + +### Fixed + +- Elements are now working when authenticated +- First interaction is correctly set when resuming a chat + +### Changed + +- The copy button is hidden if `disable_feedback` is `true` + +## [1.0.0rc2] - 2023-12-18 + +### Added + +- Copy button under messages +- OAuth samesite cookie policy is now configurable through the `CHAINLIT_COOKIE_SAMESITE` env var + +### Changed + +- Relax Python version requirements +- If `hide_cot` is configured to `true`, steps will never be sent to the UI, but still persisted. +- Message buttons are now positioned below + +## [1.0.0rc0] - 2023-12-12 + +### Added + +- cl.Step + +### Changed + +- File upload uses HTTP instead of WS and no longer has size limitation +- `cl.AppUser` becomes `cl.User` +- `Prompt` has been split in `ChatGeneration` and `CompletionGeneration` +- `Action` now display a toaster in the UI while running + +## [0.7.700] - 2023-11-28 + +### Added + +- Support for custom HTML in message content is now an opt in feature in the config +- Uvicorn `ws_per_message_deflate` config param is now configurable like `UVICORN_WS_PER_MESSAGE_DEFLATE=false` + +### Changed + +- Latex support is no longer enabled by default and is now a feature in the config + +### Fixed + +- Fixed LCEL memory message order in the prompt playground +- Fixed a key error when using the file watcher (-w) +- Fixed several user experience issues with `on_chat_resume` +- `on_chat_end` is now always called when a chat ends +- Switching chat profiles correctly clears previous AskMessages + +## [0.7.604] - 2023-11-15 + +### Fixed + +- `on_chat_resume` now works properly with non json serializable objects +- `LangchainCallbackHandler` no longer send tokens to the wrong user under high concurrency +- Langchain cache should work when `cache` is to `true` in `config.toml` + +## [0.7.603] - 2023-11-15 + +### Fixed + +- Markdown links special characters are no longer encoded +- Collapsed messages no longer make the chat scroll +- Stringified Python objects are now displayed in a Python code block + +## [0.7.602] - 2023-11-14 + +### Added + +- Latex support (only supporting $$ notation) +- Go back button on element page + +### Fixed + +- Code blocks should no longer flicker or display `[object object]`. +- Now properly displaying empty messages with inlined elements +- Fixed `Too many values to unpack error` in langchain callback +- Langchain final streamed answer is now annotable with human feedback +- AzureOpenAI should now work properly in the Prompt Playground + +### Changed + +- Code blocks display has been enhanced +- Replaced aiohttp with httpx +- Prompt Playground has been updated to work with the new openai release (v1). Including tools +- Auth0 oauth provider has a new configurable env variable `OAUTH_AUTH0_ORIGINAL_DOMAIN` + +## [0.7.500] - 2023-11-07 + +### Added + +- `cl.on_chat_resume` decorator to enable users to continue a conversation. +- Support for OpenAI functions in the Prompt Playground +- Ability to add/remove messages in the Prompt Playground +- Plotly element to display interactive charts + +### Fixed + +- Langchain intermediate steps display are now much more readable +- Chat history loading latency has been enhanced +- UTF-8 characters are now correctly displayed in json code blocks +- Select widget `items` attribute is now working properly +- Chat profiles widget is no longer scrolling horizontally + +## [0.7.400] - 2023-10-27 + +### Added + +- Support for Langchain Expression Language. https://docs.chainlit.io/integrations/langchain +- UI rendering optimization to guarantee high framerate +- Chainlit Cloud latency optimization +- Speech recognition to type messages. https://docs.chainlit.io/backend/config/features +- Descope OAuth provider + +### Changed + +- `LangchainCallbackHandler` is now displaying inputs and outputs of intermediate steps. + +### Fixed + +- AskUserMessage now work properly with data persistence +- You can now use a custom okta authorization server for authentication + +## [0.7.3] - 2023-10-17 + +### Added + +- `ChatProfile` allows to configure different agents that the user can freely chose +- Multi modal support at the input bar level. Enabled by `features.multi_modal` in the config +- `cl.AskUserAction` allows to block code execution until the user clicked an action. +- Displaying readme when chat is empty is now configurable through `ui.show_readme_as_default` in the config + +### Changed + +- `cl.on_message` is no longer taking a string as parameter but rather a `cl.Message` + +### Fixed + +- Chat history is now correctly displayed on mobile +- Azure AD OAuth authentication should now correctly display the user profile picture + +### Removed + +- `@cl.on_file_upload` is replaced by true multi modal support at the input bar level + +## [0.7.2] - 2023-10-10 + +### Added + +- Logo is displayed in the UI header (works with custom logo) +- Azure AD single tenant is now supported +- `collapsed` attribute on the `Action` class +- Latency improvements when data persistence is enabled + +### Changed + +- Chat history has been entirely reworked +- Chat messages redesign +- `config.ui.base_url` becomes `CHAINLIT_URL` env variable + +### Fixed + +- File watcher (-w) is now working with nested module imports +- Unsupported character during OAuth authentication + +## [0.7.1] - 2023-09-29 + +### Added + +- Pydantic v2 support +- Okta auth provider +- Auth0 auth provider +- Prompt playground support for mix of template/formatted prompts +- `@cl.on_chat_end` decorator +- Textual comments to user feedback + +### Fixed + +- Langchain errors are now correctly indented +- Langchain nested chains prompts are now correctly displayed +- Langchain error TypeError: 'NoneType' object is not a mapping. +- Actions are now displayed on mobile +- Custom logo is now working as intended + +## [0.7.0] - 2023-09-13 + +### Changed + +- Authentication is now unopinionated: + 1. `@cl.password_auth_callback` for login/password auth + 2. `@cl.oauth_callback` for oAuth auth + 3. `@cl.header_auth_callback` for header auth +- Data persistence is now enabled through `CHAINLIT_API_KEY` env variable + +### Removed + +- `@cl.auth_client_factory` (see new authentication) +- `@cl.db_client_factory` (see new data persistence) + +### Added + +- `disable_human_feedback` parameter on `cl.Message` +- Configurable logo +- Configurable favicon +- Custom CSS injection +- GCP Vertex AI LLM provider +- Long message collpasing feature flag +- Enable Prompt Playground feature flag + +### Fixed + +- History page filters now work properly +- History page does not show empty conversations anymore +- Langchain callback handler Message errors + +## [0.6.4] - 2023-08-30 + +### Added + +- `@cl.on_file_upload` to enable spontaneous file uploads +- `LangchainGenericProvider` to add any Langchain LLM in the Prompt Playground +- `cl.Message` content now support dict (previously only supported string) +- Long messages are now collapsed by default + +### Fixed + +- Deadlock in the Llama Index callback handler +- Langchain MessagesPlaceholder and FunctionMessage are now correctly supported + +## [0.6.3] - 2023-08-22 + +### Added + +- Complete rework of the Prompt playground. Now supports custom LLMs, templates, variables and more +- Enhanced Langchain final answer streaming +- `remove_actions` method on the `Message` class +- Button to clear message history + +### Fixed + +- Chainlit CLI performance issue +- Llama Index v0.8+ callback handler. Now supports messages prompts +- Tasklist display, persistence and `.remove()` +- Custom headers growing infinitely large +- Action callback can now handle multiple actions +- Langflow integration load_flow_from_json +- Video and audio elements on Safari + +## [0.6.2] - 2023-08-06 + +### Added + +- Make the chat experience configurable with Chat Settings +- Authenticate users based on custom headers with the Custom Auth client + +### Fixed + +- Author rename now works with all kinds of messages +- Create message error with chainlit cloud (chenjuneking) + +## [0.6.1] - 2023-07-24 + +### Added + +- Security improvements +- Haystack callback handler +- Theme customizability + +### Fixed + +- Allow multiple browser tabs to connect to one Chainlit app +- Sidebar blocking the send button on mobile + +## [0.6.0] - 2023-07-20 + +### Breaking changes + +- Factories, run and post process decorators are removed. +- langchain_rename becomes author_rename and works globally +- Message.update signature changed + +Migration guide available [here](https://docs.chainlit.io/guides/migration/0.6.0). + +### Added + +- Langchain final answer streaming +- Redesign of chainlit input elements +- Possibility to add custom endpoints to the fast api server +- New File Element +- Copy button in code blocks + +### Fixed + +- Persist session between websocket reconnection +- The UI is now more mobile friendly +- Avatar element Path parameter +- Increased web socket message max size to 100 mb +- Duplicated conversations in the history tab + +## [0.5.2] - 2023-07-10 + +### Added + +- Add the video element + +### Fixed + +- Fix the inline element flashing when scrolling the page, due to un-needed re-rendering +- Fix the orange flash effect on messages + +## [0.5.1] - 2023-07-06 + +### Added + +- Task list element +- Audio element +- All elements can use the `.remove()` method to remove themselves from the UI +- Can now use cloud auth with any data persistence mode (like local) +- Microsoft auth + +### Fixed + +- Files in app dir are now properly served (typical use case is displaying an image in the readme) +- Add missing attribute `size` to Pyplot element + +## [0.5.0] - 2023-06-28 + +### Added + +- Llama Index integration. Learn more [here](https://docs.chainlit.io/integrations/llama-index). +- Langflow integration. Learn more [here](https://docs.chainlit.io/integrations/langflow). + +### Fixed + +- AskUserMessage.remove() now works properly +- Avatar element cannot be referenced in messages anymore + +## [0.4.2] - 2023-06-26 + +### Added + +- New data persistence mode `local` and `custom` are available on top of the pre-existing `cloud` one. Learn more [here](https://docs.chainlit.io/data). + +## [0.4.101] - 2023-06-24 + +### Fixed + +- Performance improvements and bug fixes on run_sync and asyncify + +## [0.4.1] - 2023-06-20 + +### Added + +- File watcher now reloads the app when the config is updated +- cl.cache to avoid wasting time reloading expensive resources every time the app reloads + +### Fixed + +- Bug introduced by 0.4.0 preventing to run private apps +- Long line content breaking the sidebar with Text elements +- File watcher preventing to keyboard interrupt the chainlit process +- Updated socket io to fix a security issue +- Bug preventing config settings to be the default values for the settings in the UI + +## [0.4.0] - 2023-06-16 + +### Added + +- Pyplot chart element +- Config option `default_expand_messages` to enable the default expand message settings by default in the UI (breaking change) + +### Fixed + +- Scoped elements sharing names are now correctly displayed +- Clickable Element refs are now correctly displayed, even if another ref being a substring of it exists + +## [0.3.0] - 2023-06-13 + +### Added + +- Moving from sync to async runtime (breaking change): + - Support async implementation (eg openai, langchain) + - Performance improvements + - Removed patching of different libraries +- Elements: + - Merged LocalImage and RemoteImage to Image (breaking change) + - New Avatar element to display avatars in messages +- AskFileMessage now supports multi file uploads (small breaking change) +- New settings interface including a new "Expand all" messages setting +- The element sidebar is resizable + +### Fixed + +- Secure origin issues when running on HTTP +- Updated the callback handler to langchain 0.0.198 latest changes +- Filewatcher issues +- Blank screen issues +- Port option in the CLI does not fail anymore because of os import + +## [0.2.111] - 2023-06-09 + +### Fixed + +- Pdf element reloading issue +- CI is more stable + +## [0.2.110] - 2023-06-08 + +### Added + +- `AskFileMessage`'s accept parameter can now can take a Dict to allow more fine grained rules. More infos here https://react-dropzone.org/#!/Accepting%20specific%20file%20types. +- The PDF viewer element helps you display local or remote PDF files ([documentation](https://docs.chainlit.io/api-reference/elements/pdf-viewer)). + +### Fixed + +- When running the tests, the chainlit cli is installed is installed in editable mode to run faster. + +## [0.2.109] - 2023-05-31 + +### Added + +- URL preview for social media share + +### Fixed + +- `max_http_buffer_size` is now set to 100mb, fixing the `max_size_mb` parameter of `AskFileMessage` + +## [0.2.108] - 2023-05-30 + +### Fixed + +- Enhanced security +- Global element display +- Display elements with display `page` based on their ids instead of their names + +## [0.2.107] - 2023-05-28 + +### Added + +- Rework of the Message, AskUserMessage and AskFileMessage APIs: +- `cl.send_message(...)` becomes `cl.Message(...).send()` +- `cl.send_ask_user(...)` becomes `cl.AskUserMessage(...).send()` +- `cl.send_ask_file(...)` becomes `cl.AskFileMessage(...).send()` +- `update` and `remove` methods to the `cl.Message` class + +### Fixed + +- Blank screen for windows users (https://github.com/Chainlit/chainlit/issues/3) +- Header navigation for mobile (https://github.com/Chainlit/chainlit/issues/12) + +## [0.2.106] - 2023-05-26 + +### Added + +- Starting to log changes in CHANGELOG.md +- Port and hostname are now configurable through the `CHAINLIT_HOST` and `CHAINLIT_PORT` env variables. You can also use `--host` and `--port` when running `chainlit run ...`. +- A label attribute to Actions to facilitate localization. + +### Fixed + +- Clicks on inlined `RemoteImage` now opens the image in a NEW tab. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..078c29c --- /dev/null +++ b/CLAUDE.md @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..6ada6e0 --- /dev/null +++ b/CONTRIBUTING.md @@ -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 + + + +- [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. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c5e9eee --- /dev/null +++ b/LICENSE @@ -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. \ No newline at end of file diff --git a/PRIVACY_POLICY.md b/PRIVACY_POLICY.md new file mode 100644 index 0000000..f698056 --- /dev/null +++ b/PRIVACY_POLICY.md @@ -0,0 +1,3 @@ +# Privacy Policy + +Chainlit doesn't collect any data from its users after 2.6.1 release. diff --git a/README.md b/README.md new file mode 120000 index 0000000..c3e5058 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +backend/README.md \ No newline at end of file diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..e11026c --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,7 @@ +# WeHub 来源说明 + +- 原始项目:`Chainlit/chainlit` +- 原始仓库:https://github.com/Chainlit/chainlit +- 导入方式:上游默认分支的最新快照 +- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准 +- 本文件仅用于记录来源,不代表 WeHub 是原项目作者 diff --git a/RELENG.md b/RELENG.md new file mode 100644 index 0000000..1c333f2 --- /dev/null +++ b/RELENG.md @@ -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! diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..8a01245 --- /dev/null +++ b/SECURITY.md @@ -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. diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..e19a982 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,119 @@ +

Welcome to Chainlit 👋

+ +

+Build python production-ready conversational AI applications in minutes, not weeks ⚡️ + +

+

+ + chat on Discord + Twitter + Downloads + Contributors + CI +

+ +> ⚠️ **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) + +

+ Website • + Documentation • + Chainlit Help • + Cookbook +

+ +

+ Chainlit%2Fchainlit | Trendshift +

+ +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 +``` + +Quick Start + +## 📚 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. diff --git a/backend/build.py b/backend/build.py new file mode 100644 index 0000000..9103257 --- /dev/null +++ b/backend/build.py @@ -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() diff --git a/backend/chainlit/__init__.py b/backend/chainlit/__init__.py new file mode 100644 index 0000000..99dd9d1 --- /dev/null +++ b/backend/chainlit/__init__.py @@ -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__ diff --git a/backend/chainlit/__main__.py b/backend/chainlit/__main__.py new file mode 100644 index 0000000..c07c66f --- /dev/null +++ b/backend/chainlit/__main__.py @@ -0,0 +1,4 @@ +from chainlit.cli import cli + +if __name__ == "__main__": + cli(prog_name="chainlit") diff --git a/backend/chainlit/_utils.py b/backend/chainlit/_utils.py new file mode 100644 index 0000000..7a37301 --- /dev/null +++ b/backend/chainlit/_utils.py @@ -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 diff --git a/backend/chainlit/action.py b/backend/chainlit/action.py new file mode 100644 index 0000000..906e358 --- /dev/null +++ b/backend/chainlit/action.py @@ -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()) diff --git a/backend/chainlit/auth/__init__.py b/backend/chainlit/auth/__init__.py new file mode 100644 index 0000000..7e96ff6 --- /dev/null +++ b/backend/chainlit/auth/__init__.py @@ -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", +] diff --git a/backend/chainlit/auth/cookie.py b/backend/chainlit/auth/cookie.py new file mode 100644 index 0000000..1cb4cc5 --- /dev/null +++ b/backend/chainlit/auth/cookie.py @@ -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? diff --git a/backend/chainlit/auth/jwt.py b/backend/chainlit/auth/jwt.py new file mode 100644 index 0000000..c1107e8 --- /dev/null +++ b/backend/chainlit/auth/jwt.py @@ -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) diff --git a/backend/chainlit/cache.py b/backend/chainlit/cache.py new file mode 100644 index 0000000..736ad3c --- /dev/null +++ b/backend/chainlit/cache.py @@ -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 diff --git a/backend/chainlit/callbacks.py b/backend/chainlit/callbacks.py new file mode 100644 index 0000000..0cdd209 --- /dev/null +++ b/backend/chainlit/callbacks.py @@ -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 diff --git a/backend/chainlit/chat_context.py b/backend/chainlit/chat_context.py new file mode 100644 index 0000000..81bf66b --- /dev/null +++ b/backend/chainlit/chat_context.py @@ -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() diff --git a/backend/chainlit/chat_settings.py b/backend/chainlit/chat_settings.py new file mode 100644 index 0000000..db68191 --- /dev/null +++ b/backend/chainlit/chat_settings.py @@ -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 diff --git a/backend/chainlit/cli/__init__.py b/backend/chainlit/cli/__init__.py new file mode 100644 index 0000000..85daf7d --- /dev/null +++ b/backend/chainlit/cli/__init__.py @@ -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() diff --git a/backend/chainlit/config.py b/backend/chainlit/config.py new file mode 100644 index 0000000..a540752 --- /dev/null +++ b/backend/chainlit/config.py @@ -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() diff --git a/backend/chainlit/context.py b/backend/chainlit/context.py new file mode 100644 index 0000000..e6cabfe --- /dev/null +++ b/backend/chainlit/context.py @@ -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) diff --git a/backend/chainlit/data/__init__.py b/backend/chainlit/data/__init__.py new file mode 100644 index 0000000..6363625 --- /dev/null +++ b/backend/chainlit/data/__init__.py @@ -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 diff --git a/backend/chainlit/data/acl.py b/backend/chainlit/data/acl.py new file mode 100644 index 0000000..60e45ac --- /dev/null +++ b/backend/chainlit/data/acl.py @@ -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 diff --git a/backend/chainlit/data/base.py b/backend/chainlit/data/base.py new file mode 100644 index 0000000..b75c5fd --- /dev/null +++ b/backend/chainlit/data/base.py @@ -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 diff --git a/backend/chainlit/data/chainlit_data_layer.py b/backend/chainlit/data/chainlit_data_layer.py new file mode 100644 index 0000000..4faae56 --- /dev/null +++ b/backend/chainlit/data/chainlit_data_layer.py @@ -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] diff --git a/backend/chainlit/data/dynamodb.py b/backend/chainlit/data/dynamodb.py new file mode 100644 index 0000000..b79d201 --- /dev/null +++ b/backend/chainlit/data/dynamodb.py @@ -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() diff --git a/backend/chainlit/data/literalai.py b/backend/chainlit/data/literalai.py new file mode 100644 index 0000000..04e0c5b --- /dev/null +++ b/backend/chainlit/data/literalai.py @@ -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() diff --git a/backend/chainlit/data/sql_alchemy.py b/backend/chainlit/data/sql_alchemy.py new file mode 100644 index 0000000..50a65d2 --- /dev/null +++ b/backend/chainlit/data/sql_alchemy.py @@ -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() diff --git a/backend/chainlit/data/storage_clients/__init__.py b/backend/chainlit/data/storage_clients/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/chainlit/data/storage_clients/azure.py b/backend/chainlit/data/storage_clients/azure.py new file mode 100644 index 0000000..0fc6f8d --- /dev/null +++ b/backend/chainlit/data/storage_clients/azure.py @@ -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://.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() diff --git a/backend/chainlit/data/storage_clients/azure_blob.py b/backend/chainlit/data/storage_clients/azure_blob.py new file mode 100644 index 0000000..8f74b43 --- /dev/null +++ b/backend/chainlit/data/storage_clients/azure_blob.py @@ -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() diff --git a/backend/chainlit/data/storage_clients/base.py b/backend/chainlit/data/storage_clients/base.py new file mode 100644 index 0000000..ef0b094 --- /dev/null +++ b/backend/chainlit/data/storage_clients/base.py @@ -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 diff --git a/backend/chainlit/data/storage_clients/gcs.py b/backend/chainlit/data/storage_clients/gcs.py new file mode 100644 index 0000000..3cf913d --- /dev/null +++ b/backend/chainlit/data/storage_clients/gcs.py @@ -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() diff --git a/backend/chainlit/data/storage_clients/s3.py b/backend/chainlit/data/storage_clients/s3.py new file mode 100644 index 0000000..fc3f999 --- /dev/null +++ b/backend/chainlit/data/storage_clients/s3.py @@ -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() diff --git a/backend/chainlit/data/utils.py b/backend/chainlit/data/utils.py new file mode 100644 index 0000000..23dc329 --- /dev/null +++ b/backend/chainlit/data/utils.py @@ -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 diff --git a/backend/chainlit/discord/__init__.py b/backend/chainlit/discord/__init__.py new file mode 100644 index 0000000..eb65bc5 --- /dev/null +++ b/backend/chainlit/discord/__init__.py @@ -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`" + ) diff --git a/backend/chainlit/discord/app.py b/backend/chainlit/discord/app.py new file mode 100644 index 0000000..1a16629 --- /dev/null +++ b/backend/chainlit/discord/app.py @@ -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, + ) diff --git a/backend/chainlit/element.py b/backend/chainlit/element.py new file mode 100644 index 0000000..901eae2 --- /dev/null +++ b/backend/chainlit/element.py @@ -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) diff --git a/backend/chainlit/emitter.py b/backend/chainlit/emitter.py new file mode 100644 index 0000000..97a0110 --- /dev/null +++ b/backend/chainlit/emitter.py @@ -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}) diff --git a/backend/chainlit/input_widget.py b/backend/chainlit/input_widget.py new file mode 100644 index 0000000..3ccec2b --- /dev/null +++ b/backend/chainlit/input_widget.py @@ -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, + } diff --git a/backend/chainlit/langchain/__init__.py b/backend/chainlit/langchain/__init__.py new file mode 100644 index 0000000..63ca78c --- /dev/null +++ b/backend/chainlit/langchain/__init__.py @@ -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`" + ) diff --git a/backend/chainlit/langchain/callbacks.py b/backend/chainlit/langchain/callbacks.py new file mode 100644 index 0000000..ef0b9b7 --- /dev/null +++ b/backend/chainlit/langchain/callbacks.py @@ -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", + "", +] +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 diff --git a/backend/chainlit/langflow/__init__.py b/backend/chainlit/langflow/__init__.py new file mode 100644 index 0000000..9b914f7 --- /dev/null +++ b/backend/chainlit/langflow/__init__.py @@ -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 diff --git a/backend/chainlit/llama_index/__init__.py b/backend/chainlit/llama_index/__init__.py new file mode 100644 index 0000000..e7d312b --- /dev/null +++ b/backend/chainlit/llama_index/__init__.py @@ -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`" + ) diff --git a/backend/chainlit/llama_index/callbacks.py b/backend/chainlit/llama_index/callbacks.py new file mode 100644 index 0000000..389145b --- /dev/null +++ b/backend/chainlit/llama_index/callbacks.py @@ -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 diff --git a/backend/chainlit/logger.py b/backend/chainlit/logger.py new file mode 100644 index 0000000..82aa37b --- /dev/null +++ b/backend/chainlit/logger.py @@ -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") diff --git a/backend/chainlit/markdown.py b/backend/chainlit/markdown.py new file mode 100644 index 0000000..42642c0 --- /dev/null +++ b/backend/chainlit/markdown.py @@ -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 diff --git a/backend/chainlit/mcp.py b/backend/chainlit/mcp.py new file mode 100644 index 0000000..f21a735 --- /dev/null +++ b/backend/chainlit/mcp.py @@ -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 diff --git a/backend/chainlit/message.py b/backend/chainlit/message.py new file mode 100644 index 0000000..0700f63 --- /dev/null +++ b/backend/chainlit/message.py @@ -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 diff --git a/backend/chainlit/mistralai/__init__.py b/backend/chainlit/mistralai/__init__.py new file mode 100644 index 0000000..16844ce --- /dev/null +++ b/backend/chainlit/mistralai/__init__.py @@ -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) diff --git a/backend/chainlit/mode.py b/backend/chainlit/mode.py new file mode 100644 index 0000000..16b9191 --- /dev/null +++ b/backend/chainlit/mode.py @@ -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 diff --git a/backend/chainlit/oauth_providers.py b/backend/chainlit/oauth_providers.py new file mode 100644 index 0000000..901472c --- /dev/null +++ b/backend/chainlit/oauth_providers.py @@ -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()] diff --git a/backend/chainlit/openai/__init__.py b/backend/chainlit/openai/__init__.py new file mode 100644 index 0000000..62baac8 --- /dev/null +++ b/backend/chainlit/openai/__init__.py @@ -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) diff --git a/backend/chainlit/py.typed b/backend/chainlit/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/backend/chainlit/sample/hello.py b/backend/chainlit/sample/hello.py new file mode 100644 index 0000000..b4b8192 --- /dev/null +++ b/backend/chainlit/sample/hello.py @@ -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() diff --git a/backend/chainlit/sample/starters_demo.py b/backend/chainlit/sample/starters_demo.py new file mode 100644 index 0000000..0492474 --- /dev/null +++ b/backend/chainlit/sample/starters_demo.py @@ -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() diff --git a/backend/chainlit/secret.py b/backend/chainlit/secret.py new file mode 100644 index 0000000..cc85257 --- /dev/null +++ b/backend/chainlit/secret.py @@ -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)) diff --git a/backend/chainlit/semantic_kernel/__init__.py b/backend/chainlit/semantic_kernel/__init__.py new file mode 100644 index 0000000..8bda831 --- /dev/null +++ b/backend/chainlit/semantic_kernel/__init__.py @@ -0,0 +1,111 @@ +from collections.abc import Awaitable, Callable +from typing import TYPE_CHECKING, Any + +from pydantic import BaseModel + +from chainlit import Step + +if TYPE_CHECKING: + from semantic_kernel import Kernel + from semantic_kernel.filters import FunctionInvocationContext + from semantic_kernel.functions import KernelArguments + + +class SemanticKernelFilter(BaseModel): + """Semantic Kernel Filter for Chainlit. + + This filter wraps any function calls that are executed and will capture the input and output of that function + as a Chainlit Step. + + You can pass your kernel into the constructor, or you can call `add_to_kernel` later. + + Args: + excluded_plugins: a list of plugin_names that will be excluded from displaying steps. + excluded_functions: a list of function names that will be excluded from displaying steps. + kernel: the Kernel to add the filter to. If not provided, you can call `add_to_kernel` later. + + Methods: + add_to_kernel: this method takes a Kernel and adds the filter to that kernel. + parse_arguments: this method is called with KernelArguments used for the function + it can be subclassed to customize how to represent the input arguments. + + Example:: + + filter = SemanticKernelFilter(kernel=kernel) + + # or when you create your kernel later on: + + filter = SemanticKernelFilter() + # ... + # other code, including kernel creation. + # ... + filter.add_to_kernel(kernel) + """ + + excluded_plugins: list[str] | None = None + excluded_functions: list[str] | None = None + + def __init__( + self, + excluded_plugins: list[str] | None = None, + excluded_functions: list[str] | None = None, + *, + kernel: "Kernel | None" = None, + ) -> None: + super().__init__( + excluded_plugins=excluded_plugins, excluded_functions=excluded_functions + ) + if kernel: + self.add_to_kernel(kernel) + + def add_to_kernel(self, kernel: "Kernel") -> None: + """Adds the filter to the provided kernel. + + Args: + kernel: the Kernel to add the filter to. + """ + kernel.add_filter("function_invocation", self._function_invocation_filter) # type: ignore[arg-type] + + def parse_arguments(self, arguments: "KernelArguments") -> dict[str, Any] | str: + """Parse the KernelArguments used for the function. + + This function can be subclassed to easily adopt how the input arguments are displayed. + + Args: + arguments: KernelArguments + + Returns: + a dict or string with the input. + """ + if len(arguments) == 0: + return "" + input_dict = {} + for key, value in arguments.items(): + if isinstance(value, BaseModel): + input_dict[key] = value.model_dump(exclude_none=True, by_alias=True) + else: + input_dict[key] = value + return input_dict + + async def _function_invocation_filter( + self, + context: "FunctionInvocationContext", + next: Callable[["FunctionInvocationContext"], Awaitable[None]], + ): + if ( + self.excluded_plugins + and context.function.plugin_name in self.excluded_plugins + ) or ( + self.excluded_functions and context.function.name in self.excluded_functions + ): + await next(context) + return + async with Step( + type="tool", name=context.function.fully_qualified_name + ) as step: + step.input = self.parse_arguments(context.arguments) + await step.send() + await next(context) + if context.result: + step.output = context.result.value + await step.update() diff --git a/backend/chainlit/server.py b/backend/chainlit/server.py new file mode 100644 index 0000000..cab8fef --- /dev/null +++ b/backend/chainlit/server.py @@ -0,0 +1,1854 @@ +import asyncio +import fnmatch +import glob +import json +import mimetypes +import os +import re +import shutil +import urllib.parse +import webbrowser +from contextlib import AsyncExitStack, asynccontextmanager +from pathlib import Path +from typing import TYPE_CHECKING, List, Optional, Union, cast + +import socketio +from fastapi import ( + APIRouter, + Depends, + FastAPI, + Form, + HTTPException, + Query, + Request, + Response, + UploadFile, + status, +) +from fastapi.middleware.gzip import GZipMiddleware +from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse +from fastapi.security import OAuth2PasswordRequestForm +from starlette.datastructures import URL +from starlette.middleware.cors import CORSMiddleware +from starlette.types import Receive, Scope, Send +from typing_extensions import Annotated +from watchfiles import awatch + +from chainlit.auth import create_jwt, decode_jwt, get_configuration, get_current_user +from chainlit.auth.cookie import ( + clear_auth_cookie, + clear_oauth_state_cookie, + set_auth_cookie, + set_oauth_state_cookie, + validate_oauth_state_cookie, +) +from chainlit.config import ( + APP_ROOT, + BACKEND_ROOT, + DEFAULT_HOST, + FILES_DIRECTORY, + PACKAGE_ROOT, + ChainlitConfig, + config, + load_module, + public_dir, + reload_config, +) +from chainlit.data import get_data_layer +from chainlit.data.acl import is_thread_author +from chainlit.logger import logger +from chainlit.markdown import get_markdown_str +from chainlit.oauth_providers import get_oauth_provider +from chainlit.secret import random_secret +from chainlit.types import ( + AskFileSpec, + CallActionRequest, + ConnectMCPRequest, + DeleteFeedbackRequest, + DeleteThreadRequest, + DisconnectMCPRequest, + ElementRequest, + GetThreadsRequest, + ShareThreadRequest, + Theme, + UpdateFeedbackRequest, + UpdateThreadRequest, +) +from chainlit.user import PersistedUser, User +from chainlit.utils import utc_now + +from ._utils import is_path_inside + +if TYPE_CHECKING: + from chainlit.element import CustomElement, ElementDict + +mimetypes.add_type("application/javascript", ".js") +mimetypes.add_type("application/javascript", ".mjs") +mimetypes.add_type("text/css", ".css") + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Context manager to handle app start and shutdown.""" + if config.code.on_app_startup: + await config.code.on_app_startup() + + host = config.run.host + port = config.run.port + root_path = os.getenv("CHAINLIT_ROOT_PATH", "") + scheme = "https" if config.run.ssl_cert else "http" + + if host == DEFAULT_HOST: + url = f"{scheme}://localhost:{port}{root_path}" + else: + url = f"{scheme}://{host}:{port}{root_path}" + + logger.info(f"Your app is available at {url}") + + if not config.run.headless: + # Add a delay before opening the browser + await asyncio.sleep(1) + webbrowser.open(url) + + watch_task = None + stop_event = asyncio.Event() + + if config.run.watch: + + async def watch_files_for_changes(): + extensions = [".py"] + files = ["chainlit.md", "config.toml"] + async for changes in awatch(config.root, stop_event=stop_event): + for change_type, file_path in changes: + file_name = os.path.basename(file_path) + file_ext = os.path.splitext(file_name)[1] + + if file_ext.lower() in extensions or file_name.lower() in files: + logger.info( + f"File {change_type.name}: {file_name}. Reloading app..." + ) + + try: + reload_config() + except Exception as e: + logger.error(f"Error reloading config: {e}") + break + + # Reload the module if the module name is specified in the config + if config.run.module_name: + try: + load_module(config.run.module_name, force_refresh=True) + except Exception as e: + logger.error(f"Error reloading module: {e}") + + await asyncio.sleep(1) + await sio.emit("reload", {}) + + break + + watch_task = asyncio.create_task(watch_files_for_changes()) + + discord_task = None + + if discord_bot_token := os.environ.get("DISCORD_BOT_TOKEN"): + from chainlit.discord.app import client + + discord_task = asyncio.create_task(client.start(discord_bot_token)) + + slack_task = None + + # Slack Socket Handler if env variable SLACK_WEBSOCKET_TOKEN is set + if os.environ.get("SLACK_BOT_TOKEN") and os.environ.get("SLACK_WEBSOCKET_TOKEN"): + from chainlit.slack.app import start_socket_mode + + slack_task = asyncio.create_task(start_socket_mode()) + + try: + yield + finally: + try: + if config.code.on_app_shutdown: + await config.code.on_app_shutdown() + + if watch_task: + stop_event.set() + watch_task.cancel() + await watch_task + + if discord_task: + discord_task.cancel() + await discord_task + + if slack_task: + slack_task.cancel() + await slack_task + + if data_layer := get_data_layer(): + await data_layer.close() + except asyncio.exceptions.CancelledError: + pass + + if FILES_DIRECTORY.is_dir(): + shutil.rmtree(FILES_DIRECTORY) + + # Force exit the process to avoid potential AnyIO threads still running + os._exit(0) + + +def get_build_dir(local_target: str, packaged_target: str) -> str: + """ + Get the build directory based on the UI build strategy. + + Args: + local_target (str): The local target directory. + packaged_target (str): The packaged target directory. + + Returns: + str: The build directory + """ + + local_build_dir = os.path.join(PACKAGE_ROOT, local_target, "dist") + packaged_build_dir = os.path.join(BACKEND_ROOT, packaged_target, "dist") + + if config.ui.custom_build and os.path.exists( + os.path.join(APP_ROOT, config.ui.custom_build) + ): + return os.path.join(APP_ROOT, config.ui.custom_build) + elif os.path.exists(local_build_dir): + return local_build_dir + elif os.path.exists(packaged_build_dir): + return packaged_build_dir + else: + raise FileNotFoundError(f"{local_target} built UI dir not found") + + +build_dir = get_build_dir("frontend", "frontend") +copilot_build_dir = get_build_dir(os.path.join("libs", "copilot"), "copilot") + +app = FastAPI(lifespan=lifespan) + +sio = socketio.AsyncServer(cors_allowed_origins=[], async_mode="asgi") + +asgi_app = socketio.ASGIApp(socketio_server=sio, socketio_path="") + +# config.run.root_path is only set when started with --root-path. Not on submounts. +SOCKET_IO_PATH = f"{config.run.root_path}/ws/socket.io" +app.mount(SOCKET_IO_PATH, asgi_app) + +app.add_middleware( + CORSMiddleware, + allow_origins=config.project.allow_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +class SafariWebSocketsCompatibleGZipMiddleware(GZipMiddleware): + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http": + return await self.app(scope, receive, send) + + # Prevent gzip compression for HTTP requests to socket.io path due to a bug in Safari + if URL(scope=scope).path.startswith(SOCKET_IO_PATH): + await self.app(scope, receive, send) + else: + await super().__call__(scope, receive, send) + + +app.add_middleware(SafariWebSocketsCompatibleGZipMiddleware) + +# config.run.root_path is only set when started with --root-path. Not on submounts. +router = APIRouter(prefix=config.run.root_path) + + +@router.get("/public/{filename:path}") +async def serve_public_file( + filename: str, +): + """Serve a file from public dir.""" + + base_path = Path(public_dir) + file_path = (base_path / filename).resolve() + + if not is_path_inside(file_path, base_path): + raise HTTPException(status_code=400, detail="Invalid filename") + + if file_path.is_file(): + return FileResponse(file_path) + else: + raise HTTPException(status_code=404, detail="File not found") + + +@router.get("/assets/{filename:path}") +async def serve_asset_file( + filename: str, +): + """Serve a file from assets dir.""" + + base_path = Path(os.path.join(build_dir, "assets")) + file_path = (base_path / filename).resolve() + + if not is_path_inside(file_path, base_path): + raise HTTPException(status_code=400, detail="Invalid filename") + + if file_path.is_file(): + return FileResponse(file_path) + else: + raise HTTPException(status_code=404, detail="File not found") + + +@router.get("/copilot/{filename:path}") +async def serve_copilot_file( + filename: str, +): + """Serve a file from assets dir.""" + + base_path = Path(copilot_build_dir) + file_path = (base_path / filename).resolve() + + if not is_path_inside(file_path, base_path): + raise HTTPException(status_code=400, detail="Invalid filename") + + if file_path.is_file(): + return FileResponse(file_path) + else: + raise HTTPException(status_code=404, detail="File not found") + + +# ------------------------------------------------------------------------------- +# SLACK HTTP HANDLER +# ------------------------------------------------------------------------------- + +if ( + os.environ.get("SLACK_BOT_TOKEN") + and os.environ.get("SLACK_SIGNING_SECRET") + and not os.environ.get("SLACK_WEBSOCKET_TOKEN") +): + from chainlit.slack.app import slack_app_handler + + @router.post("/slack/events") + async def slack_endpoint(req: Request): + return await slack_app_handler.handle(req) + + +# ------------------------------------------------------------------------------- +# TEAMS HANDLER +# ------------------------------------------------------------------------------- + +if os.environ.get("TEAMS_APP_ID") and os.environ.get("TEAMS_APP_PASSWORD"): + from botbuilder.schema import Activity + + from chainlit.teams.app import adapter, bot + + @router.post("/teams/events") + async def teams_endpoint(req: Request): + body = await req.json() + activity = Activity().deserialize(body) + auth_header = req.headers.get("Authorization", "") + response = await adapter.process_activity(activity, auth_header, bot.on_turn) + return response + + +# ------------------------------------------------------------------------------- +# HTTP HANDLERS +# ------------------------------------------------------------------------------- + + +def replace_between_tags( + text: str, start_tag: str, end_tag: str, replacement: str +) -> str: + """Replace text between two tags in a string.""" + + pattern = start_tag + ".*?" + end_tag + return re.sub(pattern, start_tag + replacement + end_tag, text, flags=re.DOTALL) + + +def get_html_template(root_path): + """ + Get HTML template for the index view. + """ + root_path = root_path.rstrip("/") # Avoid duplicated / when joining with root path. + + custom_theme = None + custom_theme_file_path = Path(public_dir) / "theme.json" + if ( + is_path_inside(custom_theme_file_path, Path(public_dir)) + and custom_theme_file_path.is_file() + ): + custom_theme = json.loads(custom_theme_file_path.read_text(encoding="utf-8")) + + PLACEHOLDER = "" + JS_PLACEHOLDER = "" + CSS_PLACEHOLDER = "" + + default_url = config.ui.custom_meta_url or "https://github.com/Chainlit/chainlit" + default_meta_image_url = ( + "https://chainlit-cloud.s3.eu-west-3.amazonaws.com/logo/chainlit_banner.png" + ) + meta_image_url = config.ui.custom_meta_image_url or default_meta_image_url + favicon_path = "/favicon" + + tags = f"""{config.ui.name} + + + + + + + + """ + + js = f"""""" + + css = None + if config.ui.custom_css: + css = f"""""" + + if config.ui.custom_js: + js += f"""""" + + font = None + if custom_theme and "custom_fonts" in custom_theme: + font = "\n".join( + f"""""" + for f in custom_theme["custom_fonts"] + ) + + index_html_file_path = os.path.join(build_dir, "index.html") + + with open(index_html_file_path, encoding="utf-8") as f: + content = f.read() + content = content.replace(PLACEHOLDER, tags) + if js: + content = content.replace(JS_PLACEHOLDER, js) + if css: + content = content.replace(CSS_PLACEHOLDER, css) + if font is not None: + content = replace_between_tags( + content, "", "", font + ) + content = content.replace('href="/', f'href="{root_path}/') + content = content.replace('src="/', f'src="{root_path}/') + return content + + +def get_user_facing_url(url: URL): + """ + Return the user facing URL for a given URL. + Handles deployment with proxies (like cloud run). + """ + chainlit_url = os.environ.get("CHAINLIT_URL") + + # No config, we keep the URL as is + if not chainlit_url: + url = url.replace(query="", fragment="") + return url.__str__() + + config_url = URL(chainlit_url).replace( + query="", + fragment="", + ) + # Remove trailing slash from config URL + if config_url.path.endswith("/"): + config_url = config_url.replace(path=config_url.path[:-1]) + + return config_url.__str__() + url.path + + +@router.get("/auth/config") +async def auth(request: Request): + return get_configuration() + + +def _get_response_dict(access_token: str) -> dict: + """Get the response dictionary for the auth response.""" + + return {"success": True} + + +def _get_auth_response(access_token: str, redirect_to_callback: bool) -> Response: + """Get the redirect params for the OAuth callback.""" + + response_dict = _get_response_dict(access_token) + + if redirect_to_callback: + root_path = os.environ.get("CHAINLIT_ROOT_PATH", "") + root_path = "" if root_path == "/" else root_path + redirect_url = ( + f"{root_path}/login/callback?{urllib.parse.urlencode(response_dict)}" + ) + + return RedirectResponse( + # FIXME: redirect to the right frontend base url to improve the dev environment + url=redirect_url, + status_code=302, + ) + + return JSONResponse(response_dict) + + +def _get_oauth_redirect_error(request: Request, error: str) -> Response: + """Get the redirect response for an OAuth error.""" + params = urllib.parse.urlencode( + { + "error": error, + } + ) + response = RedirectResponse(url=str(request.url_for("login")) + "?" + params) + return response + + +async def _authenticate_user( + request: Request, user: Optional[User], redirect_to_callback: bool = False +) -> Response: + """Authenticate a user and return the response.""" + + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="credentialssignin", + ) + + # If a data layer is defined, attempt to persist user. + if data_layer := get_data_layer(): + try: + await data_layer.create_user(user) + except Exception as e: + # Catch and log exceptions during user creation. + # TODO: Make this catch only specific errors and allow others to propagate. + logger.error(f"Error creating user: {e}") + + access_token = create_jwt(user) + + response = _get_auth_response(access_token, redirect_to_callback) + + set_auth_cookie(request, response, access_token) + + return response + + +@router.post("/login") +async def login( + request: Request, + response: Response, + form_data: OAuth2PasswordRequestForm = Depends(), +): + """ + Login a user using the password auth callback. + """ + if not config.code.password_auth_callback: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="No auth_callback defined" + ) + + user = await config.code.password_auth_callback( + form_data.username, form_data.password + ) + + return await _authenticate_user(request, user) + + +@router.post("/logout") +async def logout(request: Request, response: Response): + """Logout the user by calling the on_logout callback.""" + clear_auth_cookie(request, response) + + if config.code.on_logout: + return await config.code.on_logout(request, response) + + return {"success": True} + + +@router.post("/auth/jwt") +async def jwt_auth(request: Request): + """Login a user using a valid jwt.""" + from jwt import InvalidTokenError + + auth_header: Optional[str] = request.headers.get("Authorization") + if not auth_header: + raise HTTPException(status_code=401, detail="Authorization header missing") + + # Check if it starts with "Bearer " + try: + scheme, token = auth_header.split() + if scheme.lower() != "bearer": + raise HTTPException( + status_code=401, + detail="Invalid authentication scheme. Please use Bearer", + ) + except ValueError: + raise HTTPException( + status_code=401, detail="Invalid authorization header format" + ) + + try: + user = decode_jwt(token) + return await _authenticate_user(request, user) + except InvalidTokenError: + raise HTTPException(status_code=401, detail="Invalid token") + + +@router.post("/auth/header") +async def header_auth(request: Request): + """Login a user using the header_auth_callback.""" + if not config.code.header_auth_callback: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="No header_auth_callback defined", + ) + + user = await config.code.header_auth_callback(request.headers) + + return await _authenticate_user(request, user) + + +@router.get("/auth/oauth/{provider_id}") +async def oauth_login(provider_id: str, request: Request): + """Redirect the user to the oauth provider login page.""" + if config.code.oauth_callback is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="No oauth_callback defined", + ) + + provider = get_oauth_provider(provider_id) + if not provider: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Provider {provider_id} not found", + ) + + random = random_secret(32) + + params = urllib.parse.urlencode( + { + "client_id": provider.client_id, + "redirect_uri": f"{get_user_facing_url(request.url)}/callback", + "state": random, + **provider.authorize_params, + } + ) + response = RedirectResponse( + url=f"{provider.authorize_url}?{params}", + ) + + set_oauth_state_cookie(response, random) + + return response + + +@router.get("/auth/oauth/{provider_id}/callback") +async def oauth_callback( + provider_id: str, + request: Request, + error: Optional[str] = None, + code: Optional[str] = None, + state: Optional[str] = None, +): + """Handle the oauth callback and login the user.""" + + if config.code.oauth_callback is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="No oauth_callback defined", + ) + + provider = get_oauth_provider(provider_id) + if not provider: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Provider {provider_id} not found", + ) + + if error: + return _get_oauth_redirect_error(request, error) + + if not code or not state: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Missing code or state", + ) + + try: + validate_oauth_state_cookie(request, state) + except Exception as e: + logger.exception("Unable to validate oauth state: %s", e) + + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Unauthorized", + ) + + url = get_user_facing_url(request.url) + token = await provider.get_token(code, url) + + (raw_user_data, default_user) = await provider.get_user_info(token) + + user = await config.code.oauth_callback( + provider_id, token, raw_user_data, default_user + ) + + response = await _authenticate_user(request, user, redirect_to_callback=True) + + clear_oauth_state_cookie(response) + + return response + + +# specific route for azure ad hybrid flow +@router.post("/auth/oauth/azure-ad-hybrid/callback") +async def oauth_azure_hf_callback( + request: Request, + error: Optional[str] = None, + code: Annotated[Optional[str], Form()] = None, + id_token: Annotated[Optional[str], Form()] = None, +): + """Handle the azure ad hybrid flow callback and login the user.""" + + provider_id = "azure-ad-hybrid" + if config.code.oauth_callback is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="No oauth_callback defined", + ) + + provider = get_oauth_provider(provider_id) + if not provider: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Provider {provider_id} not found", + ) + + if error: + return _get_oauth_redirect_error(request, error) + + if not code: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Missing code", + ) + + url = get_user_facing_url(request.url) + token = await provider.get_token(code, url) + + (raw_user_data, default_user) = await provider.get_user_info(token) + + user = await config.code.oauth_callback( + provider_id, token, raw_user_data, default_user, id_token + ) + + response = await _authenticate_user(request, user, redirect_to_callback=True) + + clear_oauth_state_cookie(response) + + return response + + +GenericUser = Union[User, PersistedUser, None] +UserParam = Annotated[GenericUser, Depends(get_current_user)] + + +@router.get("/user") +async def get_user(current_user: UserParam) -> GenericUser: + return current_user + + +_language_pattern = ( + "^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,4})?(-[a-zA-Z0-9]{2,8})?(-x-[a-zA-Z0-9]{1,8})?$" +) + + +@router.post("/set-session-cookie") +async def set_session_cookie(request: Request, response: Response): + body = await request.json() + session_id = body.get("session_id") + + is_local = request.client and request.client.host in ["127.0.0.1", "localhost"] + + response.set_cookie( + key="X-Chainlit-Session-id", + value=session_id, + path="/", + httponly=True, + secure=not is_local, + samesite="lax" if is_local else "none", + ) + + return {"message": "Session cookie set"} + + +@router.get("/project/translations") +async def project_translations( + language: str = Query( + default="en-US", description="Language code", pattern=_language_pattern + ), +): + """Return project translations.""" + + # Use configured language if set, otherwise use the language from query + effective_language = config.ui.language or language + + # Load translation based on the effective language + translation = config.load_translation(effective_language) + + return JSONResponse( + content={ + "translation": translation, + } + ) + + +@router.get("/project/settings") +async def project_settings( + current_user: UserParam, + language: str = Query( + default="en-US", description="Language code", pattern=_language_pattern + ), + chat_profile: Optional[str] = Query( + default=None, description="Current chat profile name" + ), +): + """Return project settings. This is called by the UI before the establishing the websocket connection.""" + + # Use configured language if set, otherwise use the language from query + effective_language = config.ui.language or language + + # Load the markdown file based on the provided language + markdown = get_markdown_str(config.root, effective_language) + + chat_profiles = [] + profiles: list[dict] = [] + if config.code.set_chat_profiles: + chat_profiles = await config.code.set_chat_profiles( + current_user, effective_language + ) + if chat_profiles: + for p in chat_profiles: + d = p.to_dict() + d.pop("config_overrides", None) + profiles.append(d) + + starters = [] + if config.code.set_starters: + s = await config.code.set_starters(current_user, effective_language) + if s: + starters = [it.to_dict() for it in s] + + starter_categories = [] + if config.code.set_starter_categories: + sc = await config.code.set_starter_categories( + current_user, effective_language, chat_profile + ) + if sc: + starter_categories = [it.to_dict() for it in sc] + + data_layer = get_data_layer() + debug_url = ( + await data_layer.build_debug_url() if data_layer and config.run.debug else None + ) + + cfg = config + if chat_profile and chat_profiles: + current_profile = next( + (p for p in chat_profiles if p.name == chat_profile), None + ) + if current_profile and getattr(current_profile, "config_overrides", None): + cfg = config.with_overrides(current_profile.config_overrides) + + return JSONResponse( + content={ + "ui": cfg.ui.model_dump(), + "features": cfg.features.model_dump(), + "userEnv": cfg.project.user_env, + "maskUserEnv": cfg.project.mask_user_env, + "dataPersistence": data_layer is not None, + "threadResumable": bool(config.code.on_chat_resume), + # Expose whether shared threads feature is enabled (flag + app callback) + "threadSharing": bool( + getattr(cfg.features, "allow_thread_sharing", False) + and getattr(config.code, "on_shared_thread_view", None) + ), + "markdown": markdown, + "chatProfiles": profiles, + "starters": starters, + "starterCategories": starter_categories, + "debugUrl": debug_url, + } + ) + + +@router.put("/feedback") +async def update_feedback( + request: Request, + update: UpdateFeedbackRequest, + current_user: UserParam, +): + """Update the human feedback for a particular message.""" + data_layer = get_data_layer() + if not data_layer: + raise HTTPException(status_code=500, detail="Data persistence is not enabled") + + try: + feedback_id = await data_layer.upsert_feedback(feedback=update.feedback) + + if config.code.on_feedback: + try: + from chainlit.context import init_ws_context + from chainlit.session import WebsocketSession + + session = WebsocketSession.get_by_id(update.sessionId) + init_ws_context(session) + + await config.code.on_feedback(update.feedback) + except Exception as callback_error: + logger.error( + f"Error in user-provided on_feedback callback: {callback_error}" + ) + # Optionally, you could continue without raising an exception to avoid disrupting the endpoint. + except Exception as e: + raise HTTPException(detail=str(e), status_code=500) from e + + return JSONResponse(content={"success": True, "feedbackId": feedback_id}) + + +@router.delete("/feedback") +async def delete_feedback( + request: Request, + payload: DeleteFeedbackRequest, + current_user: UserParam, +): + """Delete a feedback.""" + + data_layer = get_data_layer() + + if not data_layer: + raise HTTPException(status_code=400, detail="Data persistence is not enabled") + + feedback_id = payload.feedbackId + + await data_layer.delete_feedback(feedback_id) + return JSONResponse(content={"success": True}) + + +@router.post("/project/threads") +async def get_user_threads( + request: Request, + payload: GetThreadsRequest, + current_user: UserParam, +): + """Get the threads page by page.""" + + data_layer = get_data_layer() + + if not data_layer: + raise HTTPException(status_code=400, detail="Data persistence is not enabled") + + if not current_user: + raise HTTPException(status_code=401, detail="Unauthorized") + + if not isinstance(current_user, PersistedUser): + persisted_user = await data_layer.get_user(identifier=current_user.identifier) + if not persisted_user: + raise HTTPException(status_code=404, detail="User not found") + payload.filter.userId = persisted_user.id + else: + payload.filter.userId = current_user.id + + res = await data_layer.list_threads(payload.pagination, payload.filter) + return JSONResponse(content=res.to_dict()) + + +@router.get("/project/thread/{thread_id}") +async def get_thread( + request: Request, + thread_id: str, + current_user: UserParam, +): + """Get a specific thread.""" + data_layer = get_data_layer() + + if not data_layer: + raise HTTPException(status_code=400, detail="Data persistence is not enabled") + + if not current_user: + raise HTTPException(status_code=401, detail="Unauthorized") + + await is_thread_author(current_user.identifier, thread_id) + + res = await data_layer.get_thread(thread_id) + return JSONResponse(content=res) + + +@router.get("/project/share/{thread_id}") +async def get_shared_thread( + request: Request, + thread_id: str, + current_user: UserParam, +): + """Get a shared thread (read-only for everyone). + + This endpoint is separate from the resume endpoint and does not require the caller + to be the author of the thread. It only returns the thread if its metadata + contains is_shared=True. Otherwise, it returns 404 to avoid leaking existence. + """ + + data_layer = get_data_layer() + + if not data_layer: + raise HTTPException(status_code=400, detail="Data persistence is not enabled") + + # No auth required: allow anonymous access to shared threads + thread = await data_layer.get_thread(thread_id) + + if not thread: + raise HTTPException(status_code=404, detail="Thread not found") + # Extract and normalize metadata (may be dict, strified JSON, or None) + metadata = (thread.get("metadata") if isinstance(thread, dict) else {}) or {} + if isinstance(metadata, str): + try: + metadata = json.loads(metadata) + except Exception: + metadata = {} + if not isinstance(metadata, dict): + metadata = {} + + user_can_view = False + if getattr(config.code, "on_shared_thread_view", None): + try: + user_can_view = await config.code.on_shared_thread_view( + thread, current_user + ) + except Exception: + user_can_view = False + + is_shared = bool(metadata.get("is_shared")) + + # Proceed only raise an error if both conditions are False. + if (not user_can_view) and (not is_shared): + raise HTTPException(status_code=404, detail="Thread not found") + + metadata.pop("chat_profile", None) + metadata.pop("chat_settings", None) + metadata.pop("env", None) + thread["metadata"] = metadata + return JSONResponse(content=thread) + + +@router.get("/project/thread/{thread_id}/element/{element_id}") +async def get_thread_element( + request: Request, + thread_id: str, + element_id: str, + current_user: UserParam, +): + """Get a specific thread element.""" + data_layer = get_data_layer() + + if not data_layer: + raise HTTPException(status_code=400, detail="Data persistence is not enabled") + + if not current_user: + raise HTTPException(status_code=401, detail="Unauthorized") + + await is_thread_author(current_user.identifier, thread_id) + + res = await data_layer.get_element(thread_id, element_id) + return JSONResponse(content=res) + + +@router.put("/project/element") +async def update_thread_element( + payload: ElementRequest, + current_user: UserParam, +): + """Update a specific thread element.""" + + from chainlit.context import init_ws_context + from chainlit.element import ElementDict + from chainlit.session import WebsocketSession + + session = WebsocketSession.get_by_id(payload.sessionId) + context = init_ws_context(session) + + element_dict = cast(ElementDict, payload.element) + + if element_dict["type"] != "custom": + return {"success": False} + + element = _sanitize_custom_element(element_dict) + + if current_user: + if ( + not context.session.user + or context.session.user.identifier != current_user.identifier + ): + raise HTTPException( + status_code=401, + detail="You are not authorized to update elements for this session", + ) + + await element.update() + + return {"success": True} + + +@router.delete("/project/element") +async def delete_thread_element( + payload: ElementRequest, + current_user: UserParam, +): + """Delete a specific thread element.""" + + from chainlit.context import init_ws_context + from chainlit.element import ElementDict + from chainlit.session import WebsocketSession + + session = WebsocketSession.get_by_id(payload.sessionId) + context = init_ws_context(session) + + element_dict = cast(ElementDict, payload.element) + + if element_dict["type"] != "custom": + return {"success": False} + + element = _sanitize_custom_element(element_dict) + + if current_user: + if ( + not context.session.user + or context.session.user.identifier != current_user.identifier + ): + raise HTTPException( + status_code=401, + detail="You are not authorized to remove elements for this session", + ) + + await element.remove() + + return {"success": True} + + +def _sanitize_custom_element(element_dict: "ElementDict") -> "CustomElement": + from chainlit.element import CustomElement + + return CustomElement( + id=element_dict["id"], + for_id=element_dict.get("forId") or "", + thread_id=element_dict.get("threadId") or "", + name=element_dict["name"], + props=element_dict.get("props") or {}, + display=element_dict["display"], + ) + + +@router.put("/project/thread") +async def rename_thread( + request: Request, + payload: UpdateThreadRequest, + current_user: UserParam, +): + """Rename a thread.""" + + data_layer = get_data_layer() + + if not data_layer: + raise HTTPException(status_code=400, detail="Data persistence is not enabled") + + if not current_user: + raise HTTPException(status_code=401, detail="Unauthorized") + + thread_id = payload.threadId + + await is_thread_author(current_user.identifier, thread_id) + + await data_layer.update_thread(thread_id, name=payload.name) + + return JSONResponse(content={"success": True}) + + +@router.put("/project/thread/share") +async def share_thread( + request: Request, + payload: ShareThreadRequest, + current_user: UserParam, +): + """Share or un-share a thread (author only).""" + + data_layer = get_data_layer() + + if not data_layer: + raise HTTPException(status_code=400, detail="Data persistence is not enabled") + + if not current_user: + raise HTTPException(status_code=401, detail="Unauthorized") + + thread_id = payload.threadId + + await is_thread_author(current_user.identifier, thread_id) + + # Fetch current thread and metadata, then toggle is_shared + thread = await data_layer.get_thread(thread_id=thread_id) + metadata = (thread.get("metadata") if thread else {}) or {} + if isinstance(metadata, str): + try: + metadata = json.loads(metadata) + except Exception: + metadata = {} + if not isinstance(metadata, dict): + metadata = {} + + metadata = dict(metadata) + is_shared = bool(payload.isShared) + metadata["is_shared"] = is_shared + if is_shared: + metadata["shared_at"] = utc_now() + else: + metadata.pop("shared_at", None) + try: + await data_layer.update_thread(thread_id=thread_id, metadata=metadata) + logger.debug( + "[share_thread] updated metadata for thread=%s to %s", + thread_id, + metadata, + ) + except Exception as e: + logger.exception("[share_thread] update_thread failed: %s", e) + raise + + return JSONResponse(content={"success": True}) + + +@router.delete("/project/thread") +async def delete_thread( + request: Request, + payload: DeleteThreadRequest, + current_user: UserParam, +): + """Delete a thread.""" + + data_layer = get_data_layer() + + if not data_layer: + raise HTTPException(status_code=400, detail="Data persistence is not enabled") + + if not current_user: + raise HTTPException(status_code=401, detail="Unauthorized") + + thread_id = payload.threadId + + await is_thread_author(current_user.identifier, thread_id) + + await data_layer.delete_thread(thread_id) + return JSONResponse(content={"success": True}) + + +@router.post("/project/action") +async def call_action( + payload: CallActionRequest, + current_user: UserParam, +): + """Run an action.""" + + from chainlit.action import Action + from chainlit.context import init_ws_context + from chainlit.session import WebsocketSession + + session = WebsocketSession.get_by_id(payload.sessionId) + context = init_ws_context(session) + config: ChainlitConfig = session.get_config() + + action = Action(**payload.action) + + if current_user: + if ( + not context.session.user + or context.session.user.identifier != current_user.identifier + ): + raise HTTPException( + status_code=401, + detail="You are not authorized to upload files for this session", + ) + + callback = config.code.action_callbacks.get(action.name) + if callback: + if not context.session.has_first_interaction: + context.session.has_first_interaction = True + asyncio.create_task(context.emitter.init_thread(action.name)) + + response = await callback(action) + else: + raise HTTPException( + status_code=404, + detail=f"No callback found for action {action.name}", + ) + + return JSONResponse(content={"success": True, "response": response}) + + +@router.post("/mcp") +async def connect_mcp( + payload: ConnectMCPRequest, + current_user: UserParam, +): + import asyncio + + from mcp import ClientSession + from mcp.client.sse import sse_client + from mcp.client.stdio import ( + StdioServerParameters, + get_default_environment, + stdio_client, + ) + from mcp.client.streamable_http import streamablehttp_client + + from chainlit.context import init_ws_context + from chainlit.mcp import ( + HttpMcpConnection, + McpConnection, + SseMcpConnection, + StdioMcpConnection, + validate_mcp_command, + ) + from chainlit.session import McpSession, WebsocketSession + + session = WebsocketSession.get_by_id(payload.sessionId) + context = init_ws_context(session) + config: ChainlitConfig = session.get_config() + + if current_user: + if ( + not context.session.user + or context.session.user.identifier != current_user.identifier + ): + raise HTTPException( + status_code=401, + ) + + mcp_enabled = config.features.mcp.enabled + if not mcp_enabled: + raise HTTPException( + status_code=400, + detail="This app does not support MCP.", + ) + + # Disconnect previous session for this name (reconnection) + if payload.name in session.mcp_sessions: + old_mcp = session.mcp_sessions.pop(payload.name) + if on_mcp_disconnect := config.code.on_mcp_disconnect: + try: + await on_mcp_disconnect(payload.name, old_mcp.client) + except Exception: + logger.debug( + "Error in on_mcp_disconnect callback for %s", + payload.name, + exc_info=True, + ) + try: + await old_mcp.close() + except Exception: + logger.debug( + "Error closing old MCP session %s", payload.name, exc_info=True + ) + + # ── Validate config before launching the background task ── + mcp_connection: McpConnection + + if payload.clientType == "sse": + if not config.features.mcp.sse.enabled: + raise HTTPException( + status_code=400, + detail="SSE MCP is not enabled", + ) + mcp_connection = SseMcpConnection( + url=payload.url, + name=payload.name, + headers=getattr(payload, "headers", None), + ) + elif payload.clientType == "stdio": + if not config.features.mcp.stdio.enabled: + raise HTTPException( + status_code=400, + detail="Stdio MCP is not enabled", + ) + env_from_cmd, command, args = validate_mcp_command(payload.fullCommand) + mcp_connection = StdioMcpConnection( + command=command, args=args, name=payload.name + ) + elif payload.clientType == "streamable-http": + if not config.features.mcp.streamable_http.enabled: + raise HTTPException( + status_code=400, + detail="HTTP MCP is not enabled", + ) + mcp_connection = HttpMcpConnection( + url=payload.url, + name=payload.name, + headers=getattr(payload, "headers", None), + ) + else: + raise HTTPException( + status_code=400, + detail=f"Unknown MCP client type: {payload.clientType}", + ) + + # ── Launch the MCP connection in its own background task ── + # + # The background task owns the AsyncExitStack: it enters all context + # managers, calls initialize(), signals ``ready_event``, and then + # blocks on ``stop_event.wait()``. When the stop event fires the + # task wakes up and closes the exit stack *in the same task* that + # opened it — avoiding the cross-task cancel-scope corruption from + # https://github.com/Chainlit/chainlit/issues/2182. + + ready_event: asyncio.Event = asyncio.Event() + stop_event: asyncio.Event = asyncio.Event() + # Mutable container to pass the ClientSession back from the bg task. + result_holder: dict[str, object] = {} + + async def _mcp_session_runner() -> None: + exit_stack = AsyncExitStack() + try: + try: + if isinstance(mcp_connection, SseMcpConnection): + transport = await exit_stack.enter_async_context( + sse_client( + url=mcp_connection.url, + headers=mcp_connection.headers, + ) + ) + elif isinstance(mcp_connection, StdioMcpConnection): + env = get_default_environment() + env.update(env_from_cmd) + server_params = StdioServerParameters( + command=command, args=args, env=env + ) + transport = await exit_stack.enter_async_context( + stdio_client(server_params) + ) + elif isinstance(mcp_connection, HttpMcpConnection): + transport = await exit_stack.enter_async_context( + streamablehttp_client( + url=mcp_connection.url, + headers=mcp_connection.headers, + ) + ) + else: + raise ValueError(f"Unknown client type: {payload.clientType}") + + read, write = transport[:2] + + mcp_client: ClientSession = await exit_stack.enter_async_context( + ClientSession( + read_stream=read, + write_stream=write, + sampling_callback=None, + ) + ) + + await mcp_client.initialize() + result_holder["client"] = mcp_client + + except BaseException as exc: + result_holder["error"] = exc + return # outer finally closes exit_stack + finally: + # Always signal the caller so it doesn't wait forever. + ready_event.set() + + # ── Keep the task (and the exit stack) alive ── + try: + await stop_event.wait() + except asyncio.CancelledError: + logger.debug("MCP background task for %r cancelled", payload.name) + finally: + # Close exit_stack in ALL paths (error, normal shutdown, + # cancellation) — always in the same task that opened it. + logger.debug("Closing MCP exit stack for %r (same-task)", payload.name) + try: + await exit_stack.aclose() + except BaseException: + logger.debug( + "Error closing MCP exit stack for %r", + payload.name, + exc_info=True, + ) + + task = asyncio.create_task( + _mcp_session_runner(), name=f"mcp-session-{payload.name}" + ) + + # Wait for the background task to finish initialisation. + await ready_event.wait() + + if "error" in result_holder: + # The task already exited and cleaned up its exit stack. + # Make sure the task itself is fully done. + try: + await task + except BaseException: + pass + return JSONResponse( + status_code=400, + content={ + "detail": f"Could not connect to the MCP: {result_holder['error']!s}" + }, + ) + + mcp_client_session = cast("ClientSession", result_holder["client"]) + + # Call the user callback + if config.code.on_mcp_connect: + try: + await config.code.on_mcp_connect(mcp_connection, mcp_client_session) + except Exception as e: + # Callback failed — tear down the connection. + stop_event.set() + try: + await task + except BaseException: + pass + return JSONResponse( + status_code=400, + content={"detail": f"Could not connect to the MCP: {e!s}"}, + ) + + # Store the session + mcp_session_obj = McpSession( + name=mcp_connection.name, + client=mcp_client_session, + task=task, + stop_event=stop_event, + ) + session.mcp_sessions[mcp_connection.name] = mcp_session_obj + + tool_list = await mcp_client_session.list_tools() + + return JSONResponse( + content={ + "success": True, + "mcp": { + "name": payload.name, + "tools": [{"name": t.name} for t in tool_list.tools], + "clientType": payload.clientType, + "command": payload.fullCommand + if payload.clientType == "stdio" + else None, + "url": getattr(payload, "url", None) + if payload.clientType in ["sse", "streamable-http"] + else None, + # Include optional headers for SSE and streamable-http connections + "headers": getattr(payload, "headers", None) + if payload.clientType in ["sse", "streamable-http"] + else None, + }, + } + ) + + +@router.delete("/mcp") +async def disconnect_mcp( + payload: DisconnectMCPRequest, + current_user: UserParam, +): + from chainlit.context import init_ws_context + from chainlit.session import WebsocketSession + + session = WebsocketSession.get_by_id(payload.sessionId) + context = init_ws_context(session) + + if current_user: + if ( + not context.session.user + or context.session.user.identifier != current_user.identifier + ): + raise HTTPException( + status_code=401, + ) + + callback = config.code.on_mcp_disconnect + if payload.name in session.mcp_sessions: + mcp_session_obj = session.mcp_sessions.pop(payload.name) + try: + if callback: + await callback(payload.name, mcp_session_obj.client) + except Exception as e: + raise HTTPException( + status_code=400, + detail=f"Could not disconnect from the MCP: {e!s}", + ) + finally: + await mcp_session_obj.close() + + return JSONResponse(content={"success": True}) + + +@router.post("/project/file") +async def upload_file( + current_user: UserParam, + session_id: str, + file: UploadFile, + ask_parent_id: Optional[str] = None, +): + """Upload a file to the session files directory.""" + + from chainlit.session import WebsocketSession + + session = WebsocketSession.get_by_id(session_id) + + if not session: + raise HTTPException( + status_code=404, + detail="Session not found", + ) + + if current_user: + if not session.user or session.user.identifier != current_user.identifier: + raise HTTPException( + status_code=401, + detail="You are not authorized to upload files for this session", + ) + + session.files_dir.mkdir(exist_ok=True) + + try: + content = await file.read() + + assert file.filename, "No filename for uploaded file" + assert file.content_type, "No content type for uploaded file" + + spec: AskFileSpec = session.files_spec.get(ask_parent_id, None) + if not spec and ask_parent_id: + raise HTTPException( + status_code=404, + detail="Parent message not found", + ) + + try: + validate_file_upload(file, spec=spec) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + file_response = await session.persist_file( + name=file.filename, content=content, mime=file.content_type + ) + + return JSONResponse(content=file_response) + finally: + await file.close() + + +def validate_file_upload(file: UploadFile, spec: Optional[AskFileSpec] = None): + """Validate the file upload as configured in config.features.spontaneous_file_upload or by AskFileSpec + for a specific message. + + Args: + file (UploadFile): The file to validate. + spec (AskFileSpec): The file spec to validate against if any. + Raises: + ValueError: If the file is not allowed. + """ + if not spec and config.features.spontaneous_file_upload is None: + """Default for a missing config is to allow the fileupload without any restrictions""" + return + + if not spec and not config.features.spontaneous_file_upload.enabled: + raise ValueError("File upload is not enabled") + + validate_file_mime_type(file, spec) + validate_file_size(file, spec) + + +def validate_file_mime_type(file: UploadFile, spec: Optional[AskFileSpec]): + """Validate the file mime type as configured in config.features.spontaneous_file_upload. + Args: + file (UploadFile): The file to validate. + Raises: + ValueError: If the file type is not allowed. + """ + + if not spec and ( + config.features.spontaneous_file_upload is None + or config.features.spontaneous_file_upload.accept is None + ): + "Accept is not configured, allowing all file types" + return + + accept = config.features.spontaneous_file_upload.accept if not spec else spec.accept + + assert isinstance(accept, List) or isinstance(accept, dict), ( + "Invalid configuration for spontaneous_file_upload, accept must be a list or a dict" + ) + + if isinstance(accept, List): + for pattern in accept: + if fnmatch.fnmatch(str(file.content_type), pattern): + return + elif isinstance(accept, dict): + for pattern, extensions in accept.items(): + if fnmatch.fnmatch(str(file.content_type), pattern): + if len(extensions) == 0: + return + for extension in extensions: + if file.filename is not None and file.filename.lower().endswith( + extension.lower() + ): + return + raise ValueError("File type not allowed") + + +def validate_file_size(file: UploadFile, spec: Optional[AskFileSpec]): + """Validate the file size as configured in config.features.spontaneous_file_upload. + Args: + file (UploadFile): The file to validate. + Raises: + ValueError: If the file size is too large. + """ + if not spec and ( + config.features.spontaneous_file_upload is None + or config.features.spontaneous_file_upload.max_size_mb is None + ): + return + + max_size_mb = ( + config.features.spontaneous_file_upload.max_size_mb + if not spec + else spec.max_size_mb + ) + if file.size is not None and file.size > max_size_mb * 1024 * 1024: + raise ValueError("File size too large") + + +@router.get("/project/file/{file_id}") +async def get_file( + file_id: str, + session_id: str, + current_user: UserParam, +): + """Get a file from the session files directory.""" + from chainlit.session import WebsocketSession + + session = WebsocketSession.get_by_id(session_id) if session_id else None + + if not session: + raise HTTPException( + status_code=401, + detail="Unauthorized", + ) + + if current_user: + if not session.user or session.user.identifier != current_user.identifier: + raise HTTPException( + status_code=401, + detail="You are not authorized to download files from this session", + ) + + if file_id in session.files: + file = session.files[file_id] + return FileResponse(file["path"], media_type=file["type"]) + else: + raise HTTPException(status_code=404, detail="File not found") + + +@router.get("/favicon") +async def get_favicon(): + """Get the favicon for the UI.""" + custom_favicon_path = os.path.join(APP_ROOT, "public", "favicon.*") + files = glob.glob(custom_favicon_path) + + if files: + favicon_path = files[0] + else: + favicon_path = os.path.join(build_dir, "favicon.svg") + + media_type, _ = mimetypes.guess_type(favicon_path) + + return FileResponse(favicon_path, media_type=media_type) + + +@router.get("/logo") +async def get_logo(theme: Optional[Theme] = Query(Theme.light)): + """Get the default logo for the UI.""" + theme_value = theme.value if theme else Theme.light.value + logo_path = None + + for path in [ + os.path.join(APP_ROOT, "public", f"logo_{theme_value}.*"), + os.path.join(build_dir, "assets", f"logo_{theme_value}*.*"), + ]: + files = glob.glob(path) + + if files: + logo_path = files[0] + break + + if not logo_path: + logo_path = os.path.join( + os.path.dirname(__file__), + "frontend", + "dist", + f"logo_{theme_value}.svg", + ) + logger.info("Missing custom logo. Falling back to default logo.") + + media_type, _ = mimetypes.guess_type(logo_path) + + return FileResponse(logo_path, media_type=media_type) + + +@router.get("/avatars/{avatar_id:str}") +async def get_avatar(avatar_id: str): + """Get the avatar for the user based on the avatar_id.""" + if not re.match(r"^[a-zA-Z0-9_ .-]+$", avatar_id): + raise HTTPException(status_code=400, detail="Invalid avatar_id") + + if avatar_id == "default": + avatar_id = config.ui.name + + avatar_id = avatar_id.strip().lower().replace(" ", "_").replace(".", "_") + + base_path = Path(APP_ROOT) / "public" / "avatars" + avatar_pattern = f"{avatar_id}.*" + + matching_files = base_path.glob(avatar_pattern) + + if avatar_path := next(matching_files, None): + if not is_path_inside(avatar_path, base_path): + raise HTTPException(status_code=400, detail="Invalid filename") + media_type, _ = mimetypes.guess_type(str(avatar_path)) + + return FileResponse(avatar_path, media_type=media_type) + + return await get_favicon() + + +@router.head("/") +def status_check(): + """Check if the site is operational.""" + return {"message": "Site is operational"} + + +@router.get("/health") +def health_check(): + """Health check endpoint for container orchestration and monitoring.""" + return {"status": "ok"} + + +@router.get("/{full_path:path}") +async def serve(request: Request): + """Serve the UI files.""" + root_path = os.getenv("CHAINLIT_PARENT_ROOT_PATH", "") + os.getenv( + "CHAINLIT_ROOT_PATH", "" + ) + html_template = get_html_template(root_path) + response = HTMLResponse(content=html_template, status_code=200) + + return response + + +app.include_router(router) + +import chainlit.socket # noqa diff --git a/backend/chainlit/session.py b/backend/chainlit/session.py new file mode 100644 index 0000000..d88160d --- /dev/null +++ b/backend/chainlit/session.py @@ -0,0 +1,419 @@ +import asyncio +import json +import mimetypes +import re +import shutil +import uuid +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any, Callable, Deque, Dict, Literal, Optional, Union + +import aiofiles + +from chainlit.logger import logger +from chainlit.types import AskFileSpec, FileReference + +if TYPE_CHECKING: + from mcp import ClientSession + + from chainlit.config import ChainlitConfig + from chainlit.types import FileDict + from chainlit.user import PersistedUser, User + +_CLOSE_TIMEOUT = 10.0 # seconds to wait for a background MCP task to finish + + +@dataclass +class McpSession: + """Lifecycle wrapper for a single MCP connection. + + Each MCP connection is run inside its own ``asyncio.Task``. That task + creates the ``AsyncExitStack``, enters all context managers (transport, + ``ClientSession``), calls ``initialize()``, and then blocks on + ``stop_event.wait()``. When the event is set the task wakes up and + closes the exit stack **in the same task** that opened it, avoiding + the cross-task cancel-scope corruption described in + https://github.com/Chainlit/chainlit/issues/2182. + + Original solution by @nigiva: + https://github.com/Chainlit/chainlit/issues/2182#issuecomment-2840283194 + """ + + name: str + client: "ClientSession" + task: asyncio.Task + stop_event: asyncio.Event = field(default_factory=asyncio.Event) + + async def close(self) -> None: + """Signal the background task to shut down and wait for it.""" + self.stop_event.set() + try: + await asyncio.wait_for(self.task, timeout=_CLOSE_TIMEOUT) + except asyncio.TimeoutError: + logger.warning( + "MCP session %r did not shut down within %.1fs — cancelling", + self.name, + _CLOSE_TIMEOUT, + ) + self.task.cancel() + try: + await self.task + except BaseException: + pass + except asyncio.CancelledError: + pass + except BaseException: + logger.debug("Error while closing MCP session %r", self.name, exc_info=True) + + # Backward-compatible tuple unpacking. + # The original Chainlit format is ``(ClientSession, AsyncExitStack)``. + # Code that does ``client, _ = mcp_sessions[name]`` will get the + # ``ClientSession`` and a safe sentinel (not the real exit stack, + # which must only be closed by the owning background task). + def __iter__(self): + return iter((self.client, self)) + + +ClientType = Literal["webapp", "copilot", "teams", "slack", "discord"] + + +class JSONEncoderIgnoreNonSerializable(json.JSONEncoder): + def default(self, o): + try: + return super().default(o) + except TypeError: + return None + + +def clean_metadata(metadata: Dict, max_size: int = 1048576): + cleaned_metadata = json.loads( + json.dumps(metadata, cls=JSONEncoderIgnoreNonSerializable, ensure_ascii=False) + ) + + metadata_size = len(json.dumps(cleaned_metadata).encode("utf-8")) + if metadata_size > max_size: + # Redact the metadata if it exceeds the maximum size + cleaned_metadata = { + "message": f"Metadata size exceeds the limit of {max_size} bytes. Redacted." + } + + return cleaned_metadata + + +class BaseSession: + """Base object.""" + + thread_id_to_resume: Optional[str] = None + client_type: ClientType + current_task: Optional[asyncio.Task] = None + chat_started: bool = False + + def __init__( + self, + # Id of the session + id: str, + client_type: ClientType, + # Thread id + thread_id: Optional[str], + # Logged-in user information + user: Optional[Union["User", "PersistedUser"]], + # Logged-in user token + token: Optional[str], + # User specific environment variables. Empty if no user environment variables are required. + user_env: Optional[Dict[str, str]], + # WSGI environment variables for the connection request + environ: Optional[dict[str, Any]] = None, + # Chat profile selected before the session was created + chat_profile: Optional[str] = None, + ): + if thread_id: + self.thread_id_to_resume = thread_id + self.thread_id = thread_id or str(uuid.uuid4()) + self.user = user + self.client_type = client_type + self.token = token + self.has_first_interaction = False + self.chat_started = False + self.user_env = user_env or {} + self.environ = environ or {} + self.chat_profile = chat_profile + + self.files: Dict[str, FileDict] = {} + self.files_spec: Dict[str, AskFileSpec] = {} + + self.id = id + + self.chat_settings: Dict[str, Any] = {} + + @property + def files_dir(self): + from chainlit.config import FILES_DIRECTORY + + return FILES_DIRECTORY / self.id + + async def persist_file( + self, + name: str, + mime: str, + path: Optional[str] = None, + content: Optional[Union[bytes, str]] = None, + ) -> FileReference: + if not path and not content: + raise ValueError( + "Either path or content must be provided to persist a file" + ) + + self.files_dir.mkdir(exist_ok=True) + + file_id = str(uuid.uuid4()) + + file_path = self.files_dir / file_id + + file_extension = mimetypes.guess_extension(mime) + + if file_extension: + file_path = file_path.with_suffix(file_extension) + + if path: + # Copy the file from the given path + async with ( + aiofiles.open(path, "rb") as src, + aiofiles.open(file_path, "wb") as dst, + ): + await dst.write(await src.read()) + elif content: + # Write the provided content to the file + async with aiofiles.open(file_path, "wb") as buffer: + if isinstance(content, str): + content = content.encode("utf-8") + await buffer.write(content) + + # Get the file size + file_size = file_path.stat().st_size + # Store the file content in memory + self.files[file_id] = { + "id": file_id, + "path": file_path, + "name": name, + "type": mime, + "size": file_size, + } + + return {"id": file_id} + + def to_persistable(self) -> Dict: + from chainlit.config import config + from chainlit.user_session import user_sessions + + user_session = user_sessions.get(self.id) or {} # type: Dict + user_session["chat_settings"] = self.chat_settings + user_session["chat_profile"] = self.chat_profile + user_session["client_type"] = self.client_type + + # Check config setting for whether to persist user environment variables + user_session_copy = user_session.copy() + if not config.project.persist_user_env: + # Remove user environment variables (API keys) before persisting to database + user_session_copy["env"] = {} + + metadata = clean_metadata(user_session_copy) + return metadata + + +class HTTPSession(BaseSession): + """Internal HTTP session object. Used to consume Chainlit through API (no websocket).""" + + def __init__( + self, + # Id of the session + id: str, + client_type: ClientType, + # Thread id + thread_id: Optional[str] = None, + # Logged-in user information + user: Optional[Union["User", "PersistedUser"]] = None, + # Logged-in user token + token: Optional[str] = None, + user_env: Optional[Dict[str, str]] = None, + # WSGI environment variables for the connection request + environ: Optional[dict[str, Any]] = None, + ): + super().__init__( + id=id, + thread_id=thread_id, + user=user, + token=token, + client_type=client_type, + user_env=user_env, + environ=environ, + ) + + async def delete(self): + """Delete the session.""" + if self.files_dir.is_dir(): + shutil.rmtree(self.files_dir) + + +ThreadQueue = Deque[tuple[Callable, object, tuple, Dict]] + + +class WebsocketSession(BaseSession): + """Internal web socket session object. + + A socket id is an ephemeral id that can't be used as a session id + (as it is for instance regenerated after each reconnection). + + The Session object store an internal mapping between socket id and + a server generated session id, allowing to persists session + between socket reconnection but also retrieving a session by + socket id for convenience. + """ + + to_clear: bool = False + + mcp_sessions: dict[str, McpSession] + + def __init__( + self, + # Id from the session cookie + id: str, + # Associated socket id + socket_id: str, + # Function to emit to the client + emit: Callable[[str, Any], None], + # Function to emit to the client and wait for a response + emit_call: Callable[[Literal["ask", "call_fn"], Any, Optional[int]], Any], + # User specific environment variables. Empty if no user environment variables are required. + user_env: Dict[str, str], + client_type: ClientType, + # WSGI environment variables for the connection request + environ: Optional[dict[str, Any]] = None, + # Thread id + thread_id: Optional[str] = None, + # Logged-in user information + user: Optional[Union["User", "PersistedUser"]] = None, + # Logged-in user token + token: Optional[str] = None, + # Chat profile selected before the session was created + chat_profile: Optional[str] = None, + ): + super().__init__( + id=id, + thread_id=thread_id, + user=user, + token=token, + user_env=user_env, + client_type=client_type, + chat_profile=chat_profile, + environ=environ, + ) + + self.socket_id = socket_id + self.emit_call = emit_call + self.emit = emit + + self.restored = False + + self.thread_queues: Dict[str, ThreadQueue] = {} + self.mcp_sessions = {} + + match = ( + re.match( + r"^\s*([a-zA-Z0-9-]+)", environ.get("HTTP_ACCEPT_LANGUAGE", "en-US") + ) + if environ + else None + ) + self.language = match.group(1) if match else "en-US" + + self.config: ChainlitConfig = self.get_config() + + ws_sessions_id[self.id] = self + ws_sessions_sid[socket_id] = self + + def get_config(self) -> "ChainlitConfig": + """ + Return the config for this session: overridden if chat profile exists and has overrides, else global config. + """ + from chainlit.config import config as global_config + + # If no chat profile, always fallback to global config + if not self.chat_profile: + return global_config + # If already computed, use self.config + if hasattr(self, "config") and self.config: + return self.config + # Try to compute overrides + cfg = global_config + if global_config.code.set_chat_profiles: + import asyncio + + try: + profiles = asyncio.get_event_loop().run_until_complete( + global_config.code.set_chat_profiles(self.user, self.language) + ) + current_profile = next( + (p for p in profiles if p.name == self.chat_profile), None + ) + if current_profile and getattr( + current_profile, "config_overrides", None + ): + cfg = global_config.with_overrides(current_profile.config_overrides) + except Exception: + pass + self.config = cfg + return cfg + + def restore(self, new_socket_id: str): + """Associate a new socket id to the session.""" + ws_sessions_sid.pop(self.socket_id, None) + ws_sessions_sid[new_socket_id] = self + self.socket_id = new_socket_id + self.restored = True + + async def delete(self): + """Delete the session.""" + if self.files_dir.is_dir(): + shutil.rmtree(self.files_dir) + ws_sessions_sid.pop(self.socket_id, None) + ws_sessions_id.pop(self.id, None) + + for mcp_session in list(self.mcp_sessions.values()): + try: + await mcp_session.close() + except Exception: + logger.debug( + "Error closing MCP session %r during session delete", + mcp_session.name, + exc_info=True, + ) + self.mcp_sessions.clear() + + async def flush_method_queue(self): + for method_name, queue in self.thread_queues.items(): + while queue: + method, self, args, kwargs = queue.popleft() + try: + await method(self, *args, **kwargs) + except Exception as e: + logger.error(f"Error while flushing {method_name}: {e}") + + @classmethod + def get(cls, socket_id: str): + """Get session by socket id.""" + return ws_sessions_sid.get(socket_id) + + @classmethod + def get_by_id(cls, session_id: str): + """Get session by session id.""" + return ws_sessions_id.get(session_id) + + @classmethod + def require(cls, socket_id: str): + """Throws an exception if the session is not found.""" + if session := cls.get(socket_id): + return session + raise ValueError("Session not found") + + +ws_sessions_sid: Dict[str, WebsocketSession] = {} +ws_sessions_id: Dict[str, WebsocketSession] = {} diff --git a/backend/chainlit/sidebar.py b/backend/chainlit/sidebar.py new file mode 100644 index 0000000..aedda66 --- /dev/null +++ b/backend/chainlit/sidebar.py @@ -0,0 +1,55 @@ +import asyncio +from typing import List, Optional + +from chainlit.context import context +from chainlit.element import ElementBased + + +class ElementSidebar: + """Helper class to open/close the element sidebar server side. + The element sidebar accepts a title and list of elements.""" + + @staticmethod + async def set_title(title: str): + """ + Sets the title of the element sidebar and opens it. + + The sidebar will automatically open when a title is set using this method. + + Args: + title (str): The title to display at the top of the sidebar. + + Returns: + None: This method does not return anything. + """ + await context.emitter.emit("set_sidebar_title", title) + + @staticmethod + async def set_elements(elements: List[ElementBased], key: Optional[str] = None): + """ + Sets the elements to display in the sidebar and controls sidebar visibility. + + This method sends all provided elements to the client and updates the sidebar. + Passing an empty list will close the sidebar, while passing at least one element + will open it. + + Args: + elements (List[ElementBased]): A list of ElementBased objects to display in the sidebar. + key (Optional[str], optional): If the sidebar is already opened with the same key, elements will not be replaced. + + Returns: + None: This method does not return anything. + + Note: + This method first sends each element separately using their send() method, + then emits an event with all element dictionaries and the optional key. + """ + coros = [ + element.send(for_id=element.for_id or "", persist=False) + for element in elements + ] + await asyncio.gather(*coros) + await context.emitter.emit( + "set_sidebar_elements", + {"elements": [el.to_dict() for el in elements], "key": key}, + ) diff --git a/backend/chainlit/slack/__init__.py b/backend/chainlit/slack/__init__.py new file mode 100644 index 0000000..0e1b780 --- /dev/null +++ b/backend/chainlit/slack/__init__.py @@ -0,0 +1,6 @@ +import importlib.util + +if importlib.util.find_spec("slack_bolt") is None: + raise ValueError( + "The slack_bolt package is required to integrate Chainlit with a Slack app. Run `pip install slack_bolt --upgrade`" + ) diff --git a/backend/chainlit/slack/app.py b/backend/chainlit/slack/app.py new file mode 100644 index 0000000..a5c62c0 --- /dev/null +++ b/backend/chainlit/slack/app.py @@ -0,0 +1,558 @@ +import asyncio +import os +import re +import uuid +from functools import partial +from typing import Dict, List, Optional, Union + +import httpx +from slack_bolt.adapter.fastapi.async_handler import AsyncSlackRequestHandler +from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler +from slack_bolt.async_app import AsyncApp +from slack_sdk.errors import SlackApiError + +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 SlackEmitter(BaseChainlitEmitter): + def __init__( + self, + session: HTTPSession, + app: AsyncApp, + channel_id: str, + say, + thread_ts: Optional[str] = None, + ): + super().__init__(session) + self.app = app + self.channel_id = channel_id + self.say = say + self.thread_ts = thread_ts + + 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[bytes, str]] = None + + if persisted_file: + file = str(persisted_file["path"]) + 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 = response.content + + if not file: + return + + await self.app.client.files_upload_v2( + channel=self.channel_id, + thread_ts=self.thread_ts, + file=file, + title=element_dict.get("name"), + ) + + async def send_step(self, step_dict: StepDict): + step_type = step_dict.get("type") + is_assistant_message = step_type == "assistant_message" + is_empty_output = not step_dict.get("output") + + if is_empty_output or not is_assistant_message: + return + + enable_feedback = get_data_layer() + blocks: List[Dict] = [ + { + "type": "section", + "text": {"type": "mrkdwn", "text": step_dict["output"]}, + } + ] + if enable_feedback: + current_run = context.current_run + scorable_id = current_run.id if current_run else step_dict.get("id") + blocks.append( + { + "type": "actions", + "elements": [ + { + "action_id": "thumbdown", + "type": "button", + "text": { + "type": "plain_text", + "emoji": True, + "text": ":thumbsdown:", + }, + "value": scorable_id, + }, + { + "action_id": "thumbup", + "type": "button", + "text": { + "type": "plain_text", + "emoji": True, + "text": ":thumbsup:", + }, + "value": scorable_id, + }, + ], + } + ) + await self.say( + text=step_dict["output"], blocks=blocks, thread_ts=self.thread_ts + ) + + async def update_step(self, step_dict: StepDict): + is_assistant_message = step_dict["type"] == "assistant_message" + + if not is_assistant_message: + return + + await self.send_step(step_dict) + + +slack_app = AsyncApp( + token=os.environ.get("SLACK_BOT_TOKEN"), + signing_secret=os.environ.get("SLACK_SIGNING_SECRET"), +) + + +async def start_socket_mode(): + """ + Initializes and starts the Slack app in Socket Mode asynchronously. + + Uses the SLACK_WEBSOCKET_TOKEN from environment variables to authenticate. + """ + handler = AsyncSocketModeHandler(slack_app, os.environ.get("SLACK_WEBSOCKET_TOKEN")) + await handler.start_async() + + +def init_slack_context( + session: HTTPSession, + slack_channel_id: str, + event, + say, + thread_ts: Optional[str] = None, +) -> ChainlitContext: + emitter = SlackEmitter( + session=session, + app=slack_app, + channel_id=slack_channel_id, + say=say, + thread_ts=thread_ts, + ) + context = ChainlitContext(session=session, emitter=emitter) + context_var.set(context) + user_session.set("slack_event", event) + user_session.set( + "fetch_slack_message_history", + partial( + fetch_message_history, channel_id=slack_channel_id, thread_ts=thread_ts + ), + ) + return context + + +slack_app_handler = AsyncSlackRequestHandler(slack_app) + +users_by_slack_id: Dict[str, Union[User, PersistedUser]] = {} + +USER_PREFIX = "slack_" + + +bot_user_id: Optional[str] = None + + +async def get_bot_user_id() -> Optional[str]: + """Get and cache the bot's user ID.""" + global bot_user_id + if bot_user_id: + return bot_user_id + + try: + result = await slack_app.client.auth_test() + if result.get("ok"): + bot_user_id = result.get("user_id") + return bot_user_id + except Exception as e: + logger.error(f"Failed to get bot user ID: {e}") + + return None + + +def clean_content(message: str): + cleaned_text = re.sub(r"<@[\w]+>", "", message).strip() + return cleaned_text + + +async def _get_bot_profile(bot_id: str) -> dict: + """Fetch bot info from Slack and return a profile-like dict.""" + try: + bot_info = await slack_app.client.bots_info(bot=bot_id) + bot = bot_info.get("bot") or {} + bot_name = bot.get("name", bot_id) + return {"real_name": bot_name} + except SlackApiError: + logger.info("Could not fetch bot info for %s", bot_id) + return {"real_name": bot_id} + + +async def get_user(slack_user_id: str, is_bot: bool = False): + if slack_user_id in users_by_slack_id: + return users_by_slack_id[slack_user_id] + + if is_bot: + slack_user_profile = await _get_bot_profile(slack_user_id) + else: + slack_user = await slack_app.client.users_info(user=slack_user_id) + slack_user_profile = slack_user["user"]["profile"] + + user_identifier = slack_user_profile.get("email") or slack_user_id + user = User(identifier=USER_PREFIX + user_identifier, metadata=slack_user_profile) + + users_by_slack_id[slack_user_id] = user + + if data_layer := get_data_layer(): + try: + persisted_user = await data_layer.create_user(user) + if persisted_user: + users_by_slack_id[slack_user_id] = persisted_user + except Exception as e: + logger.error(f"Error creating user: {e}") + + return users_by_slack_id[slack_user_id] + + +async def fetch_message_history( + channel_id: str, thread_ts: Optional[str] = None, limit=30 +): + if not thread_ts: + result = await slack_app.client.conversations_history( + channel=channel_id, limit=limit + ) + else: + result = await slack_app.client.conversations_replies( + channel=channel_id, ts=thread_ts, limit=limit + ) + if result["ok"]: + messages = result["messages"] + return messages + else: + raise Exception(f"Failed to fetch messages: {result['error']}") + + +async def download_slack_file(url, token): + headers = {"Authorization": f"Bearer {token}"} + async with httpx.AsyncClient() as client: + response = await client.get(url, headers=headers) + if response.status_code == 200: + return response.content + else: + return None + + +async def download_slack_files(session: HTTPSession, files, token): + download_coros = [ + download_slack_file(file.get("url_private"), token) for file in files + ] + file_bytes_list = await asyncio.gather(*download_coros) + file_refs = [] + for idx, file_bytes in enumerate(file_bytes_list): + if file_bytes: + name = files[idx].get("name") + mime_type = files[idx].get("mimetype") + 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 + + +async def add_reaction_if_enabled(event, emoji: str = "eyes"): + if config.features.slack.reaction_on_message_received: + try: + await slack_app.client.reactions_add( + channel=event["channel"], timestamp=event["ts"], name=emoji + ) + except Exception as e: + logger.warning(f"Failed to add reaction: {e}") + + +async def process_slack_message( + event, + say, + thread_id: str, + thread_name: Optional[str] = None, + bind_thread_to_user=False, + thread_ts: Optional[str] = None, +): + await add_reaction_if_enabled(event) + + slack_user_id = event.get("user") + is_bot = False + if not slack_user_id: + # Workflow or bot messages may not have a "user" field. + # Fall back to bot_id if available, otherwise skip processing. + slack_user_id = event.get("bot_id") + if not slack_user_id: + logger.warning( + "Skipping Slack message with no user or bot_id: %s", + event.get("subtype"), + ) + return + is_bot = True + + user = await get_user(slack_user_id, is_bot=is_bot) + + channel_id = event["channel"] + + text = event.get("text") + slack_files = event.get("files", []) + + session_id = str(uuid.uuid4()) + session = HTTPSession( + id=session_id, + thread_id=thread_id, + user=user, + client_type="slack", + ) + + ctx = init_slack_context( + session=session, + slack_channel_id=channel_id, + event=event, + say=say, + thread_ts=thread_ts, + ) + + file_elements = await download_slack_files( + session, slack_files, slack_app.client.token + ) + + if on_chat_start := config.code.on_chat_start: + await on_chat_start() + + msg = Message( + content=clean_content(text), + elements=file_elements, + type="user_message", + author=user.metadata.get("real_name"), + ) + + if on_message := config.code.on_message: + 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 or msg.content, + metadata=ctx.session.to_persistable(), + user_id=user_id, + ) + except Exception as e: + logger.error(f"Error updating thread: {e}") + + await ctx.session.delete() + + +@slack_app.event("app_home_opened") +async def handle_app_home_opened(event, say): + pass + + +@slack_app.event("app_mention") +async def handle_app_mentions(event, say): + thread_ts = event.get("thread_ts", event["ts"]) + thread_id = str(uuid.uuid5(uuid.NAMESPACE_DNS, thread_ts)) + + await process_slack_message(event, say, thread_id=thread_id, thread_ts=thread_ts) + + +@slack_app.event("message") +async def handle_message(message, say): + # Ignore bot/workflow messages in DMs — only human messages should be + # processed here. Workflow mentions are handled via app_mention instead. + if message.get("bot_id") or message.get("subtype") == "bot_message": + return + + thread_ts = message.get("thread_ts", message["ts"]) + thread_id = str(uuid.uuid5(uuid.NAMESPACE_DNS, thread_ts)) + + await process_slack_message( + event=message, + say=say, + thread_id=thread_id, + bind_thread_to_user=True, + thread_ts=thread_ts, + ) + + +@slack_app.event("reaction_added") +async def handle_reaction_added(event): + bot_id = await get_bot_user_id() + + if event.get("user") == bot_id: + return + + item = event.get("item", {}) + channel_id = item.get("channel") + thread_ts = item.get("ts") + + if not channel_id: + logger.warning( + "reaction_added event missing channel_id, skipping context setup" + ) + return + + try: + result = await slack_app.client.conversations_replies( + channel=channel_id, ts=thread_ts, limit=1 + ) + + if result.get("ok"): + messages = result.get("messages") + message = messages[0] + message_user = message.get("user") + message_bot_id = message.get("bot_id") + + if message_user != bot_id and message_bot_id != bot_id: + return + else: + raise Exception( + f"Failed to fetch message: {result.get('error', 'Unknown error')}" + ) + + except Exception as e: + logger.warning(f"Failed to fetch message for reaction: {e}") + return + + async def say(text: str = "", **kwargs): + await slack_app.client.chat_postMessage( + channel=channel_id, text=text, thread_ts=thread_ts, **kwargs + ) + + user = await get_user(event["user"]) + + thread_id = ( + str(uuid.uuid5(uuid.NAMESPACE_DNS, thread_ts)) + if thread_ts + else str(uuid.uuid4()) + ) + + session_id = str(uuid.uuid4()) + session = HTTPSession( + id=session_id, + thread_id=thread_id, + user=user, + client_type="slack", + ) + + ctx = init_slack_context( + session=session, + slack_channel_id=channel_id, + event=event, + say=say, + thread_ts=thread_ts, + ) + + try: + if on_chat_start := config.code.on_chat_start: + await on_chat_start() + + if on_slack_reaction_added := config.code.on_slack_reaction_added: + await on_slack_reaction_added(event) + finally: + await ctx.session.delete() + + +@slack_app.block_action("thumbdown") +async def thumb_down(ack, context, body): + await ack() + step_id = body["actions"][0]["value"] + thread_ts = body["message"]["thread_ts"] + thread_id = str(uuid.uuid5(uuid.NAMESPACE_DNS, thread_ts)) + + if data_layer := get_data_layer(): + feedback = Feedback(forId=step_id, value=0, threadId=thread_id) + await data_layer.upsert_feedback(feedback) + + text = body["message"]["text"] + blocks = body["message"]["blocks"] + updated_blocks = [block for block in blocks if block["type"] != "actions"] + updated_blocks.append( + { + "type": "section", + "text": {"type": "mrkdwn", "text": ":thumbsdown: Feedback received."}, + } + ) + await context.client.chat_update( + channel=body["channel"]["id"], + ts=body["container"]["message_ts"], + text=text, + blocks=updated_blocks, + ) + + +@slack_app.block_action("thumbup") +async def thumb_up(ack, context, body): + await ack() + step_id = body["actions"][0]["value"] + thread_ts = body["message"]["thread_ts"] + thread_id = str(uuid.uuid5(uuid.NAMESPACE_DNS, thread_ts)) + + if data_layer := get_data_layer(): + feedback = Feedback(forId=step_id, value=1, threadId=thread_id) + await data_layer.upsert_feedback(feedback) + + text = body["message"]["text"] + blocks = body["message"]["blocks"] + updated_blocks = [block for block in blocks if block["type"] != "actions"] + updated_blocks.append( + { + "type": "section", + "text": {"type": "mrkdwn", "text": ":thumbsup: Feedback received."}, + } + ) + await context.client.chat_update( + channel=body["channel"]["id"], + ts=body["container"]["message_ts"], + text=text, + blocks=updated_blocks, + ) diff --git a/backend/chainlit/socket.py b/backend/chainlit/socket.py new file mode 100644 index 0000000..7430600 --- /dev/null +++ b/backend/chainlit/socket.py @@ -0,0 +1,508 @@ +import asyncio +import json +from typing import Any, Dict, Literal, Optional, Tuple, TypedDict, Union +from urllib.parse import unquote + +from starlette.requests import cookie_parser +from typing_extensions import TypeAlias + +from chainlit.auth import ( + get_current_user, + get_token_from_cookies, + require_login, +) +from chainlit.chat_context import chat_context +from chainlit.config import ChainlitConfig, config +from chainlit.context import init_ws_context +from chainlit.data import get_data_layer +from chainlit.logger import logger +from chainlit.message import ErrorMessage, Message +from chainlit.server import sio +from chainlit.session import ClientType, WebsocketSession +from chainlit.types import ( + InputAudioChunk, + InputAudioChunkPayload, + MessagePayload, +) +from chainlit.user import PersistedUser, User +from chainlit.user_session import user_sessions + +WSGIEnvironment: TypeAlias = dict[str, Any] + + +class WebSocketSessionAuth(TypedDict): + sessionId: str + userEnv: str | None + clientType: ClientType + chatProfile: str | None + threadId: str | None + + +def _session_owner_matches_user( + session: WebsocketSession, user: User | PersistedUser | None +) -> bool: + if session.user is None and user is None: + return True + + if session.user is None or user is None: + return False + + return session.user.identifier == user.identifier + + +def restore_existing_session( + sid, + session_id, + emit_fn, + emit_call_fn, + environ, + user: User | PersistedUser | None = None, +): + """Restore a session from the sessionId provided by the client.""" + if session := WebsocketSession.get_by_id(session_id): + if not _session_owner_matches_user(session, user): + logger.error("Authorization for the session failed.") + raise ConnectionRefusedError("authorization failed") + + session.restore(new_socket_id=sid) + session.emit = emit_fn + session.emit_call = emit_call_fn + session.environ = environ + return True + return False + + +async def persist_user_session(thread_id: str, metadata: Dict): + if data_layer := get_data_layer(): + await data_layer.update_thread(thread_id=thread_id, metadata=metadata) + + +async def resume_thread(session: WebsocketSession): + data_layer = get_data_layer() + if not data_layer or not session.user or not session.thread_id_to_resume: + return + thread = await data_layer.get_thread(thread_id=session.thread_id_to_resume) + if not thread: + return + + author = thread.get("userIdentifier") + user_is_author = author == session.user.identifier + + if user_is_author: + metadata = thread.get("metadata") or {} + if isinstance(metadata, str): + metadata = json.loads(metadata) + user_sessions[session.id] = metadata.copy() + if chat_profile := metadata.get("chat_profile"): + session.chat_profile = chat_profile + if chat_settings := metadata.get("chat_settings"): + session.chat_settings = chat_settings + + return thread + + +def load_user_env(user_env): + user_env_dict = {} + if user_env: + user_env_dict = json.loads(user_env) + # Check user env + if config.project.user_env: + if not user_env_dict: + raise ConnectionRefusedError("Missing user environment variables") + # Check if requested user environment variables are provided + for key in config.project.user_env: + if key not in user_env_dict: + raise ConnectionRefusedError( + "Missing user environment variable: " + key + ) + return user_env_dict + + +def _get_token_from_cookie(environ: WSGIEnvironment) -> Optional[str]: + if cookie_header := environ.get("HTTP_COOKIE", None): + cookies = cookie_parser(cookie_header) + return get_token_from_cookies(cookies) + + return None + + +def _get_token(environ: WSGIEnvironment) -> Optional[str]: + """Take WSGI environ, return access token.""" + return _get_token_from_cookie(environ) + + +async def _authenticate_connection( + environ: WSGIEnvironment, +) -> Union[Tuple[Union[User, PersistedUser], str], Tuple[None, None]]: + if token := _get_token(environ): + user = await get_current_user(token=token) + if user: + return user, token + + return None, None + + +@sio.on("connect") # pyright: ignore [reportOptionalCall] +async def connect(sid: str, environ: WSGIEnvironment, auth: WebSocketSessionAuth): + user: User | PersistedUser | None = None + token: str | None = None + thread_id = auth.get("threadId", None) + + if require_login(): + try: + user, token = await _authenticate_connection(environ) + except Exception as e: + logger.exception("Exception authenticating connection: %s", e) + + if not user: + logger.error("Authentication failed in websocket connect.") + raise ConnectionRefusedError("authentication failed") + + if thread_id: + if data_layer := get_data_layer(): + thread = await data_layer.get_thread(thread_id) + if thread and not (thread["userIdentifier"] == user.identifier): + logger.error("Authorization for the thread failed.") + raise ConnectionRefusedError("authorization failed") + + # Session scoped function to emit to the client + def emit_fn(event, data): + return sio.emit(event, data, to=sid) + + # Session scoped function to emit to the client and wait for a response + def emit_call_fn(event: Literal["ask", "call_fn"], data, timeout): + return sio.call(event, data, timeout=timeout, to=sid) + + session_id = auth["sessionId"] + if restore_existing_session( + sid, session_id, emit_fn, emit_call_fn, environ, user=user + ): + return True + + user_env_string = auth.get("userEnv", None) + user_env = load_user_env(user_env_string) + + client_type = auth["clientType"] + url_encoded_chat_profile = auth.get("chatProfile", None) + chat_profile = ( + unquote(url_encoded_chat_profile) if url_encoded_chat_profile else None + ) + + WebsocketSession( + id=session_id, + socket_id=sid, + emit=emit_fn, + emit_call=emit_call_fn, + client_type=client_type, + user_env=user_env, + user=user, + token=token, + chat_profile=chat_profile, + thread_id=thread_id, + environ=environ, + ) + + return True + + +@sio.on("connection_successful") # pyright: ignore [reportOptionalCall] +async def connection_successful(sid): + context = init_ws_context(sid) + + await context.emitter.task_end() + await context.emitter.clear("clear_ask") + await context.emitter.clear("clear_call_fn") + + if context.session.restored and not context.session.has_first_interaction: + if config.code.on_chat_start and not context.session.chat_started: + context.session.chat_started = True + task = asyncio.create_task(config.code.on_chat_start()) + context.session.current_task = task + return + + if context.session.thread_id_to_resume and config.code.on_chat_resume: + thread = await resume_thread(context.session) + if thread: + context.session.has_first_interaction = True + await context.emitter.emit( + "first_interaction", + {"interaction": "resume", "thread_id": thread.get("id")}, + ) + await config.code.on_chat_resume(thread) + + for step in thread.get("steps", []): + if "message" in step["type"]: + chat_context.add(Message.from_dict(step)) + + await context.emitter.resume_thread(thread) + return + else: + await context.emitter.send_resume_thread_error("Thread not found.") + + if config.code.on_chat_start and not context.session.chat_started: + context.session.chat_started = True + task = asyncio.create_task(config.code.on_chat_start()) + context.session.current_task = task + + +@sio.on("clear_session") # pyright: ignore [reportOptionalCall] +async def clean_session(sid): + session = WebsocketSession.get(sid) + if session: + session.to_clear = True + + +@sio.on("disconnect") # pyright: ignore [reportOptionalCall] +async def disconnect(sid): + session = WebsocketSession.get(sid) + + if not session: + return + + init_ws_context(session) + + if config.code.on_chat_end: + await config.code.on_chat_end() + + if session.thread_id and session.has_first_interaction: + await persist_user_session(session.thread_id, session.to_persistable()) + + async def clear(_sid): + if session := WebsocketSession.get(_sid): + # Clean up the user session + if session.id in user_sessions: + user_sessions.pop(session.id) + # Clean up the session + await session.delete() + + if session.to_clear: + await clear(sid) + else: + + async def clear_on_timeout(_sid): + await asyncio.sleep(config.project.session_timeout) + await clear(_sid) + + asyncio.ensure_future(clear_on_timeout(sid)) + + +@sio.on("stop") # pyright: ignore [reportOptionalCall] +async def stop(sid): + if session := WebsocketSession.get(sid): + init_ws_context(session) + await Message(content="Task manually stopped.").send() + + if session.current_task: + session.current_task.cancel() + + if config.code.on_stop: + await config.code.on_stop() + + +async def process_message(session: WebsocketSession, payload: MessagePayload): + """Process a message from the user.""" + try: + context = init_ws_context(session) + await context.emitter.task_start() + message = await context.emitter.process_message(payload) + + if config.code.on_message: + await asyncio.sleep(0.001) + await config.code.on_message(message) + except asyncio.CancelledError: + pass + except Exception as e: + logger.exception(e) + await ErrorMessage( + author="Error", content=str(e) or e.__class__.__name__ + ).send() + finally: + await context.emitter.task_end() + + +@sio.on("edit_message") # pyright: ignore [reportOptionalCall] +async def edit_message(sid, payload: MessagePayload): + """Handle a message sent by the User.""" + session = WebsocketSession.require(sid) + context = init_ws_context(session) + + messages = chat_context.get() + + orig_message = None + + for message in messages: + if orig_message: + await message.remove() + + if message.id == payload["message"]["id"]: + message.content = payload["message"]["output"] + await message.update() + orig_message = message + + await context.emitter.task_start() + + if config.code.on_message: + try: + await config.code.on_message(orig_message) + except asyncio.CancelledError: + pass + finally: + await context.emitter.task_end() + + +@sio.on("message_favorite") # pyright: ignore [reportOptionalCall] +async def message_favorite(sid, payload: MessagePayload): + """Handle a message favorite toggle.""" + session = WebsocketSession.require(sid) + context = init_ws_context(session) + data_layer = get_data_layer() + + if not config.features.favorites or not session.user: + return + + payload_message = payload["message"] + payload_metadata = payload_message.get("metadata") or {} + favorite = bool(payload_metadata.get("favorite", False)) + + step_dict = None + + if favorite: + for message in chat_context.get(): + if message.id == payload_message["id"]: + message.metadata = message.metadata or {} + message.metadata["favorite"] = favorite + step_dict = message.to_dict() + break + elif data_layer: + favorites = await data_layer.get_favorite_steps(session.user.id) + for fav in favorites: + if fav["id"] == payload_message["id"]: + step_dict = fav + break + + if step_dict is None: + logger.error("Could not find step to update favorite status.") + return + + created_at = step_dict.get("createdAt") + if created_at and not created_at.endswith("Z"): + step_dict["createdAt"] = f"{created_at}Z" + + if data_layer: + step_dict = await data_layer.set_step_favorite(step_dict, favorite) + + await context.emitter.update_step(step_dict) + await fetch_favorites(sid) + + +@sio.on("fetch_favorites") # pyright: ignore [reportOptionalCall] +async def fetch_favorites(sid): + session = WebsocketSession.require(sid) + context = init_ws_context(session) + if session.user and config.features.favorites: + if data_layer := get_data_layer(): + favorites = await data_layer.get_favorite_steps(session.user.id) + await context.emitter.set_favorites(favorites) + + +@sio.on("client_message") # pyright: ignore [reportOptionalCall] +async def message(sid, payload: MessagePayload): + """Handle a message sent by the User.""" + session = WebsocketSession.require(sid) + + task = asyncio.create_task(process_message(session, payload)) + session.current_task = task + + +@sio.on("window_message") # pyright: ignore [reportOptionalCall] +async def window_message(sid, data): + """Handle a message send by the host window.""" + session = WebsocketSession.require(sid) + init_ws_context(session) + + if config.code.on_window_message: + try: + await config.code.on_window_message(data) + except asyncio.CancelledError: + pass + + +@sio.on("audio_start") # pyright: ignore [reportOptionalCall] +async def audio_start(sid): + """Handle audio init.""" + session = WebsocketSession.require(sid) + + context = init_ws_context(session) + config: ChainlitConfig = session.get_config() # type: ignore + + if config.features.audio and config.features.audio.enabled: + connected = bool(await config.code.on_audio_start()) + connection_state = "on" if connected else "off" + await context.emitter.update_audio_connection(connection_state) + + +@sio.on("audio_chunk") +async def audio_chunk(sid, payload: InputAudioChunkPayload): + """Handle an audio chunk sent by the user.""" + session = WebsocketSession.require(sid) + + init_ws_context(session) + + config: ChainlitConfig = session.get_config() + + if ( + config.features.audio + and config.features.audio.enabled + and config.code.on_audio_chunk + ): + asyncio.create_task(config.code.on_audio_chunk(InputAudioChunk(**payload))) + + +@sio.on("audio_end") +async def audio_end(sid): + """Handle the end of the audio stream.""" + session = WebsocketSession.require(sid) + + try: + context = init_ws_context(session) + await context.emitter.task_start() + + if not session.has_first_interaction: + session.has_first_interaction = True + asyncio.create_task(context.emitter.init_thread("audio")) + + config: ChainlitConfig = session.get_config() # type: ignore + + if config.features.audio and config.features.audio.enabled: + await config.code.on_audio_end() + + except asyncio.CancelledError: + pass + except Exception as e: + logger.exception(e) + await ErrorMessage( + author="Error", content=str(e) or e.__class__.__name__ + ).send() + finally: + await context.emitter.task_end() + + +@sio.on("chat_settings_change") +async def change_settings(sid, settings: Dict[str, Any]): + """Handle change settings submit from the UI.""" + context = init_ws_context(sid) + + for key, value in settings.items(): + context.session.chat_settings[key] = value + + if config.code.on_settings_update: + await config.code.on_settings_update(settings) + + +@sio.on("chat_settings_edit") +async def edit_settings(sid, settings: Dict[str, Any]): + """Handle change settings edit from the UI (on the fly).""" + init_ws_context(sid) + + if config.code.on_settings_edit: + await config.code.on_settings_edit(settings) diff --git a/backend/chainlit/step.py b/backend/chainlit/step.py new file mode 100644 index 0000000..1604bac --- /dev/null +++ b/backend/chainlit/step.py @@ -0,0 +1,511 @@ +import asyncio +import inspect +import json +import time +import uuid +from copy import deepcopy +from functools import wraps +from typing import Callable, Dict, List, Optional, TypedDict, Union + +from literalai import BaseGeneration +from literalai.observability.step import StepType, TrueStepType + +from chainlit.config import config +from chainlit.context import CL_RUN_NAMES, context, local_steps +from chainlit.data import get_data_layer +from chainlit.element import Element +from chainlit.logger import logger +from chainlit.types import FeedbackDict +from chainlit.utils import utc_now + + +def check_add_step_in_cot(step: "Step"): + is_message = step.type in [ + "user_message", + "assistant_message", + ] + is_cl_run = step.name in CL_RUN_NAMES and step.type == "run" + if config.ui.cot == "hidden" and not is_message and not is_cl_run: + return False + return True + + +def stub_step(step: "Step") -> "StepDict": + return { + "type": step.type, + "name": step.name, + "id": step.id, + "parentId": step.parent_id, + "threadId": step.thread_id, + "input": "", + "output": "", + } + + +class StepDict(TypedDict, total=False): + name: str + type: StepType + id: str + threadId: str + parentId: Optional[str] + command: Optional[str] + modes: Optional[Dict[str, str]] + streaming: bool + waitForAnswer: Optional[bool] + isError: Optional[bool] + metadata: Dict + tags: Optional[List[str]] + input: str + output: str + createdAt: Optional[str] + start: Optional[str] + end: Optional[str] + generation: Optional[Dict] + showInput: Optional[Union[bool, str]] + defaultOpen: Optional[bool] + autoCollapse: Optional[bool] + language: Optional[str] + icon: Optional[str] + feedback: Optional[FeedbackDict] + + +def flatten_args_kwargs(func, args, kwargs): + signature = inspect.signature(func) + bound_arguments = signature.bind(*args, **kwargs) + bound_arguments.apply_defaults() + return {k: deepcopy(v) for k, v in bound_arguments.arguments.items()} + + +def step( + original_function: Optional[Callable] = None, + *, + name: Optional[str] = "", + type: TrueStepType = "undefined", + id: Optional[str] = None, + parent_id: Optional[str] = None, + tags: Optional[List[str]] = None, + metadata: Optional[Dict] = None, + language: Optional[str] = None, + icon: Optional[str] = None, + show_input: Union[bool, str] = "json", + default_open: bool = False, + auto_collapse: bool = False, +): + """Step decorator for async and sync functions.""" + + def wrapper(func: Callable): + nonlocal name + if not name: + name = func.__name__ + + # Handle async decorator + + if inspect.iscoroutinefunction(func): + + @wraps(func) + async def async_wrapper(*args, **kwargs): + async with Step( + type=type, + name=name, + id=id, + parent_id=parent_id, + tags=tags, + language=language, + icon=icon, + show_input=show_input, + default_open=default_open, + auto_collapse=auto_collapse, + metadata=metadata, + ) as step: + try: + step.input = flatten_args_kwargs(func, args, kwargs) + except Exception as e: + logger.exception(e) + result = await func(*args, **kwargs) + try: + if result and not step.output: + step.output = result + except Exception as e: + step.is_error = True + step.output = str(e) + return result + + return async_wrapper + else: + # Handle sync decorator + @wraps(func) + def sync_wrapper(*args, **kwargs): + with Step( + type=type, + name=name, + id=id, + parent_id=parent_id, + tags=tags, + language=language, + icon=icon, + show_input=show_input, + default_open=default_open, + auto_collapse=auto_collapse, + metadata=metadata, + ) as step: + try: + step.input = flatten_args_kwargs(func, args, kwargs) + except Exception as e: + logger.exception(e) + result = func(*args, **kwargs) + try: + if result and not step.output: + step.output = result + except Exception as e: + step.is_error = True + step.output = str(e) + return result + + return sync_wrapper + + func = original_function + if not func: + return wrapper + else: + return wrapper(func) + + +class Step: + # Constructor + name: str + type: TrueStepType + id: str + parent_id: Optional[str] + + streaming: bool + persisted: bool + + show_input: Union[bool, str] + + is_error: Optional[bool] + metadata: Dict + tags: Optional[List[str]] + thread_id: str + created_at: Union[str, None] + start: Union[str, None] + end: Union[str, None] + generation: Optional[BaseGeneration] + language: Optional[str] + icon: Optional[str] + default_open: Optional[bool] + auto_collapse: Optional[bool] + elements: Optional[List[Element]] + fail_on_persist_error: bool + + def __init__( + self, + name: Optional[str] = config.ui.name, + type: TrueStepType = "undefined", + id: Optional[str] = None, + parent_id: Optional[str] = None, + elements: Optional[List[Element]] = None, + metadata: Optional[Dict] = None, + tags: Optional[List[str]] = None, + language: Optional[str] = None, + icon: Optional[str] = None, + default_open: Optional[bool] = False, + auto_collapse: Optional[bool] = False, + show_input: Union[bool, str] = "json", + thread_id: Optional[str] = None, + ): + time.sleep(0.001) + self._input = "" + self._output = "" + self.thread_id = thread_id or context.session.thread_id + self.name = name or "" + self.type = type + self.id = id or str(uuid.uuid4()) + self.metadata = metadata or {} + self.tags = tags + self.is_error = False + self.show_input = show_input + self.parent_id = parent_id + + self.language = language + self.icon = icon + self.default_open = default_open + self.auto_collapse = auto_collapse + self.generation = None + self.elements = elements or [] + + self.created_at = utc_now() + self.start = None + self.end = None + + self.streaming = False + self.persisted = False + self.fail_on_persist_error = False + + def _clean_content(self, content): + """ + Recursively checks and converts bytes objects in content. + """ + + def handle_bytes(item): + if isinstance(item, bytes): + return "STRIPPED_BINARY_DATA" + elif isinstance(item, dict): + return {k: handle_bytes(v) for k, v in item.items()} + elif isinstance(item, list): + return [handle_bytes(i) for i in item] + elif isinstance(item, tuple): + return tuple(handle_bytes(i) for i in item) + return item + + return handle_bytes(content) + + def _process_content(self, content, set_language=False): + if content is None: + return "" + content = self._clean_content(content) + + if ( + isinstance(content, dict) + or isinstance(content, list) + or isinstance(content, tuple) + ): + try: + processed_content = json.dumps(content, indent=4, ensure_ascii=False) + if set_language: + self.language = "json" + except TypeError: + processed_content = str(content).replace("\\n", "\n") + if set_language: + self.language = "text" + elif isinstance(content, str): + processed_content = content + else: + processed_content = str(content).replace("\\n", "\n") + if set_language: + self.language = "text" + return processed_content + + @property + def input(self): + return self._input + + @input.setter + def input(self, content: Union[Dict, str]): + self._input = self._process_content(content, set_language=False) + + @property + def output(self): + return self._output + + @output.setter + def output(self, content: Union[Dict, str]): + self._output = self._process_content(content, set_language=True) + + def to_dict(self) -> StepDict: + # Move icon into metadata for storage + metadata = dict(self.metadata) + if self.icon: + metadata["icon"] = self.icon + + _dict: StepDict = { + "name": self.name, + "type": self.type, + "id": self.id, + "threadId": self.thread_id, + "parentId": self.parent_id, + "streaming": self.streaming, + "metadata": metadata, + "tags": self.tags, + "input": self.input, + "isError": self.is_error, + "output": self.output, + "createdAt": self.created_at, + "start": self.start, + "end": self.end, + "language": self.language, + "defaultOpen": self.default_open, + "autoCollapse": self.auto_collapse, + "showInput": self.show_input, + "generation": self.generation.to_dict() if self.generation else None, + } + return _dict + + async def update(self): + """ + Update a step already sent to the UI. + """ + if self.streaming: + self.streaming = False + + step_dict = self.to_dict() + data_layer = get_data_layer() + + if data_layer: + try: + asyncio.create_task(data_layer.update_step(step_dict.copy())) + except Exception as e: + if self.fail_on_persist_error: + raise e + logger.error(f"Failed to persist step update: {e!s}") + + tasks = [el.send(for_id=self.id) for el in self.elements] + await asyncio.gather(*tasks) + + if not check_add_step_in_cot(self): + await context.emitter.update_step(stub_step(self)) + else: + await context.emitter.update_step(step_dict) + + return True + + async def remove(self): + """ + Remove a step already sent to the UI. + """ + step_dict = self.to_dict() + data_layer = get_data_layer() + + if data_layer: + try: + asyncio.create_task(data_layer.delete_step(self.id)) + except Exception as e: + if self.fail_on_persist_error: + raise e + logger.error(f"Failed to persist step deletion: {e!s}") + + await context.emitter.delete_step(step_dict) + + return True + + async def send(self): + if self.persisted: + return self + + if config.code.author_rename: + self.name = await config.code.author_rename(self.name) + + if self.streaming: + self.streaming = False + + step_dict = self.to_dict() + + data_layer = get_data_layer() + + if data_layer: + try: + asyncio.create_task(data_layer.create_step(step_dict.copy())) + self.persisted = True + except Exception as e: + if self.fail_on_persist_error: + raise e + logger.error(f"Failed to persist step creation: {e!s}") + + tasks = [el.send(for_id=self.id) for el in self.elements] + await asyncio.gather(*tasks) + + if not check_add_step_in_cot(self): + await context.emitter.send_step(stub_step(self)) + else: + await context.emitter.send_step(step_dict) + + return self + + async def stream_token(self, token: str, is_sequence=False, is_input=False): + """ + Sends a token to the UI. + Once all tokens have been streamed, call .send() to end the stream and persist the step if persistence is enabled. + """ + if not token: + return + + if is_sequence: + if is_input: + self.input = token + else: + self.output = token + else: + if is_input: + self.input += token + else: + self.output += token + + assert self.id + + if not check_add_step_in_cot(self): + await context.emitter.send_step(stub_step(self)) + return + + 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, is_input=is_input + ) + + # Handle parameter less decorator + def __call__(self, func): + return step( + original_function=func, + type=self.type, + name=self.name, + id=self.id, + parent_id=self.parent_id, + thread_id=self.thread_id, + ) + + # Handle Context Manager Protocol + async def __aenter__(self): + self.start = utc_now() + previous_steps = local_steps.get() or [] + parent_step = previous_steps[-1] if previous_steps else None + + if not self.parent_id: + if parent_step: + self.parent_id = parent_step.id + local_steps.set(previous_steps + [self]) + await self.send() + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + self.end = utc_now() + + if exc_type: + self.output = str(exc_val) + self.is_error = True + + current_steps = local_steps.get() + if current_steps and self in current_steps: + current_steps.remove(self) + local_steps.set(current_steps) + + await self.update() + + def __enter__(self): + self.start = utc_now() + + previous_steps = local_steps.get() or [] + parent_step = previous_steps[-1] if previous_steps else None + + if not self.parent_id: + if parent_step: + self.parent_id = parent_step.id + local_steps.set(previous_steps + [self]) + + asyncio.create_task(self.send()) + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.end = utc_now() + + if exc_type: + self.output = str(exc_val) + self.is_error = True + + current_steps = local_steps.get() + if current_steps and self in current_steps: + current_steps.remove(self) + local_steps.set(current_steps) + + asyncio.create_task(self.update()) diff --git a/backend/chainlit/sync.py b/backend/chainlit/sync.py new file mode 100644 index 0000000..ccb86d9 --- /dev/null +++ b/backend/chainlit/sync.py @@ -0,0 +1,36 @@ +import asyncio +import threading +from typing import Any, Coroutine, ParamSpec, TypeVar + +from asyncer import asyncify +from syncer import sync + +from chainlit.context import context_var + +make_async = asyncify + +T_Retval = TypeVar("T_Retval") +T_ParamSpec = ParamSpec("T_ParamSpec") +T = TypeVar("T") + + +def run_sync(co: Coroutine[Any, Any, T_Retval]) -> T_Retval: + """Run the coroutine synchronously.""" + + # Copy the current context + current_context = context_var.get() + + # Define a wrapper coroutine that sets the context before running the original coroutine + async def context_preserving_coroutine(): + # Set the copied context to the coroutine + context_var.set(current_context) + return await co + + # Execute from the main thread in the main event loop + if threading.current_thread() == threading.main_thread(): + return sync(context_preserving_coroutine()) + else: # Execute from a thread in the main event loop + result = asyncio.run_coroutine_threadsafe( + context_preserving_coroutine(), loop=current_context.loop + ) + return result.result() diff --git a/backend/chainlit/teams/__init__.py b/backend/chainlit/teams/__init__.py new file mode 100644 index 0000000..e3cbfaa --- /dev/null +++ b/backend/chainlit/teams/__init__.py @@ -0,0 +1,6 @@ +import importlib.util + +if importlib.util.find_spec("botbuilder") is None: + raise ValueError( + "The botbuilder-core package is required to integrate Chainlit with a Slack app. Run `pip install botbuilder-core --upgrade`" + ) diff --git a/backend/chainlit/teams/app.py b/backend/chainlit/teams/app.py new file mode 100644 index 0000000..1de0acb --- /dev/null +++ b/backend/chainlit/teams/app.py @@ -0,0 +1,349 @@ +import asyncio +import base64 +import mimetypes +import os +import uuid +from datetime import datetime +from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Union + +import filetype + +if TYPE_CHECKING: + from botbuilder.core import TurnContext + from botbuilder.schema import Activity + +import httpx +from botbuilder.core import ( + BotFrameworkAdapter, + BotFrameworkAdapterSettings, + MessageFactory, + TurnContext, +) +from botbuilder.schema import ( + ActionTypes, + Activity, + ActivityTypes, + Attachment, + CardAction, + ChannelAccount, + HeroCard, +) + +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 TeamsEmitter(BaseChainlitEmitter): + def __init__(self, session: HTTPSession, turn_context: TurnContext): + super().__init__(session) + self.turn_context = turn_context + + 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 "") + attachment: Optional[Attachment] = None + mime: Optional[str] = None + + element_name: str = element_dict.get("name", "Untitled") + + if mime: + file_extension = mimetypes.guess_extension(mime) + if file_extension: + element_name += file_extension + + if persisted_file: + mime = element_dict.get("mime") + with open(persisted_file["path"], "rb") as file: + dencoded_string = base64.b64encode(file.read()).decode() + content_url = f"data:{mime};base64,{dencoded_string}" + attachment = Attachment( + content_type=mime, content_url=content_url, name=element_name + ) + + elif url := element_dict.get("url"): + attachment = Attachment( + content_type=mime, content_url=url, name=element_name + ) + + if not attachment: + return + + await self.turn_context.send_activity(Activity(attachments=[attachment])) + + 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: + reply = MessageFactory.text(step_dict["output"]) + enable_feedback = get_data_layer() + if enable_feedback: + current_run = context.current_run + scorable_id = current_run.id if current_run else step_dict["id"] + like_button = CardAction( + type=ActionTypes.message_back, + title="👍", + text="like", + value={"feedback": "like", "step_id": scorable_id}, + ) + dislike_button = CardAction( + type=ActionTypes.message_back, + title="👎", + text="dislike", + value={"feedback": "dislike", "step_id": scorable_id}, + ) + card = HeroCard(buttons=[like_button, dislike_button]) + attachment = Attachment( + content_type="application/vnd.microsoft.card.hero", content=card + ) + reply.attachments = [attachment] + + await self.turn_context.send_activity(reply) + + async def update_step(self, step_dict: StepDict): + if not step_dict["type"] == "assistant_message": + return + + await self.send_step(step_dict) + + +adapter_settings = BotFrameworkAdapterSettings( + app_id=os.environ.get("TEAMS_APP_ID"), + app_password=os.environ.get("TEAMS_APP_PASSWORD"), + channel_auth_tenant=os.environ.get("TEAMS_APP_TENANT_ID"), +) +adapter = BotFrameworkAdapter(adapter_settings) + + +def init_teams_context( + session: HTTPSession, + turn_context: TurnContext, +) -> ChainlitContext: + emitter = TeamsEmitter(session=session, turn_context=turn_context) + context = ChainlitContext(session=session, emitter=emitter) + context_var.set(context) + user_session.set("teams_turn_context", turn_context) + return context + + +users_by_teams_id: Dict[str, Union[User, PersistedUser]] = {} + +USER_PREFIX = "teams_" + + +async def get_user(teams_user: ChannelAccount): + if teams_user.id in users_by_teams_id: + return users_by_teams_id[teams_user.id] + + metadata = { + "name": teams_user.name, + "id": teams_user.id, + } + user = User(identifier=USER_PREFIX + str(teams_user.name), metadata=metadata) + + users_by_teams_id[teams_user.id] = user + + if data_layer := get_data_layer(): + try: + persisted_user = await data_layer.create_user(user) + if persisted_user: + users_by_teams_id[teams_user.id] = persisted_user + except Exception as e: + logger.error(f"Error creating user: {e}") + + return users_by_teams_id[teams_user.id] + + +async def download_teams_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_teams_files( + session: HTTPSession, attachments: Optional[List[Attachment]] = None +): + if not attachments: + return [] + + attachments = [ + attachment for attachment in attachments if isinstance(attachment.content, dict) + ] + download_coros = [ + download_teams_file(attachment.content.get("downloadUrl")) + 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].name + mime_type = filetype.guess_mime(file_bytes) 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(activity: Activity): + return activity.text.strip() + + +async def process_teams_message( + turn_context: TurnContext, + thread_name: str, +): + user = await get_user(turn_context.activity.from_property) + + thread_id = str( + uuid.uuid5( + uuid.NAMESPACE_DNS, + str( + turn_context.activity.conversation.id + + datetime.today().strftime("%Y-%m-%d") + ), + ) + ) + + text = clean_content(turn_context.activity) + teams_files = turn_context.activity.attachments + + session_id = str(uuid.uuid4()) + + session = HTTPSession( + id=session_id, + thread_id=thread_id, + user=user, + client_type="teams", + ) + + ctx = init_teams_context( + session=session, + turn_context=turn_context, + ) + + file_elements = await download_teams_files(session, teams_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: + await on_message(msg) + + if on_chat_end := config.code.on_chat_end: + await on_chat_end() + + if data_layer := get_data_layer(): + if isinstance(user, PersistedUser): + 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() + + +async def handle_message(turn_context: TurnContext): + if turn_context.activity.type == ActivityTypes.message: + if ( + turn_context.activity.text == "like" + or turn_context.activity.text == "dislike" + ): + feedback_value: Literal[0, 1] = ( + 0 if turn_context.activity.text == "dislike" else 1 + ) + step_id = turn_context.activity.value.get("step_id") + if data_layer := get_data_layer(): + await data_layer.upsert_feedback( + Feedback(forId=step_id, value=feedback_value) + ) + updated_text = "👍" if turn_context.activity.text == "like" else "👎" + # Update the existing message to remove the buttons + updated_message = Activity( + type=ActivityTypes.message, + id=turn_context.activity.reply_to_id, + text=updated_text, + attachments=[], + ) + await turn_context.update_activity(updated_message) + else: + # Send typing activity + typing_activity = Activity( + type=ActivityTypes.typing, + from_property=turn_context.activity.recipient, + recipient=turn_context.activity.from_property, + conversation=turn_context.activity.conversation, + ) + await turn_context.send_activity(typing_activity) + thread_name = f"{turn_context.activity.from_property.name} Teams DM {datetime.today().strftime('%Y-%m-%d')}" + await process_teams_message(turn_context, thread_name) + + +async def on_turn(turn_context: TurnContext): + await handle_message(turn_context) + + +# Create the main bot class +class TeamsBot: + async def on_turn(self, turn_context: TurnContext): + await on_turn(turn_context) + + +# Create the bot instance +bot = TeamsBot() diff --git a/backend/chainlit/translations.py b/backend/chainlit/translations.py new file mode 100644 index 0000000..41fe2eb --- /dev/null +++ b/backend/chainlit/translations.py @@ -0,0 +1,60 @@ +# TODO: +# - Support linting plural +# - Support interpolation + + +def compare_json_structures(truth, to_compare, path=""): + """ + Compare the structure of two deeply nested JSON objects. + Args: + truth (dict): The 'truth' JSON object. + to_compare (dict): The 'to_compare' JSON object. + path (str): The current path for error reporting (used internally). + Returns: + A list of differences found. + """ + if not isinstance(truth, dict) or not isinstance(to_compare, dict): + raise ValueError("Both inputs must be dictionaries.") + + errors = [] + + truth_keys = set(truth.keys()) + to_compare_keys = set(to_compare.keys()) + + extra_keys = to_compare_keys - truth_keys + missing_keys = truth_keys - to_compare_keys + + for key in extra_keys: + errors.append(f"⚠️ Extra key: '{path + '.' + key if path else key}'") + + for key in missing_keys: + errors.append(f"❌ Missing key: '{path + '.' + key if path else key}'") + + for key in truth_keys & to_compare_keys: + if isinstance(truth[key], dict) and isinstance(to_compare[key], dict): + # Recursive call to navigate through nested dictionaries + errors += compare_json_structures( + truth[key], to_compare[key], path + "." + key if path else key + ) + elif not isinstance(truth[key], dict) and not isinstance(to_compare[key], dict): + # If both are not dicts, we are at leaf nodes and structure matches; skip value comparison + continue + else: + # Structure mismatch: one is a dict, the other is not + errors.append( + f"❌ Structure mismatch at: '{path + '.' + key if path else key}'" + ) + + return errors + + +def lint_translation_json(file, truth, to_compare): + print(f"\nLinting {file}...") + + errors = compare_json_structures(truth, to_compare) + + if errors: + for error in errors: + print(f"{error}") + else: + print(f"✅ No errors found in {file}") diff --git a/backend/chainlit/translations/ar-SA.json b/backend/chainlit/translations/ar-SA.json new file mode 100644 index 0000000..b98b427 --- /dev/null +++ b/backend/chainlit/translations/ar-SA.json @@ -0,0 +1,259 @@ +{ + "common": { + "actions": { + "cancel": "إلغاء", + "confirm": "تأكيد", + "continue": "متابعة", + "goBack": "رجوع", + "reset": "إعادة تعيين", + "submit": "إرسال" + }, + "status": { + "loading": "جاري التحميل...", + "error": { + "default": "حدث خطأ", + "serverConnection": "تعذر الاتصال بالخادم" + } + } + }, + "auth": { + "login": { + "title": "قم بتسجيل الدخول للوصول إلى التطبيق", + "form": { + "email": { + "label": "البريد الإلكتروني", + "required": "البريد الإلكتروني حقل إلزامي", + "placeholder": "me@example.com" + }, + "password": { + "label": "كلمة المرور", + "required": "كلمة المرور حقل إلزامي" + }, + "actions": { + "signin": "تسجيل الدخول" + }, + "alternativeText": { + "or": "أو" + } + }, + "errors": { + "default": "تعذر تسجيل الدخول", + "signin": "حاول تسجيل الدخول بحساب آخر", + "oauthSignin": "حاول تسجيل الدخول بحساب آخر", + "redirectUriMismatch": "عنوان URI لإعادة التوجيه لا يتطابق مع تكوين تطبيق OAuth", + "oauthCallback": "حاول تسجيل الدخول بحساب آخر", + "oauthCreateAccount": "حاول تسجيل الدخول بحساب آخر", + "emailCreateAccount": "حاول تسجيل الدخول بحساب آخر", + "callback": "حاول تسجيل الدخول بحساب آخر", + "oauthAccountNotLinked": "لتأكيد هويتك، قم بتسجيل الدخول بنفس الحساب الذي استخدمته في الأصل", + "emailSignin": "تعذر إرسال البريد الإلكتروني", + "emailVerify": "يرجى التحقق من بريدك الإلكتروني، تم إرسال بريد إلكتروني جديد", + "credentialsSignin": "فشل تسجيل الدخول. تحقق من صحة المعلومات المقدمة", + "sessionRequired": "يرجى تسجيل الدخول للوصول إلى هذه الصفحة" + } + }, + "provider": { + "continue": "متابعة مع {{provider}}" + } + }, + "chat": { + "input": { + "placeholder": "اكتب رسالتك هنا...", + "actions": { + "send": "إرسال الرسالة", + "stop": "إيقاف المهمة", + "attachFiles": "إرفاق ملفات" + } + }, + "favorites": { + "use": "استخدام رسالة مفضلة", + "headline": "الرسائل المفضلة", + "empty": { + "title": "لا توجد رسائل محفوظة بعد", + "description": "ابدأ بإرسال رسالة وقم بتمييزها بنجمة أو ميّز رسالة من محادثاتك السابقة" + } + }, + "commands": { + "button": "أدوات", + "changeTool": "تغيير الأداة", + "availableTools": "الأدوات المتاحة" + }, + "speech": { + "start": "بدء التسجيل", + "stop": "إيقاف التسجيل", + "connecting": "جاري الاتصال" + }, + "fileUpload": { + "dragDrop": "اسحب وأفلت الملفات هنا", + "browse": "تصفح الملفات", + "sizeLimit": "الحد الأقصى:", + "errors": { + "failed": "فشل التحميل", + "cancelled": "تم إلغاء تحميل" + }, + "actions": { + "cancelUpload": "إلغاء التحميل", + "removeAttachment": "إزالة المرفق" + } + }, + "messages": { + "status": { + "using": "يستخدم", + "used": "مستخدم" + }, + "actions": { + "copy": { + "button": "نسخ إلى الحافظة", + "success": "تم النسخ!" + } + }, + "feedback": { + "positive": "مفيد", + "negative": "غير مفيد", + "edit": "تعديل التعليق", + "dialog": { + "title": "إضافة تعليق", + "submit": "إرسال التعليق", + "yourFeedback": "رأيك..." + }, + "status": { + "updating": "جاري التحديث", + "updated": "تم تحديث التعليق" + } + } + }, + "history": { + "title": "المدخلات الأخيرة", + "empty": "فارغ تماماً...", + "show": "عرض السجل" + }, + "settings": { + "title": "لوحة الإعدادات", + "customize": "خصص إعدادات المحادثة هنا" + }, + "watermark": "قد تخطئ نماذج الذكاء الاصطناعي. تحقق من المعلومات المهمة." + }, + "threadHistory": { + "sidebar": { + "title": "المحادثات السابقة", + "filters": { + "search": "بحث", + "placeholder": "البحث في المحادثات..." + }, + "timeframes": { + "today": "اليوم", + "yesterday": "أمس", + "previous7days": "آخر 7 أيام", + "previous30days": "آخر 30 يوماً" + }, + "empty": "لم يتم العثور على محادثات", + "actions": { + "close": "إغلاق الشريط الجانبي", + "open": "فتح الشريط الجانبي" + } + }, + "thread": { + "untitled": "محادثة بدون عنوان", + "menu": { + "rename": "إعادة تسمية", + "share": "مشاركة", + "delete": "حذف" + }, + "actions": { + "share": { + "title": "مشاركة رابط المحادثة", + "button": "مشاركة", + "status": { + "copied": "تم نسخ الرابط", + "created": "تم إنشاء رابط المشاركة!", + "unshared": "تم تعطيل المشاركة لهذه المحادثة" + }, + "error": { + "create": "فشل إنشاء رابط المشاركة", + "unshare": "فشل تعطيل مشاركة المحادثة" + } + }, + "delete": { + "title": "تأكيد الحذف", + "description": "سيؤدي هذا إلى حذف المحادثة مع رسائلها وعناصرها. لا يمكن التراجع عن هذا الإجراء", + "success": "تم حذف المحادثة", + "inProgress": "جاري حذف المحادثة" + }, + "rename": { + "title": "إعادة تسمية المحادثة", + "description": "أدخل اسماً جديداً لهذه المحادثة", + "form": { + "name": { + "label": "الاسم", + "placeholder": "أدخل الاسم الجديد" + } + }, + "success": "تمت إعادة تسمية المحادثة!", + "inProgress": "جاري إعادة تسمية المحادثة" + } + } + } + }, + "navigation": { + "header": { + "chat": "محادثة", + "readme": "اقرأني", + "theme": { + "light": "السمة الفاتحة", + "dark": "السمة الداكنة", + "system": "متابعة النظام" + } + }, + "newChat": { + "button": "محادثة جديدة", + "dialog": { + "title": "إنشاء محادثة جديدة", + "description": "سيؤدي هذا إلى مسح سجل المحادثة الحالي. هل أنت متأكد من أنك تريد المتابعة؟", + "tooltip": "محادثة جديدة" + } + }, + "user": { + "menu": { + "settings": "الإعدادات", + "settingsKey": "S", + "apiKeys": "مفاتيح API", + "logout": "تسجيل الخروج" + } + } + }, + "apiKeys": { + "title": "مفاتيح API المطلوبة", + "description": "لاستخدام هذا التطبيق، مفاتيح API التالية مطلوبة. يتم تخزين المفاتيح في التخزين المحلي لجهازك.", + "success": { + "saved": "تم الحفظ بنجاح" + } + }, + "alerts": { + "info": "معلومات", + "note": "ملاحظة", + "tip": "نصيحة", + "important": "مهم", + "warning": "تحذير", + "caution": "تنبيه", + "debug": "تصحيح", + "example": "مثال", + "success": "نجاح", + "help": "مساعدة", + "idea": "فكرة", + "pending": "قيد الانتظار", + "security": "أمان", + "beta": "تجريبي", + "best-practice": "أفضل ممارسة" + }, + "components": { + "MultiSelectInput": { + "placeholder": "اختر..." + }, + "DatePickerInput": { + "placeholder": { + "single": "اختر تاريخاً", + "range": "اختر نطاقاً من التواريخ" + } + } + } +} diff --git a/backend/chainlit/translations/bn.json b/backend/chainlit/translations/bn.json new file mode 100644 index 0000000..432d53b --- /dev/null +++ b/backend/chainlit/translations/bn.json @@ -0,0 +1,260 @@ +{ + "common": { + "actions": { + "cancel": "বাতিল করুন", + "confirm": "নিশ্চিত করুন", + "continue": "চালিয়ে যান", + "goBack": "পিছনে যান", + "reset": "রিসেট করুন", + "submit": "জমা দিন" + }, + "status": { + "loading": "লোড হচ্ছে...", + "error": { + "default": "একটি ত্রুটি ঘটেছে", + "serverConnection": "সার্ভারের সাথে সংযোগ করা যাচ্ছে না" + } + } + }, + "auth": { + "login": { + "title": "অ্যাপ্লিকেশন ব্যবহার করতে লগইন করুন", + "form": { + "email": { + "label": "ইমেইল ঠিকানা", + "required": "ইমেইল একটি আবশ্যক ক্ষেত্র", + "placeholder": "me@example.com" + }, + "password": { + "label": "পাসওয়ার্ড", + "required": "পাসওয়ার্ড একটি আবশ্যক ক্ষেত্র" + }, + "actions": { + "signin": "সাইন ইন করুন" + }, + "alternativeText": { + "or": "অথবা" + } + }, + "errors": { + "default": "সাইন ইন করা সম্ভব হচ্ছে না", + "signin": "অন্য একটি অ্যাকাউন্ট দিয়ে সাইন ইন করার চেষ্টা করুন", + "oauthSignin": "অন্য একটি অ্যাকাউন্ট দিয়ে সাইন ইন করার চেষ্টা করুন", + "redirectUriMismatch": "রিডাইরেক্ট URI ওআথ অ্যাপ কনফিগারেশনের সাথে মিলছে না", + "oauthCallback": "অন্য একটি অ্যাকাউন্ট দিয়ে সাইন ইন করার চেষ্টা করুন", + "oauthCreateAccount": "অন্য একটি অ্যাকাউন্ট দিয়ে সাইন ইন করার চেষ্টা করুন", + "emailCreateAccount": "অন্য একটি অ্যাকাউন্ট দিয়ে সাইন ইন করার চেষ্টা করুন", + "callback": "অন্য একটি অ্যাকাউন্ট দিয়ে সাইন ইন করার চেষ্টা করুন", + "oauthAccountNotLinked": "আপনার পরিচয় নিশ্চিত করতে, আপনি যে অ্যাকাউন্টটি মূলত ব্যবহার করেছিলেন সেটি দিয়ে সাইন ইন করুন", + "emailSignin": "ইমেইল পাঠানো যায়নি", + "emailVerify": "অনুগ্রহ করে আপনার ইমেইল যাচাই করুন, একটি নতুন ইমেইল পাঠানো হয়েছে", + "credentialsSignin": "সাইন ইন ব্যর্থ হয়েছে। আপনার দেওয়া তথ্য সঠিক কিনা যাচাই করুন", + "sessionRequired": "এই পৃষ্ঠা দেখতে অনুগ্রহ করে সাইন ইন করুন" + } + }, + "provider": { + "continue": "{{provider}} দিয়ে চালিয়ে যান" + } + }, + "chat": { + "input": { + "placeholder": "আপনার বার্তা এখানে টাইপ করুন...", + "actions": { + "send": "বার্তা পাঠান", + "stop": "কাজ বন্ধ করুন", + "attachFiles": "ফাইল সংযুক্ত করুন" + } + }, + "speech": { + "start": "রেকর্ডিং শুরু করুন", + "stop": "রেকর্ডিং বন্ধ করুন", + "connecting": "সংযোগ করা হচ্ছে" + }, + "favorites": { + "use": "একটি পছন্দের মেসেজ ব্যবহার করুন", + "headline": "পছন্দের মেসেজ", + "remove": "পছন্দ বাতিল করুন", + "empty": { + "title": "এখনও কোনো প্রম্পট সংরক্ষিত নেই", + "description": "একটি প্রম্পট পাঠিয়ে এবং তাতে তারকা চিহ্ন দিয়ে শুরু করুন বা আগের চ্যাট থেকে একটি প্রম্পটে তারকা চিহ্ন দিন" + } + }, + "commands": { + "button": "টুলস", + "changeTool": "টুল পরিবর্তন করুন", + "availableTools": "উপলব্ধ টুলস" + }, + "fileUpload": { + "dragDrop": "এখানে ফাইল টেনে আনুন", + "browse": "ফাইল ব্রাউজ করুন", + "sizeLimit": "সীমা:", + "errors": { + "failed": "আপলোড ব্যর্থ হয়েছে", + "cancelled": "আপলোড বাতিল করা হয়েছে" + }, + "actions": { + "cancelUpload": "আপলোড বাতিল করুন", + "removeAttachment": "সংযুক্তি মুছে ফেলুন" + } + }, + "messages": { + "status": { + "using": "ব্যবহার করছে", + "used": "ব্যবহৃত" + }, + "actions": { + "copy": { + "button": "ক্লিপবোর্ডে কপি করুন", + "success": "কপি করা হয়েছে!" + } + }, + "feedback": { + "positive": "সহায়ক", + "negative": "সহায়ক নয়", + "edit": "প্রতিক্রিয়া সম্পাদনা করুন", + "dialog": { + "title": "মন্তব্য যোগ করুন", + "submit": "প্রতিক্রিয়া জমা দিন", + "yourFeedback": "আপনার প্রতিক্রিয়া..." + }, + "status": { + "updating": "হালনাগাদ করা হচ্ছে", + "updated": "প্রতিক্রিয়া হালনাগাদ করা হয়েছে" + } + } + }, + "history": { + "title": "সর্বশেষ ইনপুট", + "empty": "কোনো তথ্য নেই...", + "show": "ইতিহাস দেখুন" + }, + "settings": { + "title": "সেটিংস প্যানেল", + "customize": "এখানে আপনার চ্যাট সেটিংস কাস্টমাইজ করুন" + }, + "watermark": "এলএলএম ভুল করতে পারে। গুরুত্বপূর্ণ তথ্য যাচাই করার কথা বিবেচনা করুন।" + }, + "threadHistory": { + "sidebar": { + "title": "পূর্ববর্তী চ্যাট", + "filters": { + "search": "অনুসন্ধান", + "placeholder": "Search conversations..." + }, + "timeframes": { + "today": "আজ", + "yesterday": "গতকাল", + "previous7days": "গত ৭ দিন", + "previous30days": "গত ৩০ দিন" + }, + "empty": "কোনো থ্রেড পাওয়া যায়নি", + "actions": { + "close": "সাইডবার বন্ধ করুন", + "open": "সাইডবার খুলুন" + } + }, + "thread": { + "untitled": "শিরোনামহীন আলোচনা", + "menu": { + "rename": "পুনঃনামকরণ", + "share": "শেয়ার", + "delete": "Delete" + }, + "actions": { + "share": { + "title": "চ্যাটের লিঙ্ক শেয়ার করুন", + "button": "শেয়ার", + "status": { + "copied": "লিঙ্ক কপি করা হয়েছে", + "created": "শেয়ার লিঙ্ক তৈরি হয়েছে!", + "unshared": "এই থ্রেডের জন্য শেয়ারিং অক্ষম করা হয়েছে" + }, + "error": { + "create": "শেয়ার লিঙ্ক তৈরি করতে ব্যর্থ", + "unshare": "থ্রেডের শেয়ারিং বন্ধ করতে ব্যর্থ" + } + }, + "delete": { + "title": "মুছে ফেলা নিশ্চিত করুন", + "description": "এটি থ্রেড এবং এর বার্তা ও উপাদানগুলি মুছে ফেলবে। এই কাজটি পূর্বাবস্থায় ফেরানো যাবে না", + "success": "চ্যাট মুছে ফেলা হয়েছে", + "inProgress": "চ্যাট মুছে ফেলা হচ্ছে" + }, + "rename": { + "title": "থ্রেডের নাম পরিবর্তন করুন", + "description": "এই থ্রেডের জন্য একটি নতুন নাম দিন", + "form": { + "name": { + "label": "নাম", + "placeholder": "নতুন নাম লিখুন" + } + }, + "success": "থ্রেডের নাম পরিবর্তন করা হয়েছে!", + "inProgress": "থ্রেডের নাম পরিবর্তন করা হচ্ছে" + } + } + } + }, + "navigation": { + "header": { + "chat": "চ্যাট", + "readme": "রিডমি", + "theme": { + "light": "Light Theme", + "dark": "Dark Theme", + "system": "Follow System" + } + }, + "newChat": { + "button": "নতুন চ্যাট", + "dialog": { + "title": "নতুন চ্যাট তৈরি করুন", + "description": "এটি আপনার বর্তমান চ্যাট ইতিহাস মুছে ফেলবে। আপনি কি চালিয়ে যেতে চান?", + "tooltip": "নতুন চ্যাট" + } + }, + "user": { + "menu": { + "settings": "সেটিংস", + "settingsKey": "S", + "apiKeys": "এপিআই কী", + "logout": "লগআউট" + } + } + }, + "apiKeys": { + "title": "প্রয়োজনীয় এপিআই কী", + "description": "এই অ্যাপ্লিকেশন ব্যবহার করতে নিম্নলিখিত এপিআই কী প্রয়োজন। কীগুলি আপনার ডিভাইসের লোকাল স্টোরেজে সংরক্ষিত থাকে।", + "success": { + "saved": "সফলভাবে সংরক্ষিত হয়েছে" + } + }, + "alerts": { + "info": "Info", + "note": "Note", + "tip": "Tip", + "important": "Important", + "warning": "Warning", + "caution": "Caution", + "debug": "Debug", + "example": "Example", + "success": "Success", + "help": "Help", + "idea": "Idea", + "pending": "Pending", + "security": "Security", + "beta": "Beta", + "best-practice": "Best Practice" + }, + "components": { + "MultiSelectInput": { + "placeholder": "বেছে নিন..." + }, + "DatePickerInput": { + "placeholder": { + "single": "একটি তারিখ বেছে নিন", + "range": "তারিখের পরিসীমা বেছে নিন" + } + } + } +} diff --git a/backend/chainlit/translations/da-DK.json b/backend/chainlit/translations/da-DK.json new file mode 100644 index 0000000..aba214e --- /dev/null +++ b/backend/chainlit/translations/da-DK.json @@ -0,0 +1,259 @@ +{ + "common": { + "actions": { + "cancel": "Annuller", + "confirm": "Bekræft", + "continue": "Fortsæt", + "goBack": "Gå tilbage", + "reset": "Nulstil", + "submit": "Indsend" + }, + "status": { + "loading": "Indlæser...", + "error": { + "default": "Der opstod en fejl", + "serverConnection": "Kunne ikke nå serveren" + } + } + }, + "auth": { + "login": { + "title": "Log ind for at få adgang til appen", + "form": { + "email": { + "label": "E-mailadresse", + "required": "e-mail er et påkrævet felt", + "placeholder": "me@example.com" + }, + "password": { + "label": "Adgangskode", + "required": "adgangskode er et påkrævet felt" + }, + "actions": { + "signin": "Log ind" + }, + "alternativeText": { + "or": "ELLER" + } + }, + "errors": { + "default": "Kunne ikke logge ind", + "signin": "Prøv at logge ind med en anden konto", + "oauthSignin": "Prøv at logge ind med en anden konto", + "redirectUriMismatch": "Omdirigerings-URI'en matcher ikke oauth-app konfigurationen", + "oauthCallback": "Prøv at logge ind med en anden konto", + "oauthCreateAccount": "Prøv at logge ind med en anden konto", + "emailCreateAccount": "Prøv at logge ind med en anden konto", + "callback": "Prøv at logge ind med en anden konto", + "oauthAccountNotLinked": "For at bekræfte din identitet, log ind med samme konto, som du oprindeligt brugte", + "emailSignin": "E-mailen kunne ikke sendes", + "emailVerify": "Bekræft venligst din e-mail, en ny e-mail er blevet sendt", + "credentialsSignin": "Login mislykkedes. Kontroller at de angivne oplysninger er korrekte", + "sessionRequired": "Log venligst ind for at få adgang til denne side" + } + }, + "provider": { + "continue": "Fortsæt med {{provider}}" + } + }, + "chat": { + "input": { + "placeholder": "Skriv din besked her...", + "actions": { + "send": "Send besked", + "stop": "Stop opgave", + "attachFiles": "Vedhæft filer" + } + }, + "favorites": { + "use": "Brug en favorit besked", + "headline": "Favorit beskeder", + "empty": { + "title": "Ingen gemte prompts endnu", + "description": "Start med at sende en prompt og markere den med en stjerne, eller vælg en prompt fra tidligere samtaler" + } + }, + "commands": { + "button": "Værktøjer", + "changeTool": "Skift værktøj", + "availableTools": "Tilgængelige værktøjer" + }, + "speech": { + "start": "Start optagelse", + "stop": "Stop optagelse", + "connecting": "Forbinder" + }, + "fileUpload": { + "dragDrop": "Træk og slip filer her", + "browse": "Gennemse filer", + "sizeLimit": "Grænse:", + "errors": { + "failed": "Upload mislykkedes", + "cancelled": "Annullerede upload af" + }, + "actions": { + "cancelUpload": "Annullere upload", + "removeAttachment": "Fjern vedhæftning" + } + }, + "messages": { + "status": { + "using": "Bruger", + "used": "Brugte" + }, + "actions": { + "copy": { + "button": "Kopier til udklipsholder", + "success": "Kopieret!" + } + }, + "feedback": { + "positive": "Hjælpsom", + "negative": "Ikke hjælpsom", + "edit": "Rediger feedback", + "dialog": { + "title": "Tilføj en kommentar", + "submit": "Indsend feedback", + "yourFeedback": "Din feedback..." + }, + "status": { + "updating": "Opdaterer", + "updated": "Feedback opdateret" + } + } + }, + "history": { + "title": "Seneste input", + "empty": "Så tomt...", + "show": "Vis historik" + }, + "settings": { + "title": "Indstillingspanel", + "customize": "Tilpas dine chatindstillinger her" + }, + "watermark": "Bygget med" + }, + "threadHistory": { + "sidebar": { + "title": "Tidligere samtaler", + "filters": { + "search": "Søg", + "placeholder": "Søg i samtaler..." + }, + "timeframes": { + "today": "I dag", + "yesterday": "I går", + "previous7days": "Seneste 7 dage", + "previous30days": "Seneste 30 dage" + }, + "empty": "Ingen tråde fundet", + "actions": { + "close": "Luk sidepanel", + "open": "Åbn sidepanel" + } + }, + "thread": { + "untitled": "Unavngivet samtale", + "menu": { + "rename": "Omdøb", + "share": "Del", + "delete": "Slet" + }, + "actions": { + "share": { + "title": "Del link til chat", + "button": "Del", + "status": { + "copied": "Link kopieret", + "created": "Delingslink oprettet!", + "unshared": "Deling deaktiveret for denne tråd" + }, + "error": { + "create": "Kunne ikke oprette delingslink", + "unshare": "Kunne ikke fjerne deling af tråd" + } + }, + "delete": { + "title": "Bekræft sletning", + "description": "Dette vil slette tråden samt dens beskeder og elementer. Denne handling kan ikke fortrydes", + "success": "Chat slettet", + "inProgress": "Sletter chat" + }, + "rename": { + "title": "Omdøb tråd", + "description": "Indtast et nyt navn til denne tråd", + "form": { + "name": { + "label": "Navn", + "placeholder": "Indtast nyt navn" + } + }, + "success": "Tråd omdøbt!", + "inProgress": "Omdøber tråd" + } + } + } + }, + "navigation": { + "header": { + "chat": "Chat", + "readme": "📖", + "theme": { + "light": "Lyst tema", + "dark": "Mørkt tema", + "system": "Følg system" + } + }, + "newChat": { + "button": "Ny chat", + "dialog": { + "title": "Opret ny chat", + "description": "Dette vil rydde din nuværende chathistorik. Er du sikker på, at du vil fortsætte?", + "tooltip": "Ny chat" + } + }, + "user": { + "menu": { + "settings": "Indstillinger", + "settingsKey": "S", + "apiKeys": "API-nøgler", + "logout": "Log ud" + } + } + }, + "apiKeys": { + "title": "Påkrævede API-nøgler", + "description": "For at bruge denne app kræves følgende API-nøgler. Nøglerne gemmes på din enheds lokale lager.", + "success": { + "saved": "Gemt succesfuldt" + } + }, + "alerts": { + "info": "Info", + "note": "Bemærk", + "tip": "Tip", + "important": "Vigtigt", + "warning": "Advarsel", + "caution": "Forsigtig", + "debug": "Fejlfinding", + "example": "Eksempel", + "success": "Succes", + "help": "Hjælp", + "idea": "Idé", + "pending": "Afventer", + "security": "Sikkerhed", + "beta": "Beta", + "best-practice": "Bedste praksis" + }, + "components": { + "MultiSelectInput": { + "placeholder": "Vælg..." + }, + "DatePickerInput": { + "placeholder": { + "single": "Vælg en dato", + "range": "Vælg et datointerval" + } + } + } +} diff --git a/backend/chainlit/translations/de-DE.json b/backend/chainlit/translations/de-DE.json new file mode 100644 index 0000000..db5934b --- /dev/null +++ b/backend/chainlit/translations/de-DE.json @@ -0,0 +1,254 @@ +{ + "common": { + "actions": { + "cancel": "Abbrechen", + "confirm": "Bestätigen", + "continue": "Fortfahren", + "goBack": "Zurück", + "reset": "Zurücksetzen", + "submit": "Absenden" + }, + "status": { + "loading": "Lädt...", + "error": { + "default": "Ein Fehler ist aufgetreten", + "serverConnection": "Server konnte nicht erreicht werden" + } + } + }, + "auth": { + "login": { + "title": "Melde dich an, um auf die App zuzugreifen", + "form": { + "email": { + "label": "E-Mail Adresse", + "required": "E-Mail Adresse ist ein Pflichtfeld", + "placeholder": "me@example.com" + }, + "password": { + "label": "Passwort", + "required": "Passwort ist ein Pflichtfeld" + }, + "actions": { + "signin": "Anmelden" + }, + "alternativeText": { + "or": "ODER" + } + }, + "errors": { + "default": "Anmeldung fehlgeschlagen", + "signin": "Versuche dich mit einem anderen Konto anzumelden", + "oauthSignin": "Versuche dich mit einem anderen Konto anzumelden", + "redirectUriMismatch": "Der Redirect-URI stimmt nicht mit der Konfiguration der Oauth-Anwendung überein", + "oauthCallback": "Versuche dich mit einem anderen Konto anzumelden", + "oauthCreateAccount": "Versuche dich mit einem anderen Konto anzumelden", + "emailCreateAccount": "Versuche dich mit einem anderen Konto anzumelden", + "callback": "Versuche dich mit einem anderen Konto anzumelden", + "oauthAccountNotLinked": "Um die Identität zu bestätigen, melde dich mit demselben Konto an, das du ursprünglich verwendet hast", + "emailSignin": "Die E-Mail konnte nicht gesendet werden", + "emailVerify": "Es wurde eine neue E-Mail versandt. Bitte überprüfe dein E-Mail Postfach", + "credentialsSignin": "Anmeldung fehlgeschlagen. Überprüfe, ob die angegebenen Benutzerdaten korrekt sind", + "sessionRequired": "Bitte melde dich an, um auf diese Seite zuzugreifen" + } + }, + "provider": { + "continue": "Fortfahren mit {{provider}}" + } + }, + "chat": { + "input": { + "placeholder": "Nachricht eingeben...", + "actions": { + "send": "Nachricht senden", + "stop": "Aufgabe stoppen", + "attachFiles": "Dateien anhängen" + } + }, + "favorites": { + "use": "Eine favorisierte Nachricht verwenden", + "headline": "Favorisierte Nachrichten", + "remove": "Favorit entfernen", + "empty": { + "title": "Noch keine Prompts gespeichert", + "description": "Beginne, indem du einen Prompt sendest und mit einem Stern markierst oder markiere einen Prompt aus vorherigen Chats" + } + }, + "commands": { + "button": "Tools", + "changeTool": "Tool wechseln", + "availableTools": "Verfügbare Tools" + }, + "speech": { + "start": "Aufnahme starten", + "stop": "Aufnahme stoppen", + "connecting": "Verbinde" + }, + "fileUpload": { + "dragDrop": "Ziehe deine Dateien hierher", + "browse": "Dateien durchsuchen", + "sizeLimit": "Limit:", + "errors": { + "failed": "Hochladen fehlgeschlagen", + "cancelled": "Abbruch des hochladens von" + }, + "actions": { + "cancelUpload": "Upload abbrechen", + "removeAttachment": "Anhang entfernen" + } + }, + "messages": { + "status": { + "using": "Verwendet", + "used": "Verwendete" + }, + "actions": { + "copy": { + "button": "In Zwischenablage kopieren", + "success": "Kopiert!" + } + }, + "feedback": { + "positive": "Hilfreich", + "negative": "Nicht hilfreich", + "edit": "Feedback editieren", + "dialog": { + "title": "Füge einen Kommentar hinzu", + "submit": "Feedback absenden", + "yourFeedback": "Dein Feedback..." + }, + "status": { + "updating": "Aktualisiert", + "updated": "Feedback aktualisiert" + } + } + }, + "history": { + "title": "Vergangene Eingaben", + "empty": "Leer...", + "show": "Historie anzeigen" + }, + "settings": { + "title": "Einstellungen", + "customize": "Passe die Chat Einstellungen hier an" + }, + "watermark": "LLMs können Fehler machen. Überprüfe bitte stets die Inhalte." + }, + "threadHistory": { + "sidebar": { + "title": "Vergangene Chats", + "filters": { + "search": "Suche", + "placeholder": "Suche konversationen..." + }, + "timeframes": { + "today": "Heute", + "yesterday": "Gestern", + "previous7days": "Vor 7 Tagen", + "previous30days": "Vor 30 Tagen" + }, + "empty": "Kein Chat gefunden", + "actions": { + "close": "Seitenleiste schließen", + "open": "Seitenleiste öffnen" + } + }, + "thread": { + "untitled": "Unbenannter Thread", + "menu": { + "rename": "Umbenennen", + "share": "Teilen", + "delete": "Löschen" + }, + "actions": { + "share": { + "title": "Thread löschen bestätigen", + "button": "Teilen", + "status": { + "copied": "Link kopiert", + "created": "Freigabelink erstellt!", + "unshared": "Teilen ist für diesen Thread deaktiviert" + }, + "error": { + "create": "Fehler beim Erstellen des Freigabelinks", + "unshare": "Freigabe des Threads konnte nicht aufgehoben werden" + } + }, + "delete": { + "title": "Löschen bestätigen", + "description": "Dies wird den Thread sowie seine Nachrichten und Elemente löschen. Dies kann nicht rückgängig gemacht werden", + "success": "Chat gelöscht", + "inProgress": "Chat wird gelöscht" + }, + "rename": { + "title": "Thread umbenennen", + "description": "Gebe einen neuen Namen für den Thread ein", + "form": { + "name": { + "label": "Name", + "placeholder": "Neuen Namen eingeben" + } + }, + "success": "Thread umbenannt!", + "inProgress": "Thread wird umbenannt" + } + } + } + }, + "navigation": { + "header": { + "chat": "Chat", + "readme": "Anleitung", + "theme": { + "light": "Helles Design", + "dark": "Dunkles Design", + "system": "System Design" + } + }, + "newChat": { + "button": "Neuer Chat", + "dialog": { + "title": "Möchtest du einen neuen Chat erstellen?", + "description": "Es werden die aktuellen Nachrichten gelöscht und ein neuer Chat geöffnet.", + "tooltip": "Neuer Chat" + } + }, + "user": { + "menu": { + "settings": "Einstellungen", + "settingsKey": "S", + "apiKeys": "API Schlüssel", + "logout": "Abmelden" + } + } + }, + "apiKeys": { + "title": "Benötigte API Schlüssel", + "description": "Um diese App zu nutzen, werden die folgenden API Schlüssel benötigt. Die Schlüssel werden im lokalen Speicher Ihres Geräts gespeichert.", + "success": { + "saved": "Erfolgreich gespeichert" + } + }, + "alerts": { + "info": "Info", + "note": "Hinweis", + "tip": "Tipp", + "important": "Wichtig", + "warning": "Warnung", + "caution": "Vorsicht", + "debug": "Debug", + "example": "Beispiel", + "success": "Erfolg", + "help": "Hilfe", + "idea": "Idee", + "pending": "Ausstehend", + "security": "Sicherheit", + "beta": "Beta", + "best-practice": "Bewährte Praxis" + }, + "components": { + "MultiSelectInput": { + "placeholder": "Wähle aus..." + } + } +} diff --git a/backend/chainlit/translations/el-GR.json b/backend/chainlit/translations/el-GR.json new file mode 100644 index 0000000..54e1226 --- /dev/null +++ b/backend/chainlit/translations/el-GR.json @@ -0,0 +1,260 @@ +{ + "common": { + "actions": { + "cancel": "Άκυρο", + "confirm": "Επιβεβαίωση", + "continue": "Συνέχεια", + "goBack": "Επιστροφή", + "reset": "Επαναφορά", + "submit": "Υποβολή" + }, + "status": { + "loading": "Φόρτωση...", + "error": { + "default": "Παρουσιάστηκε σφάλμα", + "serverConnection": "Δεν ήταν δυνατή η επικοινωνία με τον διακομιστή" + } + } + }, + "auth": { + "login": { + "title": "Συνδεθείτε για να αποκτήσετε πρόσβαση στην εφαρμογή", + "form": { + "email": { + "label": "Διεύθυνση ηλεκτρονικού ταχυδρομείου", + "required": "Το email είναι υποχρεωτικό πεδίο", + "placeholder": "me@example.com" + }, + "password": { + "label": "Κωδικός πρόσβασης", + "required": "Ο κωδικός πρόσβασης είναι υποχρεωτικό πεδίο" + }, + "actions": { + "signin": "Σύνδεση" + }, + "alternativeText": { + "or": "ή" + } + }, + "errors": { + "default": "Δεν είναι δυνατή η σύνδεση", + "signin": "Δοκιμάστε να συνδεθείτε με διαφορετικό λογαριασμό", + "oauthSignin": "Δοκιμάστε να συνδεθείτε με διαφορετικό λογαριασμό", + "redirectUriMismatch": "Ο σύνδεσμος ανακατεύθυνσης δεν ταιριάζει με τη ρύθμιση της αυθεντικοποιήσης της εφαρμογής", + "oauthCallback": "Δοκιμάστε να συνδεθείτε με διαφορετικό λογαριασμό", + "oauthCreateAccount": "Δοκιμάστε να συνδεθείτε με διαφορετικό λογαριασμό", + "emailCreateAccount": "Δοκιμάστε να συνδεθείτε με διαφορετικό λογαριασμό", + "callback": "Δοκιμάστε να συνδεθείτε με διαφορετικό λογαριασμό", + "oauthAccountNotLinked": "Για να επιβεβαιώσετε την ταυτότητά σας, συνδεθείτε με τον ίδιο λογαριασμό που χρησιμοποιήσατε αρχικά", + "emailSignin": "Δεν ήταν δυνατή η αποστολή του email", + "emailVerify": "Παρακαλώ επαληθεύστε την διεύθυνση ηλεκτρονικού ταχυδρομείου σας, ένα νέο email σας έχει σταλεί", + "credentialsSignin": "Η σύνδεση απέτυχε. Ελέγξτε ότι τα στοιχεία που δώσατε είναι σωστά", + "sessionRequired": "Παρακαλώ συνδεθείτε για να αποκτήσετε πρόσβαση σε αυτήν τη σελίδα" + } + }, + "provider": { + "continue": "Συνέχεια με {{provider}}" + } + }, + "chat": { + "input": { + "placeholder": "Πληκτρολογήστε το μήνυμά σας εδώ...", + "actions": { + "send": "Αποστολή μηνύματος", + "stop": "Διακοπή εργασίας", + "attachFiles": "Επισύναψη αρχείων" + } + }, + "favorites": { + "use": "Χρησιμοποιήστε ένα αγαπημένο μήνυμα", + "headline": "Αγαπημένα μηνύματα", + "remove": "Αφαίρεση αγαπημένου", + "empty": { + "title": "Δεν υπάρχουν αποθηκευμένες προτροπές ακόμα", + "description": "Ξεκινήστε στέλνοντας μια προτροπή και προσθέστε την στα αγαπημένα ή προσθέστε μια προτροπή από προηγούμενες συνομιλίες" + } + }, + "commands": { + "button": "Εργαλεία", + "changeTool": "Αλλαγή Εργαλείου", + "availableTools": "Διαθέσιμα Εργαλεία" + }, + "speech": { + "start": "Έναρξη εγγραφής", + "stop": "Διακοπή εγγραφής", + "connecting": "Σύνδεση" + }, + "fileUpload": { + "dragDrop": "Σύρετε αρχεία εδώ", + "browse": "Αναζήτηση αρχείων", + "sizeLimit": "Όριο:", + "errors": { + "failed": "Η μεταφόρτωση απέτυχε", + "cancelled": "Ακυρώθηκε η μεταφόρτωση του" + }, + "actions": { + "cancelUpload": "Ακύρωση μεταφόρτωσης", + "removeAttachment": "Αφαίρεση επισύναψης" + } + }, + "messages": { + "status": { + "using": "Με τη χρήση", + "used": "Χρησιμοποιήθηκε" + }, + "actions": { + "copy": { + "button": "Αντιγραφή στο πρόχειρο", + "success": "Αντιγράφηκε!" + } + }, + "feedback": { + "positive": "Χρήσιμος", + "negative": "Μη χρήσιμος", + "edit": "Επεξεργασία σχολίων", + "dialog": { + "title": "Προσθήκη σχολίου", + "submit": "Υποβολή σχολίων", + "yourFeedback": "Η γνώμη σας" + }, + "status": { + "updating": "Ενημερώνεται", + "updated": "Τα σχόλια ενημερώθηκαν" + } + } + }, + "history": { + "title": "Τελευταίες εισαγωγές", + "empty": "Τόσο άδειο...", + "show": "Προβολή ιστορικού" + }, + "settings": { + "title": "Πίνακας ρυθμίσεων", + "customize": "Προσαρμογή" + }, + "watermark": "Τα ΜΓΜ μπορεί να κάνουν λάθη. Ελέγξτε σημαντικές πληροφορίες." + }, + "threadHistory": { + "sidebar": { + "title": "Παλαιότερες συνομιλίες", + "filters": { + "search": "Αναζήτηση", + "placeholder": "Αναζήτηση συνομιλιών..." + }, + "timeframes": { + "today": "Σήμερα", + "yesterday": "Χθες", + "previous7days": "Προηγούμενες 7 ημέρες", + "previous30days": "Προηγούμενες 30 ημέρες" + }, + "empty": "Δεν βρέθηκαν νήματα", + "actions": { + "close": "Κλείσιμο πλαϊνής γραμμής", + "open": "Άνοιγμα πλαϊνής γραμμής" + } + }, + "thread": { + "untitled": "Συνομιλία χωρίς τίτλο", + "menu": { + "rename": "Μετονομασία", + "share": "Κοινοποίηση", + "delete": "Διαγραφή" + }, + "actions": { + "share": { + "title": "Κοινοποίηση συνδέσμου συνομιλίας", + "button": "Κοινοποίηση", + "status": { + "copied": "Ο σύνδεσμος αντιγράφηκε", + "created": "Ο σύνδεσμος κοινοποίησης δημιουργήθηκε!", + "unshared": "Η κοινοποίηση απενεργοποιήθηκε για αυτό το νήμα" + }, + "error": { + "create": "Αποτυχία δημιουργίας συνδέσμου κοινοποίησης", + "unshare": "Αποτυχία διακοπής κοινοποίησης νήματος" + } + }, + "delete": { + "title": "Επιβεβαίωση διαγραφής", + "description": "Αυτό θα διαγράψει το νήμα καθώς και τα μηνύματα και τα στοιχεία του. Αυτή η ενέργεια δεν μπορεί να αναιρεθεί.", + "success": "Η συνομιλία διαγράφηκε", + "inProgress": "Διαγραφή συνομιλίας" + }, + "rename": { + "title": "Μετονομασία Νήματος", + "description": "Εισαγάγετε ένα νέο όνομα για αυτό το νήμα", + "form": { + "name": { + "label": "Όνομα", + "placeholder": "Εισαγάγετε νέο όνομα" + } + }, + "success": "Το νήμα μετονομάστηκε!", + "inProgress": "Μετονομασία Νήματος" + } + } + } + }, + "navigation": { + "header": { + "chat": "Συνομιλία", + "readme": "Διάβασέ με", + "theme": { + "light": "Φωτεινό Θέμα", + "dark": "Σκοτεινό θέμα", + "system": "Ακολουθήστε το σύστημα" + } + }, + "newChat": { + "button": "Νέα Συνομιλία", + "dialog": { + "title": "Δημιουργία Νέας Συνομιλίας", + "description": "Αυτό θα διαγράψει το τρέχον ιστορικό συνομιλίας σας. Είστε βέβαιοι ότι θέλετε να συνεχίσετε;", + "tooltip": "Νέα Συνομιλία" + } + }, + "user": { + "menu": { + "settings": "Ρυθμίσεις", + "settingsKey": "S", + "apiKeys": "Κλειδιά API", + "logout": "Αποσύνδεση" + } + } + }, + "apiKeys": { + "title": "Απαιτούμενα κλειδιά API", + "description": "Για να χρησιμοποιήσετε αυτήν την εφαρμογή, απαιτούνται τα ακόλουθα κλειδιά API. Τα κλειδιά είναι αποθηκευμένα στον τοπικό χώρο αποθήκευσης της συσκευής σας.", + "success": { + "saved": "Αποθηκεύτηκε με επιτυχία" + } + }, + "alerts": { + "info": "Πληροφορίες", + "note": "Σημείωση", + "tip": "Συμβουλή", + "important": "Σημαντικό", + "warning": "Προειδοποίηση", + "caution": "Προσοχή", + "debug": "Εντοπισμός σφαλμάτων", + "example": "Παράδειγμα", + "success": "Επιτυχία", + "help": "Βοήθεια", + "idea": "Ιδέα", + "pending": "Σε εκκρεμότητα", + "security": "Ασφάλεια", + "beta": "Beta", + "best-practice": "Βέλτιστη Πρακτική" + }, + "components": { + "MultiSelectInput": { + "placeholder": "Επιλέξτε..." + }, + "DatePickerInput": { + "placeholder": { + "single": "Επιλέξτε ημερομηνία", + "range": "Επιλέξτε εύρος ημερομηνιών" + } + } + } +} diff --git a/backend/chainlit/translations/en-US.json b/backend/chainlit/translations/en-US.json new file mode 100644 index 0000000..5a62b3c --- /dev/null +++ b/backend/chainlit/translations/en-US.json @@ -0,0 +1,260 @@ +{ + "common": { + "actions": { + "cancel": "Cancel", + "confirm": "Confirm", + "continue": "Continue", + "goBack": "Go Back", + "reset": "Reset", + "submit": "Submit" + }, + "status": { + "loading": "Loading...", + "error": { + "default": "An error occurred", + "serverConnection": "Could not reach the server" + } + } + }, + "auth": { + "login": { + "title": "Login to access the app", + "form": { + "email": { + "label": "Email address", + "required": "email is a required field", + "placeholder": "me@example.com" + }, + "password": { + "label": "Password", + "required": "password is a required field" + }, + "actions": { + "signin": "Sign In" + }, + "alternativeText": { + "or": "OR" + } + }, + "errors": { + "default": "Unable to sign in", + "signin": "Try signing in with a different account", + "oauthSignin": "Try signing in with a different account", + "redirectUriMismatch": "The redirect URI is not matching the oauth app configuration", + "oauthCallback": "Try signing in with a different account", + "oauthCreateAccount": "Try signing in with a different account", + "emailCreateAccount": "Try signing in with a different account", + "callback": "Try signing in with a different account", + "oauthAccountNotLinked": "To confirm your identity, sign in with the same account you used originally", + "emailSignin": "The e-mail could not be sent", + "emailVerify": "Please verify your email, a new email has been sent", + "credentialsSignin": "Sign in failed. Check the details you provided are correct", + "sessionRequired": "Please sign in to access this page" + } + }, + "provider": { + "continue": "Continue with {{provider}}" + } + }, + "chat": { + "input": { + "placeholder": "Type your message here...", + "actions": { + "send": "Send message", + "stop": "Stop Task", + "attachFiles": "Attach files" + } + }, + "favorites": { + "use": "Use a favorite message", + "headline": "Favorite Messages", + "remove": "Remove favorite", + "empty": { + "title": "No Saved Prompts Yet", + "description": "Start by sending a prompt and star it or star a prompt from previous chats" + } + }, + "commands": { + "button": "Tools", + "changeTool": "Change Tool", + "availableTools": "Available Tools" + }, + "speech": { + "start": "Start recording", + "stop": "Stop recording", + "connecting": "Connecting" + }, + "fileUpload": { + "dragDrop": "Drag and drop files here", + "browse": "Browse Files", + "sizeLimit": "Limit:", + "errors": { + "failed": "Failed to upload", + "cancelled": "Cancelled upload of" + }, + "actions": { + "cancelUpload": "Cancel upload", + "removeAttachment": "Remove attachment" + } + }, + "messages": { + "status": { + "using": "Using", + "used": "Used" + }, + "actions": { + "copy": { + "button": "Copy to clipboard", + "success": "Copied!" + } + }, + "feedback": { + "positive": "Helpful", + "negative": "Not helpful", + "edit": "Edit feedback", + "dialog": { + "title": "Add a comment", + "submit": "Submit feedback", + "yourFeedback": "Your feedback..." + }, + "status": { + "updating": "Updating", + "updated": "Feedback updated" + } + } + }, + "history": { + "title": "Last Inputs", + "empty": "Such empty...", + "show": "Show history" + }, + "settings": { + "title": "Settings panel", + "customize": "Customize your chat settings here" + }, + "watermark": "LLMs can make mistakes. Check important info." + }, + "threadHistory": { + "sidebar": { + "title": "Past Chats", + "filters": { + "search": "Search", + "placeholder": "Search conversations..." + }, + "timeframes": { + "today": "Today", + "yesterday": "Yesterday", + "previous7days": "Previous 7 days", + "previous30days": "Previous 30 days" + }, + "empty": "No threads found", + "actions": { + "close": "Close sidebar", + "open": "Open sidebar" + } + }, + "thread": { + "untitled": "Untitled Conversation", + "menu": { + "rename": "Rename", + "share": "Share", + "delete": "Delete" + }, + "actions": { + "share": { + "title": "Share link to chat", + "button": "Share", + "status": { + "copied": "Link copied", + "created": "Share link created!", + "unshared": "Sharing disabled for this thread" + }, + "error": { + "create": "Failed to create share link", + "unshare": "Failed to unshare thread" + } + }, + "delete": { + "title": "Confirm deletion", + "description": "This will delete the thread as well as its messages and elements. This action cannot be undone", + "success": "Chat deleted", + "inProgress": "Deleting chat" + }, + "rename": { + "title": "Rename Thread", + "description": "Enter a new name for this thread", + "form": { + "name": { + "label": "Name", + "placeholder": "Enter new name" + } + }, + "success": "Thread renamed!", + "inProgress": "Renaming thread" + } + } + } + }, + "navigation": { + "header": { + "chat": "Chat", + "readme": "Readme", + "theme": { + "light": "Light Theme", + "dark": "Dark Theme", + "system": "Follow System" + } + }, + "newChat": { + "button": "New Chat", + "dialog": { + "title": "Create New Chat", + "description": "This will clear your current chat history. Are you sure you want to continue?", + "tooltip": "New Chat" + } + }, + "user": { + "menu": { + "settings": "Settings", + "settingsKey": "S", + "apiKeys": "API Keys", + "logout": "Logout" + } + } + }, + "apiKeys": { + "title": "Required API Keys", + "description": "To use this app, the following API keys are required. The keys are stored on your device's local storage.", + "success": { + "saved": "Saved successfully" + } + }, + "alerts": { + "info": "Info", + "note": "Note", + "tip": "Tip", + "important": "Important", + "warning": "Warning", + "caution": "Caution", + "debug": "Debug", + "example": "Example", + "success": "Success", + "help": "Help", + "idea": "Idea", + "pending": "Pending", + "security": "Security", + "beta": "Beta", + "best-practice": "Best Practice" + }, + "components": { + "MultiSelectInput": { + "placeholder": "Select..." + }, + "DatePickerInput": { + "placeholder": { + "single": "Pick a date", + "range": "Pick a date range" + } + } + } +} diff --git a/backend/chainlit/translations/es.json b/backend/chainlit/translations/es.json new file mode 100644 index 0000000..f47a53f --- /dev/null +++ b/backend/chainlit/translations/es.json @@ -0,0 +1,260 @@ +{ + "common": { + "actions": { + "cancel": "Cancelar", + "confirm": "Confirmar", + "continue": "Continuar", + "goBack": "Volver", + "reset": "Restablecer", + "submit": "Enviar" + }, + "status": { + "loading": "Cargando...", + "error": { + "default": "Ocurrió un error", + "serverConnection": "No se pudo conectar con el servidor" + } + } + }, + "auth": { + "login": { + "title": "Inicia sesión para acceder a la aplicación", + "form": { + "email": { + "label": "Correo electrónico", + "required": "el correo electrónico es obligatorio", + "placeholder": "me@example.com" + }, + "password": { + "label": "Contraseña", + "required": "la contraseña es obligatoria" + }, + "actions": { + "signin": "Iniciar sesión" + }, + "alternativeText": { + "or": "O" + } + }, + "errors": { + "default": "No se pudo iniciar sesión", + "signin": "Intenta iniciar sesión con otra cuenta", + "oauthSignin": "Intenta iniciar sesión con otra cuenta", + "redirectUriMismatch": "El URI de redirección no coincide con la configuración de la aplicación OAuth", + "oauthCallback": "Intenta iniciar sesión con otra cuenta", + "oauthCreateAccount": "Intenta iniciar sesión con otra cuenta", + "emailCreateAccount": "Intenta iniciar sesión con otra cuenta", + "callback": "Intenta iniciar sesión con otra cuenta", + "oauthAccountNotLinked": "Para confirmar tu identidad, inicia sesión con la misma cuenta que usaste originalmente", + "emailSignin": "No se pudo enviar el correo electrónico", + "emailVerify": "Por favor verifica tu correo, se ha enviado un nuevo correo", + "credentialsSignin": "Error al iniciar sesión. Verifica que los datos proporcionados sean correctos", + "sessionRequired": "Por favor inicia sesión para acceder a esta página" + } + }, + "provider": { + "continue": "Continuar con {{provider}}" + } + }, + "chat": { + "input": { + "placeholder": "Escribe tu mensaje aquí...", + "actions": { + "send": "Enviar mensaje", + "stop": "Detener tarea", + "attachFiles": "Adjuntar archivos" + } + }, + "favorites": { + "use": "Usar un mensaje favorito", + "headline": "Mensajes favoritos", + "remove": "Eliminar favorito", + "empty": { + "title": "Aún no hay prompts guardados", + "description": "Comienza enviando un prompt y márcalo con estrella o marca un prompt de chats anteriores" + } + }, + "commands": { + "button": "Herramientas", + "changeTool": "Cambiar herramienta", + "availableTools": "Herramientas disponibles" + }, + "speech": { + "start": "Comenzar grabación", + "stop": "Detener grabación", + "connecting": "Conectando" + }, + "fileUpload": { + "dragDrop": "Arrastra y suelta archivos aquí", + "browse": "Buscar archivos", + "sizeLimit": "Límite:", + "errors": { + "failed": "Error al subir", + "cancelled": "Carga cancelada de" + }, + "actions": { + "cancelUpload": "Cancelar subida", + "removeAttachment": "Eliminar adjunto" + } + }, + "messages": { + "status": { + "using": "Usando", + "used": "Usado" + }, + "actions": { + "copy": { + "button": "Copiar al portapapeles", + "success": "¡Copiado!" + } + }, + "feedback": { + "positive": "Útil", + "negative": "No útil", + "edit": "Editar comentario", + "dialog": { + "title": "Agregar un comentario", + "submit": "Enviar comentario", + "yourFeedback": "Tu comentario..." + }, + "status": { + "updating": "Actualizando", + "updated": "Comentario actualizado" + } + } + }, + "history": { + "title": "Últimas entradas", + "empty": "Tan vacío...", + "show": "Mostrar historial" + }, + "settings": { + "title": "Panel de configuración", + "customize": "Personaliza la configuración de tu chat aquí" + }, + "watermark": "Los LLM pueden cometer errores. Verifica la información importante." + }, + "threadHistory": { + "sidebar": { + "title": "Chats anteriores", + "filters": { + "search": "Buscar", + "placeholder": "Buscar conversaciones..." + }, + "timeframes": { + "today": "Hoy", + "yesterday": "Ayer", + "previous7days": "Últimos 7 días", + "previous30days": "Últimos 30 días" + }, + "empty": "No se encontraron conversaciones", + "actions": { + "close": "Cerrar barra lateral", + "open": "Abrir barra lateral" + } + }, + "thread": { + "untitled": "Conversación sin título", + "menu": { + "rename": "Renombrar", + "share": "Compartir", + "delete": "Eliminar" + }, + "actions": { + "share": { + "title": "Compartir enlace del chat", + "button": "Compartir", + "status": { + "copied": "Enlace copiado", + "created": "¡Enlace de uso compartido creado!", + "unshared": "Uso compartido deshabilitado para este hilo" + }, + "error": { + "create": "Error al crear el enlace de uso compartido", + "unshare": "Error al dejar de compartir el hilo" + } + }, + "delete": { + "title": "Confirmar eliminación", + "description": "Esto eliminará la conversación, sus mensajes y elementos. Esta acción no se puede deshacer", + "success": "Chat eliminado", + "inProgress": "Eliminando chat" + }, + "rename": { + "title": "Renombrar conversación", + "description": "Ingresa un nuevo nombre para esta conversación", + "form": { + "name": { + "label": "Nombre", + "placeholder": "Ingresa nuevo nombre" + } + }, + "success": "¡Conversación renombrada!", + "inProgress": "Renombrando conversación" + } + } + } + }, + "navigation": { + "header": { + "chat": "Chat", + "readme": "Léeme", + "theme": { + "light": "Tema claro", + "dark": "Tema oscuro", + "system": "Seguir sistema" + } + }, + "newChat": { + "button": "Nuevo chat", + "dialog": { + "title": "Crear nuevo chat", + "description": "Esto borrará tu historial de chat actual. ¿Seguro que quieres continuar?", + "tooltip": "Nuevo chat" + } + }, + "user": { + "menu": { + "settings": "Configuración", + "settingsKey": "S", + "apiKeys": "Claves API", + "logout": "Cerrar sesión" + } + } + }, + "apiKeys": { + "title": "Claves API requeridas", + "description": "Para usar esta aplicación, se requieren las siguientes claves API. Las claves se almacenan en el almacenamiento local de tu dispositivo.", + "success": { + "saved": "Guardado exitosamente" + } + }, + "alerts": { + "info": "Información", + "note": "Nota", + "tip": "Consejo", + "important": "Importante", + "warning": "Advertencia", + "caution": "Precaución", + "debug": "Depuración", + "example": "Ejemplo", + "success": "Éxito", + "help": "Ayuda", + "idea": "Idea", + "pending": "Pendiente", + "security": "Seguridad", + "beta": "Beta", + "best-practice": "Mejor práctica" + }, + "components": { + "MultiSelectInput": { + "placeholder": "Seleccionar..." + }, + "DatePickerInput": { + "placeholder": { + "single": "Elige una fecha", + "range": "Elige un rango de fechas" + } + } + } +} diff --git a/backend/chainlit/translations/fr-FR.json b/backend/chainlit/translations/fr-FR.json new file mode 100644 index 0000000..163be03 --- /dev/null +++ b/backend/chainlit/translations/fr-FR.json @@ -0,0 +1,260 @@ +{ + "common": { + "actions": { + "cancel": "Annuler", + "confirm": "Confirmer", + "continue": "Continuer", + "goBack": "Retour", + "reset": "Réinitialiser", + "submit": "Envoyer" + }, + "status": { + "loading": "Chargement...", + "error": { + "default": "Une erreur est survenue", + "serverConnection": "Impossible de joindre le serveur" + } + } + }, + "auth": { + "login": { + "title": "Connectez-vous pour accéder à l'application", + "form": { + "email": { + "label": "Adresse e-mail", + "required": "l'e-mail est un champ obligatoire", + "placeholder": "me@example.com" + }, + "password": { + "label": "Mot de passe", + "required": "le mot de passe est un champ obligatoire" + }, + "actions": { + "signin": "Se connecter" + }, + "alternativeText": { + "or": "OU" + } + }, + "errors": { + "default": "Impossible de se connecter", + "signin": "Essayez de vous connecter avec un autre compte", + "oauthSignin": "Essayez de vous connecter avec un autre compte", + "redirectUriMismatch": "L'URI de redirection ne correspond pas à la configuration de l'application oauth", + "oauthCallback": "Essayez de vous connecter avec un autre compte", + "oauthCreateAccount": "Essayez de vous connecter avec un autre compte", + "emailCreateAccount": "Essayez de vous connecter avec un autre compte", + "callback": "Essayez de vous connecter avec un autre compte", + "oauthAccountNotLinked": "Pour confirmer votre identité, connectez-vous avec le même compte que vous avez utilisé à l'origine", + "emailSignin": "L'e-mail n'a pas pu être envoyé", + "emailVerify": "Veuillez vérifier votre e-mail, un nouvel e-mail a été envoyé", + "credentialsSignin": "La connexion a échoué. Vérifiez que les informations que vous avez fournies sont correctes", + "sessionRequired": "Veuillez vous connecter pour accéder à cette page" + } + }, + "provider": { + "continue": "Continuer avec {{provider}}" + } + }, + "chat": { + "input": { + "placeholder": "Tapez votre message ici...", + "actions": { + "send": "Envoyer le message", + "stop": "Arrêter la tâche", + "attachFiles": "Joindre des fichiers" + } + }, + "favorites": { + "use": "Utiliser un message favori", + "headline": "Messages favoris", + "remove": "Supprimer des favoris", + "empty": { + "title": "Aucun prompt enregistré pour le moment", + "description": "Commencez par envoyer un prompt et ajoutez-le aux favoris ou ajoutez un prompt de discussions précédentes aux favoris" + } + }, + "commands": { + "button": "Outils", + "changeTool": "Changer d'outil", + "availableTools": "Outils disponibles" + }, + "speech": { + "start": "Démarrer l'enregistrement", + "stop": "Arrêter l'enregistrement", + "connecting": "Connexion en cours" + }, + "fileUpload": { + "dragDrop": "Glissez et déposez des fichiers ici", + "browse": "Parcourir les fichiers", + "sizeLimit": "Limite :", + "errors": { + "failed": "Échec du téléversement", + "cancelled": "Téléversement annulé de" + }, + "actions": { + "cancelUpload": "Annuler le téléversement", + "removeAttachment": "Supprimer la pièce jointe" + } + }, + "messages": { + "status": { + "using": "Utilise", + "used": "Utilisé" + }, + "actions": { + "copy": { + "button": "Copier dans le presse-papiers", + "success": "Copié !" + } + }, + "feedback": { + "positive": "Utile", + "negative": "Pas utile", + "edit": "Modifier le commentaire", + "dialog": { + "title": "Ajouter un commentaire", + "submit": "Envoyer le commentaire", + "yourFeedback": "Votre avis..." + }, + "status": { + "updating": "Mise à jour", + "updated": "Commentaire mis à jour" + } + } + }, + "history": { + "title": "Dernières entrées", + "empty": "Tellement vide...", + "show": "Afficher l'historique" + }, + "settings": { + "title": "Panneau des paramètres", + "customize": "Personnalisez vos paramètres de chat ici" + }, + "watermark": "Les LLMs peuvent se tromper. Vérifiez les réponses." + }, + "threadHistory": { + "sidebar": { + "title": "Discussions passées", + "filters": { + "search": "Rechercher", + "placeholder": "Rechercher des conversations..." + }, + "timeframes": { + "today": "Aujourd'hui", + "yesterday": "Hier", + "previous7days": "Les 7 derniers jours", + "previous30days": "Les 30 derniers jours" + }, + "empty": "Aucun fil de discussion trouvé", + "actions": { + "close": "Fermer la barre latérale", + "open": "Ouvrir la barre latérale" + } + }, + "thread": { + "untitled": "Conversation sans titre", + "menu": { + "rename": "Renommer", + "share": "Partager", + "delete": "Supprimer" + }, + "actions": { + "share": { + "title": "Partager le lien de la discussion", + "button": "Partager", + "status": { + "copied": "Lien copié", + "created": "Lien de partage créé !", + "unshared": "Partage désactivé pour ce fil" + }, + "error": { + "create": "Échec de la création du lien de partage", + "unshare": "Échec de la désactivation du partage du fil" + } + }, + "delete": { + "title": "Confirmer la suppression", + "description": "Cela supprimera le fil de discussion ainsi que ses messages et éléments. Cette action ne peut pas être annulée", + "success": "Discussion supprimée", + "inProgress": "Suppression de la discussion" + }, + "rename": { + "title": "Renommer le fil de discussion", + "description": "Entrez un nouveau nom pour ce fil de discussion", + "form": { + "name": { + "label": "Nom", + "placeholder": "Entrez le nouveau nom" + } + }, + "success": "Fil de discussion renommé !", + "inProgress": "Renommage du fil de discussion" + } + } + } + }, + "navigation": { + "header": { + "chat": "Discussion", + "readme": "Lisez-moi", + "theme": { + "light": "Thème clair", + "dark": "Thème sombre", + "system": "Suivre le système" + } + }, + "newChat": { + "button": "Nouvelle discussion", + "dialog": { + "title": "Créer une nouvelle discussion", + "description": "Cela effacera votre historique de discussion actuel. Êtes-vous sûr de vouloir continuer ?", + "tooltip": "Nouvelle discussion" + } + }, + "user": { + "menu": { + "settings": "Paramètres", + "settingsKey": "S", + "apiKeys": "Clés API", + "logout": "Se déconnecter" + } + } + }, + "apiKeys": { + "title": "Clés API requises", + "description": "Pour utiliser cette application, les clés API suivantes sont requises. Les clés sont stockées dans le stockage local de votre appareil.", + "success": { + "saved": "Enregistré avec succès" + } + }, + "alerts": { + "info": "Info", + "note": "Note", + "tip": "Astuce", + "important": "Important", + "warning": "Avertissement", + "caution": "Attention", + "debug": "Débogage", + "example": "Exemple", + "success": "Succès", + "help": "Aide", + "idea": "Idée", + "pending": "En attente", + "security": "Sécurité", + "beta": "Bêta", + "best-practice": "Meilleure pratique" + }, + "components": { + "MultiSelectInput": { + "placeholder": "Sélectionner..." + }, + "DatePickerInput": { + "placeholder": { + "single": "Choisir une date", + "range": "Choisir une plage de dates" + } + } + } +} diff --git a/backend/chainlit/translations/gu.json b/backend/chainlit/translations/gu.json new file mode 100644 index 0000000..2abb735 --- /dev/null +++ b/backend/chainlit/translations/gu.json @@ -0,0 +1,260 @@ +{ + "common": { + "actions": { + "cancel": "રદ કરો", + "confirm": "પુષ્ટિ કરો", + "continue": "ચાલુ રાખો", + "goBack": "પાછા જાઓ", + "reset": "રીસેટ કરો", + "submit": "સબમિટ કરો" + }, + "status": { + "loading": "લોડ થઈ રહ્યું છે...", + "error": { + "default": "એક ભૂલ થઈ", + "serverConnection": "સર્વર સુધી પહોંચી શકાયું નથી" + } + } + }, + "auth": { + "login": { + "title": "એપ્લિકેશન ઍક્સેસ કરવા માટે લૉગિન કરો", + "form": { + "email": { + "label": "ઈમેલ એડ્રેસ", + "required": "ઈમેલ આવશ્યક છે", + "placeholder": "me@example.com" + }, + "password": { + "label": "પાસવર્ડ", + "required": "પાસવર્ડ આવશ્યક છે" + }, + "actions": { + "signin": "સાઇન ઇન કરો" + }, + "alternativeText": { + "or": "અથવા" + } + }, + "errors": { + "default": "સાઇન ઇન કરી શકાયું નથી", + "signin": "અલગ એકાઉન્ટથી સાઇન ઇન કરવાનો પ્રયાસ કરો", + "oauthSignin": "અલગ એકાઉન્ટથી સાઇન ઇન કરવાનો પ્રયાસ કરો", + "redirectUriMismatch": "રીડાયરેક્ટ URI oauth ઍપ કન્ફિગરેશન સાથે મેળ ખાતો નથી", + "oauthCallback": "અલગ એકાઉન્ટથી સાઇન ઇન કરવાનો પ્રયાસ કરો", + "oauthCreateAccount": "અલગ એકાઉન્ટથી સાઇન ઇન કરવાનો પ્રયાસ કરો", + "emailCreateAccount": "અલગ એકાઉન્ટથી સાઇન ઇન કરવાનો પ્રયાસ કરો", + "callback": "અલગ એકાઉન્ટથી સાઇન ઇન કરવાનો પ્રયાસ કરો", + "oauthAccountNotLinked": "તમારી ઓળખની પુષ્ટિ કરવા માટે, મૂળ રૂપે વાપરેલા એકાઉન્ટથી સાઇન ઇન કરો", + "emailSignin": "ઈમેલ મોકલી શકાયો નથી", + "emailVerify": "કૃપા કરી તમારો ઈમેલ ચકાસો, નવો ઈમેલ મોકલવામાં આવ્યો છે", + "credentialsSignin": "સાઇન ઇન નિષ્ફળ. આપેલી વિગતો સાચી છે કે નહીં તે ચકાસો", + "sessionRequired": "આ પેજને ઍક્સેસ કરવા માટે કૃપા કરી સાઇન ઇન કરો" + } + }, + "provider": { + "continue": "{{provider}} સાથે ચાલુ રાખો" + } + }, + "chat": { + "input": { + "placeholder": "અહીં તમારો સંદેશ લખો...", + "actions": { + "send": "સંદેશ મોકલો", + "stop": "કાર્ય રોકો", + "attachFiles": "ફાઇલ્સ જોડો" + } + }, + "speech": { + "start": "રેકોર્ડિંગ શરૂ કરો", + "stop": "રેકોર્ડિંગ બંધ કરો", + "connecting": "કનેક્ટ થઈ રહ્યું છે" + }, + "favorites": { + "use": "મનપસંદ સંદેશનો ઉપયોગ કરો", + "headline": "મનપસંદ સંદેશાઓ", + "remove": "મનપસંદ સંદેશ દૂર કરો", + "empty": { + "title": "હજી સુધી કોઈ પ્રોમ્પ્ટ સાચવેલ નથી", + "description": "એક પ્રોમ્પ્ટ મોકલીને અને તેને સ્ટાર કરીને શરૂઆત કરો અથવા અગાઉની ચેટમાંથી કોઈ પ્રોમ્પ્ટને સ્ટાર કરો" + } + }, + "commands": { + "button": "ટૂલ્સ", + "changeTool": "ટૂલ બદલો", + "availableTools": "ઉપલબ્ધ ટૂલ્સ" + }, + "fileUpload": { + "dragDrop": "અહીં ફાઇલ્સ ખેંચો અને છોડો", + "browse": "ફાઇલ્સ બ્રાઉઝ કરો", + "sizeLimit": "મર્યાદા:", + "errors": { + "failed": "અપલોડ કરવામાં નિષ્ફળ", + "cancelled": "અપલોડ રદ કર્યું" + }, + "actions": { + "cancelUpload": "અપલોડ રદ કરો", + "removeAttachment": "જોડાણ દૂર કરો" + } + }, + "messages": { + "status": { + "using": "વાપરી રહ્યા છે", + "used": "વપરાયેલ" + }, + "actions": { + "copy": { + "button": "ક્લિપબોર્ડ પર કૉપિ કરો", + "success": "કૉપિ થયું!" + } + }, + "feedback": { + "positive": "ઉપયોગી", + "negative": "બિનઉપયોગી", + "edit": "પ્રતિસાદ સંપાદિત કરો", + "dialog": { + "title": "ટિપ્પણી ઉમેરો", + "submit": "પ્રતિસાદ સબમિટ કરો", + "yourFeedback": "તમારો પ્રતિસાદ..." + }, + "status": { + "updating": "અપડેટ થઈ રહ્યું છે", + "updated": "પ્રતિસાદ અપડેટ થયો" + } + } + }, + "history": { + "title": "છેલ્લા ઇનપુટ્સ", + "empty": "ખાલી છે...", + "show": "ઇતિહાસ બતાવો" + }, + "settings": { + "title": "સેટિંગ્સ પેનલ", + "customize": "તમારા ચેટ સેટિંગ્સ અહીં કસ્ટમાઇઝ કરો" + }, + "watermark": "LLM ભૂલો કરી શકે છે. મહત્વપૂર્ણ માહિતી તપાસવાનું વિચારો." + }, + "threadHistory": { + "sidebar": { + "title": "પાછલી ચેટ્સ", + "filters": { + "search": "શોધો", + "placeholder": "Search conversations..." + }, + "timeframes": { + "today": "આજે", + "yesterday": "ગઈકાલે", + "previous7days": "પાછલા 7 દિવસ", + "previous30days": "પાછલા 30 દિવસ" + }, + "empty": "કોઈ થ્રેડ્સ મળ્યા નથી", + "actions": { + "close": "સાઇડબાર બંધ કરો", + "open": "સાઇડબાર ખોલો" + } + }, + "thread": { + "untitled": "શીર્ષક વગરની વાતચીત", + "menu": { + "rename": "નામ બદલો", + "share": "શેર કરો", + "delete": "Delete" + }, + "actions": { + "share": { + "title": "ચેટની લિંક શેર કરો", + "button": "શેર કરો", + "status": { + "copied": "લિંક કૉપિ થઈ", + "created": "શેર લિંક બનાવાઈ!", + "unshared": "આ થ્રેડ માટે શેરિંગ નિષ્ક્રિય છે" + }, + "error": { + "create": "શેર લિંક બનાવવામાં નિષ્ફળ", + "unshare": "થ્રેડ અનશેર કરવામાં નિષ્ફળ" + } + }, + "delete": { + "title": "કાઢી નાખવાની પુષ્ટિ કરો", + "description": "આ થ્રેડ અને તેના સંદેશાઓ અને તત્વોને કાઢી નાખશે. આ ક્રિયા પાછી ફેરવી શકાશે નહીં", + "success": "ચેટ કાઢી નાખી", + "inProgress": "ચેટ કાઢી નાખી રહ્યા છીએ" + }, + "rename": { + "title": "થ્રેડનું નામ બદલો", + "description": "આ થ્રેડ માટે નવું નામ દાખલ કરો", + "form": { + "name": { + "label": "નામ", + "placeholder": "નવું નામ દાખલ કરો" + } + }, + "success": "થ્રેડનું નામ બદલાયું!", + "inProgress": "થ્રેડનું નામ બદલી રહ્યા છીએ" + } + } + } + }, + "navigation": { + "header": { + "chat": "ચેટ", + "readme": "વાંચો", + "theme": { + "light": "Light Theme", + "dark": "Dark Theme", + "system": "Follow System" + } + }, + "newChat": { + "button": "નવી ચેટ", + "dialog": { + "title": "નવી ચેટ બનાવો", + "description": "આ તમારો વર્તમાન ચેટ ઇતિહાસ સાફ કરશે. શું તમે ચાલુ રાખવા માંગો છો?", + "tooltip": "નવી ચેટ" + } + }, + "user": { + "menu": { + "settings": "સેટિંગ્સ", + "settingsKey": "S", + "apiKeys": "API કી", + "logout": "લૉગઆઉટ" + } + } + }, + "apiKeys": { + "title": "જરૂરી API કી", + "description": "આ એપ્લિકેશન વાપરવા માટે, નીચેની API કી જરૂરી છે. કી તમારા ડિવાઇસના લોકલ સ્ટોરેજમાં સંગ્રહિત થશે.", + "success": { + "saved": "સફળતાપૂર્વક સાચવ્યું" + } + }, + "alerts": { + "info": "Info", + "note": "Note", + "tip": "Tip", + "important": "Important", + "warning": "Warning", + "caution": "Caution", + "debug": "Debug", + "example": "Example", + "success": "Success", + "help": "Help", + "idea": "Idea", + "pending": "Pending", + "security": "Security", + "beta": "Beta", + "best-practice": "Best Practice" + }, + "components": { + "MultiSelectInput": { + "placeholder": "બેંચી લો..." + }, + "DatePickerInput": { + "placeholder": { + "single": "તારીખ પસંદ કરો", + "range": "તારીખની શ્રેણી પસંદ કરો" + } + } + } +} diff --git a/backend/chainlit/translations/he-IL.json b/backend/chainlit/translations/he-IL.json new file mode 100644 index 0000000..b184890 --- /dev/null +++ b/backend/chainlit/translations/he-IL.json @@ -0,0 +1,260 @@ +{ + "common": { + "actions": { + "cancel": "ביטול", + "confirm": "אישור", + "continue": "המשך", + "goBack": "חזור", + "reset": "איפוס", + "submit": "שלח" + }, + "status": { + "loading": "טוען...", + "error": { + "default": "אירעה שגיאה", + "serverConnection": "לא ניתן להתחבר לשרת" + } + } + }, + "auth": { + "login": { + "title": "התחבר כדי לגשת לאפליקציה", + "form": { + "email": { + "label": "כתובת אימייל", + "required": "שדה האימייל הוא שדה חובה", + "placeholder": "me@example.com" + }, + "password": { + "label": "סיסמה", + "required": "שדה הסיסמה הוא שדה חובה" + }, + "actions": { + "signin": "התחבר" + }, + "alternativeText": { + "or": "או" + } + }, + "errors": { + "default": "לא ניתן להתחבר", + "signin": "נסה להתחבר עם חשבון אחר", + "oauthSignin": "נסה להתחבר עם חשבון אחר", + "redirectUriMismatch": "כתובת ההפניה אינה תואמת את תצורת אפליקציית OAuth", + "oauthCallback": "נסה להתחבר עם חשבון אחר", + "oauthCreateAccount": "נסה להתחבר עם חשבון אחר", + "emailCreateAccount": "נסה להתחבר עם חשבון אחר", + "callback": "נסה להתחבר עם חשבון אחר", + "oauthAccountNotLinked": "כדי לאמת את זהותך, התחבר עם אותו חשבון בו השתמשת במקור", + "emailSignin": "לא ניתן היה לשלוח את האימייל", + "emailVerify": "אנא אמת את האימייל שלך, נשלח אימייל חדש", + "credentialsSignin": "ההתחברות נכשלה. בדוק שהפרטים שהזנת נכונים", + "sessionRequired": "אנא התחבר כדי לגשת לדף זה" + } + }, + "provider": { + "continue": "המשך עם {{provider}}" + } + }, + "chat": { + "input": { + "placeholder": "הקלד את ההודעה שלך כאן...", + "actions": { + "send": "שלח הודעה", + "stop": "עצור משימה", + "attachFiles": "צרף קבצים" + } + }, + "speech": { + "start": "התחל הקלטה", + "stop": "עצור הקלטה", + "connecting": "מתחבר" + }, + "favorites": { + "use": "השתמש בהודעה מועדפת", + "headline": "הודעות מועדפות", + "remove": "הסר מהמועדפים", + "empty": { + "title": "עדיין אין הנחיות שמורות", + "description": "התחל בשליחת הנחיה וסמן אותה בכוכב או סמן הנחיה משיחות קודמות" + } + }, + "commands": { + "button": "כלים", + "changeTool": "שנה כלי", + "availableTools": "כלים זמינים" + }, + "fileUpload": { + "dragDrop": "גרור ושחרר קבצים כאן", + "browse": "עיין בקבצים", + "sizeLimit": "מגבלה:", + "errors": { + "failed": "העלאה נכשלה", + "cancelled": "העלאה בוטלה של" + }, + "actions": { + "cancelUpload": "ביטול העלאה", + "removeAttachment": "הסרת קובץ מצורף" + } + }, + "messages": { + "status": { + "using": "משתמש ב", + "used": "השתמש ב" + }, + "actions": { + "copy": { + "button": "העתק ללוח", + "success": "הועתק!" + } + }, + "feedback": { + "positive": "מועיל", + "negative": "לא מועיל", + "edit": "ערוך משוב", + "dialog": { + "title": "הוסף תגובה", + "submit": "שלח משוב", + "yourFeedback": "המשוב שלך..." + }, + "status": { + "updating": "מעדכן", + "updated": "המשוב עודכן" + } + } + }, + "history": { + "title": "קלטים אחרונים", + "empty": "כל כך ריק...", + "show": "הצג היסטוריה" + }, + "settings": { + "title": "פאנל הגדרות", + "customize": "התאם אישית את הגדרות הצ'אט שלך כאן" + }, + "watermark": "מודלי שפה גדולים עלולים לעשות טעויות. כדאי לבדוק מידע חשוב." + }, + "threadHistory": { + "sidebar": { + "title": "צ'אטים קודמים", + "filters": { + "search": "חיפוש", + "placeholder": "Search conversations..." + }, + "timeframes": { + "today": "היום", + "yesterday": "אתמול", + "previous7days": "7 ימים אחרונים", + "previous30days": "30 ימים אחרונים" + }, + "empty": "לא נמצאו שיחות", + "actions": { + "close": "סגור סרגל צד", + "open": "פתח סרגל צד" + } + }, + "thread": { + "untitled": "שיחה ללא כותרת", + "menu": { + "rename": "שינוי שם", + "share": "שיתוף", + "delete": "Delete" + }, + "actions": { + "share": { + "title": "שיתוף קישור לשיחה", + "button": "שיתוף", + "status": { + "copied": "הקישור הועתק", + "created": "קישור השיתוף נוצר!", + "unshared": "השיתוף בוטל עבור שיחה זו" + }, + "error": { + "create": "יצירת קישור השיתוף נכשלה", + "unshare": "ביטול השיתוף של השיחה נכשל" + } + }, + "delete": { + "title": "אשר מחיקה", + "description": "פעולה זו תמחק את השיחה וכן את ההודעות והאלמנטים שלה. לא ניתן לבטל פעולה זו", + "success": "הצ'אט נמחק", + "inProgress": "מוחק צ'אט" + }, + "rename": { + "title": "שנה שם שיחה", + "description": "הזן שם חדש לשיחה זו", + "form": { + "name": { + "label": "שם", + "placeholder": "הזן שם חדש" + } + }, + "success": "שם השיחה שונה!", + "inProgress": "משנה שם שיחה" + } + } + } + }, + "navigation": { + "header": { + "chat": "צ'אט", + "readme": "קרא אותי", + "theme": { + "light": "Light Theme", + "dark": "Dark Theme", + "system": "Follow System" + } + }, + "newChat": { + "button": "צ'אט חדש", + "dialog": { + "title": "צור צ'אט חדש", + "description": "פעולה זו תנקה את היסטוריית הצ'אט הנוכחית שלך. האם אתה בטוח שברצונך להמשיך?", + "tooltip": "צ'אט חדש" + } + }, + "user": { + "menu": { + "settings": "הגדרות", + "settingsKey": "ה", + "apiKeys": "מפתחות API", + "logout": "התנתק" + } + } + }, + "apiKeys": { + "title": "מפתחות API נדרשים", + "description": "כדי להשתמש באפליקציה זו, נדרשים מפתחות API הבאים. המפתחות מאוחסנים באחסון המקומי של המכשיר שלך.", + "success": { + "saved": "נשמר בהצלחה" + } + }, + "alerts": { + "info": "Info", + "note": "Note", + "tip": "Tip", + "important": "Important", + "warning": "Warning", + "caution": "Caution", + "debug": "Debug", + "example": "Example", + "success": "Success", + "help": "Help", + "idea": "Idea", + "pending": "Pending", + "security": "Security", + "beta": "Beta", + "best-practice": "Best Practice" + }, + "components": { + "MultiSelectInput": { + "placeholder": "בחר..." + }, + "DatePickerInput": { + "placeholder": { + "single": "בחר תאריך", + "range": "בחר טווח תאריכים" + } + } + } +} diff --git a/backend/chainlit/translations/hi.json b/backend/chainlit/translations/hi.json new file mode 100644 index 0000000..457d70e --- /dev/null +++ b/backend/chainlit/translations/hi.json @@ -0,0 +1,260 @@ +{ + "common": { + "actions": { + "cancel": "रद्द करें", + "confirm": "पुष्टि करें", + "continue": "जारी रखें", + "goBack": "वापस जाएं", + "reset": "रीसेट करें", + "submit": "जमा करें" + }, + "status": { + "loading": "लोड हो रहा है...", + "error": { + "default": "एक त्रुटि हुई", + "serverConnection": "सर्वर से संपर्क नहीं हो पा रहा" + } + } + }, + "auth": { + "login": { + "title": "ऐप का उपयोग करने के लिए लॉगिन करें", + "form": { + "email": { + "label": "ईमेल पता", + "required": "ईमेल एक आवश्यक फ़ील्ड है", + "placeholder": "me@example.com" + }, + "password": { + "label": "पासवर्ड", + "required": "पासवर्ड एक आवश्यक फ़ील्ड है" + }, + "actions": { + "signin": "साइन इन करें" + }, + "alternativeText": { + "or": "या" + } + }, + "errors": { + "default": "साइन इन करने में असमर्थ", + "signin": "किसी दूसरे खाते से साइन इन करने का प्रयास करें", + "oauthSignin": "किसी दूसरे खाते से साइन इन करने का प्रयास करें", + "redirectUriMismatch": "रीडायरेक्ट URI oauth ऐप कॉन्फ़िगरेशन से मेल नहीं खा रहा", + "oauthCallback": "किसी दूसरे खाते से साइन इन करने का प्रयास करें", + "oauthCreateAccount": "किसी दूसरे खाते से साइन इन करने का प्रयास करें", + "emailCreateAccount": "किसी दूसरे खाते से साइन इन करने का प्रयास करें", + "callback": "किसी दूसरे खाते से साइन इन करने का प्रयास करें", + "oauthAccountNotLinked": "अपनी पहचान की पुष्टि करने के लिए, उसी खाते से साइन इन करें जिसका उपयोग आपने मूल रूप से किया था", + "emailSignin": "ईमेल नहीं भेजा जा सका", + "emailVerify": "कृपया अपना ईमेल सत्यापित करें, एक नया ईमेल भेजा गया है", + "credentialsSignin": "साइन इन विफल। आपके द्वारा प्रदान किए गए विवरण की जांच करें", + "sessionRequired": "इस पृष्ठ तक पहुंचने के लिए कृपया साइन इन करें" + } + }, + "provider": { + "continue": "{{provider}} के साथ जारी रखें" + } + }, + "chat": { + "input": { + "placeholder": "अपना संदेश यहां टाइप करें...", + "actions": { + "send": "संदेश भेजें", + "stop": "कार्य रोकें", + "attachFiles": "फ़ाइलें संलग्न करें" + } + }, + "speech": { + "start": "रिकॉर्डिंग शुरू करें", + "stop": "रिकॉर्डिंग रोकें", + "connecting": "कनेक्ट हो रहा है" + }, + "fileUpload": { + "dragDrop": "फ़ाइलों को यहां खींचें और छोड़ें", + "browse": "फ़ाइलें ब्राउज़ करें", + "sizeLimit": "सीमा:", + "errors": { + "failed": "अपलोड करने में विफल", + "cancelled": "का अपलोड रद्द किया गया" + }, + "actions": { + "cancelUpload": "अपलोड रद्द करें", + "removeAttachment": "संलग्नक हटाएं" + } + }, + "favorites": { + "use": "पसंदीदा संदेश का उपयोग करें", + "headline": "पसंदीदा संदेश", + "remove": "पसंदीदा हटाएं", + "empty": { + "title": "अभी तक कोई प्रॉम्प्ट सहेजा नहीं गया", + "description": "एक प्रॉम्प्ट भेजकर और उसे स्टार करके शुरू करें या पिछली चैट से किसी प्रॉम्प्ट को स्टार करें" + } + }, + "commands": { + "button": "उपकरण", + "changeTool": "उपकरण बदलें", + "availableTools": "उपलब्ध उपकरण" + }, + "messages": { + "status": { + "using": "उपयोग कर रहे हैं", + "used": "उपयोग किया" + }, + "actions": { + "copy": { + "button": "क्लिपबोर्ड पर कॉपी करें", + "success": "कॉपी किया गया!" + } + }, + "feedback": { + "positive": "सहायक", + "negative": "सहायक नहीं", + "edit": "प्रतिक्रिया संपादित करें", + "dialog": { + "title": "टिप्पणी जोड़ें", + "submit": "प्रतिक्रिया जमा करें", + "yourFeedback": "आपकी प्रतिक्रिया..." + }, + "status": { + "updating": "अपडेट हो रहा है", + "updated": "प्रतिक्रिया अपडेट की गई" + } + } + }, + "history": { + "title": "पिछले इनपुट", + "empty": "कुछ भी नहीं है...", + "show": "इतिहास दिखाएं" + }, + "settings": { + "title": "सेटिंग्स पैनल", + "customize": "अपने चैट सेटिंग्स को यहां अनुकूलित करें" + }, + "watermark": "एलएलएम गलतियां कर सकते हैं। महत्वपूर्ण जानकारी की जांच करने पर विचार करें।" + }, + "threadHistory": { + "sidebar": { + "title": "पिछली चैट", + "filters": { + "search": "खोजें", + "placeholder": "Search conversations..." + }, + "timeframes": { + "today": "आज", + "yesterday": "कल", + "previous7days": "पिछले 7 दिन", + "previous30days": "पिछले 30 दिन" + }, + "empty": "कोई थ्रेड नहीं मिला", + "actions": { + "close": "साइडबार बंद करें", + "open": "साइडबार खोलें" + } + }, + "thread": { + "untitled": "शीर्षकहीन वार्तालाप", + "menu": { + "rename": "नाम बदलें", + "share": "साझा करें", + "delete": "Delete" + }, + "actions": { + "share": { + "title": "चैट का लिंक साझा करें", + "button": "साझा करें", + "status": { + "copied": "लिंक कॉपी किया गया", + "created": "शेयर लिंक बनाया गया!", + "unshared": "इस थ्रेड के लिए साझा करना निष्क्रिय है" + }, + "error": { + "create": "शेयर लिंक बनाने में विफल", + "unshare": "थ्रेड को अनशेयर करने में विफल" + } + }, + "delete": { + "title": "हटाने की पुष्टि करें", + "description": "यह थ्रेड और इसके संदेशों और तत्वों को हटा देगा। यह क्रिया वापस नहीं की जा सकती", + "success": "चैट हटा दी गई", + "inProgress": "चैट हटाई जा रही है" + }, + "rename": { + "title": "थ्रेड का नाम बदलें", + "description": "इस थ्रेड के लिए एक नया नाम दर्ज करें", + "form": { + "name": { + "label": "नाम", + "placeholder": "नया नाम दर्ज करें" + } + }, + "success": "थ्रेड का नाम बदल दिया गया!", + "inProgress": "थ्रेड का नाम बदला जा रहा है" + } + } + } + }, + "navigation": { + "header": { + "chat": "चैट", + "readme": "रीडमी", + "theme": { + "light": "Light Theme", + "dark": "Dark Theme", + "system": "Follow System" + } + }, + "newChat": { + "button": "नई चैट", + "dialog": { + "title": "नई चैट बनाएं", + "description": "यह आपका वर्तमान चैट इतिहास साफ़ कर देगा। क्या आप जारी रखना चाहते हैं?", + "tooltip": "नई चैट" + } + }, + "user": { + "menu": { + "settings": "सेटिंग्स", + "settingsKey": "S", + "apiKeys": "API कुंजियां", + "logout": "लॉगआउट" + } + } + }, + "apiKeys": { + "title": "आवश्यक API कुंजियां", + "description": "इस ऐप का उपयोग करने के लिए, निम्नलिखित API कुंजियां आवश्यक हैं। कुंजियां आपके डिवाइस के स्थानीय संग्रहण में संग्रहीत की जाती हैं।", + "success": { + "saved": "सफलतापूर्वक सहेजा गया" + } + }, + "alerts": { + "info": "Info", + "note": "Note", + "tip": "Tip", + "important": "Important", + "warning": "Warning", + "caution": "Caution", + "debug": "Debug", + "example": "Example", + "success": "Success", + "help": "Help", + "idea": "Idea", + "pending": "Pending", + "security": "Security", + "beta": "Beta", + "best-practice": "Best Practice" + }, + "components": { + "MultiSelectInput": { + "placeholder": "चुनें..." + }, + "DatePickerInput": { + "placeholder": { + "single": "एक तारीख चुनें", + "range": "तारीख सीमा चुनें" + } + } + } +} diff --git a/backend/chainlit/translations/it.json b/backend/chainlit/translations/it.json new file mode 100644 index 0000000..69021fe --- /dev/null +++ b/backend/chainlit/translations/it.json @@ -0,0 +1,254 @@ +{ + "common": { + "actions": { + "cancel": "Cancella", + "confirm": "Conferma", + "continue": "Continua", + "goBack": "Ritorna", + "reset": "Reset", + "submit": "Invia" + }, + "status": { + "loading": "Caricamento...", + "error": { + "default": "Si è verificato un errore", + "serverConnection": "Impossibile connettersi al server" + } + } + }, + "auth": { + "login": { + "title": "Accedi per utilizzare l'app", + "form": { + "email": { + "label": "Indirizzo email", + "required": "l'email è un campo obbligatorio", + "placeholder": "me@example.com" + }, + "password": { + "label": "Password", + "required": "la password è un campo obbligatorio" + }, + "actions": { + "signin": "Accedi" + }, + "alternativeText": { + "or": "O" + } + }, + "errors": { + "default": "Impossibile effettuare l'accesso", + "signin": "Prova ad accedere con un account diverso", + "oauthSignin": "Prova ad accedere con un account diverso", + "redirectUriMismatch": "L'URI di reindirizzamento non corrisponde alla configurazione dell'app OAuth", + "oauthCallback": "Prova ad accedere con un account diverso", + "oauthCreateAccount": "Prova ad accedere con un account diverso", + "emailCreateAccount": "Prova ad accedere con un account diverso", + "callback": "Prova ad accedere con un account diverso", + "oauthAccountNotLinked": "Per confermare la tua identità, accedi con lo stesso account che hai usato in precedenza", + "emailSignin": "Impossibile inviare l'email", + "emailVerify": "Verifica la tua email, è stata inviata una nuova email", + "credentialsSignin": "Accesso non riuscito. Verifica che i dati forniti siano corretti", + "sessionRequired": "Accedi per visualizzare questa pagina" + } + }, + "provider": { + "continue": "Continua con {{provider}}" + } + }, + "chat": { + "input": { + "placeholder": "Scrivi un messaggio...", + "actions": { + "send": "Invia messaggio", + "stop": "Interrompi attività", + "attachFiles": "Allega file" + } + }, + "favorites": { + "use": "Usa un messaggio preferito", + "headline": "Messaggi preferiti", + "remove": "Rimuovi preferito", + "empty": { + "title": "Nessun prompt salvato ancora", + "description": "Inizia inviando un prompt e aggiungilo ai preferiti o aggiungi un prompt dalle chat precedenti" + } + }, + "commands": { + "button": "Strumenti", + "changeTool": "Cambia strumento", + "availableTools": "Strumenti disponibili" + }, + "speech": { + "start": "Inizia registrazione", + "stop": "Interrompi registrazione", + "connecting": "Connettendo" + }, + "fileUpload": { + "dragDrop": "Trascina e rilascia i file qui", + "browse": "Sfoglia file", + "sizeLimit": "Limite:", + "errors": { + "failed": "Caricamento file non riuscito", + "cancelled": "Caricamento annullato di" + }, + "actions": { + "cancelUpload": "Annulla caricamento", + "removeAttachment": "Rimuovi allegato" + } + }, + "messages": { + "status": { + "using": "In uso", + "used": "Utilizzato" + }, + "actions": { + "copy": { + "button": "Copia negli appunti", + "success": "Copiato!" + } + }, + "feedback": { + "positive": "Utile", + "negative": "Non utile", + "edit": "Modifica feedback", + "dialog": { + "title": "Aggiungi un commento", + "submit": "Invia feedback", + "yourFeedback": "Il tuo feedback..." + }, + "status": { + "updating": "Aggiornamento", + "updated": "Feedback aggiornato" + } + } + }, + "history": { + "title": "Cronologia chat", + "empty": "Così vuoto...", + "show": "Mostra cronologia" + }, + "settings": { + "title": "Impostazioni", + "customize": "Personalizza le impostazioni della tua chat qui" + }, + "watermark": "Gli LLMS possono commettere errori. Verifica le info importanti." + }, + "threadHistory": { + "sidebar": { + "title": "Chat precedenti", + "filters": { + "search": "Cerca", + "placeholder": "Cerca conversazioni..." + }, + "timeframes": { + "today": "Oggi", + "yesterday": "Ieri", + "previous7days": "Ultimi 7 giorni", + "previous30days": "Ultimi 30 giorni" + }, + "empty": "Nessuna chat trovata", + "actions": { + "close": "Chiudi barra laterale", + "open": "Apri barra laterale" + } + }, + "thread": { + "untitled": "Conversazione senza titolo", + "menu": { + "rename": "Rinomina", + "share": "Condividi", + "delete": "Elimina" + }, + "actions": { + "share": { + "title": "Condividi link conversazione", + "button": "Condividi", + "status": { + "copied": "Link copiato", + "created": "Link di condivisione creato!", + "unshared": "Condivisione disabilitata per questa chat" + }, + "error": { + "create": "Impossibile creare il link di condivisione", + "unshare": "Impossibile annullare la condivisione della chat" + } + }, + "delete": { + "title": "Conferma eliminazione", + "description": "Stai per eliminare la chat insieme ai suoi messaggi ed elementi. Questa azione non può essere annullata", + "success": "Chat eliminata", + "inProgress": "Eliminazione chat" + }, + "rename": { + "title": "Rinomina chat", + "description": "Inserisci un nuovo nome per questa conversazione", + "form": { + "name": { + "label": "Nome", + "placeholder": "Inserisci nuovo nome" + } + }, + "success": "Chat rinominata!", + "inProgress": "Rinomina chat" + } + } + } + }, + "navigation": { + "header": { + "chat": "Chat", + "readme": "Leggimi", + "theme": { + "light": "Tema Chiaro", + "dark": "Tema Scuro", + "system": "Usa tema di sistema" + } + }, + "newChat": { + "button": "Nuova Chat", + "dialog": { + "title": "Crea Nuova Chat", + "description": "Sei sicuro di voler creare una nuova chat? La chat corrente verrà chiusa.", + "tooltip": "Nuova Chat" + } + }, + "user": { + "menu": { + "settings": "Impostazioni", + "settingsKey": "S", + "apiKeys": "Chiavi API", + "logout": "Disconnettiti" + } + } + }, + "apiKeys": { + "title": "Chiavi API richieste", + "description": "Per utilizzare l'app, sono necessarie le seguenti chiavi API. Le chiavi sono salvate nella memoria locale del tuo dispositivo.", + "success": { + "saved": "Salvataggio riuscito" + } + }, + "alerts": { + "info": "Info", + "note": "Nota", + "tip": "Suggerimento", + "important": "Importante", + "warning": "Avviso", + "caution": "Attenzione", + "debug": "Debug", + "example": "Esempio", + "success": "Successo", + "help": "Aiuto", + "idea": "Idea", + "pending": "In sospeso", + "security": "Sicurezza", + "beta": "Beta", + "best-practice": "Miglior Soluzione" + }, + "components": { + "MultiSelectInput": { + "placeholder": "Seleziona..." + } + } +} diff --git a/backend/chainlit/translations/ja.json b/backend/chainlit/translations/ja.json new file mode 100644 index 0000000..b1ea496 --- /dev/null +++ b/backend/chainlit/translations/ja.json @@ -0,0 +1,259 @@ +{ + "common": { + "actions": { + "cancel": "キャンセル", + "confirm": "確認", + "continue": "続ける", + "goBack": "戻る", + "reset": "リセット", + "submit": "送信" + }, + "status": { + "loading": "読み込み中...", + "error": { + "default": "エラーが発生しました", + "serverConnection": "サーバーに接続できませんでした" + } + } + }, + "auth": { + "login": { + "title": "アプリにログイン", + "form": { + "email": { + "label": "メールアドレス", + "required": "メールアドレスは必須項目です", + "placeholder": "me@example.com" + }, + "password": { + "label": "パスワード", + "required": "パスワードは必須項目です" + }, + "actions": { + "signin": "サインイン" + }, + "alternativeText": { + "or": "または" + } + }, + "errors": { + "default": "サインインできません", + "signin": "別のアカウントでサインインしてください", + "oauthSignin": "別のアカウントでサインインしてください", + "redirectUriMismatch": "リダイレクトURIがOAuthアプリの設定と一致しません", + "oauthCallback": "別のアカウントでサインインしてください", + "oauthCreateAccount": "別のアカウントでサインインしてください", + "emailCreateAccount": "別のアカウントでサインインしてください", + "callback": "別のアカウントでサインインしてください", + "oauthAccountNotLinked": "本人確認のため、最初に使用したのと同じアカウントでサインインしてください", + "emailSignin": "メールを送信できませんでした", + "emailVerify": "メールアドレスを確認してください。新しいメールが送信されました", + "credentialsSignin": "サインインに失敗しました。入力した情報が正しいか確認してください", + "sessionRequired": "このページにアクセスするにはサインインしてください" + } + }, + "provider": { + "continue": "{{provider}}で続ける" + } + }, + "chat": { + "input": { + "placeholder": "メッセージを入力してください...", + "actions": { + "send": "メッセージを送信", + "stop": "タスクを停止", + "attachFiles": "ファイルを添付" + } + }, + "speech": { + "start": "録音開始", + "stop": "録音停止", + "connecting": "接続中" + }, + "favorites": { + "use": "お気に入りのメッセージを使用", + "headline": "お気に入りのメッセージ", + "remove": "お気に入りを削除", + "empty": { + "title": "保存されたプロンプトがまだありません", + "description": "プロンプトを送信してスターを付けるか、以前のチャットからプロンプトをスターしてください" + } + }, + "commands": { + "button": "ツール", + "changeTool": "ツールを変更", + "availableTools": "利用可能なツール" + }, + "fileUpload": { + "dragDrop": "ここにファイルをドラッグ&ドロップ", + "sizeLimit": "制限:", + "errors": { + "failed": "アップロードに失敗しました", + "cancelled": "アップロードをキャンセルしました:" + }, + "actions": { + "cancelUpload": "アップロードをキャンセル", + "removeAttachment": "添付ファイルを削除" + } + }, + "messages": { + "status": { + "using": "使用中", + "used": "使用済み" + }, + "actions": { + "copy": { + "button": "クリップボードにコピー", + "success": "コピーしました!" + } + }, + "feedback": { + "positive": "役に立った", + "negative": "役に立たなかった", + "edit": "フィードバックを編集", + "dialog": { + "title": "コメントを追加", + "submit": "フィードバックを送信", + "yourFeedback": "あなたのフィードバック..." + }, + "status": { + "updating": "更新中", + "updated": "フィードバックを更新しました" + } + } + }, + "history": { + "title": "最近の入力", + "empty": "何もありません...", + "show": "履歴を表示" + }, + "settings": { + "title": "設定パネル", + "customize": "ここでチャット設定をカスタマイズします" + }, + "watermark": "大規模言語モデルは間違いを犯す可能性があります。重要な情報については確認を検討してください。" + }, + "threadHistory": { + "sidebar": { + "title": "過去のチャット", + "filters": { + "search": "検索", + "placeholder": "Search conversations..." + }, + "timeframes": { + "today": "今日", + "yesterday": "昨日", + "previous7days": "過去7日間", + "previous30days": "過去30日間" + }, + "empty": "スレッドが見つかりません", + "actions": { + "close": "サイドバーを閉じる", + "open": "サイドバーを開く" + } + }, + "thread": { + "untitled": "無題の会話", + "menu": { + "rename": "名前を変更", + "share": "共有", + "delete": "削除" + }, + "actions": { + "share": { + "title": "チャットのリンクを共有", + "button": "共有", + "status": { + "copied": "リンクをコピーしました", + "created": "共有リンクを作成しました!", + "unshared": "このスレッドの共有を無効にしました" + }, + "error": { + "create": "共有リンクの作成に失敗しました", + "unshare": "スレッドの共有解除に失敗しました" + } + }, + "delete": { + "title": "削除の確認", + "description": "このスレッドとそのメッセージ、要素が削除されます。この操作は取り消せません", + "success": "チャットを削除しました", + "inProgress": "チャットを削除中" + }, + "rename": { + "title": "スレッドの名前を変更", + "description": "このスレッドの新しい名前を入力してください", + "form": { + "name": { + "label": "名前", + "placeholder": "新しい名前を入力" + } + }, + "success": "スレッド名を変更しました!", + "inProgress": "スレッド名を変更中" + } + } + } + }, + "navigation": { + "header": { + "chat": "チャット", + "readme": "説明書", + "theme": { + "light": "Light Theme", + "dark": "Dark Theme", + "system": "Follow System" + } + }, + "newChat": { + "button": "新規チャット", + "dialog": { + "title": "新規チャットの作成", + "description": "現在のチャット履歴がクリアされます。続行しますか?", + "tooltip": "新規チャット" + } + }, + "user": { + "menu": { + "settings": "設定", + "settingsKey": "S", + "apiKeys": "APIキー", + "logout": "ログアウト" + } + } + }, + "apiKeys": { + "title": "必要なAPIキー", + "description": "このアプリを使用するには、以下のAPIキーが必要です。キーはお使いのデバイスのローカルストレージに保存されます。", + "success": { + "saved": "保存が完了しました" + } + }, + "alerts": { + "info": "Info", + "note": "Note", + "tip": "Tip", + "important": "Important", + "warning": "Warning", + "caution": "Caution", + "debug": "Debug", + "example": "Example", + "success": "Success", + "help": "Help", + "idea": "Idea", + "pending": "Pending", + "security": "Security", + "beta": "Beta", + "best-practice": "Best Practice" + }, + "components": { + "MultiSelectInput": { + "placeholder": "選択..." + }, + "DatePickerInput": { + "placeholder": { + "single": "日付を選択", + "range": "日付範囲を選択" + } + } + } +} diff --git a/backend/chainlit/translations/kn.json b/backend/chainlit/translations/kn.json new file mode 100644 index 0000000..ef59021 --- /dev/null +++ b/backend/chainlit/translations/kn.json @@ -0,0 +1,260 @@ +{ + "common": { + "actions": { + "cancel": "ರದ್ದುಮಾಡಿ", + "confirm": "ದೃಢೀಕರಿಸಿ", + "continue": "ಮುಂದುವರಿಸಿ", + "goBack": "ಹಿಂದೆ ಹೋಗಿ", + "reset": "ಮರುಹೊಂದಿಸಿ", + "submit": "ಸಲ್ಲಿಸಿ" + }, + "status": { + "loading": "ಲೋಡ್ ಆಗುತ್ತಿದೆ...", + "error": { + "default": "ದೋಷ ಸಂಭವಿಸಿದೆ", + "serverConnection": "ಸರ್ವರ್‌ ಅನ್ನು ತಲುಪಲು ಸಾಧ್ಯವಾಗಲಿಲ್ಲ" + } + } + }, + "auth": { + "login": { + "title": "ಅಪ್ಲಿಕೇಶನ್‌ಗೆ ಪ್ರವೇಶಿಸಲು ಲಾಗಿನ್ ಮಾಡಿ", + "form": { + "email": { + "label": "ಇಮೇಲ್ ವಿಳಾಸ", + "required": "ಇಮೇಲ್ ಅಗತ್ಯವಿರುವ ಕ್ಷೇತ್ರ", + "placeholder": "me@example.com" + }, + "password": { + "label": "ಪಾಸ್‌ವರ್ಡ್", + "required": "ಪಾಸ್‌ವರ್ಡ್ ಅಗತ್ಯವಿರುವ ಕ್ಷೇತ್ರ" + }, + "actions": { + "signin": "ಸೈನ್ ಇನ್ ಮಾಡಿ" + }, + "alternativeText": { + "or": "ಅಥವಾ" + } + }, + "errors": { + "default": "ಸೈನ್ ಇನ್ ಮಾಡಲು ಸಾಧ್ಯವಾಗಲಿಲ್ಲ", + "signin": "ಬೇರೆ ಖಾತೆಯೊಂದಿಗೆ ಸೈನ್ ಇನ್ ಮಾಡಲು ಪ್ರಯತ್ನಿಸಿ", + "oauthSignin": "ಬೇರೆ ಖಾತೆಯೊಂದಿಗೆ ಸೈನ್ ಇನ್ ಮಾಡಲು ಪ್ರಯತ್ನಿಸಿ", + "redirectUriMismatch": "ರೀಡೈರೆಕ್ಟ್ URI ಓಥ್ ಅಪ್ಲಿಕೇಶನ್ ಕಾನ್ಫಿಗರೇಶನ್‌ಗೆ ಹೊಂದಿಕೆಯಾಗುತ್ತಿಲ್ಲ", + "oauthCallback": "ಬೇರೆ ಖಾತೆಯೊಂದಿಗೆ ಸೈನ್ ಇನ್ ಮಾಡಲು ಪ್ರಯತ್ನಿಸಿ", + "oauthCreateAccount": "ಬೇರೆ ಖಾತೆಯೊಂದಿಗೆ ಸೈನ್ ಇನ್ ಮಾಡಲು ಪ್ರಯತ್ನಿಸಿ", + "emailCreateAccount": "ಬೇರೆ ಖಾತೆಯೊಂದಿಗೆ ಸೈನ್ ಇನ್ ಮಾಡಲು ಪ್ರಯತ್ನಿಸಿ", + "callback": "ಬೇರೆ ಖಾತೆಯೊಂದಿಗೆ ಸೈನ್ ಇನ್ ಮಾಡಲು ಪ್ರಯತ್ನಿಸಿ", + "oauthAccountNotLinked": "ನಿಮ್ಮ ಗುರುತನ್ನು ದೃಢೀಕರಿಸಲು, ನೀವು ಮೊದಲು ಬಳಸಿದ ಅದೇ ಖಾತೆಯೊಂದಿಗೆ ಸೈನ್ ಇನ್ ಮಾಡಿ", + "emailSignin": "ಇಮೇಲ್ ಕಳುಹಿಸಲು ಸಾಧ್ಯವಾಗಲಿಲ್ಲ", + "emailVerify": "ದಯವಿಟ್ಟು ನಿಮ್ಮ ಇಮೇಲ್ ಪರಿಶೀಲಿಸಿ, ಹೊಸ ಇಮೇಲ್ ಕಳುಹಿಸಲಾಗಿದೆ", + "credentialsSignin": "ಸೈನ್ ಇನ್ ವಿಫಲವಾಗಿದೆ. ನೀವು ಒದಗಿಸಿದ ವಿವರಗಳು ಸರಿಯಾಗಿವೆಯೇ ಎಂದು ಪರಿಶೀಲಿಸಿ", + "sessionRequired": "ಈ ಪುಟವನ್ನು ಪ್ರವೇಶಿಸಲು ದಯವಿಟ್ಟು ಸೈನ್ ಇನ್ ಮಾಡಿ" + } + }, + "provider": { + "continue": "{{provider}} ನೊಂದಿಗೆ ಮುಂದುವರಿಸಿ" + } + }, + "chat": { + "input": { + "placeholder": "ನಿಮ್ಮ ಸಂದೇಶವನ್ನು ಇಲ್ಲಿ ಟೈಪ್ ಮಾಡಿ...", + "actions": { + "send": "ಸಂದೇಶ ಕಳುಹಿಸಿ", + "stop": "ಕಾರ್ಯ ನಿಲ್ಲಿಸಿ", + "attachFiles": "ಫೈಲ್‌ಗಳನ್ನು ಲಗತ್ತಿಸಿ" + } + }, + "favorites": { + "use": "ಮೆಚ್ಚಿನ ಸಂದೇಶವನ್ನು ಬಳಸಿ", + "headline": "ಮೆಚ್ಚಿನ ಸಂದೇಶಗಳು", + "remove": "ಮೆಚ್ಚಿನ ಸಂದೇಶವನ್ನು ತೆಗೆದುಹಾಕಿ", + "empty": { + "title": "ಇನ್ನೂ ಯಾವುದೇ ಪ್ರಾಂಪ್ಟ್‌ಗಳನ್ನು ಉಳಿಸಲಾಗಿಲ್ಲ", + "description": "ಪ್ರಾಂಪ್ಟ್ ಕಳುಹಿಸಿ ಮತ್ತು ಅದಕ್ಕೆ ಸ್ಟಾರ್ ಮಾಡಿ ಅಥವಾ ಹಿಂದಿನ ಚಾಟ್‌ಗಳಿಂದ ಪ್ರಾಂಪ್ಟ್‌ಗೆ ಸ್ಟಾರ್ ಮಾಡಿ" + } + }, + "commands": { + "button": "ಉಪಕರಣಗಳು", + "changeTool": "ಉಪಕರಣವನ್ನು ಬದಲಿಸಿ", + "availableTools": "ಲಭ್ಯವಿರುವ ಉಪಕರಣಗಳು" + }, + "speech": { + "start": "ರೆಕಾರ್ಡಿಂಗ್ ಪ್ರಾರಂಭಿಸಿ", + "stop": "ರೆಕಾರ್ಡಿಂಗ್ ನಿಲ್ಲಿಸಿ", + "connecting": "ಸಂಪರ್ಕಿಸಲಾಗುತ್ತಿದೆ" + }, + "fileUpload": { + "dragDrop": "ಫೈಲ್‌ಗಳನ್ನು ಇಲ್ಲಿ ಎಳೆದು ಬಿಡಿ", + "browse": "ಫೈಲ್‌ಗಳನ್ನು ಬ್ರೌಸ್ ಮಾಡಿ", + "sizeLimit": "ಮಿತಿ:", + "errors": { + "failed": "ಅಪ್‌ಲೋಡ್ ವಿಫಲವಾಗಿದೆ", + "cancelled": "ಅಪ್‌ಲೋಡ್ ರದ್ದುಗೊಳಿಸಲಾಗಿದೆ" + }, + "actions": { + "cancelUpload": "ಅಪ್‌ಲೋಡ್ ರದ್ದುಗೊಳಿಸಿ", + "removeAttachment": "ಅಟ್ಯಾಚ್‌ಮೆಂಟ್ ಅನ್ನು ತೆಗೆದುಹಾಕಿ" + } + }, + "messages": { + "status": { + "using": "ಬಳಸುತ್ತಿರುವುದು", + "used": "ಬಳಸಲಾಗಿದೆ" + }, + "actions": { + "copy": { + "button": "ಕ್ಲಿಪ್‌ಬೋರ್ಡ್‌ಗೆ ನಕಲಿಸಿ", + "success": "ನಕಲಿಸಲಾಗಿದೆ!" + } + }, + "feedback": { + "positive": "ಸಹಾಯಕವಾಗಿದೆ", + "negative": "ಸಹಾಯಕವಾಗಿಲ್ಲ", + "edit": "ಪ್ರತಿಕ್ರಿಯೆ ಸಂಪಾದಿಸಿ", + "dialog": { + "title": "ಕಾಮೆಂಟ್ ಸೇರಿಸಿ", + "submit": "ಪ್ರತಿಕ್ರಿಯೆ ಸಲ್ಲಿಸಿ", + "yourFeedback": "ನಿಮ್ಮ ಪ್ರತಿಕ್ರಿಯೆ..." + }, + "status": { + "updating": "ನವೀಕರಿಸಲಾಗುತ್ತಿದೆ", + "updated": "ಪ್ರತಿಕ್ರಿಯೆ ನವೀಕರಿಸಲಾಗಿದೆ" + } + } + }, + "history": { + "title": "ಕೊನೆಯ ಇನ್‌ಪುಟ್‌ಗಳು", + "empty": "ಖಾಲಿಯಾಗಿದೆ...", + "show": "ಇತಿಹಾಸ ತೋರಿಸಿ" + }, + "settings": { + "title": "ಸೆಟ್ಟಿಂಗ್‌ಗಳ ಪ್ಯಾನೆಲ್", + "customize": "ಈಗ ನಿಮ್ಮ ಚಾಟ್ ಸೆಟ್ಟಿಂಗ್‌ಗಳನ್ನು ಕಸ್ಟಮೈಸ್ ಮಾಡಿ" + }, + "watermark": "LLM ಗಳು ತಪ್ಪುಗಳನ್ನು ಮಾಡಬಹುದು. ಪ್ರಮುಖ ಮಾಹಿತಿಯನ್ನು ಪರಿಶೀಲಿಸುವುದನ್ನು ಪರಿಗಣಿಸಿ." + }, + "threadHistory": { + "sidebar": { + "title": "ಹಿಂದಿನ ಸಂಭಾಷಣೆಗಳು", + "filters": { + "search": "ಹುಡುಕಿ", + "placeholder": "Search conversations..." + }, + "timeframes": { + "today": "ಇಂದು", + "yesterday": "ನಿನ್ನೆ", + "previous7days": "ಹಿಂದಿನ 7 ದಿನಗಳು", + "previous30days": "ಹಿಂದಿನ 30 ದಿನಗಳು" + }, + "empty": "ಯಾವುದೇ ಸಂಭಾಷಣೆಗಳು ಕಂಡುಬಂದಿಲ್ಲ", + "actions": { + "close": "ಪಕ್ಕದ ಪಟ್ಟಿ ಮುಚ್ಚಿ", + "open": "ಪಕ್ಕದ ಪಟ್ಟಿ ತೆರೆಯಿರಿ" + } + }, + "thread": { + "untitled": "ಶೀರ್ಷಿಕೆರಹಿತ ಸಂಭಾಷಣೆ", + "menu": { + "rename": "ಮರುಹೆಸರಿಸಿ", + "share": "ಹಂಚಿಕೊಳ್ಳಿ", + "delete": "ಅಳಿಸಿ" + }, + "actions": { + "share": { + "title": "ಚಾಟ್‌ಗೆ ಲಿಂಕ್ ಹಂಚಿಕೊಳ್ಳಿ", + "button": "ಹಂಚಿಕೊಳ್ಳಿ", + "status": { + "copied": "ಲಿಂಕ್ ಪ್ರತಿಲಿಪಿ ಮಾಡಲಾಗಿದೆ", + "created": "ಹಂಚಿಕೆಯ ಲಿಂಕ್ ರಚಿಸಲಾಗಿದೆ!", + "unshared": "ಈ ಸಂಭಾಷಣೆಗೆ ಹಂಚಿಕೆ ನಿಷ್ಕ್ರಿಯಗೊಳಿಸಲಾಗಿದೆ" + }, + "error": { + "create": "ಹಂಚಿಕೆಯ ಲಿಂಕ್ ರಚಿಸಲು ವಿಫಲವಾಗಿದೆ", + "unshare": "ಸಂಭಾಷಣೆ ಹಂಚಿಕೆಯನ್ನು ರದ್ದು ಮಾಡಲು ವಿಫಲವಾಗಿದೆ" + } + }, + "delete": { + "title": "ಅಳಿಸುವಿಕೆಯನ್ನು ದೃಢೀಕರಿಸಿ", + "description": "ಇದು ಸಂಭಾಷಣೆಯನ್ನು ಹಾಗೂ ಅದರ ಸಂದೇಶಗಳು ಮತ್ತು ಅಂಶಗಳನ್ನು ಅಳಿಸುತ್ತದೆ. ಈ ಕ್ರಿಯೆಯನ್ನು ರದ್ದುಗೊಳಿಸಲು ಸಾಧ್ಯವಿಲ್ಲ", + "success": "ಸಂಭಾಷಣೆ ಅಳಿಸಲಾಗಿದೆ", + "inProgress": "ಸಂಭಾಷಣೆ ಅಳಿಸಲಾಗುತ್ತಿದೆ" + }, + "rename": { + "title": "ಸಂಭಾಷಣೆಯ ಹೆಸರು ಬದಲಾಯಿಸಿ", + "description": "ಈ ಸಂಭಾಷಣೆಗೆ ಹೊಸ ಹೆಸರನ್ನು ನಮೂದಿಸಿ", + "form": { + "name": { + "label": "ಹೆಸರು", + "placeholder": "ಹೊಸ ಹೆಸರನ್ನು ನಮೂದಿಸಿ" + } + }, + "success": "ಸಂಭಾಷಣೆಯ ಹೆಸರು ಬದಲಾಯಿಸಲಾಗಿದೆ!", + "inProgress": "ಸಂಭಾಷಣೆಯ ಹೆಸರು ಬದಲಾಯಿಸಲಾಗುತ್ತಿದೆ" + } + } + } + }, + "navigation": { + "header": { + "chat": "ಸಂಭಾಷಣೆ", + "readme": "ಓದಿ", + "theme": { + "light": "Light Theme", + "dark": "Dark Theme", + "system": "Follow System" + } + }, + "newChat": { + "button": "ಹೊಸ ಸಂಭಾಷಣೆ", + "dialog": { + "title": "ಹೊಸ ಸಂಭಾಷಣೆ ರಚಿಸಿ", + "description": "ಇದು ನಿಮ್ಮ ಪ್ರಸ್ತುತ ಸಂಭಾಷಣೆಯ ಇತಿಹಾಸವನ್ನು ಅಳಿಸುತ್ತದೆ. ನೀವು ಮುಂದುವರೆಯಲು ಬಯಸುವಿರಾ?", + "tooltip": "ಹೊಸ ಸಂಭಾಷಣೆ" + } + }, + "user": { + "menu": { + "settings": "ಸೆಟ್ಟಿಂಗ್‌ಗಳು", + "settingsKey": "S", + "apiKeys": "API ಕೀಗಳು", + "logout": "ಲಾಗ್ ಔಟ್" + } + } + }, + "apiKeys": { + "title": "ಅಗತ್ಯವಿರುವ API ಕೀಗಳು", + "description": "ಈ ಅಪ್ಲಿಕೇಶನ್ ಬಳಸಲು, ಈ ಕೆಳಗಿನ API ಕೀಗಳು ಅಗತ್ಯವಿರುತ್ತವೆ. ಕೀಗಳನ್ನು ನಿಮ್ಮ ಸಾಧನದ ಸ್ಥಳೀಯ ಸಂಗ್ರಹಣೆಯಲ್ಲಿ ಸಂಗ್ರಹಿಸಲಾಗುತ್ತದೆ.", + "success": { + "saved": "ಯಶಸ್ವಿಯಾಗಿ ಉಳಿಸಲಾಗಿದೆ" + } + }, + "alerts": { + "info": "Info", + "note": "Note", + "tip": "Tip", + "important": "Important", + "warning": "Warning", + "caution": "Caution", + "debug": "Debug", + "example": "Example", + "success": "Success", + "help": "Help", + "idea": "Idea", + "pending": "Pending", + "security": "Security", + "beta": "Beta", + "best-practice": "Best Practice" + }, + "components": { + "MultiSelectInput": { + "placeholder": "ಚುನಾಯಿಸಿ..." + }, + "DatePickerInput": { + "placeholder": { + "single": "ದಿನಾಂಕವನ್ನು ಆಯ್ಕೆಮಾಡಿ", + "range": "ದಿನಾಂಕ ಶ್ರೇಣಿಯನ್ನು ಆಯ್ಕೆಮಾಡಿ" + } + } + } +} diff --git a/backend/chainlit/translations/ko.json b/backend/chainlit/translations/ko.json new file mode 100644 index 0000000..f05622f --- /dev/null +++ b/backend/chainlit/translations/ko.json @@ -0,0 +1,254 @@ +{ + "common": { + "actions": { + "cancel": "취소", + "confirm": "확인", + "continue": "계속", + "goBack": "뒤로 가기", + "reset": "초기화", + "submit": "제출" + }, + "status": { + "loading": "로딩 중...", + "error": { + "default": "오류가 발생했습니다", + "serverConnection": "서버에 연결할 수 없습니다" + } + } + }, + "auth": { + "login": { + "title": "앱에 접근하려면 로그인하세요", + "form": { + "email": { + "label": "이메일 주소", + "required": "이메일은 필수 입력 항목입니다", + "placeholder": "me@example.com" + }, + "password": { + "label": "비밀번호", + "required": "비밀번호는 필수 입력 항목입니다" + }, + "actions": { + "signin": "로그인" + }, + "alternativeText": { + "or": "또는" + } + }, + "errors": { + "default": "로그인할 수 없습니다", + "signin": "다른 계정으로 로그인해보세요", + "oauthSignin": "다른 계정으로 로그인해보세요", + "redirectUriMismatch": "리다이렉트 URI가 OAuth 앱 설정과 일치하지 않습니다", + "oauthCallback": "다른 계정으로 로그인해보세요", + "oauthCreateAccount": "다른 계정으로 로그인해보세요", + "emailCreateAccount": "다른 계정으로 로그인해보세요", + "callback": "다른 계정으로 로그인해보세요", + "oauthAccountNotLinked": "신원을 확인하려면 원래 사용했던 계정으로 로그인하세요", + "emailSignin": "이메일을 보낼 수 없습니다", + "emailVerify": "이메일을 확인해주세요. 새로운 이메일이 발송되었습니다", + "credentialsSignin": "로그인 실패. 제공한 정보가 올바른지 확인하세요", + "sessionRequired": "이 페이지에 접근하려면 로그인해주세요" + } + }, + "provider": { + "continue": "{{provider}}로 계속하기" + } + }, + "chat": { + "input": { + "placeholder": "여기에 메시지를 입력하세요...", + "actions": { + "send": "메시지 보내기", + "stop": "작업 중지", + "attachFiles": "파일 첨부" + } + }, + "favorites": { + "use": "즐겨찾기 메시지 사용", + "headline": "즐겨찾기 메시지", + "remove": "즐겨찾기 제거", + "empty": { + "title": "저장된 프롬프트가 아직 없습니다", + "description": "프롬프트를 보내고 별표를 추가하거나 이전 대화에서 프롬프트에 별표를 추가하세요" + } + }, + "commands": { + "button": "도구", + "changeTool": "도구 변경", + "availableTools": "사용 가능한 도구" + }, + "speech": { + "start": "녹음 시작", + "stop": "녹음 중지", + "connecting": "연결 중" + }, + "fileUpload": { + "dragDrop": "여기에 파일을 드래그 앤 드롭하세요", + "browse": "파일 찾아보기", + "sizeLimit": "제한:", + "errors": { + "failed": "업로드 실패", + "cancelled": "업로드 취소:" + }, + "actions": { + "cancelUpload": "업로드 취소", + "removeAttachment": "첨부 파일 제거" + } + }, + "messages": { + "status": { + "using": "사용 중", + "used": "사용됨" + }, + "actions": { + "copy": { + "button": "클립보드로 복사", + "success": "복사되었습니다!" + } + }, + "feedback": { + "positive": "도움이 되었음", + "negative": "도움이 되지 않음", + "edit": "피드백 수정", + "dialog": { + "title": "댓글 추가", + "submit": "피드백 제출", + "yourFeedback": "귀하의 피드백..." + }, + "status": { + "updating": "업데이트 중", + "updated": "피드백이 업데이트되었습니다" + } + } + }, + "history": { + "title": "최근 입력", + "empty": "비어 있습니다...", + "show": "기록 표시" + }, + "settings": { + "title": "설정 패널", + "customize": "여기에서 채팅 설정을 사용자 지정하세요" + }, + "watermark": "LLM은 실수할 수 있습니다. 중요한 정보는 확인하세요." + }, + "threadHistory": { + "sidebar": { + "title": "이전 채팅", + "filters": { + "search": "검색", + "placeholder": "대화 검색..." + }, + "timeframes": { + "today": "오늘", + "yesterday": "어제", + "previous7days": "지난 7일", + "previous30days": "지난 30일" + }, + "empty": "스레드를 찾을 수 없습니다", + "actions": { + "close": "사이드바 닫기", + "open": "사이드바 열기" + } + }, + "thread": { + "untitled": "제목 없는 대화", + "menu": { + "rename": "이름 변경", + "share": "공유", + "delete": "삭제" + }, + "actions": { + "share": { + "title": "채팅 링크 공유", + "button": "공유", + "status": { + "copied": "링크 복사됨", + "created": "공유 링크가 생성되었습니다!", + "unshared": "이 스레드의 공유가 비활성화되었습니다" + }, + "error": { + "create": "공유 링크 생성 실패", + "unshare": "스레드 공유 해제 실패" + } + }, + "delete": { + "title": "삭제 확인", + "description": "이렇게 하면 스레드와 그 메시지 및 요소가 삭제됩니다. 이 작업은 취소할 수 없습니다", + "success": "채팅이 삭제되었습니다", + "inProgress": "채팅 삭제 중" + }, + "rename": { + "title": "스레드 이름 변경", + "description": "이 스레드의 새 이름을 입력하세요", + "form": { + "name": { + "label": "이름", + "placeholder": "새 이름 입력" + } + }, + "success": "스레드 이름이 변경되었습니다!", + "inProgress": "스레드 이름 변경 중" + } + } + } + }, + "navigation": { + "header": { + "chat": "채팅", + "readme": "읽어보기", + "theme": { + "light": "밝은 테마", + "dark": "어두운 테마", + "system": "시스템 따라가기" + } + }, + "newChat": { + "button": "새 채팅", + "dialog": { + "title": "새 채팅 만들기", + "description": "이렇게 하면 현재 채팅 기록이 지워집니다. 계속하시겠습니까?", + "tooltip": "새 채팅" + } + }, + "user": { + "menu": { + "settings": "설정", + "settingsKey": "S", + "apiKeys": "API 키", + "logout": "로그아웃" + } + } + }, + "apiKeys": { + "title": "필요한 API 키", + "description": "이 앱을 사용하려면 다음 API 키가 필요합니다. 키는 기기의 로컬 저장소에 저장됩니다.", + "success": { + "saved": "성공적으로 저장되었습니다" + } + }, + "alerts": { + "info": "정보", + "note": "참고", + "tip": "팁", + "important": "중요", + "warning": "경고", + "caution": "주의", + "debug": "디버그", + "example": "예시", + "success": "성공", + "help": "도움말", + "idea": "아이디어", + "pending": "대기 중", + "security": "보안", + "beta": "베타", + "best-practice": "모범 사례" + }, + "components": { + "MultiSelectInput": { + "placeholder": "선택..." + } + } +} diff --git a/backend/chainlit/translations/ml.json b/backend/chainlit/translations/ml.json new file mode 100644 index 0000000..5e01cd4 --- /dev/null +++ b/backend/chainlit/translations/ml.json @@ -0,0 +1,260 @@ +{ + "common": { + "actions": { + "cancel": "റദ്ദാക്കുക", + "confirm": "സ്ഥിരീകരിക്കുക", + "continue": "തുടരുക", + "goBack": "തിരികെ പോകുക", + "reset": "പുനഃസജ്ജമാക്കുക", + "submit": "സമർപ്പിക്കുക" + }, + "status": { + "loading": "ലോഡ് ചെയ്യുന്നു...", + "error": { + "default": "ഒരു പിശക് സംഭവിച്ചു", + "serverConnection": "സെർവറുമായി ബന്ധപ്പെടാൻ കഴിഞ്ഞില്ല" + } + } + }, + "auth": { + "login": { + "title": "ആപ്പ് ഉപയോഗിക്കാൻ ലോഗിൻ ചെയ്യുക", + "form": { + "email": { + "label": "ഇമെയിൽ വിലാസം", + "required": "ഇമെയിൽ ഒരു ആവശ്യമായ ഫീൽഡ് ആണ്", + "placeholder": "me@example.com" + }, + "password": { + "label": "പാസ്‌വേഡ്", + "required": "പാസ്‌വേഡ് ഒരു ആവശ്യമായ ഫീൽഡ് ആണ്" + }, + "actions": { + "signin": "സൈൻ ഇൻ" + }, + "alternativeText": { + "or": "അല്ലെങ്കിൽ" + } + }, + "errors": { + "default": "സൈൻ ഇൻ ചെയ്യാൻ കഴിയുന്നില്ല", + "signin": "മറ്റൊരു അക്കൗണ്ട് ഉപയോഗിച്ച് സൈൻ ഇൻ ചെയ്യാൻ ശ്രമിക്കുക", + "oauthSignin": "മറ്റൊരു അക്കൗണ്ട് ഉപയോഗിച്ച് സൈൻ ഇൻ ചെയ്യാൻ ശ്രമിക്കുക", + "redirectUriMismatch": "റീഡയറക്ട് URI oauth ആപ്പ് കോൺഫിഗറേഷനുമായി പൊരുത്തപ്പെടുന്നില്ല", + "oauthCallback": "മറ്റൊരു അക്കൗണ്ട് ഉപയോഗിച്ച് സൈൻ ഇൻ ചെയ്യാൻ ശ്രമിക്കുക", + "oauthCreateAccount": "മറ്റൊരു അക്കൗണ്ട് ഉപയോഗിച്ച് സൈൻ ഇൻ ചെയ്യാൻ ശ്രമിക്കുക", + "emailCreateAccount": "മറ്റൊരു അക്കൗണ്ട് ഉപയോഗിച്ച് സൈൻ ഇൻ ചെയ്യാൻ ശ്രമിക്കുക", + "callback": "മറ്റൊരു അക്കൗണ്ട് ഉപയോഗിച്ച് സൈൻ ഇൻ ചെയ്യാൻ ശ്രമിക്കുക", + "oauthAccountNotLinked": "നിങ്ങളുടെ വ്യക്തിത്വം സ്ഥിരീകരിക്കാൻ, ആദ്യം ഉപയോഗിച്ച അതേ അക്കൗണ്ട് ഉപയോഗിച്ച് സൈൻ ഇൻ ചെയ്യുക", + "emailSignin": "ഇമെയിൽ അയയ്ക്കാൻ കഴിഞ്ഞില്ല", + "emailVerify": "നിങ്ങളുടെ ഇമെയിൽ പരിശോധിക്കുക, ഒരു പുതിയ ഇമെയിൽ അയച്ചിട്ടുണ്ട്", + "credentialsSignin": "സൈൻ ഇൻ പരാജയപ്പെട്ടു. നിങ്ങൾ നൽകിയ വിവരങ്ങൾ ശരിയാണെന്ന് പരിശോധിക്കുക", + "sessionRequired": "ഈ പേജ് ആക്സസ് ചെയ്യാൻ ദയവായി സൈൻ ഇൻ ചെയ്യുക" + } + }, + "provider": { + "continue": "{{provider}} ഉപയോഗിച്ച് തുടരുക" + } + }, + "chat": { + "input": { + "placeholder": "നിങ്ങളുടെ സന്ദേശം ഇവിടെ ടൈപ്പ് ചെയ്യുക...", + "actions": { + "send": "സന്ദേശം അയയ്ക്കുക", + "stop": "ടാസ്ക് നിർത്തുക", + "attachFiles": "ഫയലുകൾ അറ്റാച്ച് ചെയ്യുക" + } + }, + "favorites": { + "use": "പ്രിയപ്പെട്ട സന്ദേശം ഉപയോഗിക്കുക", + "headline": "പ്രിയപ്പെട്ട സന്ദേശങ്ങൾ", + "remove": "ഇഷ്ടപ്പെട്ടത് നീക്കം ചെയ്യുക", + "empty": { + "title": "ഇതുവരെ സംരക്ഷിച്ച പ്രോംപ്റ്റുകളൊന്നുമില്ല", + "description": "ഒരു പ്രോംപ്റ്റ് അയച്ച് അതിന് സ്റ്റാർ ചെയ്തുകൊണ്ട് ആരംഭിക്കുക അല്ലെങ്കിൽ മുൻ ചാറ്റുകളിൽ നിന്ന് ഒരു പ്രോംപ്റ്റിന് സ്റ്റാർ ചെയ്യുക" + } + }, + "commands": { + "button": "ഉപകരണങ്ങൾ", + "changeTool": "ഉപകരണം മാറ്റുക", + "availableTools": "ലഭ്യമായ ഉപകരണങ്ങൾ" + }, + "speech": { + "start": "റെക്കോർഡിംഗ് ആരംഭിക്കുക", + "stop": "റെക്കോർഡിംഗ് നിർത്തുക", + "connecting": "ബന്ധിപ്പിക്കുന്നു" + }, + "fileUpload": { + "dragDrop": "ഫയലുകൾ ഇവിടെ വലിച്ചിടുക", + "browse": "ഫയലുകൾ തിരയുക", + "sizeLimit": "പരിധി:", + "errors": { + "failed": "അപ്‌ലോഡ് പരാജയപ്പെട്ടു", + "cancelled": "അപ്‌ലോഡ് റദ്ദാക്കി" + }, + "actions": { + "cancelUpload": "അപ്‌ಲോഡ് റദ്ദുചെയ്യുക", + "removeAttachment": "അറ്റാച്ച്‌മെന്റ് നീക്കം ചെയ്യുക" + } + }, + "messages": { + "status": { + "using": "ഉപയോഗിക്കുന്നു", + "used": "ഉപയോഗിച്ചു" + }, + "actions": { + "copy": { + "button": "ക്ലിപ്പ്ബോർഡിലേക്ക് പകർത്തുക", + "success": "പകർത്തി!" + } + }, + "feedback": { + "positive": "സഹായകരം", + "negative": "സഹായകരമല്ല", + "edit": "ഫീഡ്ബാക്ക് എഡിറ്റ് ചെയ്യുക", + "dialog": { + "title": "ഒരു കമന്റ് ചേർക്കുക", + "submit": "ഫീഡ്ബാക്ക് സമർപ്പിക്കുക", + "yourFeedback": "നിങ്ങളുടെ പ്രതികരണം..." + }, + "status": { + "updating": "അപ്ഡേറ്റ് ചെയ്യുന്നു", + "updated": "ഫീഡ്ബാക്ക് അപ്ഡേറ്റ് ചെയ്തു" + } + } + }, + "history": { + "title": "അവസാന ഇൻപുട്ടുകൾ", + "empty": "ഒന്നുമില്ല...", + "show": "ഹിസ്റ്ററി കാണിക്കുക" + }, + "settings": { + "title": "ക്രമീകരണങ്ങൾ പാനൽ", + "customize": "ഈ സമയം നിങ്ങളുടെ ചാറ്റ് ക്രമീകരണങ്ങൾ കസ്റ്റമൈസ് ചെയ്യുക" + }, + "watermark": "LLM കൾക്ക് തെറ്റുകൾ വരുത്താം. പ്രധാനപ്പെട്ട വിവരങ്ങൾ പരിശോധിക്കുന്നത് പരിഗണിക്കുക." + }, + "threadHistory": { + "sidebar": { + "title": "മുൻ ചാറ്റുകൾ", + "filters": { + "search": "തിരയുക", + "placeholder": "Search conversations..." + }, + "timeframes": { + "today": "ഇന്ന്", + "yesterday": "ഇന്നലെ", + "previous7days": "കഴിഞ്ഞ 7 ദിവസം", + "previous30days": "കഴിഞ്ഞ 30 ദിവസം" + }, + "empty": "ത്രെഡുകൾ കണ്ടെത്തിയില്ല", + "actions": { + "close": "സൈഡ്ബാർ അടയ്ക്കുക", + "open": "സൈഡ്ബാർ തുറക്കുക" + } + }, + "thread": { + "untitled": "പേരില്ലാത്ത സംഭാഷണം", + "menu": { + "rename": "പേര് മാറ്റുക", + "share": "പങ്കിടുക", + "delete": "ഡിലീറ്റ്" + }, + "actions": { + "share": { + "title": "ചാറ്റിലേക്ക് ലിങ്ക് പങ്കിടുക", + "button": "പങ്കിടുക", + "status": { + "copied": "ലിങ്ക് പകർത്തി", + "created": "പങ്കിടൽ ലിങ്ക് സൃഷ്ടിച്ചു!", + "unshared": "ഈ ത്രെഡിനായി പങ്കിടൽ അപ്രാപ്തമാക്കി" + }, + "error": { + "create": "പങ്കിടൽ ലിങ്ക് സൃഷ്ടിക്കൽ പരാജയപ്പെട്ടു", + "unshare": "ത്രെഡ് പങ്കിടൽ അവസാനിപ്പിക്കൽ പരാജയപ്പെട്ടു" + } + }, + "delete": { + "title": "ഡിലീറ്റ് സ്ഥിരീകരിക്കുക", + "description": "ഇത് ത്രെഡും അതിന്റെ സന്ദേശങ്ങളും ഘടകങ്ങളും ഡിലീറ്റ് ചെയ്യും. ഈ പ്രവർത്തി പഴയപടിയാക്കാൻ കഴിയില്ല", + "success": "ചാറ്റ് ഡിലീറ്റ് ചെയ്തു", + "inProgress": "ചാറ്റ് ഡിലീറ്റ് ചെയ്യുന്നു" + }, + "rename": { + "title": "ത്രെഡ് പുനർനാമകരണം ചെയ്യുക", + "description": "ഈ ത്രെഡിന് ഒരു പുതിയ പേര് നൽകുക", + "form": { + "name": { + "label": "പേര്", + "placeholder": "പുതിയ പേര് നൽകുക" + } + }, + "success": "ത്രെഡ് പുനർനാമകരണം ചെയ്തു!", + "inProgress": "ത്രെഡ് പുനർനാമകരണം ചെയ്യുന്നു" + } + } + } + }, + "navigation": { + "header": { + "chat": "ചാറ്റ്", + "readme": "വായിക്കുക", + "theme": { + "light": "Light Theme", + "dark": "Dark Theme", + "system": "Follow System" + } + }, + "newChat": { + "button": "പുതിയ ചാറ്റ്", + "dialog": { + "title": "പുതിയ ചാറ്റ് സൃഷ്ടിക്കുക", + "description": "ഇത് നിങ്ങളുടെ നിലവിലെ ചാറ്റ് ഹിസ്റ്ററി മായ്ക്കും. തുടരാൻ താൽപ്പര്യമുണ്ടോ?", + "tooltip": "പുതിയ ചാറ്റ്" + } + }, + "user": { + "menu": { + "settings": "ക്രമീകരണങ്ങൾ", + "settingsKey": "S", + "apiKeys": "API കീകൾ", + "logout": "ലോഗ്ഔട്ട്" + } + } + }, + "apiKeys": { + "title": "ആവശ്യമായ API കീകൾ", + "description": "ഈ ആപ്പ് ഉപയോഗിക്കാൻ, താഴെപ്പറയുന്ന API കീകൾ ആവശ്യമാണ്. കീകൾ നിങ്ങളുടെ ഉപകരണത്തിന്റെ ലോക്കൽ സ്റ്റോറേജിൽ സംഭരിക്കപ്പെടുന്നു.", + "success": { + "saved": "വിജയകരമായി സംരക്ഷിച്ചു" + } + }, + "alerts": { + "info": "Info", + "note": "Note", + "tip": "Tip", + "important": "Important", + "warning": "Warning", + "caution": "Caution", + "debug": "Debug", + "example": "Example", + "success": "Success", + "help": "Help", + "idea": "Idea", + "pending": "Pending", + "security": "Security", + "beta": "Beta", + "best-practice": "Best Practice" + }, + "components": { + "MultiSelectInput": { + "placeholder": "ചൂണ്ടിക്കാണിക്കുക..." + }, + "DatePickerInput": { + "placeholder": { + "single": "തീയതി തിരഞ്ഞെടുക്കുക", + "range": "തീയതി ശ്രേണി തിരഞ്ഞെടുക്കുക" + } + } + } +} diff --git a/backend/chainlit/translations/mr.json b/backend/chainlit/translations/mr.json new file mode 100644 index 0000000..c559f83 --- /dev/null +++ b/backend/chainlit/translations/mr.json @@ -0,0 +1,260 @@ +{ + "common": { + "actions": { + "cancel": "रद्द करा", + "confirm": "पुष्टी करा", + "continue": "पुढे जा", + "goBack": "मागे जा", + "reset": "रीसेट करा", + "submit": "सबमिट करा" + }, + "status": { + "loading": "लोड करत आहे...", + "error": { + "default": "एक त्रुटी आली", + "serverConnection": "सर्व्हरशी कनेक्ट होऊ शकले नाही" + } + } + }, + "auth": { + "login": { + "title": "अॅप वापरण्यासाठी लॉगिन करा", + "form": { + "email": { + "label": "ईमेल पत्ता", + "required": "ईमेल आवश्यक आहे", + "placeholder": "me@example.com" + }, + "password": { + "label": "पासवर्ड", + "required": "पासवर्ड आवश्यक आहे" + }, + "actions": { + "signin": "साइन इन करा" + }, + "alternativeText": { + "or": "किंवा" + } + }, + "errors": { + "default": "साइन इन करू शकत नाही", + "signin": "वेगळ्या खात्याने साइन इन करण्याचा प्रयत्न करा", + "oauthSignin": "वेगळ्या खात्याने साइन इन करण्याचा प्रयत्न करा", + "redirectUriMismatch": "रीडायरेक्ट URI ओथ अॅप कॉन्फिगरेशनशी जुळत नाही", + "oauthCallback": "वेगळ्या खात्याने साइन इन करण्याचा प्रयत्न करा", + "oauthCreateAccount": "वेगळ्या खात्याने साइन इन करण्याचा प्रयत्न करा", + "emailCreateAccount": "वेगळ्या खात्याने साइन इन करण्याचा प्रयत्न करा", + "callback": "वेगळ्या खात्याने साइन इन करण्याचा प्रयत्न करा", + "oauthAccountNotLinked": "तुमची ओळख पटवण्यासाठी, मूळ वापरलेल्या खात्यानेच साइन इन करा", + "emailSignin": "ईमेल पाठवू शकले नाही", + "emailVerify": "कृपया तुमचा ईमेल तपासा, नवीन ईमेल पाठवला गेला आहे", + "credentialsSignin": "साइन इन अयशस्वी. तुम्ही दिलेली माहिती योग्य आहे का ते तपासा", + "sessionRequired": "या पृष्ठावर प्रवेश करण्यासाठी कृपया साइन इन करा" + } + }, + "provider": { + "continue": "{{provider}} सह पुढे जा" + } + }, + "chat": { + "input": { + "placeholder": "तुमचा संदेश येथे टाइप करा...", + "actions": { + "send": "संदेश पाठवा", + "stop": "कार्य थांबवा", + "attachFiles": "फाइल्स जोडा" + } + }, + "speech": { + "start": "रेकॉर्डिंग सुरू करा", + "stop": "रेकॉर्डिंग थांबवा", + "connecting": "कनेक्ट करत आहे" + }, + "favorites": { + "use": "आवडता संदेश वापरा", + "headline": "आवडते संदेश", + "remove": "आवडता संदेश काढा", + "empty": { + "title": "अद्याप कोणतेही प्रॉम्प्ट जतन केलेले नाहीत", + "description": "एक प्रॉम्प्ट पाठवून आणि त्यावर स्टार करून सुरुवात करा किंवा मागील चॅटमधून प्रॉम्प्टवर स्टार करा" + } + }, + "commands": { + "button": "साधने", + "changeTool": "साधन बदला", + "availableTools": "उपलब्ध साधने" + }, + "fileUpload": { + "dragDrop": "फाइल्स येथे ड्रॅग आणि ड्रॉप करा", + "browse": "फाइल्स ब्राउझ करा", + "sizeLimit": "मर्यादा:", + "errors": { + "failed": "अपलोड अयशस्वी", + "cancelled": "यांचे अपलोड रद्द केले" + }, + "actions": { + "cancelUpload": "अपलोड रद्द करा", + "removeAttachment": "अटॅचमेंट काढा" + } + }, + "messages": { + "status": { + "using": "वापरत आहे", + "used": "वापरले" + }, + "actions": { + "copy": { + "button": "क्लिपबोर्डवर कॉपी करा", + "success": "कॉपी केले!" + } + }, + "feedback": { + "positive": "उपयुक्त", + "negative": "उपयुक्त नाही", + "edit": "फीडबॅक संपादित करा", + "dialog": { + "title": "टिप्पणी जोडा", + "submit": "फीडबॅक सबमिट करा", + "yourFeedback": "तुमची प्रतिक्रिया..." + }, + "status": { + "updating": "अपडेट करत आहे", + "updated": "फीडबॅक अपडेट केले" + } + } + }, + "history": { + "title": "शेवटचे इनपुट", + "empty": "रिकामे आहे...", + "show": "इतिहास दाखवा" + }, + "settings": { + "title": "सेटिंग्ज पॅनल", + "customize": "या वेळी तुमच्या चॅट सेटिंग्ज कस्टमाइझ करा" + }, + "watermark": "LLM चुका करू शकतात. महत्त्वाची माहिती तपासण्याचा विचार करा." + }, + "threadHistory": { + "sidebar": { + "title": "मागील चॅट्स", + "filters": { + "search": "शोधा", + "placeholder": "Search conversations..." + }, + "timeframes": { + "today": "आज", + "yesterday": "काल", + "previous7days": "मागील 7 दिवस", + "previous30days": "मागील 30 दिवस" + }, + "empty": "कोणतेही थ्रेड सापडले नाहीत", + "actions": { + "close": "साइडबार बंद करा", + "open": "साइडबार उघडा" + } + }, + "thread": { + "untitled": "शीर्षकविरहित संभाषण", + "menu": { + "rename": "नाव बदला", + "share": "शेअर करा", + "delete": "हटवा" + }, + "actions": { + "share": { + "title": "चॅटचा दुवा शेअर करा", + "button": "शेअर करा", + "status": { + "copied": "दुवा कॉपी केला", + "created": "शेअर दुवा तयार झाला!", + "unshared": "या थ्रेडसाठी शेअरिंग अक्षम केले" + }, + "error": { + "create": "शेअर दुवा तयार करण्यात अपयश", + "unshare": "थ्रेडचे शेअरिंग थांबवण्यात अपयश" + } + }, + "delete": { + "title": "हटविण्याची पुष्टी करा", + "description": "हे थ्रेड आणि त्याचे संदेश व घटक हटवेल. ही क्रिया पूर्ववत केली जाऊ शकत नाही", + "success": "चॅट हटवला", + "inProgress": "चॅट हटवत आहे" + }, + "rename": { + "title": "थ्रेडचे नाव बदला", + "description": "या थ्रेडसाठी नवीन नाव प्रविष्ट करा", + "form": { + "name": { + "label": "नाव", + "placeholder": "नवीन नाव प्रविष्ट करा" + } + }, + "success": "थ्रेडचे नाव बदलले!", + "inProgress": "थ्रेडचे नाव बदलत आहे" + } + } + } + }, + "navigation": { + "header": { + "chat": "चॅट", + "readme": "वाचा", + "theme": { + "light": "Light Theme", + "dark": "Dark Theme", + "system": "Follow System" + } + }, + "newChat": { + "button": "नवीन चॅट", + "dialog": { + "title": "नवीन चॅट तयार करा", + "description": "हे तुमचा सध्याचा चॅट इतिहास साफ करेल. तुम्हाला खात्री आहे की तुम्ही पुढे जाऊ इच्छिता?", + "tooltip": "नवीन चॅट" + } + }, + "user": { + "menu": { + "settings": "सेटिंग्ज", + "settingsKey": "S", + "apiKeys": "API कीज", + "logout": "लॉगआउट" + } + } + }, + "apiKeys": { + "title": "आवश्यक API कीज", + "description": "हे अॅप वापरण्यासाठी खालील API कीज आवश्यक आहेत. कीज तुमच्या डिव्हाइसच्या लोकल स्टोरेजमध्ये साठवल्या जातात.", + "success": { + "saved": "यशस्वीरित्या जतन केले" + } + }, + "alerts": { + "info": "Info", + "note": "Note", + "tip": "Tip", + "important": "Important", + "warning": "Warning", + "caution": "Caution", + "debug": "Debug", + "example": "Example", + "success": "Success", + "help": "Help", + "idea": "Idea", + "pending": "Pending", + "security": "Security", + "beta": "Beta", + "best-practice": "Best Practice" + }, + "components": { + "MultiSelectInput": { + "placeholder": "चुनें..." + }, + "DatePickerInput": { + "placeholder": { + "single": "तारीख निवडा", + "range": "तारीख श्रेणी निवडा" + } + } + } +} diff --git a/backend/chainlit/translations/nl.json b/backend/chainlit/translations/nl.json new file mode 100644 index 0000000..b476019 --- /dev/null +++ b/backend/chainlit/translations/nl.json @@ -0,0 +1,260 @@ +{ + "common": { + "actions": { + "cancel": "Annuleren", + "confirm": "Bevestigen", + "continue": "Doorgaan", + "goBack": "Terug", + "reset": "Herstellen", + "submit": "Versturen" + }, + "status": { + "loading": "Laden...", + "error": { + "default": "Er is een fout opgetreden", + "serverConnection": "Kon geen verbinding maken met de server" + } + } + }, + "auth": { + "login": { + "title": "Inloggen om toegang te krijgen tot de app", + "form": { + "email": { + "label": "E-mailadres", + "required": "e-mail is een verplicht veld", + "placeholder": "me@example.com" + }, + "password": { + "label": "Wachtwoord", + "required": "wachtwoord is een verplicht veld" + }, + "actions": { + "signin": "Inloggen" + }, + "alternativeText": { + "or": "OF" + } + }, + "errors": { + "default": "Kan niet inloggen", + "signin": "Probeer in te loggen met een ander account", + "oauthSignin": "Probeer in te loggen met een ander account", + "redirectUriMismatch": "De redirect URI komt niet overeen met de oauth app configuratie", + "oauthCallback": "Probeer in te loggen met een ander account", + "oauthCreateAccount": "Probeer in te loggen met een ander account", + "emailCreateAccount": "Probeer in te loggen met een ander account", + "callback": "Probeer in te loggen met een ander account", + "oauthAccountNotLinked": "Om je identiteit te bevestigen, log in met hetzelfde account dat je oorspronkelijk hebt gebruikt", + "emailSignin": "De e-mail kon niet worden verzonden", + "emailVerify": "Verifieer je e-mail, er is een nieuwe e-mail verzonden", + "credentialsSignin": "Inloggen mislukt. Controleer of de ingevoerde gegevens correct zijn", + "sessionRequired": "Log in om toegang te krijgen tot deze pagina" + } + }, + "provider": { + "continue": "Doorgaan met {{provider}}" + } + }, + "chat": { + "input": { + "placeholder": "Typ hier je bericht...", + "actions": { + "send": "Bericht versturen", + "stop": "Taak stoppen", + "attachFiles": "Bestanden bijvoegen" + } + }, + "speech": { + "start": "Start opname", + "stop": "Stop opname", + "connecting": "Verbinden" + }, + "fileUpload": { + "dragDrop": "Sleep bestanden hierheen", + "browse": "Bestanden zoeken", + "sizeLimit": "Limiet:", + "errors": { + "failed": "Uploaden mislukt", + "cancelled": "Upload geannuleerd van" + }, + "actions": { + "cancelUpload": "Annuleer upload", + "removeAttachment": "Verwijder bijlage" + } + }, + "favorites": { + "use": "Gebruik een favoriet bericht", + "headline": "Favoriete berichten", + "remove": "Verwijder favoriet", + "empty": { + "title": "Nog geen opgeslagen prompts", + "description": "Begin door een prompt te versturen en voeg deze toe aan favorieten of voeg een prompt uit eerdere chats toe" + } + }, + "commands": { + "button": "Hulpmiddelen", + "changeTool": "Wijzig hulpmiddel", + "availableTools": "Beschikbare hulpmiddelen" + }, + "messages": { + "status": { + "using": "In gebruik", + "used": "Gebruikt" + }, + "actions": { + "copy": { + "button": "Kopiëren naar klembord", + "success": "Gekopieerd!" + } + }, + "feedback": { + "positive": "Nuttig", + "negative": "Niet nuttig", + "edit": "Feedback bewerken", + "dialog": { + "title": "Voeg een opmerking toe", + "submit": "Feedback versturen", + "yourFeedback": "Je feedback..." + }, + "status": { + "updating": "Bijwerken", + "updated": "Feedback bijgewerkt" + } + } + }, + "history": { + "title": "Laatste invoer", + "empty": "Zo leeg...", + "show": "Toon geschiedenis" + }, + "settings": { + "title": "Instellingenpaneel", + "customize": "Pas hier je chatinstellingen aan" + }, + "watermark": "LLM's kunnen fouten maken. Overweeg het controleren van belangrijke informatie." + }, + "threadHistory": { + "sidebar": { + "title": "Eerdere chats", + "filters": { + "search": "Zoeken", + "placeholder": "Search conversations..." + }, + "timeframes": { + "today": "Vandaag", + "yesterday": "Gisteren", + "previous7days": "Afgelopen 7 dagen", + "previous30days": "Afgelopen 30 dagen" + }, + "empty": "Geen gesprekken gevonden", + "actions": { + "close": "Zijbalk sluiten", + "open": "Zijbalk openen" + } + }, + "thread": { + "untitled": "Naamloos gesprek", + "menu": { + "rename": "Hernoemen", + "share": "Delen", + "delete": "Verwijderen" + }, + "actions": { + "share": { + "title": "Deel link naar chat", + "button": "Delen", + "status": { + "copied": "Link gekopieerd", + "created": "Deellink gemaakt!", + "unshared": "Delen uitgeschakeld voor dit gesprek" + }, + "error": { + "create": "Aanmaken van deellink mislukt", + "unshare": "Delen van gesprek stoppen mislukt" + } + }, + "delete": { + "title": "Verwijdering bevestigen", + "description": "Dit zal het gesprek en bijbehorende berichten en elementen verwijderen. Deze actie kan niet ongedaan worden gemaakt", + "success": "Chat verwijderd", + "inProgress": "Chat verwijderen" + }, + "rename": { + "title": "Gesprek hernoemen", + "description": "Voer een nieuwe naam in voor dit gesprek", + "form": { + "name": { + "label": "Naam", + "placeholder": "Voer nieuwe naam in" + } + }, + "success": "Gesprek hernoemd!", + "inProgress": "Gesprek hernoemen" + } + } + } + }, + "navigation": { + "header": { + "chat": "Chat", + "readme": "Leesmij", + "theme": { + "light": "Light Theme", + "dark": "Dark Theme", + "system": "Follow System" + } + }, + "newChat": { + "button": "Nieuwe chat", + "dialog": { + "title": "Nieuwe chat aanmaken", + "description": "Dit zal je huidige chatgeschiedenis wissen. Weet je zeker dat je door wilt gaan?", + "tooltip": "Nieuwe chat" + } + }, + "user": { + "menu": { + "settings": "Instellingen", + "settingsKey": "I", + "apiKeys": "API-sleutels", + "logout": "Uitloggen" + } + } + }, + "apiKeys": { + "title": "Vereiste API-sleutels", + "description": "Om deze app te gebruiken zijn de volgende API-sleutels vereist. De sleutels worden opgeslagen in de lokale opslag van je apparaat.", + "success": { + "saved": "Succesvol opgeslagen" + } + }, + "alerts": { + "info": "Info", + "note": "Note", + "tip": "Tip", + "important": "Important", + "warning": "Warning", + "caution": "Caution", + "debug": "Debug", + "example": "Example", + "success": "Success", + "help": "Help", + "idea": "Idea", + "pending": "Pending", + "security": "Security", + "beta": "Beta", + "best-practice": "Best Practice" + }, + "components": { + "MultiSelectInput": { + "placeholder": "Selecteer..." + }, + "DatePickerInput": { + "placeholder": { + "single": "Kies een datum", + "range": "Kies een datumbereik" + } + } + } +} diff --git a/backend/chainlit/translations/pt-PT.json b/backend/chainlit/translations/pt-PT.json new file mode 100644 index 0000000..a1f1497 --- /dev/null +++ b/backend/chainlit/translations/pt-PT.json @@ -0,0 +1,260 @@ +{ + "common": { + "actions": { + "cancel": "Cancelar", + "confirm": "Confirmar", + "continue": "Continuar", + "goBack": "Voltar", + "reset": "Repor", + "submit": "Enviar" + }, + "status": { + "loading": "A carregar...", + "error": { + "default": "Ocorreu um erro", + "serverConnection": "N\u00e3o foi poss\u00edvel estabelecer liga\u00e7\u00e3o ao servidor" + } + } + }, + "auth": { + "login": { + "title": "Inicie sess\u00e3o para aceder \u00e0 aplica\u00e7\u00e3o", + "form": { + "email": { + "label": "E-mail", + "required": "o e-mail \u00e9 obrigat\u00f3rio", + "placeholder": "me@example.com" + }, + "password": { + "label": "Palavra-passe", + "required": "a palavra-passe \u00e9 obrigat\u00f3ria" + }, + "actions": { + "signin": "Iniciar sess\u00e3o" + }, + "alternativeText": { + "or": "Ou" + } + }, + "errors": { + "default": "N\u00e3o foi poss\u00edvel iniciar sess\u00e3o", + "signin": "Tente iniciar sess\u00e3o com outra conta", + "oauthSignin": "Tente iniciar sess\u00e3o com outra conta", + "redirectUriMismatch": "O URI de redirecionamento n\u00e3o corresponde \u00e0 configura\u00e7\u00e3o da aplica\u00e7\u00e3o OAuth", + "oauthCallback": "Tente iniciar sess\u00e3o com outra conta", + "oauthCreateAccount": "Tente iniciar sess\u00e3o com outra conta", + "emailCreateAccount": "Tente iniciar sess\u00e3o com outra conta", + "callback": "Tente iniciar sess\u00e3o com outra conta", + "oauthAccountNotLinked": "Para confirmar a sua identidade, inicie sess\u00e3o com a mesma conta utilizada anteriormente", + "emailSignin": "N\u00e3o foi poss\u00edvel enviar o e-mail", + "emailVerify": "Por favor, verifique o seu e-mail. Foi enviada uma nova mensagem", + "credentialsSignin": "Erro ao iniciar sess\u00e3o. Verifique se os dados fornecidos est\u00e3o corretos", + "sessionRequired": "Por favor, inicie sess\u00e3o para aceder a esta p\u00e1gina" + } + }, + "provider": { + "continue": "Continuar com {{provider}}" + } + }, + "chat": { + "input": { + "placeholder": "Escreva a sua mensagem aqui...", + "actions": { + "send": "Enviar mensagem", + "stop": "Parar tarefa", + "attachFiles": "Anexar ficheiros" + } + }, + "favorites": { + "use": "Utilizar mensagem favorita", + "headline": "Mensagens favoritas", + "remove": "Remover favorito", + "empty": { + "title": "Ainda n\u00e3o h\u00e1 prompts guardados", + "description": "Comece por enviar um prompt e marc\u00e1-lo com estrela, ou marque com estrela um prompt de conversas anteriores" + } + }, + "commands": { + "button": "Ferramentas", + "changeTool": "Alterar ferramenta", + "availableTools": "Ferramentas dispon\u00edveis" + }, + "speech": { + "start": "Iniciar grava\u00e7\u00e3o", + "stop": "Parar grava\u00e7\u00e3o", + "connecting": "A ligar" + }, + "fileUpload": { + "dragDrop": "Arraste e largue ficheiros aqui", + "browse": "Procurar ficheiros", + "sizeLimit": "Limite:", + "errors": { + "failed": "Erro ao carregar", + "cancelled": "Carregamento cancelado de" + }, + "actions": { + "cancelUpload": "Cancelar carregamento", + "removeAttachment": "Remover anexo" + } + }, + "messages": { + "status": { + "using": "A utilizar", + "used": "Utilizado" + }, + "actions": { + "copy": { + "button": "Copiar para a \u00e1rea de transfer\u00eancia", + "success": "Copiado!" + } + }, + "feedback": { + "positive": "\u00datil", + "negative": "N\u00e3o \u00fatil", + "edit": "Editar coment\u00e1rio", + "dialog": { + "title": "Adicionar um coment\u00e1rio", + "submit": "Enviar coment\u00e1rio", + "yourFeedback": "O seu coment\u00e1rio..." + }, + "status": { + "updating": "A atualizar", + "updated": "Coment\u00e1rio atualizado" + } + } + }, + "history": { + "title": "\u00daltimas entradas", + "empty": "Est\u00e1 vazio...", + "show": "Mostrar hist\u00f3rico" + }, + "settings": { + "title": "Painel de configura\u00e7\u00f5es", + "customize": "Personalize aqui as configura\u00e7\u00f5es do seu chat" + }, + "watermark": "Os modelos de linguagem podem cometer erros. Verifique sempre informa\u00e7\u00f5es importantes." + }, + "threadHistory": { + "sidebar": { + "title": "Conversas anteriores", + "filters": { + "search": "Pesquisar", + "placeholder": "Pesquisar conversas..." + }, + "timeframes": { + "today": "Hoje", + "yesterday": "Ontem", + "previous7days": "\u00daltimos 7 dias", + "previous30days": "\u00daltimos 30 dias" + }, + "empty": "Nenhuma conversa encontrada", + "actions": { + "close": "Fechar barra lateral", + "open": "Abrir barra lateral" + } + }, + "thread": { + "untitled": "Conversa sem t\u00edtulo", + "menu": { + "rename": "Renomear", + "share": "Partilhar", + "delete": "Eliminar" + }, + "actions": { + "share": { + "title": "Partilhar liga\u00e7\u00e3o do chat", + "button": "Partilhar", + "status": { + "copied": "Liga\u00e7\u00e3o copiada", + "created": "Liga\u00e7\u00e3o de partilha criada!", + "unshared": "Partilha desativada para esta conversa" + }, + "error": { + "create": "Erro ao criar liga\u00e7\u00e3o de partilha", + "unshare": "Erro ao desativar a partilha" + } + }, + "delete": { + "title": "Confirmar elimina\u00e7\u00e3o", + "description": "Ir\u00e1 eliminar a conversa e todos os seus conte\u00fados. Esta a\u00e7\u00e3o n\u00e3o pode ser anulada.", + "success": "Chat eliminado", + "inProgress": "A eliminar chat" + }, + "rename": { + "title": "Renomear conversa", + "description": "Insira um novo nome para esta conversa", + "form": { + "name": { + "label": "Nome", + "placeholder": "Insira o novo nome" + } + }, + "success": "Conversa renomeada!", + "inProgress": "A renomear conversa" + } + } + } + }, + "navigation": { + "header": { + "chat": "Chat", + "readme": "Leia-me", + "theme": { + "light": "Tema claro", + "dark": "Tema escuro", + "system": "Seguir sistema" + } + }, + "newChat": { + "button": "Novo chat", + "dialog": { + "title": "Criar novo chat", + "description": "Isto ir\u00e1 apagar o hist\u00f3rico de chat atual. Tem a certeza de que pretende continuar?", + "tooltip": "Novo chat" + } + }, + "user": { + "menu": { + "settings": "Configura\u00e7\u00f5es", + "settingsKey": "S", + "apiKeys": "Chaves API", + "logout": "Terminar sess\u00e3o" + } + } + }, + "apiKeys": { + "title": "Chaves API necess\u00e1rias", + "description": "Para utilizar esta aplica\u00e7\u00e3o, s\u00e3o necess\u00e1rias as seguintes chaves API. As chaves s\u00e3o guardadas localmente no seu dispositivo.", + "success": { + "saved": "Guardado com sucesso" + } + }, + "alerts": { + "info": "Informa\u00e7\u00e3o", + "note": "Nota", + "tip": "Dica", + "important": "Importante", + "warning": "Aviso", + "caution": "Cuidado", + "debug": "Depura\u00e7\u00e3o", + "example": "Exemplo", + "success": "Sucesso", + "help": "Ajuda", + "idea": "Ideia", + "pending": "Pendente", + "security": "Seguran\u00e7a", + "beta": "Beta", + "best-practice": "Boa pr\u00e1tica" + }, + "components": { + "MultiSelectInput": { + "placeholder": "Selecionar..." + }, + "DatePickerInput": { + "placeholder": { + "single": "Escolher uma data", + "range": "Escolher um intervalo de datas" + } + } + } +} diff --git a/backend/chainlit/translations/ta.json b/backend/chainlit/translations/ta.json new file mode 100644 index 0000000..be05191 --- /dev/null +++ b/backend/chainlit/translations/ta.json @@ -0,0 +1,260 @@ +{ + "common": { + "actions": { + "cancel": "ரத்து செய்", + "confirm": "உறுதிப்படுத்து", + "continue": "தொடர்க", + "goBack": "திரும்பிச் செல்", + "reset": "மீட்டமை", + "submit": "சமர்ப்பி" + }, + "status": { + "loading": "ஏற்றுகிறது...", + "error": { + "default": "பிழை ஏற்பட்டது", + "serverConnection": "சேவையகத்தை அடைய முடியவில்லை" + } + } + }, + "auth": { + "login": { + "title": "பயன்பாட்டை அணுக உள்நுழையவும்", + "form": { + "email": { + "label": "மின்னஞ்சல் முகவரி", + "required": "மின்னஞ்சல் தேவையான புலம்", + "placeholder": "me@example.com" + }, + "password": { + "label": "கடவுச்சொல்", + "required": "கடவுச்சொல் தேவையான புலம்" + }, + "actions": { + "signin": "உள்நுழைக" + }, + "alternativeText": { + "or": "அல்லது" + } + }, + "errors": { + "default": "உள்நுழைய முடியவில்லை", + "signin": "வேறு கணக்குடன் உள்நுழைய முயற்சிக்கவும்", + "oauthSignin": "வேறு கணக்குடன் உள்நுழைய முயற்சிக்கவும்", + "redirectUriMismatch": "திசைதிருப்பல் URI ஓஆத் பயன்பாட்டு கட்டமைப்புடன் பொருந்தவில்லை", + "oauthCallback": "வேறு கணக்குடன் உள்நுழைய முயற்சிக்கவும்", + "oauthCreateAccount": "வேறு கணக்குடன் உள்நுழைய முயற்சிக்கவும்", + "emailCreateAccount": "வேறு கணக்குடன் உள்நுழைய முயற்சிக்கவும்", + "callback": "வேறு கணக்குடன் உள்நுழைய முயற்சிக்கவும்", + "oauthAccountNotLinked": "உங்கள் அடையாளத்தை உறுதிப்படுத்த, முதலில் பயன்படுத்திய அதே கணக்குடன் உள்நுழையவும்", + "emailSignin": "மின்னஞ்சலை அனுப்ப முடியவில்லை", + "emailVerify": "உங்கள் மின்னஞ்சலை சரிபார்க்கவும், புதிய மின்னஞ்சல் அனுப்பப்பட்டுள்ளது", + "credentialsSignin": "உள்நுழைவு தோல்வியடைந்தது. நீங்கள் வழங்கிய விவரங்கள் சரியானவை என சரிபார்க்கவும்", + "sessionRequired": "இந்தப் பக்கத்தை அணுக உள்நுழையவும்" + } + }, + "provider": { + "continue": "{{provider}} மூலம் தொடரவும்" + } + }, + "chat": { + "input": { + "placeholder": "உங்கள் செய்தியை இங்கே தட்டச்சு செய்யவும்...", + "actions": { + "send": "செய்தி அனுப்பு", + "stop": "பணியை நிறுத்து", + "attachFiles": "கோப்புகளை இணை" + } + }, + "favorites": { + "use": "விருப்பமான செய்தியைப் பயன்படுத்தவும்", + "headline": "விருப்பமான செய்திகள்", + "remove": "பிடித்ததை நீக்கு", + "empty": { + "title": "இன்னும் சேமிக்கப்பட்ட ப்ராம்ப்ட்கள் இல்லை", + "description": "ஒரு ப்ராம்ப்ட் அனுப்பி அதை ஸ்டார் செய்வதன் மூலம் தொடங்கவும் அல்லது முந்தைய அரட்டைகளில் இருந்து ஒரு ப்ராம்ப்ட்டை ஸ்டார் செய்யவும்" + } + }, + "commands": { + "button": "கருவிகள்", + "changeTool": "கருவியை மாற்றவும்", + "availableTools": "கிடைக்கும் கருவிகள்" + }, + "speech": { + "start": "பதிவு தொடங்கு", + "stop": "பதிவை நிறுத்து", + "connecting": "இணைக்கிறது" + }, + "fileUpload": { + "dragDrop": "கோப்புகளை இங்கே இழுத்து விடவும்", + "browse": "கோப்புகளை உலாவு", + "sizeLimit": "வரம்பு:", + "errors": { + "failed": "பதிவேற்றம் தோல்வியடைந்தது", + "cancelled": "பதிவேற்றம் ரத்து செய்யப்பட்டது" + }, + "actions": { + "cancelUpload": "ரத்து செய்", + "removeAttachment": "இணைப்பை அகற்று" + } + }, + "messages": { + "status": { + "using": "பயன்படுத்துகிறது", + "used": "பயன்படுத்தப்பட்டது" + }, + "actions": { + "copy": { + "button": "கிளிப்போர்டுக்கு நகலெடு", + "success": "நகலெடுக்கப்பட்டது!" + } + }, + "feedback": { + "positive": "பயனுள்ளதாக இருந்தது", + "negative": "பயனுள்ளதாக இல்லை", + "edit": "கருத்தை திருத்து", + "dialog": { + "title": "கருத்தைச் சேர்", + "submit": "கருத்தை சமர்ப்பி", + "yourFeedback": "உங்கள் கருத்து..." + }, + "status": { + "updating": "புதுப்பிக்கிறது", + "updated": "கருத்து புதுப்பிக்கப்பட்டது" + } + } + }, + "history": { + "title": "கடைசி உள்ளீடுகள்", + "empty": "காலியாக உள்ளது...", + "show": "வரலாற்றைக் காட்டு" + }, + "settings": { + "title": "அமைப்புகள் பலகம்", + "customize": "உங்கள் உரையாடல் அமைப்புகளை இங்கே தனிப்பயனாக்கவும்" + }, + "watermark": "LLM கள் தவறுகள் செய்யலாம். முக்கியமான தகவல்களைச் சரிபார்ப்பதைக் கருத்தில் கொள்ளுங்கள்." + }, + "threadHistory": { + "sidebar": { + "title": "கடந்த உரையாடல்கள்", + "filters": { + "search": "தேடு", + "placeholder": "Search conversations..." + }, + "timeframes": { + "today": "இன்று", + "yesterday": "நேற்று", + "previous7days": "கடந்த 7 நாட்கள்", + "previous30days": "கடந்த 30 நாட்கள்" + }, + "empty": "உரையாடல்கள் எதுவும் இல்லை", + "actions": { + "close": "பக்கப்பட்டியை மூடு", + "open": "பக்கப்பட்டியை திற" + } + }, + "thread": { + "untitled": "தலைப்பிடாத உரையாடல்", + "menu": { + "rename": "பெயர் மாற்று", + "share": "பகிர்", + "delete": "அழி" + }, + "actions": { + "share": { + "title": "உரையாடல் இணைப்பை பகிரவும்", + "button": "பகிர்", + "status": { + "copied": "இணைப்பு நகலெடுக்கப்பட்டது", + "created": "பகிர்வு இணைப்பு உருவாக்கப்பட்டது!", + "unshared": "இந்த உரையாடலுக்கு பகிர்வு முடக்கப்பட்டது" + }, + "error": { + "create": "பகிர்வு இணைப்பை உருவாக்க முடியவில்லை", + "unshare": "உரையாடல் பகிர்வை நிறுத்த முடியவில்லை" + } + }, + "delete": { + "title": "நீக்குவதை உறுதிப்படுத்து", + "description": "இது உரையாடல் மற்றும் அதன் செய்திகள், உறுப்புகளை நீக்கும். இந்த செயலை மீட்டெடுக்க முடியாது", + "success": "உரையாடல் நீக்கப்பட்டது", + "inProgress": "உரையாடலை நீக்குகிறது" + }, + "rename": { + "title": "உரையாடலை மறுபெயரிடு", + "description": "இந்த உரையாடலுக்கு புதிய பெயரை உள்ளிடவும்", + "form": { + "name": { + "label": "பெயர்", + "placeholder": "புதிய பெயரை உள்ளிடவும்" + } + }, + "success": "உரையாடல் மறுபெயரிடப்பட்டது!", + "inProgress": "உரையாடலை மறுபெயரிடுகிறது" + } + } + } + }, + "navigation": { + "header": { + "chat": "உரையாடல்", + "readme": "படிக்கவும்", + "theme": { + "light": "Light Theme", + "dark": "Dark Theme", + "system": "Follow System" + } + }, + "newChat": { + "button": "புதிய உரையாடல்", + "dialog": { + "title": "புதிய உரையாடலை உருவாக்கு", + "description": "இது உங்கள் தற்போதைய உரையாடல் வரலாற்றை அழிக்கும். தொடர விரும்புகிறீர்களா?", + "tooltip": "புதிய உரையாடல்" + } + }, + "user": { + "menu": { + "settings": "அமைப்புகள்", + "settingsKey": "S", + "apiKeys": "API விசைகள்", + "logout": "வெளியேறு" + } + } + }, + "apiKeys": { + "title": "தேவையான API விசைகள்", + "description": "இந்த பயன்பாட்டைப் பயன்படுத்த, பின்வரும் API விசைகள் தேவை. விசைகள் உங்கள் சாதனத்தின் உள்ளூர் சேமிப்பகத்தில் சேமிக்கப்படும்.", + "success": { + "saved": "வெற்றிகரமாக சேமிக்கப்பட்டது" + } + }, + "alerts": { + "info": "Info", + "note": "Note", + "tip": "Tip", + "important": "Important", + "warning": "Warning", + "caution": "Caution", + "debug": "Debug", + "example": "Example", + "success": "Success", + "help": "Help", + "idea": "Idea", + "pending": "Pending", + "security": "Security", + "beta": "Beta", + "best-practice": "Best Practice" + }, + "components": { + "MultiSelectInput": { + "placeholder": "தேர்ந்தெடுக்கவும்..." + }, + "DatePickerInput": { + "placeholder": { + "single": "தேதியைத் தேர்ந்தெடுக்கவும்", + "range": "தேதி வரம்பைத் தேர்ந்தெடுக்கவும்" + } + } + } +} diff --git a/backend/chainlit/translations/te.json b/backend/chainlit/translations/te.json new file mode 100644 index 0000000..5849a3a --- /dev/null +++ b/backend/chainlit/translations/te.json @@ -0,0 +1,260 @@ +{ + "common": { + "actions": { + "cancel": "రద్దు చేయండి", + "confirm": "నిర్ధారించండి", + "continue": "కొనసాగించండి", + "goBack": "వెనక్కి వెళ్ళండి", + "reset": "రీసెట్ చేయండి", + "submit": "సమర్పించండి" + }, + "status": { + "loading": "లోడ్ అవుతోంది...", + "error": { + "default": "లోపం సంభవించింది", + "serverConnection": "సర్వర్‌ని చేరుకోలేకపోయాము" + } + } + }, + "auth": { + "login": { + "title": "యాప్‌ని ఉపయోగించడానికి లాగిన్ చేయండి", + "form": { + "email": { + "label": "ఇమెయిల్ చిరునామా", + "required": "ఇమెయిల్ తప్పనిసరి", + "placeholder": "me@example.com" + }, + "password": { + "label": "పాస్‌వర్డ్", + "required": "పాస్‌వర్డ్ తప్పనిసరి" + }, + "actions": { + "signin": "సైన్ ఇన్ చేయండి" + }, + "alternativeText": { + "or": "లేదా" + } + }, + "errors": { + "default": "సైన్ ఇన్ చేయలేకపోయాము", + "signin": "వేరే ఖాతాతో సైన్ ఇన్ చేయడానికి ప్రయత్నించండి", + "oauthSignin": "వేరే ఖాతాతో సైన్ ఇన్ చేయడానికి ప్రయత్నించండి", + "redirectUriMismatch": "రీడైరెక్ట్ URI oauth యాప్ కాన్ఫిగరేషన్‌తో సరిపోలడం లేదు", + "oauthCallback": "వేరే ఖాతాతో సైన్ ఇన్ చేయడానికి ప్రయత్నించండి", + "oauthCreateAccount": "వేరే ఖాతాతో సైన్ ఇన్ చేయడానికి ప్రయత్నించండి", + "emailCreateAccount": "వేరే ఖాతాతో సైన్ ఇన్ చేయడానికి ప్రయత్నించండి", + "callback": "వేరే ఖాతాతో సైన్ ఇన్ చేయడానికి ప్రయత్నించండి", + "oauthAccountNotLinked": "మీ గుర్తింపును నిర్ధారించడానికి, మీరు మొదట ఉపయోగించిన అదే ఖాతాతో సైన్ ఇన్ చేయండి", + "emailSignin": "ఇమెయిల్ పంపడం సాధ్యం కాలేదు", + "emailVerify": "దయచేసి మీ ఇమెయిల్‌ని ధృవీకరించండి, కొత్త ఇమెయిల్ పంపబడింది", + "credentialsSignin": "సైన్ ఇన్ విఫలమైంది. మీరు అందించిన వివరాలు సరైనవేనా అని తనిఖీ చేయండి", + "sessionRequired": "ఈ పేజీని యాక్సెస్ చేయడానికి దయచేసి సైన్ ఇన్ చేయండి" + } + }, + "provider": { + "continue": "{{provider}}తో కొనసాగించండి" + } + }, + "chat": { + "input": { + "placeholder": "మీ సందేశాన్ని ఇక్కడ టైప్ చేయండి...", + "actions": { + "send": "సందేశం పంపండి", + "stop": "పని ఆపండి", + "attachFiles": "ఫైల్స్ జోడించండి" + } + }, + "speech": { + "start": "రికార్డింగ్ ప్రారంభించండి", + "stop": "రికార్డింగ్ ఆపండి", + "connecting": "అనుసంధానిస్తోంది" + }, + "favorites": { + "use": "ఇష్టమైన సందేశాన్ని ఉపయోగించండి", + "headline": "ఇష్టమైన సందేశాలు", + "remove": "ఇష్టమైనదాన్ని తొలగించండి", + "empty": { + "title": "ఇంకా ప్రాంప్ట్‌లు సేవ్ చేయలేదు", + "description": "ఒక ప్రాంప్ట్ పంపి దానికి స్టార్ చేయడం ద్వారా ప్రారంభించండి లేదా మునుపటి చాట్‌ల నుండి ప్రాంప్ట్‌కు స్టార్ చేయండి" + } + }, + "commands": { + "button": "పరికరాలు", + "changeTool": "పరికరాన్ని మార్చండి", + "availableTools": "లభ్యమైన పరికరాలు" + }, + "fileUpload": { + "dragDrop": "ఫైల్స్‌ని ఇక్కడ డ్రాగ్ చేసి డ్రాప్ చేయండి", + "browse": "ఫైల్స్ బ్రౌజ్ చేయండి", + "sizeLimit": "పరిమితి:", + "errors": { + "failed": "అప్‌లోడ్ విఫలమైంది", + "cancelled": "అప్‌లోడ్ రద్దు చేయబడింది" + }, + "actions": { + "cancelUpload": "రద్దు చేయండి", + "removeAttachment": "అనుబంధాన్ని తొలగించండి" + } + }, + "messages": { + "status": { + "using": "ఉపయోగిస్తోంది", + "used": "ఉపయోగించబడింది" + }, + "actions": { + "copy": { + "button": "క్లిప్‌బోర్డ్‌కి కాపీ చేయండి", + "success": "కాపీ చేయబడింది!" + } + }, + "feedback": { + "positive": "సహాయకరం", + "negative": "సహాయకరం కాదు", + "edit": "అభిప్రాయాన్ని సవరించండి", + "dialog": { + "title": "వ్యాఖ్య జోడించండి", + "submit": "అభిప్రాయాన్ని సమర్పించండి", + "yourFeedback": "మీ అభిప్రాయం..." + }, + "status": { + "updating": "నవీకరిస్తోంది", + "updated": "అభిప్రాయం నవీకరించబడింది" + } + } + }, + "history": { + "title": "చివరి ఇన్‌పుట్‌లు", + "empty": "ఖాళీగా ఉంది...", + "show": "చరిత్రను చూపించు" + }, + "settings": { + "title": "సెట్టింగ్‌ల ప్యానెల్", + "customize": "మీ చాట్ సెట్టింగ్‌లను ఇక్కడ అనుకూలీకరించండి" + }, + "watermark": "LLMలు తప్పులు చేయవచ్చు. ముఖ్యమైన సమాచారాన్ని తనిఖీ చేయడాన్ని పరిగణించండి." + }, + "threadHistory": { + "sidebar": { + "title": "గత చాట్‌లు", + "filters": { + "search": "వెతకండి", + "placeholder": "Search conversations..." + }, + "timeframes": { + "today": "ఈరోజు", + "yesterday": "నిన్న", + "previous7days": "గత 7 రోజులు", + "previous30days": "గత 30 రోజులు" + }, + "empty": "థ్రెడ్‌లు కనుగొనబడలేదు", + "actions": { + "close": "సైడ్‌బార్ మూసివేయండి", + "open": "సైడ్‌బార్ తెరవండి" + } + }, + "thread": { + "untitled": "పేరు లేని సంభాషణ", + "menu": { + "rename": "పేరు మార్చండి", + "share": "షేర్ చేయండి", + "delete": "తొలగించండి" + }, + "actions": { + "share": { + "title": "చాట్ లింక్‌ను షేర్ చేయండి", + "button": "షేర్ చేయండి", + "status": { + "copied": "లింక్ కాపీ చేయబడింది", + "created": "షేర్ లింక్ సృష్టించబడింది!", + "unshared": "ఈ థ్రెడ్‌కు షేరింగ్ ఆపివేయబడింది" + }, + "error": { + "create": "షేర్ లింక్ సృష్టించడం విఫలమైంది", + "unshare": "థ్రెడ్ షేరింగ్ నిలిపివేయడం విఫలమైంది" + } + }, + "delete": { + "title": "తొలగింపును నిర్ధారించండి", + "description": "ఇది థ్రెడ్‌తో పాటు దాని సందేశాలను మరియు అంశాలను తొలగిస్తుంది. ఈ చర్యను రద్దు చేయలేరు", + "success": "చాట్ తొలగించబడింది", + "inProgress": "చాట్‌ని తొలగిస్తోంది" + }, + "rename": { + "title": "థ్రెడ్ పేరు మార్చండి", + "description": "ఈ థ్రెడ్ కోసం కొత్త పేరును నమోదు చేయండి", + "form": { + "name": { + "label": "పేరు", + "placeholder": "కొత్త పేరును నమోదు చేయండి" + } + }, + "success": "థ్రెడ్ పేరు మార్చబడింది!", + "inProgress": "థ్రెడ్ పేరు మారుస్తోంది" + } + } + } + }, + "navigation": { + "header": { + "chat": "చాట్", + "readme": "చదవండి", + "theme": { + "light": "Light Theme", + "dark": "Dark Theme", + "system": "Follow System" + } + }, + "newChat": { + "button": "కొత్త చాట్", + "dialog": { + "title": "కొత్త చాట్ సృష్టించండి", + "description": "ఇది మీ ప్రస్తుత చాట్ చరిత్రను తుడిచివేస్తుంది. మీరు కొనసాగించాలనుకుంటున్నారా?", + "tooltip": "కొత్త చాట్" + } + }, + "user": { + "menu": { + "settings": "సెట్టింగ్‌లు", + "settingsKey": "S", + "apiKeys": "API కీలు", + "logout": "లాగ్ అవుట్" + } + } + }, + "apiKeys": { + "title": "అవసరమైన API కీలు", + "description": "ఈ యాప్‌ని ఉపయోగించడానికి, కింది API కీలు అవసరం. కీలు మీ పరికరం యొక్క స్థానిక నిల్వలో నిల్వ చేయబడతాయి.", + "success": { + "saved": "విజయవంతంగా సేవ్ చేయబడింది" + } + }, + "alerts": { + "info": "Info", + "note": "Note", + "tip": "Tip", + "important": "Important", + "warning": "Warning", + "caution": "Caution", + "debug": "Debug", + "example": "Example", + "success": "Success", + "help": "Help", + "idea": "Idea", + "pending": "Pending", + "security": "Security", + "beta": "Beta", + "best-practice": "Best Practice" + }, + "components": { + "MultiSelectInput": { + "placeholder": "ఎంచుకోండి..." + }, + "DatePickerInput": { + "placeholder": { + "single": "తేదీని ఎంచుకోండి", + "range": "తేదీ పరిధిని ఎంచుకోండి" + } + } + } +} diff --git a/backend/chainlit/translations/zh-CN.json b/backend/chainlit/translations/zh-CN.json new file mode 100644 index 0000000..bce16c1 --- /dev/null +++ b/backend/chainlit/translations/zh-CN.json @@ -0,0 +1,260 @@ +{ + "common": { + "actions": { + "cancel": "取消", + "confirm": "确认", + "continue": "继续", + "goBack": "返回", + "reset": "重置", + "submit": "提交" + }, + "status": { + "loading": "加载中...", + "error": { + "default": "发生错误", + "serverConnection": "无法连接到服务器" + } + } + }, + "auth": { + "login": { + "title": "登录以访问应用", + "form": { + "email": { + "label": "电子邮箱", + "required": "邮箱是必填项", + "placeholder": "me@example.com" + }, + "password": { + "label": "密码", + "required": "密码是必填项" + }, + "actions": { + "signin": "登录" + }, + "alternativeText": { + "or": "或" + } + }, + "errors": { + "default": "无法登录", + "signin": "请尝试使用其他账号登录", + "oauthSignin": "请尝试使用其他账号登录", + "redirectUriMismatch": "重定向URI与OAuth应用配置不匹配", + "oauthCallback": "请尝试使用其他账号登录", + "oauthCreateAccount": "请尝试使用其他账号登录", + "emailCreateAccount": "请尝试使用其他账号登录", + "callback": "请尝试使用其他账号登录", + "oauthAccountNotLinked": "为确认您的身份,请使用原始账号登录", + "emailSignin": "邮件发送失败", + "emailVerify": "请验证您的邮箱,新的验证邮件已发送", + "credentialsSignin": "登录失败。请检查您提供的信息是否正确", + "sessionRequired": "请登录以访问此页面" + } + }, + "provider": { + "continue": "继续使用{{provider}}" + } + }, + "chat": { + "input": { + "placeholder": "在此输入您的消息...", + "actions": { + "send": "发送消息", + "stop": "停止任务", + "attachFiles": "附加文件" + } + }, + "speech": { + "start": "开始录音", + "stop": "停止录音", + "connecting": "连接中" + }, + "fileUpload": { + "dragDrop": "将文件拖放到这里", + "browse": "浏览文件", + "sizeLimit": "限制:", + "errors": { + "failed": "上传失败", + "cancelled": "已取消上传" + }, + "actions": { + "cancelUpload": "取消上传", + "removeAttachment": "移除附件" + } + }, + "favorites": { + "use": "使用收藏的消息", + "headline": "收藏的消息", + "remove": "移除收藏", + "empty": { + "title": "尚未保存的提示", + "description": "从发送提示并加星标开始,或从之前的聊天中加星标提示" + } + }, + "commands": { + "button": "工具", + "changeTool": "更换工具", + "availableTools": "可用工具" + }, + "messages": { + "status": { + "using": "使用中", + "used": "已使用" + }, + "actions": { + "copy": { + "button": "复制到剪贴板", + "success": "已复制!" + } + }, + "feedback": { + "positive": "有帮助", + "negative": "没有帮助", + "edit": "编辑反馈", + "dialog": { + "title": "添加评论", + "submit": "提交反馈", + "yourFeedback": "您的反馈..." + }, + "status": { + "updating": "更新中", + "updated": "反馈已更新" + } + } + }, + "history": { + "title": "最近输入", + "empty": "空空如也...", + "show": "显示历史" + }, + "settings": { + "title": "设置面板", + "customize": "在此自定义您的聊天设置" + }, + "watermark": "大语言模型可能会犯错。请核实重要信息。" + }, + "threadHistory": { + "sidebar": { + "title": "历史对话", + "filters": { + "search": "搜索", + "placeholder": "搜索会话..." + }, + "timeframes": { + "today": "今天", + "yesterday": "昨天", + "previous7days": "过去7天", + "previous30days": "过去30天" + }, + "empty": "未找到对话", + "actions": { + "close": "关闭侧边栏", + "open": "打开侧边栏" + } + }, + "thread": { + "untitled": "未命名对话", + "menu": { + "rename": "重命名", + "share": "分享", + "delete": "删除" + }, + "actions": { + "share": { + "title": "分享聊天链接", + "button": "分享", + "status": { + "copied": "链接已复制", + "created": "分享链接已创建!", + "unshared": "已禁用此对话的分享" + }, + "error": { + "create": "创建分享链接失败", + "unshare": "取消对话分享失败" + } + }, + "delete": { + "title": "确认删除", + "description": "这将删除该对话及其所有消息和元素。此操作无法撤销", + "success": "对话已删除", + "inProgress": "正在删除对话" + }, + "rename": { + "title": "重命名对话", + "description": "为此对话输入新名称", + "form": { + "name": { + "label": "名称", + "placeholder": "输入新名称" + } + }, + "success": "对话已重命名!", + "inProgress": "正在重命名对话" + } + } + } + }, + "navigation": { + "header": { + "chat": "聊天", + "readme": "说明", + "theme": { + "light": "浅色主题", + "dark": "深色主题", + "system": "跟随系统" + } + }, + "newChat": { + "button": "新建对话", + "dialog": { + "title": "创建新对话", + "description": "这将清除您当前的聊天记录。确定要继续吗?", + "tooltip": "新建对话" + } + }, + "user": { + "menu": { + "settings": "设置", + "settingsKey": "S", + "apiKeys": "API密钥", + "logout": "退出登录" + } + } + }, + "apiKeys": { + "title": "所需API密钥", + "description": "使用此应用需要以下API密钥。这些密钥存储在您设备的本地存储中。", + "success": { + "saved": "保存成功" + } + }, + "alerts": { + "info": "信息", + "note": "注释", + "tip": "提示", + "important": "重要", + "warning": "警告", + "caution": "注意", + "debug": "调试", + "example": "示例", + "success": "成功", + "help": "帮助", + "idea": "想法", + "pending": "待处理", + "security": "安全", + "beta": "测试", + "best-practice": "最佳实践" + }, + "components": { + "MultiSelectInput": { + "placeholder": "选择..." + }, + "DatePickerInput": { + "placeholder": { + "single": "选择日期", + "range": "选择日期范围" + } + } + } +} diff --git a/backend/chainlit/translations/zh-TW.json b/backend/chainlit/translations/zh-TW.json new file mode 100644 index 0000000..4c475b6 --- /dev/null +++ b/backend/chainlit/translations/zh-TW.json @@ -0,0 +1,260 @@ +{ + "common": { + "actions": { + "cancel": "取消", + "confirm": "確認", + "continue": "繼續", + "goBack": "返回", + "reset": "重設", + "submit": "送出" + }, + "status": { + "loading": "載入中...", + "error": { + "default": "發生錯誤", + "serverConnection": "無法連線到伺服器" + } + } + }, + "auth": { + "login": { + "title": "登入以存取應用程式", + "form": { + "email": { + "label": "電子信箱", + "required": "信箱是必填項目", + "placeholder": "me@example.com" + }, + "password": { + "label": "密碼", + "required": "密碼是必填項目" + }, + "actions": { + "signin": "登入" + }, + "alternativeText": { + "or": "或" + } + }, + "errors": { + "default": "無法登入", + "signin": "請嘗試使用其它帳號登入", + "oauthSignin": "請嘗試使用其它帳號登入", + "redirectUriMismatch": "重新導向URI與OAuth App設定不相符", + "oauthCallback": "請嘗試使用其它帳號登入", + "oauthCreateAccount": "請嘗試使用其它帳號登入", + "emailCreateAccount": "請嘗試使用其它帳號登入", + "callback": "請嘗試使用其它帳號登入", + "oauthAccountNotLinked": "為確認您的身份,請以原本使用的帳號登入", + "emailSignin": "電子郵件發送失敗", + "emailVerify": "請驗證您的電子信箱,新的驗證郵件已發送", + "credentialsSignin": "登入失敗。請檢查您提供的資訊是否正確", + "sessionRequired": "請登入以存取此頁面" + } + }, + "provider": { + "continue": "繼續使用{{provider}}" + } + }, + "chat": { + "input": { + "placeholder": "在此輸入您的訊息...", + "actions": { + "send": "發送訊息", + "stop": "停止任務", + "attachFiles": "附加檔案" + } + }, + "speech": { + "start": "開始錄音", + "stop": "停止錄音", + "connecting": "連線中" + }, + "fileUpload": { + "dragDrop": "拖曳檔案到這裡", + "browse": "瀏覽檔案", + "sizeLimit": "限制:", + "errors": { + "failed": "上傳失敗", + "cancelled": "已取消上傳" + }, + "actions": { + "cancelUpload": "取消上傳", + "removeAttachment": "移除附件" + } + }, + "favorites": { + "use": "使用收藏的訊息", + "headline": "收藏的訊息", + "remove": "移除收藏", + "empty": { + "title": "尚未儲存的提示", + "description": "從發送提示並加星號開始,或從之前的聊天中加星號提示" + } + }, + "commands": { + "button": "工具", + "changeTool": "更換工具", + "availableTools": "可用工具" + }, + "messages": { + "status": { + "using": "正在使用", + "used": "已使用" + }, + "actions": { + "copy": { + "button": "複製到剪貼簿", + "success": "已複製!" + } + }, + "feedback": { + "positive": "有幫助", + "negative": "沒有幫助", + "edit": "編輯回饋", + "dialog": { + "title": "新增評論", + "submit": "送出回饋", + "yourFeedback": "您的回饋..." + }, + "status": { + "updating": "更新中", + "updated": "回饋已更新" + } + } + }, + "history": { + "title": "最近輸入", + "empty": "空空如也...", + "show": "顯示歷史" + }, + "settings": { + "title": "設定面板", + "customize": "在此自定義您的聊天設定" + }, + "watermark": "大型語言模型可能會犯錯。請核實重要資訊。" + }, + "threadHistory": { + "sidebar": { + "title": "歷史對話", + "filters": { + "search": "搜尋", + "placeholder": "搜尋對話..." + }, + "timeframes": { + "today": "今天", + "yesterday": "昨天", + "previous7days": "過去7天", + "previous30days": "過去30天" + }, + "empty": "未找到對話", + "actions": { + "close": "關閉側邊欄", + "open": "打開側邊欄" + } + }, + "thread": { + "untitled": "未命名對話", + "menu": { + "rename": "重新命名", + "share": "分享", + "delete": "刪除" + }, + "actions": { + "share": { + "title": "分享聊天連結", + "button": "分享", + "status": { + "copied": "連結已複製", + "created": "分享連結已建立!", + "unshared": "已停用此對話的分享" + }, + "error": { + "create": "建立分享連結失敗", + "unshare": "取消對話分享失敗" + } + }, + "delete": { + "title": "確認刪除", + "description": "這將刪除該對話及其所有訊息和元件。此操作無法復原。", + "success": "對話已刪除", + "inProgress": "正在刪除對話" + }, + "rename": { + "title": "重新命名對話", + "description": "為此對話輸入新名稱", + "form": { + "name": { + "label": "名稱", + "placeholder": "輸入新名稱" + } + }, + "success": "對話已重新命名!", + "inProgress": "正在重新命名對話" + } + } + } + }, + "navigation": { + "header": { + "chat": "聊天", + "readme": "說明", + "theme": { + "light": "淺色主題", + "dark": "深色主題", + "system": "跟隨系統" + } + }, + "newChat": { + "button": "新建對話", + "dialog": { + "title": "創建新對話", + "description": "這將清除您當前的聊天記錄。確定要繼續嗎?", + "tooltip": "新建對話" + } + }, + "user": { + "menu": { + "settings": "設定", + "settingsKey": "S", + "apiKeys": "API金鑰", + "logout": "登出" + } + } + }, + "apiKeys": { + "title": "所需API金鑰", + "description": "使用此應用程式需要以下API金鑰。這些金鑰儲存在您設備的本地儲存空間中。", + "success": { + "saved": "儲存成功" + } + }, + "alerts": { + "info": "資訊", + "note": "注釋", + "tip": "提示", + "important": "重要", + "warning": "警告", + "caution": "注意", + "debug": "除錯", + "example": "範例", + "success": "成功", + "help": "幫助", + "idea": "想法", + "pending": "待處理", + "security": "安全", + "beta": "測試", + "best-practice": "最佳實踐" + }, + "components": { + "MultiSelectInput": { + "placeholder": "選擇..." + }, + "DatePickerInput": { + "placeholder": { + "single": "選擇日期", + "range": "選擇日期範圍" + } + } + } +} diff --git a/backend/chainlit/types.py b/backend/chainlit/types.py new file mode 100644 index 0000000..2adcc8a --- /dev/null +++ b/backend/chainlit/types.py @@ -0,0 +1,361 @@ +from enum import Enum +from pathlib import Path +from typing import ( + TYPE_CHECKING, + Any, + Dict, + Generic, + List, + Literal, + Optional, + Protocol, + TypedDict, + TypeVar, + Union, +) + +if TYPE_CHECKING: + from chainlit.element import ElementDict + from chainlit.step import StepDict + +from dataclasses import field + +from dataclasses_json import DataClassJsonMixin +from pydantic import BaseModel +from pydantic.dataclasses import dataclass + +InputWidgetType = Literal[ + "switch", + "slider", + "select", + "textinput", + "tags", + "numberinput", + "multiselect", + "checkbox", + "radio", + "datepicker", +] +ToastType = Literal["info", "success", "warning", "error"] + + +class ThreadDict(TypedDict): + id: str + createdAt: str + name: Optional[str] + userId: Optional[str] + userIdentifier: Optional[str] + tags: Optional[List[str]] + metadata: Optional[Dict] + steps: List["StepDict"] + elements: Optional[List["ElementDict"]] + + +class Pagination(BaseModel): + first: int + cursor: Optional[str] = None + + +class ThreadFilter(BaseModel): + feedback: Literal[0, 1] | None = None + userId: str | None = None + search: str | None = None + + +@dataclass +class PageInfo: + hasNextPage: bool + startCursor: Optional[str] + endCursor: Optional[str] + + def to_dict(self): + return { + "hasNextPage": self.hasNextPage, + "startCursor": self.startCursor, + "endCursor": self.endCursor, + } + + @classmethod + def from_dict(cls, page_info_dict: Dict) -> "PageInfo": + hasNextPage = page_info_dict.get("hasNextPage", False) + startCursor = page_info_dict.get("startCursor", None) + endCursor = page_info_dict.get("endCursor", None) + return cls( + hasNextPage=hasNextPage, startCursor=startCursor, endCursor=endCursor + ) + + +T = TypeVar("T", covariant=True) + + +class HasFromDict(Protocol[T]): + @classmethod + def from_dict(cls, obj_dict: Any) -> T: + raise NotImplementedError + + +@dataclass +class PaginatedResponse(Generic[T]): + pageInfo: PageInfo + data: List[T] + + def to_dict(self): + return { + "pageInfo": self.pageInfo.to_dict(), + "data": [ + (d.to_dict() if hasattr(d, "to_dict") and callable(d.to_dict) else d) + for d in self.data + ], + } + + @classmethod + def from_dict( + cls, paginated_response_dict: Dict, the_class: HasFromDict[T] + ) -> "PaginatedResponse[T]": + pageInfo = PageInfo.from_dict(paginated_response_dict.get("pageInfo", {})) + + data = [the_class.from_dict(d) for d in paginated_response_dict.get("data", [])] + + return cls(pageInfo=pageInfo, data=data) + + +@dataclass +class FileSpec(DataClassJsonMixin): + accept: Union[List[str], Dict[str, List[str]]] + max_files: int + max_size_mb: int + + +@dataclass +class ActionSpec(DataClassJsonMixin): + keys: List[str] + + +@dataclass +class AskSpec(DataClassJsonMixin): + """Specification for asking the user.""" + + timeout: int + type: Literal["text", "file", "action", "element"] + step_id: str + + +@dataclass +class AskFileSpec(FileSpec, AskSpec, DataClassJsonMixin): + """Specification for asking the user a file.""" + + +@dataclass +class AskActionSpec(ActionSpec, AskSpec, DataClassJsonMixin): + """Specification for asking the user an action""" + + +@dataclass +class AskElementSpec(AskSpec, DataClassJsonMixin): + """Specification for asking the user a custom element""" + + element_id: str + + +class FileReference(TypedDict): + id: str + + +class FileDict(TypedDict): + id: str + name: str + path: Path + size: int + type: str + + +class MessagePayload(TypedDict): + message: "StepDict" + fileReferences: Optional[List[FileReference]] + + +class InputAudioChunkPayload(TypedDict): + isStart: bool + mimeType: str + elapsedTime: float + data: bytes + + +@dataclass +class InputAudioChunk: + isStart: bool + mimeType: str + elapsedTime: float + data: bytes + + +class OutputAudioChunk(TypedDict): + track: str + mimeType: str + data: bytes + + +@dataclass +class AskFileResponse: + id: str + name: str + path: str + size: int + type: str + + +class AskActionResponse(TypedDict): + name: str + payload: Dict + label: str + tooltip: str + forId: str + id: str + + +class AskElementResponse(TypedDict, total=False): + submitted: bool + + +class UpdateThreadRequest(BaseModel): + threadId: str + name: str + + +class ShareThreadRequest(BaseModel): + threadId: str + isShared: bool + + +class DeleteThreadRequest(BaseModel): + threadId: str + + +class DeleteFeedbackRequest(BaseModel): + feedbackId: str + + +class GetThreadsRequest(BaseModel): + pagination: Pagination + filter: ThreadFilter + + +class CallActionRequest(BaseModel): + action: Dict + sessionId: str + + +class ConnectStdioMCPRequest(BaseModel): + sessionId: str + clientType: Literal["stdio"] + name: str + fullCommand: str + + +class ConnectSseMCPRequest(BaseModel): + sessionId: str + clientType: Literal["sse"] + name: str + url: str + # Optional HTTP headers to forward to the MCP transport (e.g. Authorization) + headers: Optional[Dict[str, str]] = None + + +class ConnectStreamableHttpMCPRequest(BaseModel): + sessionId: str + clientType: Literal["streamable-http"] + name: str + url: str + # Optional HTTP headers to forward to the MCP transport (e.g. Authorization) + headers: Dict[str, str] | None = None + + +ConnectMCPRequest = Union[ + ConnectStdioMCPRequest, ConnectSseMCPRequest, ConnectStreamableHttpMCPRequest +] + + +class DisconnectMCPRequest(BaseModel): + sessionId: str + name: str + + +class ElementRequest(BaseModel): + element: Dict + sessionId: str + + +class Theme(str, Enum): + light = "light" + dark = "dark" + + +@dataclass +class Starter(DataClassJsonMixin): + """Specification for a starter that can be chosen by the user at the thread start.""" + + label: str + message: str + command: Optional[str] = None + icon: Optional[str] = None + + +@dataclass +class StarterCategory(DataClassJsonMixin): + """A category/group of starters with an optional icon.""" + + label: str + icon: Optional[str] = None + starters: List[Starter] = field(default_factory=list) + + +@dataclass +class ChatProfile(DataClassJsonMixin): + """Specification for a chat profile that can be chosen by the user at the thread start.""" + + name: str + markdown_description: str + icon: Optional[str] = None + display_name: Optional[str] = None + default: bool = False + starters: Optional[List[Starter]] = None + config_overrides: Any = None + + +FeedbackStrategy = Literal["BINARY"] + + +class CommandDict(TypedDict): + # The identifier of the command, will be displayed in the UI + id: str + # The description of the command, will be displayed in the UI + description: str + # The lucide icon name + icon: str + # Display the command as a button in the composer + button: Optional[bool] + # Whether the command will be persistent unless the user toggles it + persistent: Optional[bool] + # Whether the command should be pre-selected when loaded + selected: Optional[bool] + + +class FeedbackDict(TypedDict): + forId: str + id: Optional[str] + value: Literal[0, 1] + comment: Optional[str] + + +@dataclass +class Feedback: + forId: str + value: Literal[0, 1] + threadId: Optional[str] = None + id: Optional[str] = None + comment: Optional[str] = None + + +class UpdateFeedbackRequest(BaseModel): + feedback: Feedback + sessionId: str diff --git a/backend/chainlit/user.py b/backend/chainlit/user.py new file mode 100644 index 0000000..557f9d0 --- /dev/null +++ b/backend/chainlit/user.py @@ -0,0 +1,43 @@ +from typing import Dict, Literal, Optional, TypedDict + +from dataclasses_json import DataClassJsonMixin +from pydantic import Field +from pydantic.dataclasses import dataclass + +Provider = Literal[ + "credentials", + "header", + "github", + "google", + "azure-ad", + "azure-ad-hybrid", + "okta", + "auth0", + "descope", +] + + +class UserDict(TypedDict): + id: str + identifier: str + display_name: Optional[str] + metadata: Dict + + +# Used when logging-in a user +@dataclass +class User(DataClassJsonMixin): + identifier: str + display_name: Optional[str] = None + metadata: Dict = Field(default_factory=dict) + + +@dataclass +class PersistedUserFields: + id: str + createdAt: str + + +@dataclass +class PersistedUser(User, PersistedUserFields): + pass diff --git a/backend/chainlit/user_session.py b/backend/chainlit/user_session.py new file mode 100644 index 0000000..62ac892 --- /dev/null +++ b/backend/chainlit/user_session.py @@ -0,0 +1,153 @@ +from typing import Callable, Dict, Generic, Optional, TypeVar + +from chainlit.context import context + +user_sessions: Dict[str, Dict] = {} + +T = TypeVar("T") + + +class UserSession: + """ + Developer facing user session class. + Useful for the developer to store user specific data between calls. + """ + + def get(self, key, default=None): + if not context.session: + return default + + if context.session.id not in user_sessions: + # Create a new user session + user_sessions[context.session.id] = {} + + user_session = user_sessions[context.session.id] + + # Copy important fields from the session + user_session["id"] = context.session.id + user_session["env"] = context.session.user_env + user_session["chat_settings"] = context.session.chat_settings + user_session["user"] = context.session.user + user_session["chat_profile"] = context.session.chat_profile + user_session["client_type"] = context.session.client_type + + return user_session.get(key, default) + + def set(self, key, value): + if not context.session: + return None + + if context.session.id not in user_sessions: + user_sessions[context.session.id] = {} + + user_session = user_sessions[context.session.id] + user_session[key] = value + + def create_accessor( + self, key: str, default: T, *, apply_fn: Optional[Callable[[T], T]] = None + ) -> "SessionAccessor[T]": + """ + Create a typed session accessor object for the given key and default value. + + #### Note: Creates the accessor configuration. The session value itself is only stored/updated when `.set()`, `.reset()`, or `.apply()` are called. + + Parameters + ---------- + key : str + The session dictionary key to store the value under + default : T + Default value to return when key is not present in session + apply_fn : Optional[Callable[[T], T]], default None + Optional function to transform the value when apply() is called + + Returns + ------- + SessionAccessor[T] + A typed accessor object bound to the specified session key + + Examples + -------- + + ```python + count = cl.user_session.create_accessor("count", 0) + count.get() # returns 0 + count.set(5) # type-safe setter + count.get() # returns 5 + + # With transform function + counter = cl.user_session.create_accessor("counter", 0, apply_fn=lambda x: x + 1) + counter.apply() # increments value and returns new value (1) + + @cl.on_message + async def on_message(message: cl.Message): + await cl.Message(content=f"You sent {counter.apply()} messages").send() # You sent 2 messages + ``` + """ + return SessionAccessor(key, default, apply_fn=apply_fn) + + +user_session = UserSession() + + +class SessionAccessor(Generic[T]): + """ + Extended session accessor class to store user specific data between calls with type safety. + + Provides a typed wrapper around user_session dictionary access with default values + and optional transform functions. The session value is only stored in memory when + explicitly modified through `.set()`, `.reset()`, or `.apply()` methods. + + Examples + -------- + ```python + count = cl.user_session.create_accessor("count", 0) + count.get() # returns 0 + count.set(5) # type-safe setter + count.get() # returns 5 + + # With transform function + counter = cl.user_session.create_accessor("counter", 0, apply_fn=lambda x: x + 1) + counter.apply() # increments value and returns new value (1) + + @cl.on_message + async def on_message(message: cl.Message): + await cl.Message(content=f"You sent {counter.apply()} messages").send() # You sent 2 messages + ``` + """ + + def __init__( + self, key: str, default: T, *, apply_fn: Optional[Callable[[T], T]] = None + ): + self._key = key + self._default = default + self._apply_fn = apply_fn + + def get(self) -> T: + """ + Get the current value of the accessor. + """ + return user_session.get(self._key, self._default) + + def set(self, value: T) -> None: + """ + Set the value of the accessor. + """ + return user_session.set(self._key, value) + + def reset(self) -> None: + """ + Reset the value to the default. + """ + return self.set(self._default) + + def apply(self) -> T: + """ + Apply the transform function to the current value, store the result, and return it. + + Returns the current value if no transform function is provided. + """ + value = self.get() + if self._apply_fn: + value = self._apply_fn(value) + self.set(value) + return value diff --git a/backend/chainlit/utils.py b/backend/chainlit/utils.py new file mode 100644 index 0000000..a59501f --- /dev/null +++ b/backend/chainlit/utils.py @@ -0,0 +1,173 @@ +import functools +import importlib +import inspect +import os +from asyncio import CancelledError +from datetime import datetime, timezone +from typing import Callable + +import click +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse +from packaging import version +from starlette.middleware.base import BaseHTTPMiddleware + +from chainlit.auth import ensure_jwt_secret +from chainlit.context import context +from chainlit.logger import logger + + +def utc_now(): + dt = datetime.now(timezone.utc).replace(tzinfo=None) + return dt.isoformat() + "Z" + + +def timestamp_utc(timestamp: float): + dt = datetime.fromtimestamp(timestamp, timezone.utc).replace(tzinfo=None) + return dt.isoformat() + "Z" + + +def wrap_user_function(user_function: Callable, with_task=False) -> Callable: + """ + Wraps a user-defined function to accept arguments as a dictionary. + + Args: + user_function (Callable): The user-defined function to wrap. + + Returns: + Callable: The wrapped function. + """ + + @functools.wraps(user_function) + async def wrapper(*args): + # Get the parameter names of the user-defined function + user_function_params = list(inspect.signature(user_function).parameters.keys()) + + # Create a dictionary of parameter names and their corresponding values from *args + params_values = { + param_name: arg for param_name, arg in zip(user_function_params, args) + } + + if with_task: + await context.emitter.task_start() + + try: + # Call the user-defined function with the arguments + if inspect.iscoroutinefunction(user_function): + return await user_function(**params_values) + else: + return user_function(**params_values) + except CancelledError: + pass + except Exception as e: + logger.exception(e) + if with_task: + from chainlit.message import ErrorMessage + + await ErrorMessage( + content=str(e) or e.__class__.__name__, author="Error" + ).send() + finally: + if with_task: + await context.emitter.task_end() + + return wrapper + + +def make_module_getattr(registry): + """Leverage PEP 562 to make imports lazy in an __init__.py + + The registry must be a dictionary with the items to import as keys and the + modules they belong to as a value. + """ + + def __getattr__(name): + module_path = registry[name] + module = importlib.import_module(module_path, __package__) + return getattr(module, name) + + return __getattr__ + + +def check_module_version(name, required_version): + """ + Check the version of a module. + + Args: + name (str): A module name. + version (str): Minimum version. + + Returns: + (bool): Return True if the module is installed and the version + match the minimum required version. + """ + try: + module = importlib.import_module(name) + except ModuleNotFoundError: + return False + return version.parse(module.__version__) >= version.parse(required_version) + + +def check_file(target: str): + # Define accepted file extensions for Chainlit + ACCEPTED_FILE_EXTENSIONS = ("py", "py3") + + _, extension = os.path.splitext(target) + + # Check file extension + if extension[1:] not in ACCEPTED_FILE_EXTENSIONS: + if extension[1:] == "": + raise click.BadArgumentUsage( + "Chainlit requires raw Python (.py) files, but the provided file has no extension." + ) + else: + raise click.BadArgumentUsage( + f"Chainlit requires raw Python (.py) files, not {extension}." + ) + + if not os.path.exists(target): + raise click.BadParameter(f"File does not exist: {target}") + + +def mount_chainlit(app: FastAPI, target: str, path="/chainlit"): + from chainlit.config import config, load_module + from chainlit.server import app as chainlit_app + + config.run.debug = os.environ.get("CHAINLIT_DEBUG", False) + os.environ["CHAINLIT_ROOT_PATH"] = path + + api_full_path = path + + if app.root_path: + parent_root_path = app.root_path.rstrip("/") + api_full_path = parent_root_path + path + os.environ["CHAINLIT_PARENT_ROOT_PATH"] = parent_root_path + + check_file(target) + # Load the module provided by the user + config.run.module_name = target + load_module(config.run.module_name) + + ensure_jwt_secret() + + class ChainlitMiddleware(BaseHTTPMiddleware): + """Middleware to handle path routing for submounted Chainlit applications. + + When Chainlit is submounted within a larger FastAPI application, its default route + `@router.get("/{full_path:path}")` can conflict with the main app's routing. This + middleware ensures requests are only forwarded to Chainlit if they match the + designated subpath, preventing routing collisions. + + If a request's path doesn't start with the configured subpath, the middleware + returns a 404 response instead of forwarding to Chainlit's default route. + """ + + async def dispatch(self, request: Request, call_next): + if not request.url.path.startswith(api_full_path): + return JSONResponse(status_code=404, content={"detail": "Not found"}) + + return await call_next(request) + + chainlit_app.add_middleware(ChainlitMiddleware) + + app.mount(path, chainlit_app) diff --git a/backend/chainlit/version.py b/backend/chainlit/version.py new file mode 100644 index 0000000..200c236 --- /dev/null +++ b/backend/chainlit/version.py @@ -0,0 +1 @@ +__version__ = "2.11.1" diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..a6b8e04 --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,140 @@ +[project] +name = "chainlit" +dynamic = ["version"] +keywords = [ + "LLM", + "Agents", + "MCP", + "gen ai", + "chat ui", + "chatbot ui", + "openai", + "copilot", + "langchain", + "conversational ai", +] +description = "Build Conversational AI." +authors = [ + { name = "Willy Douhard" }, + { name = "Dan Andre Constantini" } +] +license = { text = "Apache-2.0" } +readme = "README.md" +requires-python = ">=3.10,<3.14.0" +classifiers = [ + "Framework :: FastAPI", + "Topic :: Scientific/Engineering :: Artificial Intelligence", + "Topic :: Communications :: Chat", + "Programming Language :: JavaScript", + "Topic :: Software Development :: User Interfaces", + "Topic :: Software Development :: Libraries :: Python Modules", + "Environment :: Web Environment", +] + +dependencies = [ + "httpx>=0.23.0", + "literalai==0.1.201", + "dataclasses_json>=0.6.7,<0.7.0", + "fastapi>=0.116.1", + "starlette>=0.47.2", + "uvicorn>=0.35.0", + "python-socketio>=5.11.0,<6.0.0", + "aiofiles>=23.1.0", + "syncer>=2.0.3,<3.0.0", + "asyncer>=0.0.8,<0.1.0", + "mcp>=1.11.0,<2.0.0", + "nest-asyncio>=1.6.0,<2.0.0", + "click>=8.1.3,<9.0.0", + "tomli>=2.0.1,<3.0.0", + "pydantic>=2.7.2,<3", + "python-dotenv>=1.0.0,<2.0.0", + "watchfiles>=1.1.1,<2.0.0", + "filetype>=1.2.0,<2.0.0", + "lazify>=0.4.0,<0.5.0", + "packaging>=23.1", + "python-multipart>=0.0.18,<1.0.0", + "pyjwt>=2.8.0,<3.0.0", + "audioop-lts>=0.2.1,<0.3.0; python_version>='3.13'", + "pydantic-settings>=2.10.1" +] + +[dependency-groups] +dev = [ + "hatchling", + "ruff>=0.13.3,<1.0.0", +] + +[project.urls] +Homepage = "https://chainlit.io/" +Documentation = "https://docs.chainlit.io/" +Repository = "https://github.com/Chainlit/chainlit" + +[project.scripts] +chainlit = "chainlit.cli:cli" + +[project.optional-dependencies] +tests = [ + "pytest>=8.3.2,<10.0.0", + "pytest-asyncio>=1.0.0,<2.0.0", + "pytest-cov>=6.0.0,<7.0.0", + "openai>=2.0.0,<3.0.0", + "langchain>=1.0.0", + "llama-index>=0.13.0,<1.0.0", + "semantic-kernel>=1.24.0,<2.0.0", + "tenacity>=9.0.0,<10.0.0", + "transformers>=5.0,<6.0", + "matplotlib>=3.7.1,<4.0.0", + "plotly>=5.3.0,<7.0.0", + "slack_bolt>=1.18.1,<2.0.0", + "discord>=2.3.2,<3.0.0", + "botbuilder-core>=4.15.0,<5.0.0", + "aiosqlite>=0.20.0,<1.0.0", + "pandas>=2.2.2,<4.0.0", + "polars>=1.0.0,<2.0.0", + "moto>=5.0.14,<6.0.0", +] +mypy = [ + "mypy>=1.13,<2.0.0", + "types-requests>=2.31.0.2,<3.0.0", + "types-aiofiles>=23.1.0.5,<26.0.0", + "mypy-boto3-dynamodb>=1.34.113,<2.0.0", + "pandas-stubs>=2.2.2,<4.0.0; python_version>='3.9'", +] +custom-data = [ + "asyncpg>=0.30.0,<1.0.0", + "SQLAlchemy>=2.0.28,<3.0.0", + "boto3>=1.34.73,<2.0.0", + "azure-identity>=1.14.1,<2.0.0", + "azure-storage-file-datalake>=12.14.0,<13.0.0", + "azure-storage-blob>=12.24.0,<13.0.0", + "google-cloud-storage>=2.19.0,<4.0.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build] +exclude = [ + "chainlit/frontend/**/*", + "chainlit/copilot/**/**/" +] + +[tool.hatch.build.hooks.custom] +path = "build.py" + +[tool.hatch.build.targets.sdist] +artifacts = [ + "chainlit/frontend/dist/**/*", + "chainlit/copilot/dist/**/*" +] + +[tool.hatch.build.targets.wheel] +packages = ["chainlit"] +artifacts = [ + "chainlit/frontend/dist/**/*", + "chainlit/copilot/dist/**/*" +] + +[tool.hatch.version] +path = "chainlit/version.py" diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/tests/auth/__init__.py b/backend/tests/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/auth/test_cookie.py b/backend/tests/auth/test_cookie.py new file mode 100644 index 0000000..5f5c384 --- /dev/null +++ b/backend/tests/auth/test_cookie.py @@ -0,0 +1,165 @@ +import importlib + +import pytest +from fastapi import FastAPI, Form +from fastapi.testclient import TestClient +from starlette.requests import Request +from starlette.responses import Response + +import chainlit.auth.cookie as cookie_module +from chainlit.auth import ( + clear_auth_cookie, + get_token_from_cookies, + set_auth_cookie, +) + + +@pytest.fixture +def test_app(): + app = FastAPI() + + @app.post("/set-cookie") + async def set_cookie_endpoint(request: Request, token: str = Form()): + response = Response() + set_auth_cookie(request, response, token) + return response + + @app.get("/get-token") + async def get_token_endpoint(request: Request): + token = get_token_from_cookies(request.cookies) + return {"token": token} + + @app.delete("/clear-cookie") + async def clear_cookie_endpoint(request: Request): + response = Response() + clear_auth_cookie(request, response) + return response + + return app + + +@pytest.fixture +def client(test_app): + return TestClient(test_app) + + +def test_short_token(client): + """Test with a <3000 shorter token.""" + + # Set a short token + short_token = "x" * 1000 + set_response = client.post("/set-cookie", data={"token": short_token}) + assert set_response.status_code == 200 + + # Verify cookies were set + cookies = set_response.cookies + assert cookies, "No cookies set" + assert "access_token" in cookies, f"No chunking for short cookies: {cookies}" + + # Read back the token using client's cookie jar + get_response = client.get("/get-token") + assert get_response.status_code == 200 + assert get_response.json()["token"] == short_token + + +def test_set_and_read_4kb_token(client): + """Test full cookie lifecycle using actual client cookie handling.""" + + # Set a 4KB token + token_4kb = "x" * 4000 + set_response = client.post("/set-cookie", data={"token": token_4kb}) + assert set_response.status_code == 200 + + # Verify cookies were set + cookies = set_response.cookies + assert f"{cookies.keys()} should contain chunked cookies", any( + key.startswith("access_token_") for key in cookies.keys() + ) + + # Read back the token using client's cookie jar + get_response = client.get("/get-token") + assert get_response.status_code == 200 + + response_token = get_response.json()["token"] + assert len(response_token) == len(token_4kb) + assert response_token == token_4kb + + +def test_overwrite_shorter_token_chunked(client): + """Test cookie chunk cleanup when replacing a large token with a smaller one.""" + # Set initial long token + long_token = "LONG" * 2000 # 8000 characters + client.post("/set-cookie", data={"token": long_token}) + + # Verify initial chunks exist + first_cookies = client.cookies + assert len([k for k in first_cookies if k.startswith("access_token_")]) > 1 + + # Set shorter token (should clear previous chunks) + short_token = "SHORT" * 1000 # 4000 characters + client.post("/set-cookie", data={"token": short_token}) + + # Verify new cookie state + final_response = client.get("/get-token") + assert final_response.json()["token"] == short_token + + # Verify only two chunks remain + final_cookies = client.cookies + chunk_cookies = [k for k in final_cookies if k.startswith("access_token_")] + assert len(chunk_cookies) == 2, f"Found {len(chunk_cookies)} residual cookies" + + +def test_overwrite_shorter_token_unchunked(client): + """Test cookie chunk cleanup when replacing a large token with a smaller one.""" + # Set initial long token + long_token = "LONG" * 1000 # 4000 characters + client.post("/set-cookie", data={"token": long_token}) + + # Verify initial chunks exist + first_cookies = client.cookies + assert len([k for k in first_cookies if k.startswith("access_token_")]) > 1 + + # Set shorter token (should clear previous chunks) + short_token = "SHORT" + client.post("/set-cookie", data={"token": short_token}) + + # Verify new cookie state + final_response = client.get("/get-token") + assert final_response.json()["token"] == short_token + + # Verify no chunks remain + final_cookies = client.cookies + chunk_cookies = [k for k in final_cookies if k.startswith("access_token_")] + assert len(chunk_cookies) == 0, f"Found {len(chunk_cookies)} residual cookies" + + +def test_state_cookie_lifetime_default(monkeypatch): + """Test that _state_cookie_lifetime defaults to 180 seconds (3 minutes).""" + monkeypatch.delenv("CHAINLIT_STATE_COOKIE_LIFETIME", raising=False) + importlib.reload(cookie_module) + assert cookie_module._state_cookie_lifetime == 180 + + +def test_state_cookie_lifetime_custom(monkeypatch): + """Test that _state_cookie_lifetime can be set via environment variable.""" + monkeypatch.setenv("CHAINLIT_STATE_COOKIE_LIFETIME", "600") + importlib.reload(cookie_module) + assert cookie_module._state_cookie_lifetime == 600 + + +def test_clear_auth_cookie(client): + """Test cookie clearing removes all chunks.""" + # Set initial token + client.post("/set-cookie", data={"token": "x" * 4000}) + + # Verify cookies exist + assert len(client.cookies) > 0 + + # Clear cookies + clear_response = client.delete("/clear-cookie") + assert clear_response.status_code == 200 + + # Verify cookies were cleared + assert len(clear_response.cookies) == 0 + final_response = client.get("/get-token") + assert final_response.json()["token"] is None diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..0f19528 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,129 @@ +import datetime +from contextlib import asynccontextmanager +from pathlib import Path +from typing import Callable +from unittest.mock import AsyncMock, Mock + +import pytest +import pytest_asyncio + +from chainlit import config +from chainlit.callbacks import data_layer +from chainlit.context import ChainlitContext, context_var +from chainlit.data.base import BaseDataLayer +from chainlit.session import HTTPSession, WebsocketSession +from chainlit.user import PersistedUser +from chainlit.user_session import UserSession + + +@pytest.fixture +def persisted_test_user(): + return PersistedUser( + id="test_user_id", + createdAt=datetime.datetime.now().isoformat(), + identifier="test_user_identifier", + ) + + +@pytest.fixture +def mock_session_factory(persisted_test_user: PersistedUser) -> Callable[..., Mock]: + def create_mock_session(**kwargs) -> Mock: + mock = Mock(spec=WebsocketSession) + mock.user = kwargs.get("user", persisted_test_user) + mock.id = kwargs.get("id", "test_session_id") + mock.user_env = kwargs.get("user_env", {"test_env": "value"}) + mock.chat_settings = kwargs.get("chat_settings", {}) + mock.chat_profile = kwargs.get("chat_profile", None) + mock.environ = kwargs.get("environ", None) + mock.client_type = kwargs.get("client_type", "webapp") + mock.thread_id = kwargs.get("thread_id", "test_thread_id") + mock.emit = AsyncMock() + mock.has_first_interaction = kwargs.get("has_first_interaction", True) + mock.files = kwargs.get("files", {}) + mock.files_spec = kwargs.get("files_spec", {}) + + return mock + + return create_mock_session + + +@pytest.fixture +def mock_session(mock_session_factory) -> Mock: + return mock_session_factory() + + +@asynccontextmanager +async def create_chainlit_context(mock_session): + from chainlit.emitter import BaseChainlitEmitter + + # Create a mock emitter with all necessary methods + mock_emitter = Mock(spec=BaseChainlitEmitter) + mock_emitter.send_step = AsyncMock() + mock_emitter.update_step = AsyncMock() + mock_emitter.delete_step = AsyncMock() + mock_emitter.stream_start = AsyncMock() + mock_emitter.send_element = AsyncMock() + mock_emitter.send_action = AsyncMock() + mock_emitter.remove_action = AsyncMock() + mock_emitter.emit = AsyncMock() + mock_emitter.set_chat_settings = Mock() # Sync method, not async + + context = ChainlitContext(mock_session, emitter=mock_emitter) + token = context_var.set(context) + try: + yield context + finally: + context_var.reset(token) + + +@pytest_asyncio.fixture +async def mock_chainlit_context(persisted_test_user, mock_session): + mock_session.user = persisted_test_user + return create_chainlit_context(mock_session) + + +@pytest.fixture +def user_session(): + return UserSession() + + +@pytest.fixture +def mock_websocket_session(): + session = Mock(spec=WebsocketSession) + session.emit = AsyncMock() + + return session + + +@pytest.fixture +def mock_http_session(): + return Mock(spec=HTTPSession) + + +@pytest.fixture +def mock_data_layer(monkeypatch: pytest.MonkeyPatch) -> AsyncMock: + mock_data_layer = AsyncMock(spec=BaseDataLayer) + + return mock_data_layer + + +@pytest.fixture +def mock_get_data_layer(mock_data_layer: AsyncMock, test_config: config.ChainlitConfig): + # Instantiate mock data layer + mock_get_data_layer = Mock(return_value=mock_data_layer) + + # Configure it using @data_layer decorator + return data_layer(mock_get_data_layer) + + +@pytest.fixture +def test_config(monkeypatch: pytest.MonkeyPatch, tmp_path: Path): + monkeypatch.setenv("CHAINLIT_ROOT_PATH", str(tmp_path)) + + test_config = config.load_config() + + monkeypatch.setattr("chainlit.callbacks.config", test_config) + monkeypatch.setattr("chainlit.server.config", test_config) + monkeypatch.setattr("chainlit.config.config", test_config) + + return test_config diff --git a/backend/tests/data/__init__.py b/backend/tests/data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/data/conftest.py b/backend/tests/data/conftest.py new file mode 100644 index 0000000..84276df --- /dev/null +++ b/backend/tests/data/conftest.py @@ -0,0 +1,21 @@ +from unittest.mock import AsyncMock + +import pytest + +from chainlit.data.storage_clients.base import BaseStorageClient +from chainlit.user import User + + +@pytest.fixture +def mock_storage_client(): + mock_client = AsyncMock(spec=BaseStorageClient) + mock_client.upload_file.return_value = { + "url": "https://example.com/test.txt", + "object_key": "test_user/test_element/test.txt", + } + return mock_client + + +@pytest.fixture +def test_user() -> User: + return User(identifier="test_user_identifier", metadata={}) diff --git a/backend/tests/data/storage_clients/test_gcs.py b/backend/tests/data/storage_clients/test_gcs.py new file mode 100644 index 0000000..d5bdc5d --- /dev/null +++ b/backend/tests/data/storage_clients/test_gcs.py @@ -0,0 +1,337 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from chainlit.data.storage_clients.base import storage_expiry_time +from chainlit.data.storage_clients.gcs import GCSStorageClient + + +@pytest.fixture +def mock_gcs_client(): + """Create a mock Google Cloud Storage client.""" + # First mock the service_account + with patch( + "chainlit.data.storage_clients.gcs.service_account" + ) as mock_service_account: + mock_credentials = MagicMock() + mock_service_account.Credentials.from_service_account_info.return_value = ( + mock_credentials + ) + + # Then mock the storage client + with patch("chainlit.data.storage_clients.gcs.storage") as mock_storage: + mock_client = MagicMock() + mock_storage.Client.return_value = mock_client + mock_bucket = MagicMock() + mock_client.bucket.return_value = mock_bucket + mock_blob = MagicMock() + mock_bucket.blob.return_value = mock_blob + + yield { + "storage": mock_storage, + "client": mock_client, + "bucket": mock_bucket, + "blob": mock_blob, + "service_account": mock_service_account, + "credentials": mock_credentials, + } + + +class TestGCSStorageClient: + def test_init(self, mock_gcs_client): + """Test initialization of GCS client.""" + # Remove client assignment, directly call the constructor + GCSStorageClient( + bucket_name="test-bucket", + project_id="test-project", + client_email="test@example.com", + private_key="test-key", + ) + + # Verify service account credentials were created correctly + mock_gcs_client[ + "service_account" + ].Credentials.from_service_account_info.assert_called_once_with( + { + "type": "service_account", + "project_id": "test-project", + "private_key": "test-key", + "client_email": "test@example.com", + "token_uri": "https://oauth2.googleapis.com/token", + } + ) + + # Verify storage client was initialized with credentials + mock_gcs_client["storage"].Client.assert_called_once_with( + project="test-project", credentials=mock_gcs_client["credentials"] + ) + + # Verify bucket was retrieved + mock_gcs_client["client"].bucket.assert_called_once_with("test-bucket") + + def test_sync_get_read_url(self, mock_gcs_client): + """Test generating a signed URL.""" + # Set up the mock to return a URL + mock_gcs_client[ + "blob" + ].generate_signed_url.return_value = "https://signed-url.example.com" + + client = GCSStorageClient( + bucket_name="test-bucket", + project_id="test-project", + client_email="test@example.com", + private_key="test-key", + ) + + # Reset mocks to ensure clean state + mock_gcs_client["bucket"].reset_mock() + mock_gcs_client["blob"].reset_mock() + + # Test the method + url = client.sync_get_read_url("test/path/file.txt") + + # Verify correct methods were called + mock_gcs_client["bucket"].blob.assert_called_once_with("test/path/file.txt") + mock_gcs_client["blob"].generate_signed_url.assert_called_once_with( + version="v4", expiration=storage_expiry_time, method="GET" + ) + + assert url == "https://signed-url.example.com" + + @pytest.mark.asyncio + async def test_get_read_url(self, mock_gcs_client): + """Test the async wrapper for getting a read URL.""" + # Set up the mock to return a URL + mock_gcs_client[ + "blob" + ].generate_signed_url.return_value = "https://signed-url.example.com" + + client = GCSStorageClient( + bucket_name="test-bucket", + project_id="test-project", + client_email="test@example.com", + private_key="test-key", + ) + + # Reset mocks to ensure clean state + mock_gcs_client["bucket"].reset_mock() + mock_gcs_client["blob"].reset_mock() + + # Test the async method + url = await client.get_read_url("test/path/file.txt") + + # Verify correct methods were called + mock_gcs_client["bucket"].blob.assert_called_once_with("test/path/file.txt") + mock_gcs_client["blob"].generate_signed_url.assert_called_once_with( + version="v4", expiration=storage_expiry_time, method="GET" + ) + + assert url == "https://signed-url.example.com" + + def test_sync_upload_file(self, mock_gcs_client): + """Test uploading a file to GCS.""" + client = GCSStorageClient( + bucket_name="test-bucket", + project_id="test-project", + client_email="test@example.com", + private_key="test-key", + ) + + # Reset the mock to ensure clean state + mock_gcs_client["bucket"].reset_mock() + mock_gcs_client["blob"].reset_mock() + + # Mock the bucket name property + mock_gcs_client["bucket"].name = "test-bucket" + + # Test with binary data + binary_data = b"test content" + object_key = "test/path/file.txt" + + # Remove the unused result assignment + client.sync_upload_file( + object_key=object_key, data=binary_data, mime="text/plain", overwrite=True + ) + + # Check that the blob was called with the correct object key (using assert_any_call instead of assert_called_once_with) + mock_gcs_client["bucket"].blob.assert_any_call(object_key) + mock_gcs_client["blob"].upload_from_string.assert_called_once_with( + binary_data, content_type="text/plain" + ) + + def test_sync_upload_file_string_data(self, mock_gcs_client): + """Test uploading string data to GCS.""" + client = GCSStorageClient( + bucket_name="test-bucket", + project_id="test-project", + client_email="test@example.com", + private_key="test-key", + ) + + # Reset the mock to ensure clean state + mock_gcs_client["bucket"].reset_mock() + mock_gcs_client["blob"].reset_mock() + mock_gcs_client["bucket"].name = "test-bucket" + + # Test with string data that should be encoded + string_data = "test content" + object_key = "test/path/file.txt" + + # Remove the unused result assignment + client.sync_upload_file( + object_key=object_key, data=string_data, mime="text/plain", overwrite=True + ) + + # Check that the correct methods were called + mock_gcs_client["bucket"].blob.assert_any_call(object_key) + mock_gcs_client["blob"].upload_from_string.assert_called_once_with( + b"test content", content_type="text/plain" + ) + + def test_sync_upload_file_no_overwrite(self, mock_gcs_client): + """Test upload with overwrite=False and existing file.""" + client = GCSStorageClient( + bucket_name="test-bucket", + project_id="test-project", + client_email="test@example.com", + private_key="test-key", + ) + + # Reset the mock to ensure clean state + mock_gcs_client["bucket"].reset_mock() + mock_gcs_client["blob"].reset_mock() + + # Configure blob to indicate file exists + mock_gcs_client["blob"].exists.return_value = True + + with pytest.raises( + Exception, + match=r"Failed to upload file to GCS: File test/path/existing\.txt already exists and overwrite is False", + ): + client.sync_upload_file( + object_key="test/path/existing.txt", + data=b"test content", + overwrite=False, + ) + + mock_gcs_client["blob"].exists.assert_called_once() + mock_gcs_client["blob"].upload_from_string.assert_not_called() + + def test_sync_upload_file_error(self, mock_gcs_client): + """Test error handling during upload.""" + client = GCSStorageClient( + bucket_name="test-bucket", + project_id="test-project", + client_email="test@example.com", + private_key="test-key", + ) + + # Reset the mock to ensure clean state + mock_gcs_client["bucket"].reset_mock() + mock_gcs_client["blob"].reset_mock() + + # Configure upload to throw an exception + mock_gcs_client["blob"].upload_from_string.side_effect = ValueError( + "Upload failed" + ) + + with pytest.raises( + Exception, match="Failed to upload file to GCS: Upload failed" + ): + client.sync_upload_file( + object_key="test/path/file.txt", data=b"test content" + ) + + @pytest.mark.asyncio + async def test_upload_file(self, mock_gcs_client): + """Test the async wrapper for uploading a file.""" + client = GCSStorageClient( + bucket_name="test-bucket", + project_id="test-project", + client_email="test@example.com", + private_key="test-key", + ) + + # Reset the mock to ensure clean state + mock_gcs_client["bucket"].reset_mock() + mock_gcs_client["blob"].reset_mock() + mock_gcs_client["bucket"].name = "test-bucket" + + # Test with binary data + binary_data = b"test content" + object_key = "test/path/file.txt" + + # Remove the unused result assignment + await client.upload_file( + object_key=object_key, data=binary_data, mime="text/plain", overwrite=True + ) + + # Check that the correct methods were called + mock_gcs_client["bucket"].blob.assert_any_call(object_key) + mock_gcs_client["blob"].upload_from_string.assert_called_once_with( + binary_data, content_type="text/plain" + ) + + def test_sync_delete_file(self, mock_gcs_client): + """Test deleting a file from GCS.""" + client = GCSStorageClient( + bucket_name="test-bucket", + project_id="test-project", + client_email="test@example.com", + private_key="test-key", + ) + + # Reset the mock to ensure clean state + mock_gcs_client["bucket"].reset_mock() + mock_gcs_client["blob"].reset_mock() + + # Test successful delete + result = client.sync_delete_file("test/path/file.txt") + + mock_gcs_client["bucket"].blob.assert_called_once_with("test/path/file.txt") + mock_gcs_client["blob"].delete.assert_called_once() + assert result is True + + def test_sync_delete_file_error(self, mock_gcs_client): + """Test error handling during file deletion.""" + client = GCSStorageClient( + bucket_name="test-bucket", + project_id="test-project", + client_email="test@example.com", + private_key="test-key", + ) + + # Reset the mock to ensure clean state + mock_gcs_client["bucket"].reset_mock() + mock_gcs_client["blob"].reset_mock() + + # Configure delete to throw an exception + mock_gcs_client["blob"].delete.side_effect = ValueError("Delete failed") + + # The method should catch the exception and return False + result = client.sync_delete_file("test/path/file.txt") + + mock_gcs_client["bucket"].blob.assert_called_once_with("test/path/file.txt") + mock_gcs_client["blob"].delete.assert_called_once() + assert result is False + + @pytest.mark.asyncio + async def test_delete_file(self, mock_gcs_client): + """Test the async wrapper for deleting a file.""" + client = GCSStorageClient( + bucket_name="test-bucket", + project_id="test-project", + client_email="test@example.com", + private_key="test-key", + ) + + # Reset the mock to ensure clean state + mock_gcs_client["bucket"].reset_mock() + mock_gcs_client["blob"].reset_mock() + + # Test successful delete + result = await client.delete_file("test/path/file.txt") + + mock_gcs_client["bucket"].blob.assert_called_once_with("test/path/file.txt") + mock_gcs_client["blob"].delete.assert_called_once() + assert result is True diff --git a/backend/tests/data/storage_clients/test_s3.py b/backend/tests/data/storage_clients/test_s3.py new file mode 100644 index 0000000..6dc2385 --- /dev/null +++ b/backend/tests/data/storage_clients/test_s3.py @@ -0,0 +1,47 @@ +import os + +import boto3 # type: ignore +import pytest +from moto import mock_aws + +from chainlit.data.storage_clients.s3 import S3StorageClient + + +# Fixtures for setting up the DynamoDB table +@pytest.fixture +def aws_credentials(): + """Mocked AWS Credentials for moto.""" + os.environ["AWS_ACCESS_KEY_ID"] = "testing" + os.environ["AWS_SECRET_ACCESS_KEY"] = "testing" + os.environ["AWS_SECURITY_TOKEN"] = "testing" + os.environ["AWS_SESSION_TOKEN"] = "testing" + os.environ["AWS_DEFAULT_REGION"] = "us-east-1" + + +@pytest.fixture +def s3_mock(aws_credentials): + """Moto mock S3 setup.""" + with mock_aws(): + s3 = boto3.client("s3", region_name="us-east-1") + # Create a mock bucket + s3.create_bucket(Bucket="my-test-bucket") + yield s3 + + +@pytest.mark.asyncio +async def test_upload_file(s3_mock): + # Initialize the S3StorageClient with the mock bucket + client = S3StorageClient(bucket="my-test-bucket") + + # Call the upload_file method and await the result + result = await client.upload_file( + object_key="test.txt", data="This is a test file", mime="text/plain" + ) + + # Assert that the file upload returned the correct URL + assert result["object_key"] == "test.txt" + assert result["url"] == "https://my-test-bucket.s3.amazonaws.com/test.txt" + + # Verify that the file exists in the mock S3 + response = s3_mock.get_object(Bucket="my-test-bucket", Key="test.txt") + assert response["Body"].read().decode() == "This is a test file" diff --git a/backend/tests/data/test_chainlit_data_layer.py b/backend/tests/data/test_chainlit_data_layer.py new file mode 100644 index 0000000..724334d --- /dev/null +++ b/backend/tests/data/test_chainlit_data_layer.py @@ -0,0 +1,224 @@ +import json +from unittest.mock import AsyncMock + +import pytest + +from chainlit.data.chainlit_data_layer import ChainlitDataLayer + + +@pytest.mark.asyncio +async def test_update_thread_preserves_metadata_when_none_existing_thread(): + """Test that update_thread preserves existing metadata when metadata=None on an existing thread.""" + data_layer = ChainlitDataLayer( + database_url="postgresql://test", storage_client=None, show_logger=False + ) + + existing_metadata = {"important": "data", "keep": "me"} + + async def mock_execute_query(query, params): + if "SELECT" in query and "metadata" in query: + return [{"metadata": json.dumps(existing_metadata)}] + return None + + data_layer.execute_query = AsyncMock(side_effect=mock_execute_query) + + await data_layer.update_thread(thread_id="test-thread-123", name="Updated Name") + + assert data_layer.execute_query.call_count == 2 + + update_call = data_layer.execute_query.call_args_list[1] + update_params = update_call[0][1] + + metadata_json = None + for value in update_params.values(): + if isinstance(value, str) and value.startswith("{"): + try: + metadata_json = json.loads(value) + break + except json.JSONDecodeError: + pass + + assert metadata_json == existing_metadata + + +@pytest.mark.asyncio +async def test_update_thread_noop_skips_upsert_on_existing_thread(): + """Test that update_thread with no arguments early-returns for an existing thread.""" + data_layer = ChainlitDataLayer( + database_url="postgresql://test", storage_client=None, show_logger=False + ) + + async def mock_execute_query(query, params): + if "SELECT" in query and "metadata" in query: + return [{"metadata": json.dumps({"existing": "data"})}] + return None + + data_layer.execute_query = AsyncMock(side_effect=mock_execute_query) + + await data_layer.update_thread(thread_id="test-thread-123") + + assert data_layer.execute_query.call_count == 1 + + +@pytest.mark.asyncio +async def test_update_thread_new_thread_includes_empty_metadata(): + """Test that update_thread includes metadata='{}' for a new thread (NOT NULL safe).""" + data_layer = ChainlitDataLayer( + database_url="postgresql://test", storage_client=None, show_logger=False + ) + + async def mock_execute_query(query, params): + if "SELECT" in query and "metadata" in query: + return [] + return None + + data_layer.execute_query = AsyncMock(side_effect=mock_execute_query) + + await data_layer.update_thread(thread_id="new-thread", name="New Chat") + + assert data_layer.execute_query.call_count == 2 + + update_call = data_layer.execute_query.call_args_list[1] + update_query = update_call[0][0] + update_params = update_call[0][1] + + assert "metadata" in update_query.lower() + + metadata_json = None + for value in update_params.values(): + if isinstance(value, str) and value.startswith("{"): + try: + metadata_json = json.loads(value) + break + except json.JSONDecodeError: + pass + + assert metadata_json == {} + + +@pytest.mark.asyncio +async def test_update_thread_merges_metadata_when_provided(): + """Test that update_thread merges metadata correctly when provided.""" + # Create a mock data layer + data_layer = ChainlitDataLayer( + database_url="postgresql://test", storage_client=None, show_logger=False + ) + + # Mock the execute_query method to return existing metadata + existing_metadata = {"is_shared": True, "custom_field": "original"} + + async def mock_execute_query(query, params): + if "SELECT" in query and "metadata" in query: + # Return existing thread metadata + return [{"metadata": json.dumps(existing_metadata)}] + # For the UPDATE/INSERT, return None + return None + + data_layer.execute_query = AsyncMock(side_effect=mock_execute_query) + + # Call update_thread with partial metadata update + new_metadata = {"custom_field": "updated", "new_field": "added"} + await data_layer.update_thread( + thread_id="test-thread-123", name="Updated Name", metadata=new_metadata + ) + + # Verify execute_query was called twice (once for SELECT, once for UPDATE) + assert data_layer.execute_query.call_count == 2 + + # Get the UPDATE call + update_call = data_layer.execute_query.call_args_list[1] + update_params = update_call[0][1] + + # The metadata should be merged + # Expected: {"is_shared": True, "custom_field": "updated", "new_field": "added"} + # Find the JSON metadata in the params + metadata_json = None + for value in update_params.values(): + if isinstance(value, str) and value.startswith("{"): + try: + metadata_json = json.loads(value) + break + except json.JSONDecodeError: + pass + + assert metadata_json is not None + assert metadata_json.get("is_shared") is True + assert metadata_json.get("custom_field") == "updated" + assert metadata_json.get("new_field") == "added" + + +@pytest.mark.asyncio +async def test_update_thread_deletes_keys_with_none_values(): + """Test that update_thread deletes keys when value is None.""" + # Create a mock data layer + data_layer = ChainlitDataLayer( + database_url="postgresql://test", storage_client=None, show_logger=False + ) + + # Mock the execute_query method to return existing metadata + existing_metadata = { + "is_shared": True, + "to_delete": "will be removed", + "keep": "stays", + } + + async def mock_execute_query(query, params): + if "SELECT" in query and "metadata" in query: + # Return existing thread metadata + return [{"metadata": json.dumps(existing_metadata)}] + # For the UPDATE/INSERT, return None + return None + + data_layer.execute_query = AsyncMock(side_effect=mock_execute_query) + + # Call update_thread with None value to delete a key + new_metadata = {"to_delete": None, "new_field": "added"} + await data_layer.update_thread(thread_id="test-thread-123", metadata=new_metadata) + + # Verify execute_query was called twice + assert data_layer.execute_query.call_count == 2 + + # Get the UPDATE call + update_call = data_layer.execute_query.call_args_list[1] + update_params = update_call[0][1] + + # The metadata should have deleted "to_delete" key and added "new_field" + # Expected: {"is_shared": True, "keep": "stays", "new_field": "added"} + metadata_json = None + for value in update_params.values(): + if isinstance(value, str) and value.startswith("{"): + try: + metadata_json = json.loads(value) + break + except json.JSONDecodeError: + pass + + if metadata_json: + # Verify "to_delete" is not in the merged metadata + assert "to_delete" not in metadata_json + # Verify "new_field" was added + assert metadata_json.get("new_field") == "added" + # Verify "is_shared" and "keep" are preserved + assert metadata_json.get("is_shared") is True + assert metadata_json.get("keep") == "stays" + + +@pytest.mark.asyncio +async def test_create_step_uses_nullif_for_output_and_input(): + """Empty-string output/input should not overwrite existing content. + + Regression test for https://github.com/Chainlit/chainlit/issues/2789 + The SQL uses NULLIF(EXCLUDED.output, '') so that an empty string from the + initial Step.send() is treated as NULL by COALESCE, preventing it from + overwriting non-empty content saved by a subsequent Step.update(). + """ + import inspect + + source = inspect.getsource(ChainlitDataLayer.create_step) + + assert "NULLIF(EXCLUDED.output, '')" in source, ( + "output should use NULLIF to treat empty string as NULL" + ) + assert "NULLIF(EXCLUDED.input, '')" in source, ( + "input should use NULLIF to treat empty string as NULL" + ) diff --git a/backend/tests/data/test_get_data_layer.py b/backend/tests/data/test_get_data_layer.py new file mode 100644 index 0000000..403e86a --- /dev/null +++ b/backend/tests/data/test_get_data_layer.py @@ -0,0 +1,18 @@ +from unittest.mock import AsyncMock, Mock + +from chainlit.data import get_data_layer + + +async def test_get_data_layer( + mock_data_layer: AsyncMock, + mock_get_data_layer: Mock, +): + # Check whether the data layer is properly set + assert mock_data_layer == get_data_layer() + + mock_get_data_layer.assert_called_once() + + # Getting the data layer again, should not result in additional call + assert mock_data_layer == get_data_layer() + + mock_get_data_layer.assert_called_once() diff --git a/backend/tests/data/test_literalai.py b/backend/tests/data/test_literalai.py new file mode 100644 index 0000000..1eeb912 --- /dev/null +++ b/backend/tests/data/test_literalai.py @@ -0,0 +1,1063 @@ +import datetime +import uuid +from unittest.mock import ANY, AsyncMock, Mock, patch + +import pytest +from httpx import HTTPStatusError, RequestError +from literalai import ( + AsyncLiteralClient, + Attachment, + Attachment as LiteralAttachment, + PageInfo, + PaginatedResponse, + Score as LiteralScore, + Step as LiteralStep, + Thread, + Thread as LiteralThread, + User as LiteralUser, + UserDict, +) +from literalai.api import AsyncLiteralAPI +from literalai.observability.step import ( + AttachmentDict as LiteralAttachmentDict, + StepDict as LiteralStepDict, +) +from literalai.observability.thread import ThreadDict as LiteralThreadDict + +from chainlit.data.literalai import LiteralDataLayer, LiteralToChainlitConverter +from chainlit.element import Audio, File, Image, Pdf, Text, Video +from chainlit.step import Step, StepDict +from chainlit.types import ( + Feedback, + Pagination, + ThreadFilter, +) +from chainlit.user import PersistedUser, User + + +@pytest.fixture +async def mock_literal_client(monkeypatch: pytest.MonkeyPatch): + client = Mock(spec=AsyncLiteralClient) + client.api = AsyncMock(spec=AsyncLiteralAPI) + monkeypatch.setattr("literalai.AsyncLiteralClient", client) + return client + + +@pytest.fixture +async def literal_data_layer(mock_literal_client): + data_layer = LiteralDataLayer(api_key="fake_api_key", server="https://fake.server") + data_layer.client = mock_literal_client + return data_layer + + +@pytest.fixture +def test_thread(): + return LiteralThread.from_dict( + { + "id": "test_thread_id", + "name": "Test Thread", + "createdAt": "2023-01-01T00:00:00Z", + "metadata": {}, + "participant": {}, + "steps": [], + "tags": [], + } + ) + + +@pytest.fixture +def test_step_dict(test_thread) -> StepDict: + return { + "createdAt": "2023-01-01T00:00:00Z", + "start": "2023-01-01T00:00:00Z", + "end": "2023-01-01T00:00:00Z", + "generation": {}, + "id": "test_step_id", + "name": "Test Step", + "threadId": test_thread.id, + "type": "user_message", + "tags": [], + "metadata": {"key": "value"}, + "input": "test input", + "output": "test output", + "waitForAnswer": True, + "showInput": True, + "language": "en", + } + + +@pytest.fixture +def test_step(test_thread: LiteralThread): + return LiteralStep.from_dict( + { + "id": str(uuid.uuid4()), + "name": "Test Step", + "type": "user_message", + "environment": None, + "threadId": test_thread.id, + "error": None, + "input": {}, + "output": {}, + "metadata": {}, + "tags": [], + "parentId": None, + "createdAt": "2023-01-01T00:00:00Z", + "startTime": "2023-01-01T00:00:00Z", + "endTime": "2023-01-01T00:00:00Z", + "generation": {}, + "scores": [], + "attachments": [], + "rootRunId": None, + } + ) + + +@pytest.fixture +def literal_test_user(test_user: User): + return LiteralUser( + id=str(uuid.uuid4()), + created_at=datetime.datetime.now().isoformat(), + identifier=test_user.identifier, + metadata=test_user.metadata, + ) + + +@pytest.fixture +def test_filters() -> ThreadFilter: + return ThreadFilter(feedback=1, userId="user1", search="test") + + +@pytest.fixture +def test_pagination() -> Pagination: + return Pagination(first=10, cursor=None) + + +@pytest.fixture +def test_attachment( + test_thread: LiteralThread, test_step: LiteralStep +) -> LiteralAttachment: + return Attachment( + id="test_attachment_id", + step_id=test_step.id, + thread_id=test_thread.id, + metadata={ + "display": "side", + "language": "python", + "type": "file", + }, + mime="text/plain", + name="test_file.txt", + object_key="test_object_key", + url="https://example.com/test_file.txt", + ) + + +async def test_create_step( + literal_data_layer: LiteralDataLayer, + mock_literal_client: Mock, + test_step_dict: StepDict, + mock_chainlit_context, +): + async with mock_chainlit_context: + await literal_data_layer.create_step(test_step_dict) + + mock_literal_client.api.send_steps.assert_awaited_once_with( + [ + { + "createdAt": "2023-01-01T00:00:00Z", + "startTime": "2023-01-01T00:00:00Z", + "endTime": "2023-01-01T00:00:00Z", + "generation": {}, + "id": "test_step_id", + "parentId": None, + "name": "Test Step", + "threadId": "test_thread_id", + "type": "user_message", + "tags": [], + "metadata": { + "key": "value", + "waitForAnswer": True, + "language": "en", + "showInput": True, + }, + "input": {"content": "test input"}, + "output": {"content": "test output"}, + } + ] + ) + + +async def test_safely_send_steps_success( + literal_data_layer: LiteralDataLayer, + mock_literal_client: Mock, + mock_chainlit_context, +): + test_steps = [{"id": "test_step_id", "name": "Test Step"}] + + async with mock_chainlit_context: + await literal_data_layer.safely_send_steps(test_steps) + + mock_literal_client.api.send_steps.assert_awaited_once_with(test_steps) + + +async def test_safely_send_steps_http_status_error( + literal_data_layer: LiteralDataLayer, + mock_literal_client: Mock, + mock_chainlit_context, + caplog, +): + test_steps = [{"id": "test_step_id", "name": "Test Step"}] + mock_literal_client.api.send_steps.side_effect = HTTPStatusError( + "HTTP Error", request=Mock(), response=Mock(status_code=500) + ) + + async with mock_chainlit_context: + await literal_data_layer.safely_send_steps(test_steps) + + mock_literal_client.api.send_steps.assert_awaited_once_with(test_steps) + assert "HTTP Request: error sending steps: 500" in caplog.text + + +async def test_safely_send_steps_request_error( + literal_data_layer: LiteralDataLayer, + mock_literal_client: Mock, + mock_chainlit_context, + caplog, +): + test_steps = [{"id": "test_step_id", "name": "Test Step"}] + mock_request = Mock() + mock_request.url = "https://example.com/api" + mock_literal_client.api.send_steps.side_effect = RequestError( + "Request Error", request=mock_request + ) + + async with mock_chainlit_context: + await literal_data_layer.safely_send_steps(test_steps) + + mock_literal_client.api.send_steps.assert_awaited_once_with(test_steps) + assert "HTTP Request: error for 'https://example.com/api'." in caplog.text + + +async def test_get_user( + literal_data_layer: LiteralDataLayer, + mock_literal_client: Mock, + literal_test_user: LiteralUser, + persisted_test_user: PersistedUser, +): + mock_literal_client.api.get_user.return_value = literal_test_user + + user = await literal_data_layer.get_user("test_user_id") + + assert user is not None + assert user.id == literal_test_user.id + assert user.identifier == literal_test_user.identifier + + mock_literal_client.api.get_user.assert_awaited_once_with(identifier="test_user_id") + + +async def test_get_user_not_found( + literal_data_layer: LiteralDataLayer, mock_literal_client: Mock +): + mock_literal_client.api.get_user.return_value = None + + user = await literal_data_layer.get_user("non_existent_user_id") + + assert user is None + mock_literal_client.api.get_user.assert_awaited_once_with( + identifier="non_existent_user_id" + ) + + +async def test_create_user_not_existing( + literal_data_layer: LiteralDataLayer, + mock_literal_client: Mock, + test_user: User, + literal_test_user: LiteralUser, +): + mock_literal_client.api.get_user.return_value = None + mock_literal_client.api.create_user.return_value = literal_test_user + + persisted_user = await literal_data_layer.create_user(test_user) + + mock_literal_client.api.create_user.assert_awaited_once_with( + identifier=test_user.identifier, metadata=test_user.metadata + ) + + assert persisted_user is not None + assert isinstance(persisted_user, PersistedUser) + assert persisted_user.id == literal_test_user.id + assert persisted_user.identifier == literal_test_user.identifier + + +async def test_create_user_update_existing( + literal_data_layer: LiteralDataLayer, + mock_literal_client: Mock, + test_user: User, + literal_test_user: LiteralUser, + persisted_test_user: PersistedUser, +): + mock_literal_client.api.get_user.return_value = literal_test_user + + persisted_user = await literal_data_layer.create_user(test_user) + + mock_literal_client.api.create_user.assert_not_called() + mock_literal_client.api.update_user.assert_awaited_once_with( + id=literal_test_user.id, metadata=test_user.metadata + ) + + assert persisted_user is not None + assert isinstance(persisted_user, PersistedUser) + assert persisted_user.id == literal_test_user.id + assert persisted_user.identifier == literal_test_user.identifier + + +async def test_create_user_id_none( + literal_data_layer: LiteralDataLayer, + mock_literal_client: Mock, + test_user: User, + literal_test_user: LiteralUser, +): + """Weird edge case; persisted user without an id. Do we need this!??""" + + literal_test_user.id = None + mock_literal_client.api.get_user.return_value = literal_test_user + + persisted_user = await literal_data_layer.create_user(test_user) + + mock_literal_client.api.create_user.assert_not_called() + mock_literal_client.api.update_user.assert_not_called() + + assert persisted_user is not None + assert isinstance(persisted_user, PersistedUser) + assert persisted_user.id == "" + assert persisted_user.identifier == literal_test_user.identifier + + +async def test_update_thread( + literal_data_layer: LiteralDataLayer, + mock_literal_client: Mock, + test_thread: LiteralThread, +): + await literal_data_layer.update_thread(test_thread.id, name=test_thread.name) + + mock_literal_client.api.upsert_thread.assert_awaited_once_with( + id=test_thread.id, + name=test_thread.name, + participant_id=None, + metadata=None, + tags=None, + ) + + +async def test_get_thread_author( + literal_data_layer, mock_literal_client: Mock, test_thread: LiteralThread +): + test_thread.participant_identifier = "test_user_identifier" + mock_literal_client.api.get_thread.return_value = test_thread + + author = await literal_data_layer.get_thread_author(test_thread.id) + + assert author == "test_user_identifier" + mock_literal_client.api.get_thread.assert_awaited_once_with(id=test_thread.id) + + +async def test_get_thread( + literal_data_layer: LiteralDataLayer, + mock_literal_client: Mock, + test_thread: LiteralThread, + test_step: LiteralStep, +): + assert isinstance(test_thread.steps, list) + test_thread.steps.append(test_step) + + mock_literal_client.api.get_thread.return_value = test_thread + + thread = await literal_data_layer.get_thread(test_thread.id) + mock_literal_client.api.get_thread.assert_awaited_once_with(id=test_thread.id) + + assert thread is not None + assert thread["id"] == test_thread.id + assert thread["name"] == test_thread.name + assert len(thread["steps"]) == 1 + assert thread["steps"][0].get("id") == test_step.id + + +async def test_get_thread_with_stub_step( + literal_data_layer: LiteralDataLayer, + mock_literal_client: Mock, + test_thread: LiteralThread, +): + # Create a step that should be stubbed + stub_step = LiteralStep.from_dict( + { + "id": "stub_step_id", + "name": "Stub Step", + "type": "undefined", + "threadId": test_thread.id, + "createdAt": "2023-01-01T00:00:00Z", + } + ) + test_thread.steps = [stub_step] + + mock_literal_client.api.get_thread.return_value = test_thread + + # Mock the config.ui.cot value to ensure check_add_step_in_cot returns False + with patch("chainlit.config.config.ui.cot", "hidden"): + thread = await literal_data_layer.get_thread(test_thread.id) + + mock_literal_client.api.get_thread.assert_awaited_once_with(id=test_thread.id) + + assert thread is not None + assert thread["id"] == test_thread.id + assert thread["name"] == test_thread.name + assert len(thread["steps"]) == 1 + assert thread["steps"][0].get("id") == "stub_step_id" + assert thread["steps"][0].get("type") == "undefined" + assert thread["steps"][0].get("input") == "" + assert thread["steps"][0].get("output") == "" + + # Additional assertions to ensure the step is stubbed + assert "metadata" not in thread["steps"][0] + assert "createdAt" not in thread["steps"][0] + + +async def test_get_thread_with_attachment( + literal_data_layer: LiteralDataLayer, + mock_literal_client: Mock, + test_thread: LiteralThread, + test_step: LiteralStep, + test_attachment: LiteralAttachment, +): + # Add the attachment to the test step + test_step.attachments = [test_attachment] + test_thread.steps = [test_step] + + mock_literal_client.api.get_thread.return_value = test_thread + + thread = await literal_data_layer.get_thread(test_thread.id) + mock_literal_client.api.get_thread.assert_awaited_once_with(id=test_thread.id) + + assert thread is not None + assert thread["id"] == test_thread.id + assert thread["name"] == test_thread.name + assert thread["steps"] is not None + assert len(thread["steps"]) == 1 + assert thread["elements"] is not None + assert len(thread["elements"]) == 1 + + element = thread["elements"][0] if thread["elements"] else None + assert element is not None + assert element["id"] == "test_attachment_id" + assert element["forId"] == test_step.id + assert element["threadId"] == test_thread.id + assert element["type"] == "file" + assert element["display"] == "side" + assert element["language"] == "python" + assert element["mime"] == "text/plain" + assert element["name"] == "test_file.txt" + assert element["objectKey"] == "test_object_key" + assert element["url"] == "https://example.com/test_file.txt" + + +async def test_get_thread_non_existing( + literal_data_layer: LiteralDataLayer, mock_literal_client: Mock +): + mock_literal_client.api.get_thread.return_value = None + + thread = await literal_data_layer.get_thread("non_existent_thread_id") + mock_literal_client.api.get_thread.assert_awaited_once_with( + id="non_existent_thread_id" + ) + + assert thread is None + + +async def test_delete_thread( + literal_data_layer: LiteralDataLayer, + mock_literal_client: Mock, + test_thread: LiteralThread, +): + await literal_data_layer.delete_thread(test_thread.id) + + mock_literal_client.api.delete_thread.assert_awaited_once_with(id=test_thread.id) + + +async def test_list_threads( + literal_data_layer: LiteralDataLayer, + mock_literal_client: Mock, + test_filters: ThreadFilter, + test_pagination: Pagination, +): + response: PaginatedResponse[Thread] = PaginatedResponse( + page_info=PageInfo( + has_next_page=True, start_cursor="start_cursor", end_cursor="end_cursor" + ), + data=[ + Thread( + id="thread1", + name="Thread 1", + ), + Thread( + id="thread2", + name="Thread 2", + ), + ], + ) + + mock_literal_client.api.list_threads.return_value = response + + result = await literal_data_layer.list_threads(test_pagination, test_filters) + + mock_literal_client.api.list_threads.assert_awaited_once_with( + first=10, + after=None, + filters=[ + {"field": "participantId", "operator": "eq", "value": "user1"}, + { + "field": "stepOutput", + "operator": "ilike", + "value": "test", + "path": "content", + }, + { + "field": "scoreValue", + "operator": "eq", + "value": 1, + "path": "user-feedback", + }, + ], + order_by={"column": "createdAt", "direction": "DESC"}, + ) + + assert result.pageInfo.hasNextPage + assert result.pageInfo.startCursor == "start_cursor" + assert result.pageInfo.endCursor == "end_cursor" + assert len(result.data) == 2 + assert result.data[0]["id"] == "thread1" + assert result.data[1]["id"] == "thread2" + + +async def test_create_element( + literal_data_layer: LiteralDataLayer, + mock_literal_client: Mock, + mock_chainlit_context, +): + mock_literal_client.api.upload_file.return_value = {"object_key": "test_object_key"} + + async with mock_chainlit_context: + text_element = Text( + id=str(uuid.uuid4()), + name="test.txt", + mime="text/plain", + content="test content", + for_id="test_step_id", + ) + + await literal_data_layer.create_element(text_element) + + mock_literal_client.api.upload_file.assert_awaited_once_with( + content=text_element.content, + mime=text_element.mime, + thread_id=text_element.thread_id, + ) + + mock_literal_client.api.send_steps.assert_awaited_once_with( + [ + { + "id": text_element.for_id, + "threadId": text_element.thread_id, + "attachments": [ + { + "id": ANY, + "name": text_element.name, + "metadata": { + "size": None, + "language": None, + "display": text_element.display, + "type": text_element.type, + "page": None, + "props": None, + }, + "mime": text_element.mime, + "url": None, + "objectKey": "test_object_key", + } + ], + } + ] + ) + + +async def test_get_element( + literal_data_layer: LiteralDataLayer, + mock_literal_client: Mock, + test_attachment: LiteralAttachment, +): + mock_literal_client.api.get_attachment.return_value = test_attachment + + element_dict = await literal_data_layer.get_element( + "test_thread_id", "test_element_id" + ) + + mock_literal_client.api.get_attachment.assert_awaited_once_with( + id="test_element_id" + ) + + assert element_dict is not None + + # Compare element_dict attributes to attachment attributes + assert element_dict["id"] == test_attachment.id + assert element_dict["forId"] == test_attachment.step_id + assert element_dict["threadId"] == test_attachment.thread_id + assert element_dict["name"] == test_attachment.name + assert element_dict["mime"] == test_attachment.mime + assert element_dict["url"] == test_attachment.url + assert element_dict["objectKey"] == test_attachment.object_key + assert test_attachment.metadata + assert element_dict["display"] == test_attachment.metadata["display"] + assert element_dict["language"] == test_attachment.metadata["language"] + assert element_dict["type"] == test_attachment.metadata["type"] + + +async def test_upsert_feedback_create( + literal_data_layer: LiteralDataLayer, + mock_literal_client: Mock, +): + feedback = Feedback(forId="test_step_id", value=1, comment="Great!") + mock_literal_client.api.create_score.return_value = Mock(id="new_feedback_id") + + result = await literal_data_layer.upsert_feedback(feedback) + + mock_literal_client.api.create_score.assert_awaited_once_with( + step_id="test_step_id", + value=1, + comment="Great!", + name="user-feedback", + type="HUMAN", + ) + assert result == "new_feedback_id" + + +async def test_upsert_feedback_update( + literal_data_layer: LiteralDataLayer, + mock_literal_client: Mock, +): + feedback = Feedback( + id="existing_feedback_id", + forId="test_step_id", + value=0, + comment="Needs improvement", + ) + + result = await literal_data_layer.upsert_feedback(feedback) + + mock_literal_client.api.update_score.assert_awaited_once_with( + id="existing_feedback_id", + update_params={ + "comment": "Needs improvement", + "value": 0, + }, + ) + assert result == "existing_feedback_id" + + +async def test_delete_feedback( + literal_data_layer: LiteralDataLayer, + mock_literal_client: Mock, +): + feedback_id = "test_feedback_id" + + result = await literal_data_layer.delete_feedback(feedback_id) + + mock_literal_client.api.delete_score.assert_awaited_once_with(id=feedback_id) + assert result is True + + +async def test_delete_feedback_empty_id( + literal_data_layer: LiteralDataLayer, + mock_literal_client: Mock, +): + feedback_id = "" + + result = await literal_data_layer.delete_feedback(feedback_id) + + mock_literal_client.api.delete_score.assert_not_awaited() + assert result is False + + +async def test_build_debug_url( + literal_data_layer: LiteralDataLayer, + mock_literal_client: Mock, +): + mock_literal_client.api.get_my_project_id.return_value = "test_project_id" + mock_literal_client.api.url = "https://api.example.com" + + result = await literal_data_layer.build_debug_url() + + mock_literal_client.api.get_my_project_id.assert_awaited_once() + assert ( + result + == "https://api.example.com/projects/test_project_id/logs/threads/[thread_id]?currentStepId=[step_id]" + ) + + +async def test_build_debug_url_error( + literal_data_layer: LiteralDataLayer, + mock_literal_client: Mock, + caplog, +): + mock_literal_client.api.get_my_project_id.side_effect = Exception("API Error") + + result = await literal_data_layer.build_debug_url() + + mock_literal_client.api.get_my_project_id.assert_awaited_once() + assert result == "" + assert "Error building debug url: API Error" in caplog.text + + +async def test_delete_element( + literal_data_layer: LiteralDataLayer, + mock_literal_client: Mock, + mock_chainlit_context, +): + element_id = "test_element_id" + + async with mock_chainlit_context: + await literal_data_layer.delete_element(element_id) + + mock_literal_client.api.delete_attachment.assert_awaited_once_with(id=element_id) + + +async def test_delete_step( + literal_data_layer: LiteralDataLayer, + mock_literal_client: Mock, + mock_chainlit_context, +): + step_id = "test_step_id" + + async with mock_chainlit_context: + await literal_data_layer.delete_step(step_id) + + mock_literal_client.api.delete_step.assert_awaited_once_with(id=step_id) + + +async def test_update_step( + literal_data_layer: LiteralDataLayer, + mock_literal_client: Mock, + mock_chainlit_context, + test_step_dict: StepDict, +): + async with mock_chainlit_context: + await literal_data_layer.update_step(test_step_dict) + + mock_literal_client.api.send_steps.assert_awaited_once_with( + [ + { + "createdAt": "2023-01-01T00:00:00Z", + "startTime": "2023-01-01T00:00:00Z", + "endTime": "2023-01-01T00:00:00Z", + "generation": {}, + "id": "test_step_id", + "parentId": None, + "name": "Test Step", + "threadId": "test_thread_id", + "type": "user_message", + "tags": [], + "metadata": { + "key": "value", + "waitForAnswer": True, + "language": "en", + "showInput": True, + }, + "input": {"content": "test input"}, + "output": {"content": "test output"}, + } + ] + ) + + +def test_steptype_to_steptype(): + assert ( + LiteralToChainlitConverter.steptype_to_steptype("user_message") + == "user_message" + ) + assert ( + LiteralToChainlitConverter.steptype_to_steptype("assistant_message") + == "assistant_message" + ) + assert ( + LiteralToChainlitConverter.steptype_to_steptype("system_message") + == "system_message" + ) + assert LiteralToChainlitConverter.steptype_to_steptype("tool") == "tool" + assert LiteralToChainlitConverter.steptype_to_steptype(None) == "undefined" + + +def test_score_to_feedbackdict(): + score = LiteralScore( + id="test_score_id", + step_id="test_step_id", + value=1, + comment="Great job!", + name="user-feedback", + type="HUMAN", + dataset_experiment_item_id=None, + tags=None, + ) + feedback_dict = LiteralToChainlitConverter.score_to_feedbackdict(score) + assert feedback_dict == { + "id": "test_score_id", + "forId": "test_step_id", + "value": 1, + "comment": "Great job!", + } + + assert LiteralToChainlitConverter.score_to_feedbackdict(None) is None + + score.value = 0 + feedback_dict = LiteralToChainlitConverter.score_to_feedbackdict(score) + assert feedback_dict is not None + assert feedback_dict["value"] == 0 + + score.id = None + score.step_id = None + feedback_dict = LiteralToChainlitConverter.score_to_feedbackdict(score) + assert feedback_dict is not None + assert feedback_dict["id"] == "" + assert feedback_dict["forId"] == "" + + +def test_step_to_stepdict(): + literal_step = LiteralStep.from_dict( + { + "id": "test_step_id", + "threadId": "test_thread_id", + "type": "user_message", + "name": "Test Step", + "input": {"content": "test input"}, + "output": {"content": "test output"}, + "startTime": "2023-01-01T00:00:00Z", + "endTime": "2023-01-01T00:00:01Z", + "createdAt": "2023-01-01T00:00:00Z", + "metadata": {"showInput": True, "language": "en"}, + "error": None, + "scores": [ + { + "id": "test_score_id", + "stepId": "test_step_id", + "value": 1, + "comment": "Great job!", + "name": "user-feedback", + "type": "HUMAN", + } + ], + } + ) + + step_dict = LiteralToChainlitConverter.step_to_stepdict(literal_step) + + assert step_dict.get("id") == "test_step_id" + assert step_dict.get("threadId") == "test_thread_id" + assert step_dict.get("type") == "user_message" + assert step_dict.get("name") == "Test Step" + assert step_dict.get("input") == "test input" + assert step_dict.get("output") == "test output" + assert step_dict.get("start") == "2023-01-01T00:00:00Z" + assert step_dict.get("end") == "2023-01-01T00:00:01Z" + assert step_dict.get("createdAt") == "2023-01-01T00:00:00Z" + assert step_dict.get("showInput") == True + assert step_dict.get("language") == "en" + assert step_dict.get("isError") == False + assert step_dict.get("feedback") == { + "id": "test_score_id", + "forId": "test_step_id", + "value": 1, + "comment": "Great job!", + } + + +def test_attachment_to_elementdict(): + attachment = Attachment( + id="test_attachment_id", + step_id="test_step_id", + thread_id="test_thread_id", + name="test.txt", + mime="text/plain", + url="https://example.com/test.txt", + object_key="test_object_key", + metadata={ + "display": "side", + "language": "python", + "type": "file", + "size": "large", + }, + ) + + element_dict = LiteralToChainlitConverter.attachment_to_elementdict(attachment) + + assert element_dict["id"] == "test_attachment_id" + assert element_dict["forId"] == "test_step_id" + assert element_dict["threadId"] == "test_thread_id" + assert element_dict["name"] == "test.txt" + assert element_dict["mime"] == "text/plain" + assert element_dict["url"] == "https://example.com/test.txt" + assert element_dict["objectKey"] == "test_object_key" + assert element_dict["display"] == "side" + assert element_dict["language"] == "python" + assert element_dict["type"] == "file" + assert element_dict["size"] == "large" + + +def test_attachment_to_element(): + attachment = Attachment( + id="test_attachment_id", + step_id="test_step_id", + thread_id="test_thread_id", + name="test.txt", + mime="text/plain", + url="https://example.com/test.txt", + object_key="test_object_key", + metadata={ + "display": "side", + "language": "python", + "type": "text", + "size": "small", + }, + ) + + element = LiteralToChainlitConverter.attachment_to_element(attachment) + + assert isinstance(element, Text) + assert element.id == "test_attachment_id" + assert element.for_id == "test_step_id" + assert element.thread_id == "test_thread_id" + assert element.name == "test.txt" + assert element.mime == "text/plain" + assert element.url == "https://example.com/test.txt" + assert element.object_key == "test_object_key" + assert element.display == "side" + assert element.language == "python" + assert element.size == "small" + + # Test other element types + for element_type in ["file", "image", "audio", "video", "pdf"]: + attachment.metadata = {"type": element_type, "size": "small"} + + element = LiteralToChainlitConverter.attachment_to_element(attachment) + assert isinstance( + element, + { + "file": File, + "image": Image, + "audio": Audio, + "video": Video, + "text": Text, + "pdf": Pdf, + }[element_type], + ) + + +def test_step_to_step(): + literal_step = LiteralStep.from_dict( + { + "id": "test_step_id", + "threadId": "test_thread_id", + "type": "user_message", + "name": "Test Step", + "input": {"content": "test input"}, + "output": {"content": "test output"}, + "startTime": "2023-01-01T00:00:00Z", + "endTime": "2023-01-01T00:00:01Z", + "createdAt": "2023-01-01T00:00:00Z", + "metadata": {"showInput": True, "language": "en"}, + "error": None, + "attachments": [ + { + "id": "test_attachment_id", + "name": "test.txt", + "mime": "text/plain", + "url": "https://example.com/test.txt", + "objectKey": "test_object_key", + "metadata": { + "display": "side", + "language": "python", + "type": "text", + }, + } + ], + } + ) + + chainlit_step = LiteralToChainlitConverter.step_to_step(literal_step) + + assert isinstance(chainlit_step, Step) + assert chainlit_step.id == "test_step_id" + assert chainlit_step.thread_id == "test_thread_id" + assert chainlit_step.type == "user_message" + assert chainlit_step.name == "Test Step" + assert chainlit_step.input == "test input" + assert chainlit_step.output == "test output" + assert chainlit_step.start == "2023-01-01T00:00:00Z" + assert chainlit_step.end == "2023-01-01T00:00:01Z" + assert chainlit_step.created_at == "2023-01-01T00:00:00Z" + assert chainlit_step.metadata == {"showInput": True, "language": "en"} + assert not chainlit_step.is_error + assert chainlit_step.elements is not None + assert len(chainlit_step.elements) == 1 + assert isinstance(chainlit_step.elements[0], Text) + + +def test_thread_to_threaddict(): + attachment_dict = LiteralAttachmentDict( + id="test_attachment_id", + stepId="test_step_id", + threadId="test_thread_id", + name="test.txt", + mime="text/plain", + url="https://example.com/test.txt", + objectKey="test_object_key", + metadata={ + "display": "side", + "language": "python", + "type": "text", + }, + ) + step_dict = LiteralStepDict( + id="test_step_id", + threadId="test_thread_id", + type="user_message", + name="Test Step", + input={"content": "test input"}, + output={"content": "test output"}, + startTime="2023-01-01T00:00:00Z", + endTime="2023-01-01T00:00:01Z", + createdAt="2023-01-01T00:00:00Z", + metadata={"showInput": True, "language": "en"}, + error=None, + attachments=[attachment_dict], + ) + literal_thread = LiteralThread.from_dict( + LiteralThreadDict( + id="test_thread_id", + name="Test Thread", + createdAt="2023-01-01T00:00:00Z", + participant=UserDict(id="test_user_id", identifier="test_user_identifier_"), + tags=["tag1", "tag2"], + metadata={"key": "value"}, + steps=[step_dict], + ) + ) + + thread_dict = LiteralToChainlitConverter.thread_to_threaddict(literal_thread) + + assert thread_dict["id"] == "test_thread_id" + assert thread_dict["name"] == "Test Thread" + assert thread_dict["createdAt"] == "2023-01-01T00:00:00Z" + assert thread_dict["userId"] == "test_user_id" + assert thread_dict["userIdentifier"] == "test_user_identifier_" + assert thread_dict["tags"] == ["tag1", "tag2"] + assert thread_dict["metadata"] == {"key": "value"} + assert thread_dict["steps"] is not None + assert len(thread_dict["steps"]) == 1 + assert thread_dict["elements"] is not None + assert len(thread_dict["elements"]) == 1 diff --git a/backend/tests/data/test_sql_alchemy.py b/backend/tests/data/test_sql_alchemy.py new file mode 100644 index 0000000..c509c6a --- /dev/null +++ b/backend/tests/data/test_sql_alchemy.py @@ -0,0 +1,327 @@ +import json +import uuid +from pathlib import Path + +import pytest +from sqlalchemy import text +from sqlalchemy.ext.asyncio import create_async_engine + +from chainlit import User +from chainlit.data.sql_alchemy import SQLAlchemyDataLayer +from chainlit.data.storage_clients.base import BaseStorageClient +from chainlit.element import Text + + +@pytest.fixture +async def data_layer(mock_storage_client: BaseStorageClient, tmp_path: Path): + db_file = tmp_path / "test_db.sqlite" + conninfo = f"sqlite+aiosqlite:///{db_file}" + + # Create async engine + engine = create_async_engine(conninfo) + + # Execute initialization statements + # Ref: https://docs.chainlit.io/data-persistence/custom#sql-alchemy-data-layer + async with engine.begin() as conn: + await conn.execute( + text( + """ + CREATE TABLE users ( + "id" UUID PRIMARY KEY, + "identifier" TEXT NOT NULL UNIQUE, + "metadata" JSONB NOT NULL, + "createdAt" TEXT + ); + """ + ) + ) + + await conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS threads ( + "id" UUID PRIMARY KEY, + "createdAt" TEXT, + "name" TEXT, + "userId" UUID, + "userIdentifier" TEXT, + "tags" TEXT[], + "metadata" JSONB NOT NULL DEFAULT '{}', + FOREIGN KEY ("userId") REFERENCES users("id") ON DELETE CASCADE + ); + """ + ) + ) + + await conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS steps ( + "id" UUID PRIMARY KEY, + "name" TEXT NOT NULL, + "type" TEXT NOT NULL, + "threadId" UUID NOT NULL, + "parentId" UUID, + "disableFeedback" BOOLEAN NOT NULL, + "streaming" BOOLEAN NOT NULL, + "waitForAnswer" BOOLEAN, + "isError" BOOLEAN, + "metadata" JSONB, + "tags" TEXT[], + "input" TEXT, + "output" TEXT, + "createdAt" TEXT, + "start" TEXT, + "end" TEXT, + "generation" JSONB, + "showInput" TEXT, + "language" TEXT, + "indent" INT + ); + """ + ) + ) + + await conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS elements ( + "id" UUID PRIMARY KEY, + "threadId" UUID, + "type" TEXT, + "url" TEXT, + "chainlitKey" TEXT, + "name" TEXT NOT NULL, + "display" TEXT, + "objectKey" TEXT, + "size" TEXT, + "page" INT, + "language" TEXT, + "forId" UUID, + "mime" TEXT + ); + """ + ) + ) + + await conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS feedbacks ( + "id" UUID PRIMARY KEY, + "forId" UUID NOT NULL, + "threadId" UUID NOT NULL, + "value" INT NOT NULL, + "comment" TEXT + ); + """ + ) + ) + + # Create SQLAlchemyDataLayer instance + data_layer = SQLAlchemyDataLayer(conninfo, storage_provider=mock_storage_client) + + return data_layer + + +async def test_create_and_get_element( + mock_chainlit_context, data_layer: SQLAlchemyDataLayer +): + async with mock_chainlit_context: + text_element = Text( + id=str(uuid.uuid4()), + name="test.txt", + mime="text/plain", + content="test content", + for_id="test_step_id", + ) + + # Needs context because of wrapper in utils.py + await data_layer.create_element(text_element) + + retrieved_element = await data_layer.get_element( + text_element.thread_id, text_element.id + ) + assert retrieved_element is not None + assert retrieved_element["id"] == text_element.id + assert retrieved_element["name"] == text_element.name + assert retrieved_element["mime"] == text_element.mime + # The 'content' field is not part of the ElementDict, so we remove this assertion + + +async def test_get_current_timestamp(data_layer: SQLAlchemyDataLayer): + timestamp = await data_layer.get_current_timestamp() + assert isinstance(timestamp, str) + + +async def test_get_user(test_user: User, data_layer: SQLAlchemyDataLayer): + persisted_user = await data_layer.create_user(test_user) + assert persisted_user + + fetched_user = await data_layer.get_user(persisted_user.identifier) + + assert fetched_user + assert fetched_user.createdAt == persisted_user.createdAt + assert fetched_user.id == persisted_user.id + + nonexistent_user = await data_layer.get_user("nonexistent") + assert nonexistent_user is None + + +async def test_create_user(test_user: User, data_layer: SQLAlchemyDataLayer): + persisted_user = await data_layer.create_user(test_user) + + assert persisted_user + assert persisted_user.identifier == test_user.identifier + assert persisted_user.createdAt + assert persisted_user.id + + # Assert id is valid uuid + assert uuid.UUID(persisted_user.id) + + +async def test_update_thread(test_user: User, data_layer: SQLAlchemyDataLayer): + persisted_user = await data_layer.create_user(test_user) + assert persisted_user + + await data_layer.update_thread("test_thread") + + +async def test_get_thread_author(test_user: User, data_layer: SQLAlchemyDataLayer): + persisted_user = await data_layer.create_user(test_user) + assert persisted_user + + await data_layer.update_thread("test_thread", user_id=persisted_user.id) + author = await data_layer.get_thread_author("test_thread") + + assert author == persisted_user.identifier + + +async def test_get_thread(test_user: User, data_layer: SQLAlchemyDataLayer): + persisted_user = await data_layer.create_user(test_user) + assert persisted_user + + await data_layer.update_thread("test_thread") + result = await data_layer.get_thread("test_thread") + assert result is not None + + result = await data_layer.get_thread("nonexisting_thread") + assert result is None + + +async def test_delete_thread(test_user: User, data_layer: SQLAlchemyDataLayer): + persisted_user = await data_layer.create_user(test_user) + assert persisted_user + + await data_layer.update_thread("test_thread", "test_user") + await data_layer.delete_thread("test_thread") + thread = await data_layer.get_thread("test_thread") + assert thread is None + + +async def _get_thread_metadata_raw( + data_layer: SQLAlchemyDataLayer, thread_id: str +) -> str | None: + result = await data_layer.execute_sql( + query='SELECT "metadata" FROM threads WHERE "id" = :id', + parameters={"id": thread_id}, + ) + if isinstance(result, list) and result: + return result[0].get("metadata") + return None + + +async def test_update_thread_without_metadata_stores_empty_dict( + data_layer: SQLAlchemyDataLayer, +): + await data_layer.update_thread("thread_no_meta") + + raw = await _get_thread_metadata_raw(data_layer, "thread_no_meta") + assert raw is not None + assert json.loads(raw) == {} + + +async def test_update_thread_with_explicit_metadata( + data_layer: SQLAlchemyDataLayer, +): + await data_layer.update_thread( + "thread_meta", metadata={"key": "value", "count": 42} + ) + + raw = await _get_thread_metadata_raw(data_layer, "thread_meta") + assert raw is not None + assert json.loads(raw) == {"key": "value", "count": 42} + + +async def test_update_thread_with_empty_metadata( + data_layer: SQLAlchemyDataLayer, +): + await data_layer.update_thread("thread_empty_meta", metadata={}) + + raw = await _get_thread_metadata_raw(data_layer, "thread_empty_meta") + assert raw is not None + assert json.loads(raw) == {} + + +async def test_update_thread_preserves_metadata_on_noop_call( + data_layer: SQLAlchemyDataLayer, +): + await data_layer.update_thread("thread_preserve", metadata={"important": "data"}) + + await data_layer.update_thread("thread_preserve") + + raw = await _get_thread_metadata_raw(data_layer, "thread_preserve") + assert raw is not None + assert json.loads(raw) == {"important": "data"} + + +async def test_update_thread_merges_metadata( + data_layer: SQLAlchemyDataLayer, +): + await data_layer.update_thread( + "thread_merge", metadata={"existing": "old", "keep": "me"} + ) + + await data_layer.update_thread( + "thread_merge", metadata={"existing": "new", "added": "field"} + ) + + raw = await _get_thread_metadata_raw(data_layer, "thread_merge") + assert raw is not None + merged = json.loads(raw) + assert merged == {"existing": "new", "keep": "me", "added": "field"} + + +async def test_update_thread_deletes_metadata_keys_via_none( + data_layer: SQLAlchemyDataLayer, +): + await data_layer.update_thread( + "thread_delete_key", metadata={"a": 1, "b": 2, "c": 3} + ) + + await data_layer.update_thread("thread_delete_key", metadata={"b": None}) + + raw = await _get_thread_metadata_raw(data_layer, "thread_delete_key") + assert raw is not None + result = json.loads(raw) + assert result == {"a": 1, "c": 3} + + +async def test_update_thread_name_update_preserves_metadata( + data_layer: SQLAlchemyDataLayer, +): + await data_layer.update_thread("thread_name_meta", metadata={"saved": True}) + + await data_layer.update_thread("thread_name_meta", name="New Name") + + raw = await _get_thread_metadata_raw(data_layer, "thread_name_meta") + assert raw is not None + assert json.loads(raw) == {"saved": True} + + result = await data_layer.execute_sql( + query='SELECT "name" FROM threads WHERE "id" = :id', + parameters={"id": "thread_name_meta"}, + ) + assert isinstance(result, list) + assert result + assert result[0]["name"] == "New Name" diff --git a/backend/tests/langchain/__init__.py b/backend/tests/langchain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/langchain/test_async_callback.py b/backend/tests/langchain/test_async_callback.py new file mode 100644 index 0000000..910a885 --- /dev/null +++ b/backend/tests/langchain/test_async_callback.py @@ -0,0 +1,168 @@ +"""Tests for async LangChain callback handlers.""" + +from datetime import datetime +from unittest.mock import AsyncMock, Mock, patch +from uuid import uuid4 + +import pytest +from langchain_core.outputs import GenerationChunk + +from chainlit.langchain.callbacks import LangchainTracer +from chainlit.step import Step + + +def create_mock_run(**kwargs): + """Helper to create a properly mocked Run object with all required attributes.""" + run = Mock() + run.id = kwargs.get("id", uuid4()) + run.parent_run_id = kwargs.get("parent_run_id", None) + run.name = kwargs.get("name", "test_run") + run.run_type = kwargs.get("run_type", "llm") + run.tags = kwargs.get("tags", []) + run.inputs = kwargs.get("inputs", {}) + run.outputs = kwargs.get("outputs", None) + run.serialized = kwargs.get("serialized", {}) + run.extra = kwargs.get("extra", {}) + run.start_time = kwargs.get("start_time", datetime.now()) + run.end_time = kwargs.get("end_time", None) + run.error = kwargs.get("error", None) + return run + + +@pytest.fixture +def mock_run(): + """Create a mock LangChain Run object.""" + return create_mock_run( + name="test_run", + run_type="llm", + inputs={"input": "test input"}, + serialized={"name": "test_llm"}, + ) + + +async def test_tracer_initialization(mock_chainlit_context): + """Test LangchainTracer initialization.""" + async with mock_chainlit_context: + tracer = LangchainTracer() + + assert tracer.steps == {} + assert tracer.parent_id_map == {} + assert tracer.ignored_runs == set() + assert tracer.stream_final_answer is False + assert tracer.answer_reached is False + + +async def test_on_llm_start(mock_chainlit_context): + """Test on_llm_start callback.""" + async with mock_chainlit_context: + tracer = LangchainTracer() + run_id = uuid4() + prompts = ["Test prompt"] + + await tracer.on_llm_start( + serialized={"name": "test_llm"}, + prompts=prompts, + run_id=run_id, + ) + + assert str(run_id) in tracer.completion_generations + completion_gen = tracer.completion_generations[str(run_id)] + assert completion_gen["prompt"] == "Test prompt" + assert completion_gen["token_count"] == 0 + assert completion_gen["tt_first_token"] is None + assert "start" in completion_gen + + +async def test_on_llm_new_token(mock_chainlit_context): + """Test on_llm_new_token with token streaming.""" + async with mock_chainlit_context: + tracer = LangchainTracer() + run_id = uuid4() + + await tracer.on_llm_start( + serialized={"name": "test_llm"}, + prompts=["Test prompt"], + run_id=run_id, + ) + + chunk = GenerationChunk(text="Hello") + await tracer.on_llm_new_token( + token="Hello", + chunk=chunk, + run_id=run_id, + ) + + completion_gen = tracer.completion_generations[str(run_id)] + assert completion_gen["token_count"] == 1 + assert completion_gen["tt_first_token"] is not None + + +async def test_start_trace(mock_chainlit_context): + """Test _start_trace creates steps correctly.""" + async with mock_chainlit_context: + tracer = LangchainTracer() + + # Test LLM run + llm_run = create_mock_run( + id=uuid4(), + name="test_llm", + run_type="llm", + inputs={"input": "test"}, + ) + + with patch.object(Step, "send", new_callable=AsyncMock): + await tracer._start_trace(llm_run) + + assert str(llm_run.id) in tracer.steps + assert tracer.steps[str(llm_run.id)].type == "llm" + + # Test ignored run + ignored_run = create_mock_run( + id=uuid4(), + name="RunnableSequence", + run_type="chain", + ) + tracer.to_ignore = ["RunnableSequence"] + await tracer._start_trace(ignored_run) + + assert str(ignored_run.id) in tracer.ignored_runs + + +async def test_on_run_update(mock_chainlit_context): + """Test _on_run_update updates steps.""" + async with mock_chainlit_context: + tracer = LangchainTracer() + run_id = uuid4() + + step = Step(id=str(run_id), name="test_tool", type="tool") + tracer.steps[str(run_id)] = step + + run = create_mock_run( + id=run_id, + name="test_tool", + run_type="tool", + outputs={"output": "result"}, + ) + + with patch.object(step, "update", new_callable=AsyncMock): + await tracer._on_run_update(run) + + assert step.output is not None + + +async def test_error_handling(mock_chainlit_context): + """Test error handling in callbacks.""" + async with mock_chainlit_context: + tracer = LangchainTracer() + run_id = uuid4() + + step = Step(id=str(run_id), name="test_step", type="llm") + tracer.steps[str(run_id)] = step + + error = ValueError("Test error") + + with patch.object(step, "update", new_callable=AsyncMock): + await tracer._on_error(error, run_id=run_id) + + assert step.is_error is True + assert step.output == "Test error" diff --git a/backend/tests/langchain/test_chain_types.py b/backend/tests/langchain/test_chain_types.py new file mode 100644 index 0000000..21046d6 --- /dev/null +++ b/backend/tests/langchain/test_chain_types.py @@ -0,0 +1,134 @@ +"""Tests for different LangChain chain types and their integration with Chainlit.""" + +from datetime import datetime +from unittest.mock import AsyncMock, Mock, patch +from uuid import uuid4 + +from chainlit.langchain.callbacks import LangchainTracer +from chainlit.step import Step + + +def create_mock_run(**kwargs): + """Helper to create a properly mocked Run object with all required attributes.""" + run = Mock() + run.id = kwargs.get("id", uuid4()) + run.parent_run_id = kwargs.get("parent_run_id", None) + run.name = kwargs.get("name", "test_run") + run.run_type = kwargs.get("run_type", "llm") + run.tags = kwargs.get("tags", []) + run.inputs = kwargs.get("inputs", {}) + run.outputs = kwargs.get("outputs", None) + run.serialized = kwargs.get("serialized", {}) + run.extra = kwargs.get("extra", {}) + run.start_time = kwargs.get("start_time", datetime.now()) + run.end_time = kwargs.get("end_time", None) + run.error = kwargs.get("error", None) + return run + + +async def test_different_run_types(mock_chainlit_context): + """Test different LangChain run types create correct steps.""" + async with mock_chainlit_context: + tracer = LangchainTracer() + + # Test agent run + agent_run = create_mock_run( + id=uuid4(), + name="test_agent", + run_type="agent", + inputs={"input": "test"}, + ) + + with patch.object(Step, "send", new_callable=AsyncMock): + await tracer._start_trace(agent_run) + + assert str(agent_run.id) in tracer.steps + assert tracer.steps[str(agent_run.id)].type == "run" + + # Test tool run + tool_run = create_mock_run( + id=uuid4(), + name="test_tool", + run_type="tool", + inputs={"query": "test"}, + ) + + with patch.object(Step, "send", new_callable=AsyncMock): + await tracer._start_trace(tool_run) + + assert str(tool_run.id) in tracer.steps + assert tracer.steps[str(tool_run.id)].type == "tool" + + +async def test_nested_chain_hierarchy(mock_chainlit_context): + """Test nested chain with LLM calls.""" + async with mock_chainlit_context: + tracer = LangchainTracer() + + # Create parent chain + chain_run = create_mock_run( + id=uuid4(), + name="parent_chain", + run_type="chain", + inputs={"input": "test"}, + ) + + with patch.object(Step, "send", new_callable=AsyncMock): + await tracer._start_trace(chain_run) + + # Create nested LLM + llm_run = create_mock_run( + id=uuid4(), + parent_run_id=chain_run.id, + name="nested_llm", + run_type="llm", + inputs={}, + ) + + with patch.object(Step, "send", new_callable=AsyncMock): + await tracer._start_trace(llm_run) + + # Both should exist + assert str(chain_run.id) in tracer.steps + assert str(llm_run.id) in tracer.steps + + # LLM should have chain as parent + llm_step = tracer.steps[str(llm_run.id)] + assert llm_step.parent_id == str(chain_run.id) + + +async def test_ignored_runs(mock_chainlit_context): + """Test that default ignored runs are properly filtered.""" + async with mock_chainlit_context: + tracer = LangchainTracer() + + # Test RunnableSequence is ignored + sequence_run = create_mock_run( + id=uuid4(), + name="RunnableSequence", + run_type="chain", + inputs={}, + ) + + await tracer._start_trace(sequence_run) + + assert str(sequence_run.id) not in tracer.steps + assert str(sequence_run.id) in tracer.ignored_runs + + +async def test_custom_filtering(mock_chainlit_context): + """Test custom to_ignore and to_keep lists.""" + async with mock_chainlit_context: + tracer = LangchainTracer(to_ignore=["CustomIgnore"], to_keep=["llm", "tool"]) + + # Test custom ignore + custom_run = create_mock_run( + id=uuid4(), + name="CustomIgnore", + run_type="chain", + ) + + await tracer._start_trace(custom_run) + + assert str(custom_run.id) not in tracer.steps + assert str(custom_run.id) in tracer.ignored_runs diff --git a/backend/tests/langchain/test_sync_callback.py b/backend/tests/langchain/test_sync_callback.py new file mode 100644 index 0000000..906cac9 --- /dev/null +++ b/backend/tests/langchain/test_sync_callback.py @@ -0,0 +1,147 @@ +"""Tests for synchronous LangChain callback operations and helper classes.""" + +from datetime import datetime +from unittest.mock import Mock +from uuid import uuid4 + +from langchain_core.messages import AIMessage, HumanMessage + +from chainlit.langchain.callbacks import ( + FinalStreamHelper, + GenerationHelper, + LangchainTracer, +) + + +def create_mock_run(**kwargs): + """Helper to create a properly mocked Run object with all required attributes.""" + run = Mock() + run.id = kwargs.get("id", uuid4()) + run.parent_run_id = kwargs.get("parent_run_id", None) + run.name = kwargs.get("name", "test_run") + run.run_type = kwargs.get("run_type", "llm") + run.tags = kwargs.get("tags", []) + run.inputs = kwargs.get("inputs", {}) + run.outputs = kwargs.get("outputs", None) + run.serialized = kwargs.get("serialized", {}) + run.extra = kwargs.get("extra", {}) + run.start_time = kwargs.get("start_time", datetime.now()) + run.end_time = kwargs.get("end_time", None) + run.error = kwargs.get("error", None) + return run + + +class TestFinalStreamHelper: + """Test FinalStreamHelper class.""" + + def test_initialization(self): + """Test FinalStreamHelper initialization.""" + helper = FinalStreamHelper() + + assert helper.answer_prefix_tokens == ["Final", "Answer", ":"] + assert helper.stream_final_answer is False + assert helper.answer_reached is False + + def test_check_if_answer_reached(self): + """Test _check_if_answer_reached.""" + helper = FinalStreamHelper() + + helper._append_to_last_tokens("Final") + helper._append_to_last_tokens("Answer") + helper._append_to_last_tokens(":") + + assert helper._check_if_answer_reached() is True + + +class TestGenerationHelper: + """Test GenerationHelper class.""" + + def test_initialization(self): + """Test GenerationHelper initialization.""" + helper = GenerationHelper() + + assert helper.chat_generations == {} + assert helper.completion_generations == {} + assert helper.generation_inputs == {} + + def test_ensure_values_serializable(self): + """Test ensure_values_serializable method.""" + helper = GenerationHelper() + + # Test dict + result = helper.ensure_values_serializable({"key": "value", "num": 42}) + assert result == {"key": "value", "num": 42} + + # Test list + result = helper.ensure_values_serializable([1, 2, "three"]) + assert result == [1, 2, "three"] + + def test_convert_message(self): + """Test _convert_message method.""" + helper = GenerationHelper() + + # Test HumanMessage + msg = HumanMessage(content="Hello") + result = helper._convert_message(msg) + assert result["role"] == "user" + assert result["content"] == "Hello" + + # Test AIMessage + msg = AIMessage(content="Hi there") + result = helper._convert_message(msg) + assert result["role"] == "assistant" + assert result["content"] == "Hi there" + + def test_build_llm_settings(self): + """Test _build_llm_settings method.""" + helper = GenerationHelper() + + serialized = {"name": "ChatOpenAI"} + invocation_params = { + "_type": "openai", + "model": "gpt-4", + "temperature": 0.7, + } + + provider, model, _, settings = helper._build_llm_settings( + serialized, invocation_params + ) + + assert provider == "openai" + assert model == "gpt-4" + assert settings["temperature"] == 0.7 + + +async def test_should_ignore_run(mock_chainlit_context): + """Test _should_ignore_run method.""" + async with mock_chainlit_context: + tracer = LangchainTracer(to_ignore=["RunnableSequence"]) + + # Test ignored by name + run = create_mock_run(name="RunnableSequence", run_type="chain") + ignore, _ = tracer._should_ignore_run(run) + assert ignore is True + + # Test not ignored + run = create_mock_run(name="MyChain", run_type="chain") + ignore, _ = tracer._should_ignore_run(run) + assert ignore is False + + +async def test_get_non_ignored_parent_id(mock_chainlit_context): + """Test _get_non_ignored_parent_id.""" + async with mock_chainlit_context: + tracer = LangchainTracer() + + # Setup parent chain + grandparent_id = str(uuid4()) + parent_id = str(uuid4()) + + # Both parent and grandparent need to be in parent_id_map for the traversal to work + tracer.parent_id_map[parent_id] = grandparent_id + tracer.parent_id_map[grandparent_id] = None # grandparent has no parent + tracer.ignored_runs.add(parent_id) + + result = tracer._get_non_ignored_parent_id(parent_id) + + assert result == grandparent_id diff --git a/backend/tests/llama_index/test_callbacks.py b/backend/tests/llama_index/test_callbacks.py new file mode 100644 index 0000000..8c89cf5 --- /dev/null +++ b/backend/tests/llama_index/test_callbacks.py @@ -0,0 +1,97 @@ +from unittest.mock import patch + +from llama_index.core.callbacks.schema import CBEventType, EventPayload +from llama_index.core.tools.types import ToolMetadata + +from chainlit.llama_index.callbacks import LlamaIndexCallbackHandler +from chainlit.step import Step + + +async def test_on_event_start_for_function_calls(mock_chainlit_context): + TEST_EVENT_ID = "test_event_id" + async with mock_chainlit_context: + handler = LlamaIndexCallbackHandler() + + with patch.object(Step, "send") as mock_send: + result = handler.on_event_start( + CBEventType.FUNCTION_CALL, + { + EventPayload.TOOL: ToolMetadata( + name="test_tool", description="test_description" + ), + EventPayload.FUNCTION_CALL: {"arg1": "value1"}, + }, + TEST_EVENT_ID, + ) + + assert result == TEST_EVENT_ID + assert TEST_EVENT_ID in handler.steps + step = handler.steps[TEST_EVENT_ID] + assert isinstance(step, Step) + assert step.name == "test_tool" + assert step.type == "tool" + assert step.id == TEST_EVENT_ID + assert step.input == '{\n "arg1": "value1"\n}' + mock_send.assert_called_once() + + +async def test_on_event_start_for_function_calls_missing_payload(mock_chainlit_context): + TEST_EVENT_ID = "test_event_id" + async with mock_chainlit_context: + handler = LlamaIndexCallbackHandler() + + with patch.object(Step, "send") as mock_send: + result = handler.on_event_start( + CBEventType.FUNCTION_CALL, + None, + TEST_EVENT_ID, + ) + + assert result == TEST_EVENT_ID + assert TEST_EVENT_ID in handler.steps + step = handler.steps[TEST_EVENT_ID] + assert isinstance(step, Step) + assert step.name == "function_call" + assert step.type == "tool" + assert step.id == TEST_EVENT_ID + assert step.input == "{}" + mock_send.assert_called_once() + + +async def test_on_event_end_for_function_calls(mock_chainlit_context): + TEST_EVENT_ID = "test_event_id" + async with mock_chainlit_context: + handler = LlamaIndexCallbackHandler() + # Pretend that we have started a step before. + step = Step(name="test_tool", type="tool", id=TEST_EVENT_ID) + handler.steps[TEST_EVENT_ID] = step + + with patch.object(step, "update") as mock_send: + handler.on_event_end( + CBEventType.FUNCTION_CALL, + payload={EventPayload.FUNCTION_OUTPUT: "test_output"}, + event_id=TEST_EVENT_ID, + ) + + assert step.output == "test_output" + assert TEST_EVENT_ID not in handler.steps + mock_send.assert_called_once() + + +async def test_on_event_end_for_function_calls_missing_payload(mock_chainlit_context): + TEST_EVENT_ID = "test_event_id" + async with mock_chainlit_context: + handler = LlamaIndexCallbackHandler() + # Pretend that we have started a step before. + step = Step(name="test_tool", type="tool", id=TEST_EVENT_ID) + handler.steps[TEST_EVENT_ID] = step + + with patch.object(step, "update") as mock_send: + handler.on_event_end( + CBEventType.FUNCTION_CALL, + payload=None, + event_id=TEST_EVENT_ID, + ) + # TODO: Is this the desired behavior? Shouldn't we still remove the step as long as we've been told it has ended, even if the payload is missing? + assert TEST_EVENT_ID in handler.steps + mock_send.assert_not_called() diff --git a/backend/tests/test_action.py b/backend/tests/test_action.py new file mode 100644 index 0000000..1f1fcd5 --- /dev/null +++ b/backend/tests/test_action.py @@ -0,0 +1,281 @@ +import uuid + +import pytest + +from chainlit.action import Action + + +@pytest.mark.asyncio +class TestAction: + """Test suite for the Action class.""" + + async def test_action_initialization_with_required_fields(self): + """Test Action initialization with only required fields.""" + action = Action( + name="test_action", + payload={"key": "value"}, + ) + + assert action.name == "test_action" + assert action.payload == {"key": "value"} + assert action.label == "" + assert action.tooltip == "" + assert action.icon is None + assert action.forId is None + assert isinstance(action.id, str) + # Verify ID is a valid UUID + uuid.UUID(action.id) + + async def test_action_initialization_with_all_fields(self): + """Test Action initialization with all fields provided.""" + test_id = str(uuid.uuid4()) + action = Action( + name="custom_action", + payload={"param1": "value1", "param2": 42}, + label="Click Me", + tooltip="This is a tooltip", + icon="check-circle", + id=test_id, + ) + + assert action.name == "custom_action" + assert action.payload == {"param1": "value1", "param2": 42} + assert action.label == "Click Me" + assert action.tooltip == "This is a tooltip" + assert action.icon == "check-circle" + assert action.id == test_id + assert action.forId is None + + async def test_action_id_auto_generation(self): + """Test that Action generates unique IDs automatically.""" + action1 = Action(name="action1", payload={}) + action2 = Action(name="action2", payload={}) + + assert action1.id != action2.id + # Verify both are valid UUIDs + uuid.UUID(action1.id) + uuid.UUID(action2.id) + + async def test_action_to_dict(self): + """Test Action serialization to dictionary.""" + test_id = str(uuid.uuid4()) + action = Action( + name="test_action", + payload={"data": "test"}, + label="Test Label", + tooltip="Test Tooltip", + icon="star", + id=test_id, + ) + + action_dict = action.to_dict() + + assert action_dict["name"] == "test_action" + assert action_dict["payload"] == {"data": "test"} + assert action_dict["label"] == "Test Label" + assert action_dict["tooltip"] == "Test Tooltip" + assert action_dict["icon"] == "star" + assert action_dict["id"] == test_id + assert action_dict.get("forId") is None + + async def test_action_to_dict_with_for_id(self): + """Test Action serialization includes forId when set.""" + action = Action(name="test", payload={}) + action.forId = "message_123" + + action_dict = action.to_dict() + + assert action_dict["forId"] == "message_123" + + async def test_action_send(self, mock_chainlit_context): + """Test Action.send() method emits correct event.""" + async with mock_chainlit_context as ctx: + action = Action( + name="send_action", + payload={"test": "data"}, + label="Send Test", + ) + + for_id = "target_message_id" + await action.send(for_id=for_id) + + # Verify forId was set + assert action.forId == for_id + + # Verify emit was called with correct parameters + ctx.emitter.emit.assert_called_once() + call_args = ctx.emitter.emit.call_args + assert call_args[0][0] == "action" + + emitted_dict = call_args[0][1] + assert emitted_dict["name"] == "send_action" + assert emitted_dict["payload"] == {"test": "data"} + assert emitted_dict["label"] == "Send Test" + assert emitted_dict["forId"] == for_id + + async def test_action_send_updates_for_id(self, mock_chainlit_context): + """Test that send() updates the forId field.""" + async with mock_chainlit_context: + action = Action(name="test", payload={}) + + # Initially forId should be None + assert action.forId is None + + # Send with first for_id + await action.send(for_id="first_id") + assert action.forId == "first_id" + + # Send with different for_id should update + await action.send(for_id="second_id") + assert action.forId == "second_id" + + async def test_action_remove(self, mock_chainlit_context): + """Test Action.remove() method emits correct event.""" + async with mock_chainlit_context as ctx: + action = Action( + name="remove_action", + payload={"key": "value"}, + label="Remove Test", + ) + action.forId = "message_123" + + await action.remove() + + # Verify emit was called with correct parameters + ctx.emitter.emit.assert_called_once() + call_args = ctx.emitter.emit.call_args + assert call_args[0][0] == "remove_action" + + emitted_dict = call_args[0][1] + assert emitted_dict["name"] == "remove_action" + assert emitted_dict["payload"] == {"key": "value"} + assert emitted_dict["forId"] == "message_123" + + async def test_action_remove_without_for_id(self, mock_chainlit_context): + """Test Action.remove() works even without forId set.""" + async with mock_chainlit_context as ctx: + action = Action(name="test", payload={}) + + await action.remove() + + ctx.emitter.emit.assert_called_once() + call_args = ctx.emitter.emit.call_args + assert call_args[0][0] == "remove_action" + + async def test_action_with_complex_payload(self): + """Test Action with complex nested payload.""" + complex_payload = { + "nested": { + "data": [1, 2, 3], + "info": {"key": "value"}, + }, + "list": ["a", "b", "c"], + "number": 42, + "boolean": True, + "null": None, + } + + action = Action(name="complex", payload=complex_payload) + + assert action.payload == complex_payload + action_dict = action.to_dict() + assert action_dict["payload"] == complex_payload + + async def test_action_with_empty_payload(self): + """Test Action with empty payload.""" + action = Action(name="empty", payload={}) + + assert action.payload == {} + assert action.to_dict()["payload"] == {} + + async def test_action_with_empty_strings(self): + """Test Action handles empty strings correctly.""" + action = Action( + name="test", + payload={}, + label="", + tooltip="", + ) + + assert action.label == "" + assert action.tooltip == "" + + action_dict = action.to_dict() + assert action_dict["label"] == "" + assert action_dict["tooltip"] == "" + + async def test_action_serialization_deserialization(self): + """Test Action can be serialized and deserialized.""" + original = Action( + name="serialize_test", + payload={"data": "test"}, + label="Test", + tooltip="Tooltip", + icon="icon-name", + ) + + # Serialize to dict + serialized = original.to_dict() + + # Deserialize from dict + deserialized = Action.from_dict(serialized) + + assert deserialized.name == original.name + assert deserialized.payload == original.payload + assert deserialized.label == original.label + assert deserialized.tooltip == original.tooltip + assert deserialized.icon == original.icon + assert deserialized.id == original.id + + async def test_multiple_actions_with_same_name(self): + """Test that multiple actions can have the same name but different IDs.""" + action1 = Action(name="duplicate", payload={"num": 1}) + action2 = Action(name="duplicate", payload={"num": 2}) + + assert action1.name == action2.name + assert action1.id != action2.id + assert action1.payload != action2.payload + + async def test_action_send_multiple_times(self, mock_chainlit_context): + """Test that an action can be sent multiple times.""" + async with mock_chainlit_context as ctx: + action = Action(name="multi_send", payload={}) + + await action.send(for_id="id1") + await action.send(for_id="id2") + await action.send(for_id="id3") + + # Should have been called 3 times + assert ctx.emitter.emit.call_count == 3 + + # Last forId should be id3 + assert action.forId == "id3" + + async def test_action_with_special_characters_in_payload(self): + """Test Action handles special characters in payload.""" + special_payload = { + "unicode": "Hello 世界 🌍", + "quotes": 'He said "Hello"', + "newlines": "Line1\nLine2\nLine3", + "tabs": "Col1\tCol2\tCol3", + } + + action = Action(name="special", payload=special_payload) + + assert action.payload == special_payload + action_dict = action.to_dict() + assert action_dict["payload"] == special_payload + + async def test_action_icon_variations(self): + """Test Action with different icon values.""" + # With icon + action_with_icon = Action(name="test", payload={}, icon="check") + assert action_with_icon.icon == "check" + + # Without icon (None) + action_no_icon = Action(name="test", payload={}, icon=None) + assert action_no_icon.icon is None + + # Default (should be None) + action_default = Action(name="test", payload={}) + assert action_default.icon is None diff --git a/backend/tests/test_cache.py b/backend/tests/test_cache.py new file mode 100644 index 0000000..7b7c421 --- /dev/null +++ b/backend/tests/test_cache.py @@ -0,0 +1,558 @@ +import sys +import threading +from unittest.mock import Mock, patch + +import pytest + +from chainlit.cache import cache, init_lc_cache + +# Import the actual cache module to access _cache dict +cache_module = sys.modules["chainlit.cache"] + + +class TestCacheDecorator: + """Test suite for the @cache decorator.""" + + def setup_method(self): + """Clear the cache before each test.""" + cache_module._cache.clear() + + def teardown_method(self): + """Clear the cache after each test.""" + cache_module._cache.clear() + + def test_cache_basic_function(self): + """Test that cache decorator caches function results.""" + call_count = 0 + + @cache + def add(a, b): + nonlocal call_count + call_count += 1 + return a + b + + # First call + result1 = add(2, 3) + assert result1 == 5 + assert call_count == 1 + + # Second call with same args should use cache + result2 = add(2, 3) + assert result2 == 5 + assert call_count == 1 # Function not called again + + def test_cache_different_arguments(self): + """Test that different arguments create different cache entries.""" + call_count = 0 + + @cache + def multiply(x, y): + nonlocal call_count + call_count += 1 + return x * y + + result1 = multiply(2, 3) + assert result1 == 6 + assert call_count == 1 + + result2 = multiply(4, 5) + assert result2 == 20 + assert call_count == 2 # Different args, function called again + + # Same args as first call, should use cache + result3 = multiply(2, 3) + assert result3 == 6 + assert call_count == 2 # Cache hit, no new call + + def test_cache_with_kwargs(self): + """Test that cache works with keyword arguments.""" + call_count = 0 + + @cache + def greet(name, greeting="Hello"): + nonlocal call_count + call_count += 1 + return f"{greeting}, {name}!" + + result1 = greet("Alice", greeting="Hi") + assert result1 == "Hi, Alice!" + assert call_count == 1 + + # Same call should use cache + result2 = greet("Alice", greeting="Hi") + assert result2 == "Hi, Alice!" + assert call_count == 1 + + # Different kwargs should call function + result3 = greet("Alice", greeting="Hello") + assert result3 == "Hello, Alice!" + assert call_count == 2 + + def test_cache_kwargs_order_independence(self): + """Test that kwargs order doesn't affect cache key.""" + call_count = 0 + + @cache + def func(a=1, b=2, c=3): + nonlocal call_count + call_count += 1 + return a + b + c + + result1 = func(a=1, b=2, c=3) + assert result1 == 6 + assert call_count == 1 + + # Same kwargs, different order - should use cache + result2 = func(c=3, a=1, b=2) + assert result2 == 6 + assert call_count == 1 # Cache hit + + def test_cache_mixed_args_and_kwargs(self): + """Test cache with both positional and keyword arguments.""" + call_count = 0 + + @cache + def compute(x, y, z=10): + nonlocal call_count + call_count += 1 + return x + y + z + + result1 = compute(1, 2, z=3) + assert result1 == 6 + assert call_count == 1 + + result2 = compute(1, 2, z=3) + assert result2 == 6 + assert call_count == 1 # Cache hit + + result3 = compute(1, 2, z=5) + assert result3 == 8 + assert call_count == 2 # Different z value + + def test_cache_with_no_arguments(self): + """Test cache with functions that take no arguments.""" + call_count = 0 + + @cache + def get_constant(): + nonlocal call_count + call_count += 1 + return 42 + + result1 = get_constant() + assert result1 == 42 + assert call_count == 1 + + result2 = get_constant() + assert result2 == 42 + assert call_count == 1 # Cache hit + + def test_cache_with_mutable_return_value(self): + """Test that cache returns the same object reference.""" + call_count = 0 + + @cache + def get_list(): + nonlocal call_count + call_count += 1 + return [1, 2, 3] + + result1 = get_list() + assert result1 == [1, 2, 3] + assert call_count == 1 + + result2 = get_list() + assert result2 == [1, 2, 3] + assert call_count == 1 + + # Both results should be the same object + assert result1 is result2 + + def test_cache_thread_safety(self): + """Test that cache is thread-safe.""" + call_count = 0 + call_lock = threading.Lock() + + @cache + def slow_function(x): + nonlocal call_count + with call_lock: + call_count += 1 + return x * 2 + + results = [] + + def worker(): + result = slow_function(5) + results.append(result) + + threads = [threading.Thread(target=worker) for _ in range(10)] + for t in threads: + t.start() + for t in threads: + t.join() + + # All results should be the same + assert all(r == 10 for r in results) + # Function should only be called once despite multiple threads + assert call_count == 1 + + def test_cache_different_functions_same_args(self): + """Test that different functions with same args have separate cache.""" + call_count_1 = 0 + call_count_2 = 0 + + @cache + def func1(x): + nonlocal call_count_1 + call_count_1 += 1 + return x + 1 + + @cache + def func2(x): + nonlocal call_count_2 + call_count_2 += 1 + return x + 2 + + result1 = func1(5) + assert result1 == 6 + assert call_count_1 == 1 + + result2 = func2(5) + assert result2 == 7 + assert call_count_2 == 1 + + # Both should use their own cache + func1(5) + func2(5) + assert call_count_1 == 1 + assert call_count_2 == 1 + + def test_cache_with_none_arguments(self): + """Test cache with None as argument.""" + call_count = 0 + + @cache + def process(value): + nonlocal call_count + call_count += 1 + return value + + result1 = process(None) + assert result1 is None + assert call_count == 1 + + result2 = process(None) + assert result2 is None + assert call_count == 1 # Cache hit + + def test_cache_preserves_function_behavior(self): + """Test that cache decorator preserves original function behavior.""" + + @cache + def divide(a, b): + return a / b + + assert divide(10, 2) == 5.0 + assert divide(10, 2) == 5.0 # Cached + + with pytest.raises(ZeroDivisionError): + divide(10, 0) + + +class TestInitLcCache: + """Test suite for init_lc_cache function.""" + + def test_init_lc_cache_disabled_by_config(self): + """Test that cache is not initialized when disabled in config.""" + with patch.object(cache_module.config, "project") as mock_project: + mock_project.cache = False + with patch.object(cache_module.config, "run") as mock_run: + mock_run.no_cache = False + + with patch.object( + cache_module.importlib.util, "find_spec" + ) as mock_find_spec: + init_lc_cache() + + # Should not check for langchain if cache is disabled + mock_find_spec.assert_not_called() + + def test_init_lc_cache_disabled_by_no_cache_flag(self): + """Test that cache is not initialized when no_cache flag is set.""" + with patch.object(cache_module.config, "project") as mock_project: + mock_project.cache = True + with patch.object(cache_module.config, "run") as mock_run: + mock_run.no_cache = True + + with patch.object( + cache_module.importlib.util, "find_spec" + ) as mock_find_spec: + init_lc_cache() + + # Should not check for langchain if no_cache is True + mock_find_spec.assert_not_called() + + def test_init_lc_cache_langchain_not_installed(self): + """Test behavior when langchain is not installed.""" + with patch.object(cache_module.config, "project") as mock_project: + mock_project.cache = True + with patch.object(cache_module.config, "run") as mock_run: + mock_run.no_cache = False + + with patch.object( + cache_module.importlib.util, "find_spec", return_value=None + ) as mock_find_spec: + # Should not raise an error + init_lc_cache() + + mock_find_spec.assert_called_once_with("langchain") + + def test_init_lc_cache_with_langchain_installed(self): + """Test cache initialization when langchain is installed.""" + with patch.object(cache_module.config, "project") as mock_project: + mock_project.cache = True + mock_project.lc_cache_path = "/tmp/test_cache.db" + with patch.object(cache_module.config, "run") as mock_run: + mock_run.no_cache = False + + mock_spec = Mock() + with patch.object( + cache_module.importlib.util, "find_spec", return_value=mock_spec + ): + # Mock langchain modules + mock_sqlite_cache = Mock() + mock_set_llm_cache = Mock() + + with patch.dict( + sys.modules, + { + "langchain": Mock(), + "langchain_community.cache": Mock( + SQLiteCache=mock_sqlite_cache + ), + "langchain.cache": Mock(SQLiteCache=mock_sqlite_cache), + "langchain_core.globals": Mock( + set_llm_cache=mock_set_llm_cache + ), + "langchain.globals": Mock(set_llm_cache=mock_set_llm_cache), + }, + ): + with patch("os.path.exists", return_value=True): + init_lc_cache() + + mock_sqlite_cache.assert_called_once_with( + database_path="/tmp/test_cache.db" + ) + mock_set_llm_cache.assert_called_once() + + def test_init_lc_cache_creates_new_cache_file(self): + """Test that logger is called when creating new cache file.""" + with patch.object(cache_module.config, "project") as mock_project: + mock_project.cache = True + mock_project.lc_cache_path = "/tmp/new_cache.db" + with patch.object(cache_module.config, "run") as mock_run: + mock_run.no_cache = False + + mock_spec = Mock() + with patch.object( + cache_module.importlib.util, "find_spec", return_value=mock_spec + ): + mock_sqlite_cache = Mock() + mock_set_llm_cache = Mock() + + with patch.dict( + sys.modules, + { + "langchain": Mock(), + "langchain_community.cache": Mock( + SQLiteCache=mock_sqlite_cache + ), + "langchain.cache": Mock(SQLiteCache=mock_sqlite_cache), + "langchain_core.globals": Mock( + set_llm_cache=mock_set_llm_cache + ), + "langchain.globals": Mock(set_llm_cache=mock_set_llm_cache), + }, + ): + with patch("os.path.exists", return_value=False): + with patch.object(cache_module, "logger") as mock_logger: + init_lc_cache() + + mock_logger.info.assert_called_once() + assert "LangChain cache created at" in str( + mock_logger.info.call_args + ) + + def test_init_lc_cache_without_cache_path(self): + """Test that cache is not initialized when cache path is None.""" + with patch.object(cache_module.config, "project") as mock_project: + mock_project.cache = True + mock_project.lc_cache_path = None + with patch.object(cache_module.config, "run") as mock_run: + mock_run.no_cache = False + + mock_spec = Mock() + with patch.object( + cache_module.importlib.util, "find_spec", return_value=mock_spec + ): + mock_sqlite_cache = Mock() + mock_set_llm_cache = Mock() + + with patch.dict( + sys.modules, + { + "langchain": Mock(), + "langchain_community.cache": Mock( + SQLiteCache=mock_sqlite_cache + ), + "langchain.cache": Mock(SQLiteCache=mock_sqlite_cache), + "langchain_core.globals": Mock( + set_llm_cache=mock_set_llm_cache + ), + "langchain.globals": Mock(set_llm_cache=mock_set_llm_cache), + }, + ): + init_lc_cache() + + # Should not call SQLiteCache if path is None + mock_sqlite_cache.assert_not_called() + mock_set_llm_cache.assert_not_called() + + def test_init_lc_cache_falls_back_to_legacy_langchain_modules(self): + """Test cache initialization falls back to legacy langchain imports.""" + with patch.object(cache_module.config, "project") as mock_project: + mock_project.cache = True + mock_project.lc_cache_path = "/tmp/test_cache.db" + with patch.object(cache_module.config, "run") as mock_run: + mock_run.no_cache = False + + mock_spec = Mock() + with patch.object( + cache_module.importlib.util, "find_spec", return_value=mock_spec + ): + mock_sqlite_cache = Mock() + mock_set_llm_cache = Mock() + + def mock_import_module(name): + if name == "langchain_core.globals": + raise ImportError + if name == "langchain_community.cache": + raise ImportError + if name == "langchain.globals": + return Mock(set_llm_cache=mock_set_llm_cache) + if name == "langchain.cache": + return Mock(SQLiteCache=mock_sqlite_cache) + raise ImportError + + with patch.object( + cache_module.importlib, + "import_module", + side_effect=mock_import_module, + ): + with patch.object( + cache_module.os.path, "exists", return_value=True + ): + init_lc_cache() + + mock_sqlite_cache.assert_called_once_with( + database_path="/tmp/test_cache.db" + ) + mock_set_llm_cache.assert_called_once() + + +class TestCacheEdgeCases: + """Test suite for cache edge cases.""" + + def setup_method(self): + """Clear the cache before each test.""" + cache_module._cache.clear() + + def teardown_method(self): + """Clear the cache after each test.""" + cache_module._cache.clear() + + def test_cache_with_unhashable_arguments(self): + """Test that cache handles unhashable arguments gracefully.""" + + @cache + def process_list(items): + return sum(items) + + # Lists are unhashable and will cause an error + with pytest.raises(TypeError): + process_list([1, 2, 3]) + + def test_cache_with_string_arguments(self): + """Test cache with string arguments.""" + call_count = 0 + + @cache + def process_string(s): + nonlocal call_count + call_count += 1 + return s.upper() + + result1 = process_string("hello") + assert result1 == "HELLO" + assert call_count == 1 + + result2 = process_string("hello") + assert result2 == "HELLO" + assert call_count == 1 # Cache hit + + def test_cache_with_tuple_arguments(self): + """Test cache with tuple arguments.""" + call_count = 0 + + @cache + def process_tuple(t): + nonlocal call_count + call_count += 1 + return sum(t) + + result1 = process_tuple((1, 2, 3)) + assert result1 == 6 + assert call_count == 1 + + result2 = process_tuple((1, 2, 3)) + assert result2 == 6 + assert call_count == 1 # Cache hit + + def test_cache_with_boolean_arguments(self): + """Test cache with boolean arguments.""" + call_count = 0 + + @cache + def process_bool(flag): + nonlocal call_count + call_count += 1 + return "yes" if flag else "no" + + result1 = process_bool(True) + assert result1 == "yes" + assert call_count == 1 + + result2 = process_bool(True) + assert result2 == "yes" + assert call_count == 1 # Cache hit + + result3 = process_bool(False) + assert result3 == "no" + assert call_count == 2 + + def test_cache_global_state(self): + """Test that cache is global across function calls.""" + + @cache + def func(x): + return x * 2 + + func(5) + assert len(cache_module._cache) == 1 + + func(10) + assert len(cache_module._cache) == 2 + + func(5) # Cache hit + assert len(cache_module._cache) == 2 # No new entry diff --git a/backend/tests/test_callbacks.py b/backend/tests/test_callbacks.py new file mode 100644 index 0000000..1c61957 --- /dev/null +++ b/backend/tests/test_callbacks.py @@ -0,0 +1,850 @@ +from __future__ import annotations + +import asyncio +from unittest.mock import AsyncMock, Mock + +from chainlit import config +from chainlit.callbacks import password_auth_callback +from chainlit.data.base import BaseDataLayer +from chainlit.types import ThreadDict +from chainlit.user import User + + +async def test_password_auth_callback(test_config: config.ChainlitConfig): + @password_auth_callback + async def auth_func(username: str, password: str) -> User | None: + if username == "testuser" and password == "testpass": # nosec B105 + return User(identifier="testuser") + return None + + # Test that the callback is properly registered + assert test_config.code.password_auth_callback is not None + + # Test the wrapped function + result = await test_config.code.password_auth_callback("testuser", "testpass") + assert isinstance(result, User) + assert result.identifier == "testuser" + + # Test with incorrect credentials + result = await test_config.code.password_auth_callback("wronguser", "wrongpass") + assert result is None + + +async def test_header_auth_callback(test_config: config.ChainlitConfig): + from starlette.datastructures import Headers + + from chainlit.callbacks import header_auth_callback + + @header_auth_callback + async def auth_func(headers: Headers) -> User | None: + if headers.get("Authorization") == "Bearer valid_token": + return User(identifier="testuser") + return None + + # Test that the callback is properly registered + assert test_config.code.header_auth_callback is not None + + # Test the wrapped function with valid header + valid_headers = Headers({"Authorization": "Bearer valid_token"}) + result = await test_config.code.header_auth_callback(valid_headers) + assert isinstance(result, User) + assert result.identifier == "testuser" + + # Test with invalid header + invalid_headers = Headers({"Authorization": "Bearer invalid_token"}) + result = await test_config.code.header_auth_callback(invalid_headers) + assert result is None + + # Test with missing header + missing_headers = Headers({}) + result = await test_config.code.header_auth_callback(missing_headers) + assert result is None + + +async def test_oauth_callback(test_config: config.ChainlitConfig): + from unittest.mock import patch + + from chainlit.callbacks import oauth_callback + from chainlit.user import User + + # Mock the get_configured_oauth_providers function + with patch( + "chainlit.callbacks.get_configured_oauth_providers", return_value=["google"] + ): + + @oauth_callback + async def auth_func( + provider_id: str, + token: str, + raw_user_data: dict, + default_app_user: User, + id_token: str | None = None, + ) -> User | None: + if provider_id == "google" and token == "valid_token": # nosec B105 + return User(identifier="oauth_user") + return None + + # Test that the callback is properly registered + assert test_config.code.oauth_callback is not None + + # Test the wrapped function with valid data + result = await test_config.code.oauth_callback( + "google", "valid_token", {}, User(identifier="default_user") + ) + assert isinstance(result, User) + assert result.identifier == "oauth_user" + + # Test with invalid data + result = await test_config.code.oauth_callback( + "facebook", "invalid_token", {}, User(identifier="default_user") + ) + assert result is None + + +async def test_on_message(mock_chainlit_context, test_config: config.ChainlitConfig): + from chainlit.callbacks import on_message + from chainlit.message import Message + + async with mock_chainlit_context: + message_received = None + + @on_message + async def handle_message(message: Message): + nonlocal message_received + message_received = message + + # Test that the callback is properly registered + assert test_config.code.on_message is not None + + # Create a test message + test_message = Message(content="Test message", author="User") + + # Call the registered callback + await test_config.code.on_message(test_message) + + # Check that the message was received by our handler + assert message_received is not None + assert message_received.content == "Test message" + assert message_received.author == "User" + + +async def test_on_stop(mock_chainlit_context, test_config: config.ChainlitConfig): + from chainlit.callbacks import on_stop + + async with mock_chainlit_context: + stop_called = False + + @on_stop + async def handle_stop(): + nonlocal stop_called + stop_called = True + + # Test that the callback is properly registered + assert test_config.code.on_stop is not None + + # Call the registered callback + await test_config.code.on_stop() + + # Check that the stop_called flag was set + assert stop_called + + +async def test_action_callback( + mock_chainlit_context, test_config: config.ChainlitConfig +): + from chainlit.action import Action + from chainlit.callbacks import action_callback + + async with mock_chainlit_context: + action_handled = False + + @action_callback("test_action") + async def handle_action(action: Action): + nonlocal action_handled + action_handled = True + assert action.name == "test_action" + + # Test that the callback is properly registered + assert "test_action" in test_config.code.action_callbacks + + # Call the registered callback + test_action = Action(name="test_action", payload={"value": "test_value"}) + await test_config.code.action_callbacks["test_action"](test_action) + + # Check that the action_handled flag was set + assert action_handled + + +async def test_on_settings_update( + mock_chainlit_context, test_config: config.ChainlitConfig +): + from chainlit.callbacks import on_settings_update + + async with mock_chainlit_context: + settings_updated = False + + @on_settings_update + async def handle_settings_update(settings: dict): + nonlocal settings_updated + settings_updated = True + assert settings == {"test_setting": "test_value"} + + # Test that the callback is properly registered + assert test_config.code.on_settings_update is not None + + # Call the registered callback + await test_config.code.on_settings_update({"test_setting": "test_value"}) + + # Check that the settings_updated flag was set + assert settings_updated + + +async def test_author_rename(test_config: config.ChainlitConfig): + from chainlit.callbacks import author_rename + + @author_rename + async def rename_author(author: str) -> str: + if author == "AI": + return "Assistant" + return author + + # Test that the callback is properly registered + assert test_config.code.author_rename is not None + + # Call the registered callback + result = await test_config.code.author_rename("AI") + assert result == "Assistant" + + result = await test_config.code.author_rename("Human") + assert result == "Human" + + # Test that the callback is properly registered + assert test_config.code.author_rename is not None + + # Call the registered callback + result = await test_config.code.author_rename("AI") + assert result == "Assistant" + + result = await test_config.code.author_rename("Human") + assert result == "Human" + + +async def test_on_app_startup(test_config: config.ChainlitConfig): + """Test the on_app_startup callback registration and execution for sync and async functions.""" + from chainlit.callbacks import on_app_startup + + # Test with synchronous function + sync_startup_called = False + + @on_app_startup + def sync_startup(): + nonlocal sync_startup_called + sync_startup_called = True + + assert test_config.code.on_app_startup is not None, ( + "Sync startup callback not registered" + ) + # Call the wrapped function (which might be async due to wrap_user_function) + result = test_config.code.on_app_startup() + if asyncio.iscoroutine(result): + await result + assert sync_startup_called, "Sync startup function was not called" + + # Reset for async test + test_config.code.on_app_startup = None # Explicitly clear previous registration + + # Test with asynchronous function + async_startup_called = False + + @on_app_startup + async def async_startup(): + nonlocal async_startup_called + await asyncio.sleep(0) # Simulate async work + async_startup_called = True + + assert test_config.code.on_app_startup is not None, ( + "Async startup callback not registered" + ) + # Call the wrapped function (which should be async) + result = test_config.code.on_app_startup() + assert asyncio.iscoroutine(result), ( + "Async startup function did not return a coroutine" + ) + await result + assert async_startup_called, "Async startup function was not called" + + +async def test_on_app_shutdown(test_config: config.ChainlitConfig): + """Test the on_app_shutdown callback registration and execution for sync and async functions.""" + from chainlit.callbacks import on_app_shutdown + + # Test with synchronous function + sync_shutdown_called = False + + @on_app_shutdown + def sync_shutdown(): + nonlocal sync_shutdown_called + sync_shutdown_called = True + + assert test_config.code.on_app_shutdown is not None, ( + "Sync shutdown callback not registered" + ) + # Call the wrapped function + result = test_config.code.on_app_shutdown() + if asyncio.iscoroutine(result): + await result + assert sync_shutdown_called, "Sync shutdown function was not called" + + # Reset for async test + test_config.code.on_app_shutdown = None # Explicitly clear previous registration + + # Test with asynchronous function + async_shutdown_called = False + + @on_app_shutdown + async def async_shutdown(): + nonlocal async_shutdown_called + await asyncio.sleep(0) # Simulate async work + async_shutdown_called = True + + assert test_config.code.on_app_shutdown is not None, ( + "Async shutdown callback not registered" + ) + # Call the wrapped function + result = test_config.code.on_app_shutdown() + assert asyncio.iscoroutine(result), ( + "Async shutdown function did not return a coroutine" + ) + await result + assert async_shutdown_called, "Async shutdown function was not called" + + +async def test_on_chat_start(mock_chainlit_context, test_config: config.ChainlitConfig): + from chainlit.callbacks import on_chat_start + + async with mock_chainlit_context: + chat_started = False + + @on_chat_start + async def handle_chat_start(): + nonlocal chat_started + chat_started = True + + # Test that the callback is properly registered + assert test_config.code.on_chat_start is not None + + # Call the registered callback + await test_config.code.on_chat_start() + + # Check that the chat_started flag was set + assert chat_started + + +async def test_on_chat_resume( + mock_chainlit_context, test_config: config.ChainlitConfig +): + from chainlit.callbacks import on_chat_resume + + async with mock_chainlit_context: + chat_resumed = False + + @on_chat_resume + async def handle_chat_resume(thread: ThreadDict): + nonlocal chat_resumed + chat_resumed = True + assert thread["id"] == "test_thread_id" + + # Test that the callback is properly registered + assert test_config.code.on_chat_resume is not None + + # Call the registered callback + await test_config.code.on_chat_resume( + { + "id": "test_thread_id", + "createdAt": "2023-01-01T00:00:00Z", + "name": "Test Thread", + "userId": "test_user_id", + "userIdentifier": "test_user", + "tags": [], + "metadata": {}, + "steps": [], + "elements": [], + } + ) + + # Check that the chat_resumed flag was set + assert chat_resumed + + +async def test_set_chat_profiles( + mock_chainlit_context, test_config: config.ChainlitConfig +): + from chainlit.callbacks import set_chat_profiles + from chainlit.types import ChatProfile + + async with mock_chainlit_context: + + @set_chat_profiles + async def get_chat_profiles(user, language): + return [ + ChatProfile(name="Test Profile", markdown_description="A test profile") + ] + + # Test that the callback is properly registered + assert test_config.code.set_chat_profiles is not None + + # Call the registered callback + result = await test_config.code.set_chat_profiles(None, None) + + # Check the result + assert result is not None + assert isinstance(result, list) + assert len(result) == 1 + assert isinstance(result[0], ChatProfile) + assert result[0].name == "Test Profile" + assert result[0].markdown_description == "A test profile" + + +async def test_set_chat_profiles_language( + mock_chainlit_context, test_config: config.ChainlitConfig +): + from chainlit.callbacks import set_chat_profiles + from chainlit.types import ChatProfile + + async with mock_chainlit_context: + + @set_chat_profiles + async def get_chat_profiles(user, language): + if language == "fr-CA": + return [ + ChatProfile( + name="Profil de test", markdown_description="Un profil de test" + ) + ] + + return [ + ChatProfile(name="Test Profile", markdown_description="A test profile") + ] + + # Test that the callback is properly registered + assert test_config.code.set_chat_profiles is not None + + # Call the registered callback + result = await test_config.code.set_chat_profiles(None, "fr-CA") + + # Check the result + assert result is not None + assert isinstance(result, list) + assert len(result) == 1 + assert isinstance(result[0], ChatProfile) + assert result[0].name == "Profil de test" + assert result[0].markdown_description == "Un profil de test" + + +async def test_set_starters(mock_chainlit_context, test_config: config.ChainlitConfig): + from chainlit.callbacks import set_starters + from chainlit.types import Starter + + async with mock_chainlit_context: + + @set_starters + async def get_starters(user): + return [ + Starter( + label="Test Label", + message="Test Message", + ) + ] + + # Test that the callback is properly registered + assert test_config.code.set_starters is not None + + # Call the registered callback + result = await test_config.code.set_starters(None, None) + + # Check the result + assert result is not None + assert isinstance(result, list) + assert len(result) == 1 + assert isinstance(result[0], Starter) + assert result[0].label == "Test Label" + assert result[0].message == "Test Message" + + +async def test_set_starters_language( + mock_chainlit_context, test_config: config.ChainlitConfig +): + from chainlit.callbacks import set_starters + from chainlit.types import Starter + + async with mock_chainlit_context: + + @set_starters + async def get_starters(user, language): + if language == "fr-CA": + return [ + Starter( + label="Étiquette de test", + message="Message de test", + ) + ] + + return [ + Starter( + label="Test Label", + message="Test Message", + ) + ] + + # Test that the callback is properly registered + assert test_config.code.set_starters is not None + + # Call the registered callback + result = await test_config.code.set_starters(None, "fr-CA") + + # Check the result + assert result is not None + assert isinstance(result, list) + assert len(result) == 1 + assert isinstance(result[0], Starter) + assert result[0].label == "Étiquette de test" + assert result[0].message == "Message de test" + + +async def test_set_starter_categories( + mock_chainlit_context, test_config: config.ChainlitConfig +): + from chainlit.callbacks import set_starter_categories + from chainlit.types import Starter, StarterCategory + + async with mock_chainlit_context: + + @set_starter_categories + async def get_starter_categories(user, language): + return [ + StarterCategory( + label="Creative", + icon="https://example.com/creative.png", + starters=[ + Starter(label="Write a poem", message="Write a poem"), + Starter(label="Write a story", message="Write a story"), + ], + ), + StarterCategory( + label="Educational", + starters=[ + Starter(label="Explain concept", message="Explain it"), + ], + ), + ] + + assert test_config.code.set_starter_categories is not None + + result = await test_config.code.set_starter_categories(None, None, None) + + assert result is not None + assert isinstance(result, list) + assert len(result) == 2 + + assert result[0].label == "Creative" + assert result[0].icon == "https://example.com/creative.png" + assert len(result[0].starters) == 2 + assert result[0].starters[0].label == "Write a poem" + + assert result[1].label == "Educational" + assert result[1].icon is None + assert len(result[1].starters) == 1 + + category_dict = result[0].to_dict() + assert category_dict["label"] == "Creative" + assert category_dict["icon"] == "https://example.com/creative.png" + starters_list = category_dict["starters"] + assert isinstance(starters_list, list) + assert len(starters_list) == 2 + + +async def test_set_starter_categories_with_chat_profile( + mock_chainlit_context, test_config: config.ChainlitConfig +): + from chainlit.callbacks import set_starter_categories + from chainlit.types import Starter, StarterCategory + + async with mock_chainlit_context: + + @set_starter_categories + async def get_starter_categories(user, language, chat_profile): + return [ + StarterCategory( + label="Profile Specific", + icon="https://example.com/profile.png", + starters=[ + Starter( + label=f"Starter for {chat_profile}", + message=f"Message for {chat_profile}", + ), + ], + ), + ] + + assert test_config.code.set_starter_categories is not None + + result = await test_config.code.set_starter_categories( + None, "en", "test-profile" + ) + + assert result is not None + assert isinstance(result, list) + assert len(result) == 1 + + assert result[0].label == "Profile Specific" + assert result[0].icon == "https://example.com/profile.png" + assert len(result[0].starters) == 1 + assert result[0].starters[0].label == "Starter for test-profile" + assert result[0].starters[0].message == "Message for test-profile" + + +async def test_on_shared_thread_view_allow( + mock_chainlit_context, test_config: config.ChainlitConfig +): + from chainlit.callbacks import on_shared_thread_view + from chainlit.user import User + + async with mock_chainlit_context: + # Simulate a viewer with access to certain chat profiles + allowed_profiles_by_user = {"viewer": {"pro", "basic"}} + + @on_shared_thread_view + async def allow_shared_view(thread, viewer: User | None): + md = thread.get("metadata") or {} + chat_profile = (md or {}).get("chat_profile") + if not md.get("is_shared"): + return False + if not viewer: + return False + return chat_profile in allowed_profiles_by_user.get( + viewer.identifier, set() + ) + + assert test_config.code.on_shared_thread_view is not None + + thread: ThreadDict = { + "id": "t1", + "createdAt": "2025-09-03T00:00:00Z", + "name": "Shared Thread", + "userId": "author_id", + "userIdentifier": "author", + "tags": [], + "metadata": {"is_shared": True, "chat_profile": "pro"}, + "steps": [], + "elements": [], + } + viewer = User(identifier="viewer") + + res = await test_config.code.on_shared_thread_view(thread, viewer) + assert res is True + + +async def test_on_shared_thread_view_block_and_exception( + mock_chainlit_context, test_config: config.ChainlitConfig +): + from chainlit.callbacks import on_shared_thread_view + from chainlit.user import User + + async with mock_chainlit_context: + # Case 1: Explicitly return False when profile not allowed + @on_shared_thread_view + async def deny_when_not_allowed(thread, viewer: User | None): + md = thread.get("metadata") or {} + return md.get("chat_profile") == "allowed" + + assert test_config.code.on_shared_thread_view is not None + + thread: ThreadDict = { + "id": "t2", + "createdAt": "2025-09-03T00:00:00Z", + "name": "Shared Thread", + "userId": "author_id", + "userIdentifier": "author", + "tags": [], + "metadata": {"is_shared": True, "chat_profile": "restricted"}, + "steps": [], + "elements": [], + } + viewer = User(identifier="viewer") + res = await test_config.code.on_shared_thread_view(thread, viewer) + assert not res + + # Case 2: Raise an exception inside callback; wrapper should swallow and result should be falsy + @on_shared_thread_view + async def raise_on_forbidden(thread, viewer: User | None): + md = thread.get("metadata") or {} + if md.get("chat_profile") == "forbidden": + raise ValueError("Viewer not allowed for this profile") + return True + + assert test_config.code.on_shared_thread_view is not None + + thread_err: ThreadDict = { + "id": "t3", + "createdAt": "2025-09-03T00:00:00Z", + "name": "Shared Thread", + "userId": "author_id", + "userIdentifier": "author", + "tags": [], + "metadata": {"is_shared": True, "chat_profile": "forbidden"}, + "steps": [], + "elements": [], + } + res2 = await test_config.code.on_shared_thread_view(thread_err, viewer) + assert not res2 + + +async def test_on_chat_end(mock_chainlit_context, test_config: config.ChainlitConfig): + from chainlit.callbacks import on_chat_end + + async with mock_chainlit_context: + chat_ended = False + + @on_chat_end + async def handle_chat_end(): + nonlocal chat_ended + chat_ended = True + + # Test that the callback is properly registered + assert test_config.code.on_chat_end is not None + + # Call the registered callback + await test_config.code.on_chat_end() + + # Check that the chat_ended flag was set + assert chat_ended + + +def test_data_layer_config( + mock_data_layer: AsyncMock, + test_config: config.ChainlitConfig, + mock_get_data_layer: Mock, +): + """Test whether we can properly configure a data layer.""" + + # Test that the callback is properly registered + assert test_config.code.data_layer is not None + + # Call the registered callback + result = test_config.code.data_layer() + + # Check that the result is an instance of MockDataLayer + assert isinstance(result, BaseDataLayer) + + mock_get_data_layer.assert_called_once() + + +def test_chat_profile_with_config_overrides(): + """Test that ChatProfile can be created with config_overrides.""" + from chainlit.config import ( + ChainlitConfigOverrides, + FeaturesSettings, + McpFeature, + UISettings, + ) + from chainlit.types import ChatProfile + + # Test creating a profile without config_overrides + basic_profile = ChatProfile( + name="Basic Profile", markdown_description="A basic profile without overrides" + ) + assert basic_profile.config_overrides is None + + # Test creating a profile with config_overrides + config_overrides = ChainlitConfigOverrides( + features=FeaturesSettings(mcp=McpFeature(enabled=True)), + ui=UISettings( + name="Custom App Name", + description="Custom description", + default_theme="light", + ), + ) + + profile_with_overrides = ChatProfile( + name="MCP Profile", + markdown_description="A profile with MCP enabled", + config_overrides=config_overrides, + ) + + # Verify the profile was created successfully + assert profile_with_overrides.name == "MCP Profile" + assert profile_with_overrides.config_overrides is not None + assert profile_with_overrides.config_overrides.features.mcp.enabled is True + assert profile_with_overrides.config_overrides.ui.name == "Custom App Name" + assert profile_with_overrides.config_overrides.ui.default_theme == "light" + + +async def test_set_chat_profiles_with_config_overrides( + mock_chainlit_context, test_config: config.ChainlitConfig +): + """Test that set_chat_profiles callback works with profiles that have config_overrides.""" + from chainlit.callbacks import set_chat_profiles + from chainlit.config import ( + ChainlitConfigOverrides, + FeaturesSettings, + McpFeature, + UISettings, + ) + from chainlit.types import ChatProfile + + async with mock_chainlit_context: + + @set_chat_profiles + async def get_chat_profiles(user, language): + return [ + ChatProfile( + name="Basic Profile", + markdown_description="A basic profile without overrides", + ), + ChatProfile( + name="MCP Profile", + markdown_description="A profile with MCP enabled", + config_overrides=ChainlitConfigOverrides( + features=FeaturesSettings(mcp=McpFeature(enabled=True)), + ui=UISettings(name="MCP Assistant", default_theme="dark"), + ), + ), + ChatProfile( + name="Light Theme Profile", + markdown_description="A profile with light theme", + config_overrides=ChainlitConfigOverrides( + ui=UISettings(name="Light Theme App", default_theme="light") + ), + ), + ] + + # Test that the callback is properly registered + assert test_config.code.set_chat_profiles is not None + + # Call the registered callback + result = await test_config.code.set_chat_profiles(None, None) + + # Check the result + assert result is not None + assert isinstance(result, list) + assert len(result) == 3 + + # Test basic profile + basic_profile = result[0] + assert basic_profile.name == "Basic Profile" + assert basic_profile.config_overrides is None + + # Test MCP profile + mcp_profile = result[1] + assert mcp_profile.name == "MCP Profile" + assert mcp_profile.config_overrides is not None + assert mcp_profile.config_overrides.features.mcp.enabled is True + assert mcp_profile.config_overrides.ui.name == "MCP Assistant" + assert mcp_profile.config_overrides.ui.default_theme == "dark" + + # Test light theme profile + light_profile = result[2] + assert light_profile.name == "Light Theme Profile" + assert light_profile.config_overrides is not None + assert light_profile.config_overrides.ui.name == "Light Theme App" + assert light_profile.config_overrides.ui.default_theme == "light" diff --git a/backend/tests/test_chat_context.py b/backend/tests/test_chat_context.py new file mode 100644 index 0000000..dae92ac --- /dev/null +++ b/backend/tests/test_chat_context.py @@ -0,0 +1,459 @@ +import asyncio +from contextlib import contextmanager +from unittest.mock import Mock, patch + +from chainlit.chat_context import chat_context, chat_contexts +from chainlit.context import ChainlitContext, context_var + + +@contextmanager +def mock_chainlit_context(session=None): + """Context manager to set up and tear down Chainlit context.""" + # Mock the event loop since we're not in an async context + mock_loop = Mock(spec=asyncio.AbstractEventLoop) + + with patch("asyncio.get_running_loop", return_value=mock_loop): + mock_context = ChainlitContext(session=session) + token = context_var.set(mock_context) + try: + yield mock_context + finally: + context_var.reset(token) + + +class TestChatContext: + """Test suite for ChatContext class.""" + + def setup_method(self): + """Clear chat_contexts before each test.""" + chat_contexts.clear() + + def teardown_method(self): + """Clear chat_contexts after each test.""" + chat_contexts.clear() + + def test_get_without_session(self): + """Test get returns empty list when no session exists.""" + with mock_chainlit_context(session=None): + result = chat_context.get() + assert result == [] + + def test_get_with_new_session(self): + """Test get creates new chat context for new session.""" + mock_session = Mock() + mock_session.id = "session_123" + + with mock_chainlit_context(session=mock_session): + result = chat_context.get() + + assert result == [] + assert "session_123" in chat_contexts + assert chat_contexts["session_123"] == [] + + def test_get_returns_copy(self): + """Test get returns a copy of the chat context.""" + mock_session = Mock() + mock_session.id = "session_123" + + mock_message = Mock() + chat_contexts["session_123"] = [mock_message] + + with mock_chainlit_context(session=mock_session): + result = chat_context.get() + + assert result == [mock_message] + # Verify it's a copy, not the original + assert result is not chat_contexts["session_123"] + + def test_get_with_existing_messages(self): + """Test get returns existing messages.""" + mock_session = Mock() + mock_session.id = "session_123" + + mock_msg1 = Mock() + mock_msg2 = Mock() + chat_contexts["session_123"] = [mock_msg1, mock_msg2] + + with mock_chainlit_context(session=mock_session): + result = chat_context.get() + + assert len(result) == 2 + assert mock_msg1 in result + assert mock_msg2 in result + + def test_add_without_session(self): + """Test add does nothing when no session exists.""" + mock_message = Mock() + + with mock_chainlit_context(session=None): + result = chat_context.add(mock_message) + + assert result is None + assert len(chat_contexts) == 0 + + def test_add_with_new_session(self): + """Test add creates new chat context and adds message.""" + mock_session = Mock() + mock_session.id = "session_123" + mock_message = Mock() + + with mock_chainlit_context(session=mock_session): + result = chat_context.add(mock_message) + + assert result == mock_message + assert "session_123" in chat_contexts + assert mock_message in chat_contexts["session_123"] + + def test_add_message_to_existing_context(self): + """Test add appends message to existing context.""" + mock_session = Mock() + mock_session.id = "session_123" + + mock_msg1 = Mock() + mock_msg2 = Mock() + chat_contexts["session_123"] = [mock_msg1] + + with mock_chainlit_context(session=mock_session): + result = chat_context.add(mock_msg2) + + assert result == mock_msg2 + assert len(chat_contexts["session_123"]) == 2 + assert mock_msg1 in chat_contexts["session_123"] + assert mock_msg2 in chat_contexts["session_123"] + + def test_add_duplicate_message(self): + """Test add does not add duplicate messages.""" + mock_session = Mock() + mock_session.id = "session_123" + mock_message = Mock() + + chat_contexts["session_123"] = [mock_message] + + with mock_chainlit_context(session=mock_session): + result = chat_context.add(mock_message) + + assert result == mock_message + assert len(chat_contexts["session_123"]) == 1 + + def test_remove_without_session(self): + """Test remove returns False when no session exists.""" + mock_message = Mock() + + with mock_chainlit_context(session=None): + result = chat_context.remove(mock_message) + + assert result is False + + def test_remove_with_nonexistent_context(self): + """Test remove returns False when context doesn't exist.""" + mock_session = Mock() + mock_session.id = "session_123" + mock_message = Mock() + + with mock_chainlit_context(session=mock_session): + result = chat_context.remove(mock_message) + + assert result is False + + def test_remove_nonexistent_message(self): + """Test remove returns False when message not in context.""" + mock_session = Mock() + mock_session.id = "session_123" + + mock_msg1 = Mock() + mock_msg2 = Mock() + chat_contexts["session_123"] = [mock_msg1] + + with mock_chainlit_context(session=mock_session): + result = chat_context.remove(mock_msg2) + + assert result is False + assert mock_msg1 in chat_contexts["session_123"] + + def test_remove_existing_message(self): + """Test remove successfully removes message.""" + mock_session = Mock() + mock_session.id = "session_123" + + mock_msg1 = Mock() + mock_msg2 = Mock() + chat_contexts["session_123"] = [mock_msg1, mock_msg2] + + with mock_chainlit_context(session=mock_session): + result = chat_context.remove(mock_msg1) + + assert result is True + assert mock_msg1 not in chat_contexts["session_123"] + assert mock_msg2 in chat_contexts["session_123"] + assert len(chat_contexts["session_123"]) == 1 + + def test_clear_without_session(self): + """Test clear does nothing when no session exists.""" + chat_contexts["session_123"] = [Mock()] + + with mock_chainlit_context(session=None): + chat_context.clear() + + # Original context should remain + assert "session_123" in chat_contexts + + def test_clear_with_nonexistent_context(self): + """Test clear does nothing when context doesn't exist.""" + mock_session = Mock() + mock_session.id = "session_456" + + chat_contexts["session_123"] = [Mock()] + + with mock_chainlit_context(session=mock_session): + chat_context.clear() + + # Original context should remain + assert "session_123" in chat_contexts + + def test_clear_existing_context(self): + """Test clear empties existing context.""" + mock_session = Mock() + mock_session.id = "session_123" + + mock_msg1 = Mock() + mock_msg2 = Mock() + chat_contexts["session_123"] = [mock_msg1, mock_msg2] + + with mock_chainlit_context(session=mock_session): + chat_context.clear() + + assert "session_123" in chat_contexts + assert chat_contexts["session_123"] == [] + + def test_to_openai_with_assistant_message(self): + """Test to_openai converts assistant messages correctly.""" + mock_session = Mock() + mock_session.id = "session_123" + + mock_message = Mock() + mock_message.type = "assistant_message" + mock_message.content = "Hello, how can I help?" + + chat_contexts["session_123"] = [mock_message] + + with mock_chainlit_context(session=mock_session): + result = chat_context.to_openai() + + assert len(result) == 1 + assert result[0] == { + "role": "assistant", + "content": "Hello, how can I help?", + } + + def test_to_openai_with_user_message(self): + """Test to_openai converts user messages correctly.""" + mock_session = Mock() + mock_session.id = "session_123" + + mock_message = Mock() + mock_message.type = "user_message" + mock_message.content = "What is the weather?" + + chat_contexts["session_123"] = [mock_message] + + with mock_chainlit_context(session=mock_session): + result = chat_context.to_openai() + + assert len(result) == 1 + assert result[0] == {"role": "user", "content": "What is the weather?"} + + def test_to_openai_with_system_message(self): + """Test to_openai converts system messages correctly.""" + mock_session = Mock() + mock_session.id = "session_123" + + mock_message = Mock() + mock_message.type = "system_message" + mock_message.content = "You are a helpful assistant." + + chat_contexts["session_123"] = [mock_message] + + with mock_chainlit_context(session=mock_session): + result = chat_context.to_openai() + + assert len(result) == 1 + assert result[0] == { + "role": "system", + "content": "You are a helpful assistant.", + } + + def test_to_openai_with_unknown_message_type(self): + """Test to_openai treats unknown types as system messages.""" + mock_session = Mock() + mock_session.id = "session_123" + + mock_message = Mock() + mock_message.type = "unknown_type" + mock_message.content = "Unknown message" + + chat_contexts["session_123"] = [mock_message] + + with mock_chainlit_context(session=mock_session): + result = chat_context.to_openai() + + assert len(result) == 1 + assert result[0] == {"role": "system", "content": "Unknown message"} + + def test_to_openai_with_multiple_messages(self): + """Test to_openai converts multiple messages correctly.""" + mock_session = Mock() + mock_session.id = "session_123" + + mock_msg1 = Mock() + mock_msg1.type = "user_message" + mock_msg1.content = "Hello" + + mock_msg2 = Mock() + mock_msg2.type = "assistant_message" + mock_msg2.content = "Hi there!" + + mock_msg3 = Mock() + mock_msg3.type = "user_message" + mock_msg3.content = "How are you?" + + chat_contexts["session_123"] = [mock_msg1, mock_msg2, mock_msg3] + + with mock_chainlit_context(session=mock_session): + result = chat_context.to_openai() + + assert len(result) == 3 + assert result[0] == {"role": "user", "content": "Hello"} + assert result[1] == {"role": "assistant", "content": "Hi there!"} + assert result[2] == {"role": "user", "content": "How are you?"} + + def test_to_openai_with_empty_context(self): + """Test to_openai returns empty list for empty context.""" + mock_session = Mock() + mock_session.id = "session_123" + + chat_contexts["session_123"] = [] + + with mock_chainlit_context(session=mock_session): + result = chat_context.to_openai() + + assert result == [] + + def test_to_openai_without_session(self): + """Test to_openai returns empty list when no session exists.""" + with mock_chainlit_context(session=None): + result = chat_context.to_openai() + + assert result == [] + + +class TestChatContextEdgeCases: + """Test suite for chat_context edge cases.""" + + def setup_method(self): + """Clear chat_contexts before each test.""" + chat_contexts.clear() + + def teardown_method(self): + """Clear chat_contexts after each test.""" + chat_contexts.clear() + + def test_multiple_sessions_isolated(self): + """Test that different sessions have isolated contexts.""" + mock_session1 = Mock() + mock_session1.id = "session_1" + + mock_session2 = Mock() + mock_session2.id = "session_2" + + mock_msg1 = Mock() + mock_msg2 = Mock() + + with mock_chainlit_context(session=mock_session1): + chat_context.add(mock_msg1) + + with mock_chainlit_context(session=mock_session2): + chat_context.add(mock_msg2) + + assert len(chat_contexts) == 2 + assert mock_msg1 in chat_contexts["session_1"] + assert mock_msg2 in chat_contexts["session_2"] + assert mock_msg1 not in chat_contexts["session_2"] + assert mock_msg2 not in chat_contexts["session_1"] + + def test_add_then_remove_then_add_again(self): + """Test adding, removing, and re-adding the same message.""" + mock_session = Mock() + mock_session.id = "session_123" + mock_message = Mock() + + with mock_chainlit_context(session=mock_session): + # Add + chat_context.add(mock_message) + assert len(chat_contexts["session_123"]) == 1 + + # Remove + result = chat_context.remove(mock_message) + assert result is True + assert len(chat_contexts["session_123"]) == 0 + + # Add again + chat_context.add(mock_message) + assert len(chat_contexts["session_123"]) == 1 + + def test_clear_then_add(self): + """Test adding messages after clearing context.""" + mock_session = Mock() + mock_session.id = "session_123" + + mock_msg1 = Mock() + mock_msg2 = Mock() + + with mock_chainlit_context(session=mock_session): + chat_context.add(mock_msg1) + chat_context.clear() + chat_context.add(mock_msg2) + + result = chat_context.get() + assert len(result) == 1 + assert mock_msg2 in result + assert mock_msg1 not in result + + def test_to_openai_with_mixed_message_types(self): + """Test to_openai with various message types in sequence.""" + mock_session = Mock() + mock_session.id = "session_123" + + messages = [ + Mock(type="system_message", content="System prompt"), + Mock(type="user_message", content="User query"), + Mock(type="assistant_message", content="Assistant response"), + Mock(type="other_type", content="Other message"), + ] + + chat_contexts["session_123"] = messages + + with mock_chainlit_context(session=mock_session): + result = chat_context.to_openai() + + assert len(result) == 4 + assert result[0]["role"] == "system" + assert result[1]["role"] == "user" + assert result[2]["role"] == "assistant" + assert result[3]["role"] == "system" # Unknown types default to system + + def test_chat_context_singleton(self): + """Test that chat_context is a singleton instance.""" + from chainlit.chat_context import chat_context as imported_context + + assert chat_context is imported_context + + def test_add_returns_message(self): + """Test that add returns the message for chaining.""" + mock_session = Mock() + mock_session.id = "session_123" + mock_message = Mock() + + with mock_chainlit_context(session=mock_session): + result = chat_context.add(mock_message) + + assert result is mock_message diff --git a/backend/tests/test_chat_settings.py b/backend/tests/test_chat_settings.py new file mode 100644 index 0000000..1dd4e35 --- /dev/null +++ b/backend/tests/test_chat_settings.py @@ -0,0 +1,343 @@ +import pytest + +from chainlit.chat_settings import ChatSettings +from chainlit.input_widget import ( + Checkbox, + NumberInput, + Select, + Slider, + Switch, + Tab, + TextInput, +) + + +@pytest.mark.asyncio +class TestChatSettings: + """Test suite for ChatSettings class.""" + + async def test_chat_settings_initialization(self, mock_chainlit_context): + """Test ChatSettings initialization with input widgets.""" + async with mock_chainlit_context: + switch = Switch(id="enable_feature", label="Enable Feature") + slider = Slider(id="temperature", label="Temperature", initial=0.7) + + settings = ChatSettings(inputs=[switch, slider]) + + assert len(settings.inputs) == 2 + assert settings.inputs[0] == switch + assert settings.inputs[1] == slider + + async def test_chat_settings_with_empty_inputs(self, mock_chainlit_context): + """Test ChatSettings with empty inputs list.""" + async with mock_chainlit_context: + settings = ChatSettings(inputs=[]) + + assert settings.inputs == [] + + async def test_chat_settings_settings_method(self, mock_chainlit_context): + """Test ChatSettings.settings() method returns initial values.""" + async with mock_chainlit_context: + switch = Switch(id="enable_feature", label="Enable", initial=True) + slider = Slider(id="temperature", label="Temperature", initial=0.7) + text = TextInput(id="model", label="Model", initial="gpt-4") + + settings = ChatSettings(inputs=[switch, slider, text]) + result = settings.settings() + + assert result["enable_feature"] is True + assert result["temperature"] == 0.7 + assert result["model"] == "gpt-4" + + async def test_chat_settings_with_tabs(self, mock_chainlit_context): + """Test ChatSettings with Tab containers.""" + async with mock_chainlit_context: + # Create inputs for tabs + switch1 = Switch(id="switch1", label="Switch 1", initial=True) + slider1 = Slider(id="slider1", label="Slider 1", initial=5) + + switch2 = Switch(id="switch2", label="Switch 2", initial=False) + text2 = TextInput(id="text2", label="Text 2", initial="value") + + # Create tabs + tab1 = Tab(id="tab1", label="Tab 1", inputs=[switch1, slider1]) + tab2 = Tab(id="tab2", label="Tab 2", inputs=[switch2, text2]) + + settings = ChatSettings(inputs=[tab1, tab2]) + + assert len(settings.inputs) == 2 + assert isinstance(settings.inputs[0], Tab) + assert isinstance(settings.inputs[1], Tab) + + async def test_chat_settings_settings_with_tabs(self, mock_chainlit_context): + """Test ChatSettings.settings() collects values from tabs.""" + async with mock_chainlit_context: + switch1 = Switch(id="switch1", label="Switch 1", initial=True) + slider1 = Slider(id="slider1", label="Slider 1", initial=5) + + switch2 = Switch(id="switch2", label="Switch 2", initial=False) + text2 = TextInput(id="text2", label="Text 2", initial="value") + + tab1 = Tab(id="tab1", label="Tab 1", inputs=[switch1, slider1]) + tab2 = Tab(id="tab2", label="Tab 2", inputs=[switch2, text2]) + + settings = ChatSettings(inputs=[tab1, tab2]) + result = settings.settings() + + # Should collect all inputs from all tabs + assert result["switch1"] is True + assert result["slider1"] == 5 + assert result["switch2"] is False + assert result["text2"] == "value" + + async def test_chat_settings_send(self, mock_chainlit_context): + """Test ChatSettings.send() method.""" + async with mock_chainlit_context as ctx: + switch = Switch(id="enable", label="Enable", initial=True) + slider = Slider(id="temp", label="Temperature", initial=0.8) + + settings = ChatSettings(inputs=[switch, slider]) + result = await settings.send() + + # Verify settings were returned + assert result["enable"] is True + assert result["temp"] == 0.8 + + # Verify emitter methods were called + ctx.emitter.set_chat_settings.assert_called_once_with(result) + ctx.emitter.emit.assert_called_once() + + # Verify emit was called with correct arguments + call_args = ctx.emitter.emit.call_args + assert call_args[0][0] == "chat_settings" + assert len(call_args[0][1]) == 2 # Two inputs + + async def test_chat_settings_refresh(self, mock_chainlit_context): + """Test ChatSettings.refresh() emits UI without updating session.""" + async with mock_chainlit_context as ctx: + switch = Switch(id="enable", label="Enable", initial=True) + slider = Slider(id="temp", label="Temperature", initial=0.8) + + settings = ChatSettings(inputs=[switch, slider]) + result = await settings.refresh() + + assert result["enable"] is True + assert result["temp"] == 0.8 + + ctx.emitter.set_chat_settings.assert_not_called() + ctx.emitter.emit.assert_called_once() + call_args = ctx.emitter.emit.call_args + assert call_args[0][0] == "chat_settings" + assert len(call_args[0][1]) == 2 + + async def test_chat_settings_send_with_tabs(self, mock_chainlit_context): + """Test ChatSettings.send() with tabs.""" + async with mock_chainlit_context as ctx: + switch = Switch(id="switch1", label="Switch", initial=True) + slider = Slider(id="slider1", label="Slider", initial=5) + + tab = Tab(id="tab1", label="Settings", inputs=[switch, slider]) + settings = ChatSettings(inputs=[tab]) + result = await settings.send() + + # Verify settings collected from tab + assert result["switch1"] is True + assert result["slider1"] == 5 + + # Verify emitter was called + ctx.emitter.set_chat_settings.assert_called_once() + ctx.emitter.emit.assert_called_once() + + async def test_chat_settings_with_all_widget_types(self, mock_chainlit_context): + """Test ChatSettings with all widget types.""" + async with mock_chainlit_context: + widgets = [ + Switch(id="switch", label="Switch", initial=True), + Slider(id="slider", label="Slider", initial=5, min=0, max=10), + Select( + id="select", + label="Select", + values=["a", "b", "c"], + initial_index=1, + ), + TextInput(id="text", label="Text", initial="hello"), + NumberInput(id="number", label="Number", initial=42.0), + Checkbox(id="checkbox", label="Checkbox", initial=False), + ] + + settings = ChatSettings(inputs=widgets) + result = settings.settings() + + assert result["switch"] is True + assert result["slider"] == 5 + assert result["select"] == "b" + assert result["text"] == "hello" + assert result["number"] == 42.0 + assert result["checkbox"] is False + + async def test_chat_settings_with_nested_tabs(self, mock_chainlit_context): + """Test ChatSettings.settings() with nested structure.""" + async with mock_chainlit_context: + # Create multiple tabs with different inputs + tab1_inputs = [ + Switch(id="t1_switch", label="T1 Switch", initial=True), + Slider(id="t1_slider", label="T1 Slider", initial=3), + ] + + tab2_inputs = [ + TextInput(id="t2_text", label="T2 Text", initial="test"), + Checkbox(id="t2_check", label="T2 Check", initial=True), + ] + + tab1 = Tab(id="tab1", label="Tab 1", inputs=tab1_inputs) + tab2 = Tab(id="tab2", label="Tab 2", inputs=tab2_inputs) + + settings = ChatSettings(inputs=[tab1, tab2]) + result = settings.settings() + + # All inputs from all tabs should be collected + assert result["t1_switch"] is True + assert result["t1_slider"] == 3 + assert result["t2_text"] == "test" + assert result["t2_check"] is True + assert len(result) == 4 + + async def test_chat_settings_only_widgets_or_only_tabs(self, mock_chainlit_context): + """Test that ChatSettings accepts either all widgets or all tabs, not mixed.""" + async with mock_chainlit_context: + # Test with only widgets + widgets = [ + Switch(id="switch", label="Switch", initial=True), + Slider(id="slider", label="Slider", initial=7), + ] + settings_widgets = ChatSettings(inputs=widgets) + result_widgets = settings_widgets.settings() + assert result_widgets["switch"] is True + assert result_widgets["slider"] == 7 + + # Test with only tabs + tab1 = Tab( + id="tab1", + label="Tab 1", + inputs=[Switch(id="t1_switch", label="Switch", initial=False)], + ) + tab2 = Tab( + id="tab2", + label="Tab 2", + inputs=[Slider(id="t2_slider", label="Slider", initial=3)], + ) + settings_tabs = ChatSettings(inputs=[tab1, tab2]) + result_tabs = settings_tabs.settings() + assert result_tabs["t1_switch"] is False + assert result_tabs["t2_slider"] == 3 + + async def test_chat_settings_with_none_initial_values(self, mock_chainlit_context): + """Test ChatSettings with widgets having None initial values.""" + async with mock_chainlit_context: + text = TextInput(id="text", label="Text", initial=None) + number = NumberInput(id="number", label="Number", initial=None) + select = Select( + id="select", label="Select", values=["a", "b"], initial_value=None + ) + + settings = ChatSettings(inputs=[text, number, select]) + result = settings.settings() + + assert result["text"] is None + assert result["number"] is None + assert result["select"] is None + + +@pytest.mark.asyncio +class TestChatSettingsEdgeCases: + """Test suite for ChatSettings edge cases.""" + + async def test_chat_settings_empty_tabs(self, mock_chainlit_context): + """Test ChatSettings with empty tabs.""" + async with mock_chainlit_context: + empty_tab = Tab(id="empty", label="Empty Tab", inputs=[]) + settings = ChatSettings(inputs=[empty_tab]) + result = settings.settings() + + assert result == {} + + async def test_chat_settings_duplicate_ids(self, mock_chainlit_context): + """Test ChatSettings behavior with duplicate IDs (last one wins).""" + async with mock_chainlit_context: + switch1 = Switch(id="duplicate", label="Switch 1", initial=True) + switch2 = Switch(id="duplicate", label="Switch 2", initial=False) + + settings = ChatSettings(inputs=[switch1, switch2]) + result = settings.settings() + + # Last value should win + assert result["duplicate"] is False + + async def test_chat_settings_send_returns_settings(self, mock_chainlit_context): + """Test that send() returns the settings dictionary.""" + async with mock_chainlit_context: + switch = Switch(id="test", label="Test", initial=True) + settings = ChatSettings(inputs=[switch]) + + result = await settings.send() + + assert isinstance(result, dict) + assert "test" in result + assert result["test"] is True + + async def test_chat_settings_to_dict_serialization(self, mock_chainlit_context): + """Test that inputs are properly serialized in send().""" + async with mock_chainlit_context as ctx: + switch = Switch(id="switch", label="Switch", initial=True) + slider = Slider(id="slider", label="Slider", initial=5) + + settings = ChatSettings(inputs=[switch, slider]) + await settings.send() + + # Check that emit was called with serialized inputs + call_args = ctx.emitter.emit.call_args + inputs_content = call_args[0][1] + + assert len(inputs_content) == 2 + assert inputs_content[0]["type"] == "switch" + assert inputs_content[0]["id"] == "switch" + assert inputs_content[1]["type"] == "slider" + assert inputs_content[1]["id"] == "slider" + + async def test_chat_settings_with_complex_tab_structure( + self, mock_chainlit_context + ): + """Test ChatSettings with complex tab structure.""" + async with mock_chainlit_context: + # Create a complex structure with multiple tabs + tab1 = Tab( + id="general", + label="General", + inputs=[ + Switch(id="enabled", label="Enabled", initial=True), + TextInput(id="name", label="Name", initial="MyApp"), + ], + ) + + tab2 = Tab( + id="advanced", + label="Advanced", + inputs=[ + Slider(id="timeout", label="Timeout", initial=30, min=0, max=60), + Select( + id="mode", + label="Mode", + values=["dev", "prod"], + initial_index=0, + ), + ], + ) + + settings = ChatSettings(inputs=[tab1, tab2]) + result = settings.settings() + + assert result["enabled"] is True + assert result["name"] == "MyApp" + assert result["timeout"] == 30 + assert result["mode"] == "dev" + assert len(result) == 4 diff --git a/backend/tests/test_config.py b/backend/tests/test_config.py new file mode 100644 index 0000000..a1f9dff --- /dev/null +++ b/backend/tests/test_config.py @@ -0,0 +1,127 @@ +import json +from pathlib import Path + +import pytest + +from chainlit import config as chainlit_config +from chainlit.config import ChainlitConfig + + +@pytest.fixture +def translation_dir(tmp_path: Path) -> Path: + """Minimal translation directory with a controlled set of locale files.""" + t_dir = tmp_path / "translations" + t_dir.mkdir() + + files: dict[str, dict] = { + "en-US.json": {"greeting": "Hello"}, + "es.json": {"greeting": "Hola"}, + "da-DK.json": {"greeting": "Hej"}, + "de-DE.json": {"greeting": "Hallo"}, + "zh-CN.json": {"greeting": "你好 CN"}, + "zh-TW.json": {"greeting": "你好 TW"}, + } + for filename, content in files.items(): + (t_dir / filename).write_text(json.dumps(content), encoding="utf-8") + + return t_dir + + +class TestLoadTranslation: + """Regression tests for the load_translation fallback chain.""" + + def test_exact_match_regional( + self, + test_config: ChainlitConfig, + translation_dir: Path, + monkeypatch: pytest.MonkeyPatch, + ): + """Exact regional locale (da-DK) resolves directly to its file.""" + monkeypatch.setattr( + chainlit_config, "config_translation_dir", str(translation_dir) + ) + assert test_config.load_translation("da-DK") == {"greeting": "Hej"} + + def test_exact_match_base( + self, + test_config: ChainlitConfig, + translation_dir: Path, + monkeypatch: pytest.MonkeyPatch, + ): + """Exact base locale (es) resolves directly to its file.""" + monkeypatch.setattr( + chainlit_config, "config_translation_dir", str(translation_dir) + ) + assert test_config.load_translation("es") == {"greeting": "Hola"} + + def test_parent_fallback( + self, + test_config: ChainlitConfig, + translation_dir: Path, + monkeypatch: pytest.MonkeyPatch, + ): + """Regional locale (es-419) falls back to base file (es.json) when no exact match.""" + monkeypatch.setattr( + chainlit_config, "config_translation_dir", str(translation_dir) + ) + assert test_config.load_translation("es-419") == {"greeting": "Hola"} + + def test_regional_variant_lookup( + self, + test_config: ChainlitConfig, + translation_dir: Path, + monkeypatch: pytest.MonkeyPatch, + ): + """Base locale (da) resolves to regional file (da-DK.json) when no exact match exists.""" + monkeypatch.setattr( + chainlit_config, "config_translation_dir", str(translation_dir) + ) + assert test_config.load_translation("da") == {"greeting": "Hej"} + + def test_regional_variant_lookup_de( + self, + test_config: ChainlitConfig, + translation_dir: Path, + monkeypatch: pytest.MonkeyPatch, + ): + """Base locale (de) resolves to regional file (de-DE.json) via variant lookup.""" + monkeypatch.setattr( + chainlit_config, "config_translation_dir", str(translation_dir) + ) + assert test_config.load_translation("de") == {"greeting": "Hallo"} + + def test_regional_variant_sorted_deterministic( + self, + test_config: ChainlitConfig, + translation_dir: Path, + monkeypatch: pytest.MonkeyPatch, + ): + """When multiple regional variants exist, the first sorted match (zh-CN) is returned.""" + monkeypatch.setattr( + chainlit_config, "config_translation_dir", str(translation_dir) + ) + assert test_config.load_translation("zh") == {"greeting": "你好 CN"} + + def test_default_fallback_unknown_locale( + self, + test_config: ChainlitConfig, + translation_dir: Path, + monkeypatch: pytest.MonkeyPatch, + ): + """Completely unknown locale (xx) falls back to en-US.""" + monkeypatch.setattr( + chainlit_config, "config_translation_dir", str(translation_dir) + ) + assert test_config.load_translation("xx") == {"greeting": "Hello"} + + def test_default_fallback_base_without_regional_variant( + self, + test_config: ChainlitConfig, + translation_dir: Path, + monkeypatch: pytest.MonkeyPatch, + ): + """Base locale (fr) with no matching file at all falls back to en-US.""" + monkeypatch.setattr( + chainlit_config, "config_translation_dir", str(translation_dir) + ) + assert test_config.load_translation("fr") == {"greeting": "Hello"} diff --git a/backend/tests/test_context.py b/backend/tests/test_context.py new file mode 100644 index 0000000..6ffb12c --- /dev/null +++ b/backend/tests/test_context.py @@ -0,0 +1,55 @@ +from unittest.mock import Mock + +import pytest + +from chainlit.context import ( + ChainlitContext, + ChainlitContextException, + get_context, + init_http_context, + init_ws_context, +) +from chainlit.emitter import BaseChainlitEmitter, ChainlitEmitter +from chainlit.session import HTTPSession + + +@pytest.fixture +def mock_emitter(): + return Mock(spec=BaseChainlitEmitter) + + +async def test_chainlit_context_init_with_websocket( + mock_websocket_session, mock_emitter +): + context = ChainlitContext(mock_websocket_session, mock_emitter) + assert isinstance(context.emitter, BaseChainlitEmitter) + assert context.session == mock_websocket_session + + +async def test_chainlit_context_init_with_http(mock_http_session): + context = ChainlitContext(mock_http_session) + assert isinstance(context.emitter, BaseChainlitEmitter) + assert context.session == mock_http_session + + +async def test_init_ws_context(mock_websocket_session): + context = init_ws_context(mock_websocket_session) + assert isinstance(context, ChainlitContext) + assert context.session == mock_websocket_session + assert isinstance(context.emitter, ChainlitEmitter) + + +async def test_init_http_context(): + context = init_http_context() + assert isinstance(context, ChainlitContext) + assert isinstance(context.session, HTTPSession) + assert isinstance(context.emitter, BaseChainlitEmitter) + + +async def test_get_context(): + with pytest.raises(ChainlitContextException): + get_context() + + init_http_context() # Initialize a context + context = get_context() + assert isinstance(context, ChainlitContext) diff --git a/backend/tests/test_element.py b/backend/tests/test_element.py new file mode 100644 index 0000000..0a6bbd2 --- /dev/null +++ b/backend/tests/test_element.py @@ -0,0 +1,584 @@ +import json +import uuid +from unittest.mock import AsyncMock + +import pytest + +from chainlit.element import ( + Audio, + CustomElement, + Dataframe, + Element, + ElementDict, + File, + Image, + Pdf, + Task, + TaskList, + TaskStatus, + Text, + Video, +) + + +@pytest.mark.asyncio +class TestElementBase: + """Test suite for the base Element class.""" + + async def test_element_initialization_with_url(self, mock_chainlit_context): + """Test Element initialization with URL.""" + async with mock_chainlit_context: + element = File(name="test_file", url="https://example.com/file.pdf") + + assert element.name == "test_file" + assert element.url == "https://example.com/file.pdf" + assert isinstance(element.id, str) + uuid.UUID(element.id) # Verify valid UUID + assert element.persisted is False + assert element.updatable is False + + async def test_element_initialization_with_content(self, mock_chainlit_context): + """Test Element initialization with content.""" + async with mock_chainlit_context: + content = b"test content" + element = File(name="test_file", content=content) + + assert element.name == "test_file" + assert element.content == content + assert element.url is None + assert element.path is None + + async def test_element_initialization_with_path(self, mock_chainlit_context): + """Test Element initialization with path.""" + async with mock_chainlit_context: + element = File(name="test_file", path="/path/to/file.txt") + + assert element.name == "test_file" + assert element.path == "/path/to/file.txt" + assert element.url is None + assert element.content is None + + async def test_element_requires_url_path_or_content(self, mock_chainlit_context): + """Test that Element raises error without url, path, or content.""" + async with mock_chainlit_context: + with pytest.raises(ValueError, match="Must provide url, path or content"): + File(name="test_file") + + async def test_element_to_dict(self, mock_chainlit_context): + """Test Element serialization to dictionary.""" + async with mock_chainlit_context as ctx: + element = File( + name="test_file", + url="https://example.com/file.pdf", + display="inline", + ) + + element_dict = element.to_dict() + + assert element_dict["name"] == "test_file" + assert element_dict["url"] == "https://example.com/file.pdf" + assert element_dict["type"] == "file" + assert element_dict["id"] == element.id + assert element_dict["threadId"] == ctx.session.thread_id + assert element_dict["display"] == "inline" + + async def test_element_send(self, mock_chainlit_context): + """Test Element.send() method.""" + async with mock_chainlit_context as ctx: + element = File(name="test_file", url="https://example.com/file.pdf") + + await element.send(for_id="message_123") + + assert element.for_id == "message_123" + ctx.emitter.send_element.assert_called_once() + + async def test_element_remove(self, mock_chainlit_context): + """Test Element.remove() method.""" + async with mock_chainlit_context as ctx: + element = File(name="test_file", url="https://example.com/file.pdf") + + await element.remove() + + ctx.emitter.emit.assert_called_once_with( + "remove_element", {"id": element.id} + ) + + async def test_element_display_options(self, mock_chainlit_context): + """Test Element display options.""" + async with mock_chainlit_context: + element_inline = File( + name="test", url="https://example.com/file.pdf", display="inline" + ) + element_side = File( + name="test", url="https://example.com/file.pdf", display="side" + ) + element_page = File( + name="test", url="https://example.com/file.pdf", display="page" + ) + + assert element_inline.display == "inline" + assert element_side.display == "side" + assert element_page.display == "page" + + async def test_element_from_dict_file(self, mock_chainlit_context): + """Test Element.from_dict() for File type.""" + async with mock_chainlit_context: + element_dict: ElementDict = { + "id": str(uuid.uuid4()), + "name": "test_file", + "type": "file", + "url": "https://example.com/file.pdf", + "display": "inline", + } + + element = Element.from_dict(element_dict) + + assert isinstance(element, File) + assert element.name == "test_file" + assert element.url == "https://example.com/file.pdf" + + async def test_element_from_dict_image(self, mock_chainlit_context): + """Test Element.from_dict() for Image type.""" + async with mock_chainlit_context: + element_dict: ElementDict = { + "id": str(uuid.uuid4()), + "name": "test_image", + "type": "image", + "url": "https://example.com/image.png", + "display": "inline", + } + + element = Element.from_dict(element_dict) + + assert isinstance(element, Image) + assert element.name == "test_image" + assert element.type == "image" + + async def test_element_infer_type_from_mime(self): + """Test Element.infer_type_from_mime() method.""" + assert Element.infer_type_from_mime("image/png") == "image" + assert Element.infer_type_from_mime("image/jpeg") == "image" + assert Element.infer_type_from_mime("application/pdf") == "pdf" + assert Element.infer_type_from_mime("audio/mp3") == "audio" + assert Element.infer_type_from_mime("video/mp4") == "video" + assert Element.infer_type_from_mime("text/plain") == "file" + assert Element.infer_type_from_mime("application/json") == "file" + + +@pytest.mark.asyncio +class TestImageElement: + """Test suite for Image element.""" + + async def test_image_initialization(self, mock_chainlit_context): + """Test Image element initialization.""" + async with mock_chainlit_context: + image = Image( + name="test_image", + url="https://example.com/image.png", + size="large", + ) + + assert image.type == "image" + assert image.name == "test_image" + assert image.size == "large" + + async def test_image_size_options(self, mock_chainlit_context): + """Test Image size options.""" + async with mock_chainlit_context: + small = Image(name="test", url="https://example.com/img.png", size="small") + medium = Image( + name="test", url="https://example.com/img.png", size="medium" + ) + large = Image(name="test", url="https://example.com/img.png", size="large") + + assert small.size == "small" + assert medium.size == "medium" + assert large.size == "large" + + +@pytest.mark.asyncio +class TestTextElement: + """Test suite for Text element.""" + + async def test_text_initialization(self, mock_chainlit_context): + """Test Text element initialization.""" + async with mock_chainlit_context: + text = Text(name="test_text", content="Hello, World!", language="python") + + assert text.type == "text" + assert text.name == "test_text" + assert text.content == "Hello, World!" + assert text.language == "python" + + async def test_text_without_language(self, mock_chainlit_context): + """Test Text element without language.""" + async with mock_chainlit_context: + text = Text(name="test_text", content="Plain text") + + assert text.language is None + + +@pytest.mark.asyncio +class TestPdfElement: + """Test suite for Pdf element.""" + + async def test_pdf_initialization(self, mock_chainlit_context): + """Test Pdf element initialization.""" + async with mock_chainlit_context: + pdf = Pdf(name="test_pdf", url="https://example.com/document.pdf", page=5) + + assert pdf.type == "pdf" + assert pdf.name == "test_pdf" + assert pdf.mime == "application/pdf" + assert pdf.page == 5 + + async def test_pdf_without_page(self, mock_chainlit_context): + """Test Pdf element without page number.""" + async with mock_chainlit_context: + pdf = Pdf(name="test_pdf", url="https://example.com/document.pdf") + + assert pdf.page is None + + +@pytest.mark.asyncio +class TestAudioElement: + """Test suite for Audio element.""" + + async def test_audio_initialization(self, mock_chainlit_context): + """Test Audio element initialization.""" + async with mock_chainlit_context: + audio = Audio( + name="test_audio", + url="https://example.com/audio.mp3", + auto_play=True, + ) + + assert audio.type == "audio" + assert audio.name == "test_audio" + assert audio.auto_play is True + + async def test_audio_default_auto_play(self, mock_chainlit_context): + """Test Audio element default auto_play.""" + async with mock_chainlit_context: + audio = Audio(name="test_audio", url="https://example.com/audio.mp3") + + assert audio.auto_play is False + + +@pytest.mark.asyncio +class TestVideoElement: + """Test suite for Video element.""" + + async def test_video_initialization(self, mock_chainlit_context): + """Test Video element initialization.""" + async with mock_chainlit_context: + player_config = {"youtube": {"playerVars": {"showinfo": 1}}} + video = Video( + name="test_video", + url="https://example.com/video.mp4", + size="large", + player_config=player_config, + ) + + assert video.type == "video" + assert video.name == "test_video" + assert video.size == "large" + assert video.player_config == player_config + + async def test_video_without_player_config(self, mock_chainlit_context): + """Test Video element without player config.""" + async with mock_chainlit_context: + video = Video(name="test_video", url="https://example.com/video.mp4") + + assert video.player_config is None + + +@pytest.mark.asyncio +class TestFileElement: + """Test suite for File element.""" + + async def test_file_initialization(self, mock_chainlit_context): + """Test File element initialization.""" + async with mock_chainlit_context: + file = File(name="test_file", url="https://example.com/file.txt") + + assert file.type == "file" + assert file.name == "test_file" + + async def test_file_with_content(self, mock_chainlit_context): + """Test File element with content.""" + async with mock_chainlit_context: + content = b"File content" + file = File(name="test_file", content=content) + + assert file.content == content + + +@pytest.mark.asyncio +class TestTaskListElement: + """Test suite for TaskList element.""" + + async def test_tasklist_initialization(self, mock_chainlit_context): + """Test TaskList element initialization.""" + async with mock_chainlit_context: + tasklist = TaskList(name="test_tasklist") + + assert tasklist.type == "tasklist" + assert tasklist.name == "test_tasklist" + assert tasklist.tasks == [] + assert tasklist.status == "Ready" + assert tasklist.updatable is True + + async def test_tasklist_add_task(self, mock_chainlit_context): + """Test adding tasks to TaskList.""" + async with mock_chainlit_context: + tasklist = TaskList(name="test_tasklist") + task1 = Task(title="Task 1", status=TaskStatus.READY) + task2 = Task(title="Task 2", status=TaskStatus.RUNNING) + + await tasklist.add_task(task1) + await tasklist.add_task(task2) + + assert len(tasklist.tasks) == 2 + assert tasklist.tasks[0].title == "Task 1" + assert tasklist.tasks[1].title == "Task 2" + + async def test_tasklist_preprocess_content(self, mock_chainlit_context): + """Test TaskList content preprocessing.""" + async with mock_chainlit_context: + tasklist = TaskList(name="test_tasklist", status="In Progress") + task = Task(title="Test Task", status=TaskStatus.DONE) + await tasklist.add_task(task) + + await tasklist.preprocess_content() + + assert isinstance(tasklist.content, str) + assert "Test Task" in tasklist.content + assert "done" in tasklist.content + assert "In Progress" in tasklist.content + + +@pytest.mark.asyncio +class TestTaskClass: + """Test suite for Task class.""" + + def test_task_initialization(self): + """Test Task initialization.""" + task = Task(title="Test Task", status=TaskStatus.READY) + + assert task.title == "Test Task" + assert task.status == TaskStatus.READY + assert task.forId is None + + def test_task_with_for_id(self): + """Test Task with forId.""" + task = Task(title="Test Task", status=TaskStatus.RUNNING, forId="step_123") + + assert task.forId == "step_123" + + def test_task_status_enum(self): + """Test TaskStatus enum values.""" + assert TaskStatus.READY.value == "ready" + assert TaskStatus.RUNNING.value == "running" + assert TaskStatus.FAILED.value == "failed" + assert TaskStatus.DONE.value == "done" + + +@pytest.mark.asyncio +class TestCustomElement: + """Test suite for CustomElement.""" + + async def test_custom_element_initialization(self, mock_chainlit_context): + """Test CustomElement initialization.""" + async with mock_chainlit_context: + props = {"key1": "value1", "key2": 42} + custom = CustomElement(name="test_custom", props=props) + + assert custom.type == "custom" + assert custom.name == "test_custom" + assert custom.props == props + assert custom.mime == "application/json" + assert custom.updatable is True + + async def test_custom_element_content_serialization(self, mock_chainlit_context): + """Test CustomElement content serialization.""" + async with mock_chainlit_context: + props = {"nested": {"data": [1, 2, 3]}} + custom = CustomElement(name="test_custom", props=props) + + assert isinstance(custom.content, str) + assert "nested" in custom.content + assert "data" in custom.content + + async def test_custom_element_update(self, mock_chainlit_context): + """Test CustomElement update method.""" + async with mock_chainlit_context as ctx: + custom = CustomElement( + name="test_custom", + props={"key": "value"}, + url="https://example.com/custom", + ) + custom.for_id = "message_123" + + await custom.update() + + ctx.emitter.send_element.assert_called() + + +@pytest.mark.asyncio +class TestElementEdgeCases: + """Test suite for Element edge cases.""" + + async def test_element_with_custom_id(self, mock_chainlit_context): + """Test Element with custom ID.""" + async with mock_chainlit_context: + custom_id = str(uuid.uuid4()) + element = File( + id=custom_id, name="test_file", url="https://example.com/file.txt" + ) + + assert element.id == custom_id + + async def test_element_with_object_key(self, mock_chainlit_context): + """Test Element with object_key.""" + async with mock_chainlit_context: + element = File( + name="test_file", + url="https://example.com/file.txt", + object_key="s3://bucket/key", + ) + + assert element.object_key == "s3://bucket/key" + + async def test_element_with_chainlit_key(self, mock_chainlit_context): + """Test Element with chainlit_key.""" + async with mock_chainlit_context: + element = File( + name="test_file", + url="https://example.com/file.txt", + chainlit_key="chainlit_key_123", + ) + + assert element.chainlit_key == "chainlit_key_123" + + async def test_element_send_without_url_or_key_raises_error( + self, mock_chainlit_context + ): + """Test that send() raises error without url or chainlit_key.""" + async with mock_chainlit_context as ctx: + # Mock persist_file to not set chainlit_key + ctx.session.persist_file = AsyncMock(return_value={"id": None}) + + element = File(name="test_file", content=b"test content") + + with pytest.raises(ValueError, match="Must provide url or chainlit key"): + await element.send(for_id="message_123", persist=False) + + async def test_element_from_dict_with_missing_fields(self, mock_chainlit_context): + """Test Element.from_dict() with minimal fields.""" + async with mock_chainlit_context: + element_dict: ElementDict = { + "type": "file", + "url": "https://example.com/file.txt", + } + + element = Element.from_dict(element_dict) + + assert isinstance(element, File) + assert element.name == "" + assert element.url == "https://example.com/file.txt" + + async def test_element_id_uniqueness(self, mock_chainlit_context): + """Test that each Element gets a unique ID.""" + async with mock_chainlit_context: + element1 = File(name="file1", url="https://example.com/file1.txt") + element2 = File(name="file2", url="https://example.com/file2.txt") + element3 = File(name="file3", url="https://example.com/file3.txt") + + ids = {element1.id, element2.id, element3.id} + assert len(ids) == 3 # All unique + + +@pytest.mark.asyncio +class TestDataframeElement: + """Test suite for Dataframe element.""" + + async def test_dataframe_with_pandas(self, mock_chainlit_context): + """Test Dataframe element with a pandas DataFrame.""" + import pandas as pd + + async with mock_chainlit_context: + df = pd.DataFrame({"a": [4, 2, 0], "b": ["foo", "bar", "baz"]}) + element = Dataframe(name="test_df", data=df) + + assert element.type == "dataframe" + assert element.size == "large" + + parsed = json.loads(element.content) + assert parsed["columns"] == ["a", "b"] + assert parsed["data"] == [[4, "foo"], [2, "bar"], [0, "baz"]] + assert parsed["index"] == [0, 1, 2] + + async def test_dataframe_with_polars(self, mock_chainlit_context): + """Test Dataframe element with a polars DataFrame.""" + import polars as pl + + async with mock_chainlit_context: + df = pl.DataFrame({"a": [4, 2, 0], "b": ["foo", "bar", "baz"]}) + element = Dataframe(name="test_df", data=df) + + assert element.type == "dataframe" + assert element.size == "large" + + parsed = json.loads(element.content) + assert parsed["columns"] == ["a", "b"] + assert parsed["data"] == [[4, "foo"], [2, "bar"], [0, "baz"]] + assert parsed["index"] == [0, 1, 2] + + async def test_dataframe_with_invalid_data(self, mock_chainlit_context): + """Test Dataframe element rejects non-DataFrame data.""" + async with mock_chainlit_context: + with pytest.raises( + TypeError, + match=r"data must be a pandas\.DataFrame or polars\.DataFrame", + ): + Dataframe(name="test_df", data={"a": [1, 2]}) + + async def test_dataframe_polars_and_pandas_produce_equal_outputs( + self, mock_chainlit_context + ): + """Test that pandas and polars DataFrames produce the same JSON.""" + import pandas as pd + import polars as pl + + async with mock_chainlit_context: + pd_df = pd.DataFrame({"a": [4, 2, 0], "b": ["foo", "bar", "baz"]}) + pl_df = pl.DataFrame({"a": [4, 2, 0], "b": ["foo", "bar", "baz"]}) + + pd_element = Dataframe(name="pd_df", data=pd_df) + pl_element = Dataframe(name="pl_df", data=pl_df) + + pd_parsed = json.loads(pd_element.content) + pl_parsed = json.loads(pl_element.content) + + assert pd_parsed["columns"] == pl_parsed["columns"] + assert pd_parsed["index"] == pl_parsed["index"] + assert pd_parsed["data"] == pl_parsed["data"] + + async def test_dataframe_polars_with_dates(self, mock_chainlit_context): + """Test Dataframe element with polars date columns serializes correctly.""" + from datetime import date + + import polars as pl + + async with mock_chainlit_context: + df = pl.DataFrame( + {"date": [date(2026, 1, 1), date(2025, 12, 31)], "val": [1, 2]} + ) + element = Dataframe(name="test_df", data=df) + + parsed = json.loads(element.content) + assert parsed["columns"] == ["date", "val"] + assert len(parsed["data"]) == 2 + assert parsed["data"][0][0] == "2026-01-01" + assert parsed["data"][1][0] == "2025-12-31" diff --git a/backend/tests/test_emitter.py b/backend/tests/test_emitter.py new file mode 100644 index 0000000..d9589fa --- /dev/null +++ b/backend/tests/test_emitter.py @@ -0,0 +1,211 @@ +from unittest.mock import MagicMock + +import pytest + +from chainlit.element import ElementDict +from chainlit.emitter import ChainlitEmitter +from chainlit.step import StepDict + + +@pytest.fixture +def emitter(mock_websocket_session): + return ChainlitEmitter(mock_websocket_session) + + +async def test_send_element( + emitter: ChainlitEmitter, mock_websocket_session: MagicMock +) -> None: + element_dict: ElementDict = { + "id": "test_element", + "threadId": None, + "type": "text", + "chainlitKey": None, + "url": None, + "objectKey": None, + "name": "Test Element", + "display": "inline", + "size": None, + "language": None, + "page": None, + "props": None, + "autoPlay": None, + "playerConfig": None, + "forId": None, + "mime": None, + } + + await emitter.send_element(element_dict) + + mock_websocket_session.emit.assert_called_once_with("element", element_dict) + + +async def test_send_step( + emitter: ChainlitEmitter, mock_websocket_session: MagicMock +) -> None: + step_dict: StepDict = { + "id": "test_step", + "type": "user_message", + "name": "Test Step", + "output": "This is a test step", + } + + await emitter.send_step(step_dict) + + mock_websocket_session.emit.assert_called_once_with("new_message", step_dict) + + +async def test_send_step_with_icon( + emitter: ChainlitEmitter, mock_websocket_session: MagicMock +) -> None: + step_dict: StepDict = { + "id": "test_step_with_icon", + "type": "tool", + "name": "Test Step with Icon", + "output": "This is a test step with an icon", + "metadata": {"icon": "search"}, + } + + await emitter.send_step(step_dict) + + mock_websocket_session.emit.assert_called_once_with("new_message", step_dict) + + +async def test_update_step( + emitter: ChainlitEmitter, mock_websocket_session: MagicMock +) -> None: + step_dict: StepDict = { + "id": "test_step", + "type": "assistant_message", + "name": "Updated Test Step", + "output": "This is an updated test step", + } + + await emitter.update_step(step_dict) + + mock_websocket_session.emit.assert_called_once_with("update_message", step_dict) + + +async def test_update_step_with_icon( + emitter: ChainlitEmitter, mock_websocket_session: MagicMock +) -> None: + step_dict: StepDict = { + "id": "test_step_with_icon", + "type": "tool", + "name": "Updated Test Step with Icon", + "output": "This is an updated test step with an icon", + "metadata": {"icon": "database"}, + } + + await emitter.update_step(step_dict) + + mock_websocket_session.emit.assert_called_once_with("update_message", step_dict) + + +async def test_delete_step( + emitter: ChainlitEmitter, mock_websocket_session: MagicMock +) -> None: + step_dict: StepDict = { + "id": "test_step", + "type": "system_message", + "name": "Deleted Test Step", + "output": "This step will be deleted", + } + + await emitter.delete_step(step_dict) + + mock_websocket_session.emit.assert_called_once_with("delete_message", step_dict) + + +async def test_send_timeout(emitter, mock_websocket_session): + await emitter.send_timeout("ask_timeout") + mock_websocket_session.emit.assert_called_once_with("ask_timeout", {}) + + +async def test_clear(emitter, mock_websocket_session): + await emitter.clear("clear_ask") + mock_websocket_session.emit.assert_called_once_with("clear_ask", {}) + + +async def test_send_token( + emitter: ChainlitEmitter, mock_websocket_session: MagicMock +) -> None: + await emitter.send_token("test_id", "test_token", is_sequence=True, is_input=False) + mock_websocket_session.emit.assert_called_once_with( + "stream_token", + {"id": "test_id", "token": "test_token", "isSequence": True, "isInput": False}, + ) + + +async def test_set_chat_settings(emitter, mock_websocket_session): + settings = {"key": "value"} + emitter.set_chat_settings(settings) + assert emitter.session.chat_settings == settings + + +async def test_update_token_count(emitter, mock_websocket_session): + count = 100 + await emitter.update_token_count(count) + mock_websocket_session.emit.assert_called_once_with("token_usage", count) + + +async def test_task_start(emitter, mock_websocket_session): + await emitter.task_start() + mock_websocket_session.emit.assert_called_once_with("task_start", {}) + + +async def test_task_end(emitter, mock_websocket_session): + await emitter.task_end() + mock_websocket_session.emit.assert_called_once_with("task_end", {}) + + +async def test_stream_start( + emitter: ChainlitEmitter, mock_websocket_session: MagicMock +) -> None: + step_dict: StepDict = { + "id": "test_stream", + "type": "run", + "name": "Test Stream", + "output": "This is a test stream", + } + await emitter.stream_start(step_dict) + mock_websocket_session.emit.assert_called_once_with("stream_start", step_dict) + + +async def test_stream_start_with_icon( + emitter: ChainlitEmitter, mock_websocket_session: MagicMock +) -> None: + step_dict: StepDict = { + "id": "test_stream_with_icon", + "type": "tool", + "name": "Test Stream with Icon", + "output": "This is a test stream with an icon", + "metadata": {"icon": "cpu"}, + } + await emitter.stream_start(step_dict) + mock_websocket_session.emit.assert_called_once_with("stream_start", step_dict) + + +async def test_send_toast( + emitter: ChainlitEmitter, mock_websocket_session: MagicMock +) -> None: + message = "This is a test message" + await emitter.send_toast(message) + mock_websocket_session.emit.assert_called_once_with( + "toast", {"message": message, "type": "info"} + ) + + +async def test_send_toast_with_type( + emitter: ChainlitEmitter, mock_websocket_session: MagicMock +) -> None: + message = "This is a test message" + await emitter.send_toast(message, type="error") + mock_websocket_session.emit.assert_called_once_with( + "toast", {"message": message, "type": "error"} + ) + + +async def test_send_toast_invalid_type(emitter: ChainlitEmitter) -> None: + message = "This is a test message" + with pytest.raises(ValueError, match="Invalid toast type: invalid"): + await emitter.send_toast(message, type="invalid") # type: ignore[arg-type] diff --git a/backend/tests/test_input_widget.py b/backend/tests/test_input_widget.py new file mode 100644 index 0000000..6df4b2c --- /dev/null +++ b/backend/tests/test_input_widget.py @@ -0,0 +1,734 @@ +import pytest + +from chainlit.input_widget import ( + Checkbox, + MultiSelect, + NumberInput, + RadioGroup, + Select, + Slider, + Switch, + Tab, + Tags, + TextInput, +) + + +class TestInputWidgetBase: + """Test suite for base InputWidget validation.""" + + def test_input_widget_requires_id_and_label(self): + """Test that InputWidget requires both id and label.""" + with pytest.raises(ValueError, match="Must provide key and label"): + Switch(id="", label="Test Label") + + with pytest.raises(ValueError, match="Must provide key and label"): + Switch(id="test_id", label="") + + +class TestSwitchWidget: + """Test suite for Switch input widget.""" + + def test_switch_initialization(self): + """Test Switch widget initialization.""" + switch = Switch(id="test_switch", label="Enable Feature") + + assert switch.id == "test_switch" + assert switch.label == "Enable Feature" + assert switch.type == "switch" + assert switch.initial is False + assert switch.disabled is False + + def test_switch_with_initial_value(self): + """Test Switch widget with initial value.""" + switch = Switch(id="test_switch", label="Enable Feature", initial=True) + + assert switch.initial is True + + def test_switch_with_tooltip_and_description(self): + """Test Switch widget with tooltip and description.""" + switch = Switch( + id="test_switch", + label="Enable Feature", + tooltip="Toggle this feature", + description="This enables the advanced feature", + ) + + assert switch.tooltip == "Toggle this feature" + assert switch.description == "This enables the advanced feature" + + def test_switch_disabled(self): + """Test Switch widget in disabled state.""" + switch = Switch(id="test_switch", label="Enable Feature", disabled=True) + + assert switch.disabled is True + + def test_switch_to_dict(self): + """Test Switch widget serialization.""" + switch = Switch( + id="test_switch", + label="Enable Feature", + initial=True, + tooltip="Toggle", + description="Description", + disabled=False, + ) + + result = switch.to_dict() + + assert result["type"] == "switch" + assert result["id"] == "test_switch" + assert result["label"] == "Enable Feature" + assert result["initial"] is True + assert result["tooltip"] == "Toggle" + assert result["description"] == "Description" + assert result["disabled"] is False + + +class TestSliderWidget: + """Test suite for Slider input widget.""" + + def test_slider_initialization(self): + """Test Slider widget initialization.""" + slider = Slider(id="test_slider", label="Temperature") + + assert slider.id == "test_slider" + assert slider.label == "Temperature" + assert slider.type == "slider" + assert slider.initial == 0 + assert slider.min == 0 + assert slider.max == 10 + assert slider.step == 1 + + def test_slider_with_custom_range(self): + """Test Slider widget with custom range.""" + slider = Slider( + id="test_slider", + label="Temperature", + initial=0.5, + min=0.0, + max=1.0, + step=0.1, + ) + + assert slider.initial == 0.5 + assert slider.min == 0.0 + assert slider.max == 1.0 + assert slider.step == 0.1 + + def test_slider_to_dict(self): + """Test Slider widget serialization.""" + slider = Slider( + id="test_slider", + label="Temperature", + initial=0.7, + min=0.0, + max=2.0, + step=0.1, + tooltip="Adjust temperature", + ) + + result = slider.to_dict() + + assert result["type"] == "slider" + assert result["id"] == "test_slider" + assert result["label"] == "Temperature" + assert result["initial"] == 0.7 + assert result["min"] == 0.0 + assert result["max"] == 2.0 + assert result["step"] == 0.1 + assert result["tooltip"] == "Adjust temperature" + + +class TestSelectWidget: + """Test suite for Select input widget.""" + + def test_select_with_values(self): + """Test Select widget with values list.""" + select = Select( + id="test_select", + label="Choose Model", + values=["gpt-4", "gpt-3.5", "claude"], + ) + + assert select.id == "test_select" + assert select.label == "Choose Model" + assert select.type == "select" + assert select.items == { + "gpt-4": "gpt-4", + "gpt-3.5": "gpt-3.5", + "claude": "claude", + } + + def test_select_with_items(self): + """Test Select widget with items dict.""" + items = {"gpt4": "GPT-4", "gpt35": "GPT-3.5", "claude": "Claude"} + select = Select(id="test_select", label="Choose Model", items=items) + + assert select.items == items + + def test_select_with_initial_index(self): + """Test Select widget with initial_index.""" + select = Select( + id="test_select", + label="Choose Model", + values=["gpt-4", "gpt-3.5", "claude"], + initial_index=1, + ) + + assert select.initial == "gpt-3.5" + + def test_select_with_initial_value(self): + """Test Select widget with initial_value.""" + select = Select( + id="test_select", + label="Choose Model", + values=["gpt-4", "gpt-3.5", "claude"], + initial_value="claude", + ) + + assert select.initial == "claude" + + def test_select_requires_values_or_items(self): + """Test that Select requires either values or items.""" + with pytest.raises(ValueError, match="Must provide values or items"): + Select(id="test_select", label="Choose Model") + + def test_select_cannot_have_both_values_and_items(self): + """Test that Select cannot have both values and items.""" + with pytest.raises(ValueError, match="only provide either values or items"): + Select( + id="test_select", + label="Choose Model", + values=["a", "b"], + items={"a": "A"}, + ) + + def test_select_initial_index_requires_values(self): + """Test that initial_index requires values.""" + with pytest.raises( + ValueError, + match="Initial_index can only be used in combination with values", + ): + Select( + id="test_select", + label="Choose Model", + items={"a": "A"}, + initial_index=0, + ) + + def test_select_to_dict(self): + """Test Select widget serialization.""" + select = Select( + id="test_select", + label="Choose Model", + values=["gpt-4", "gpt-3.5"], + initial_index=0, + tooltip="Select a model", + ) + + result = select.to_dict() + + assert result["type"] == "select" + assert result["id"] == "test_select" + assert result["label"] == "Choose Model" + assert result["initial"] == "gpt-4" + assert len(result["items"]) == 2 + assert result["items"][0] == {"label": "gpt-4", "value": "gpt-4"} + assert result["tooltip"] == "Select a model" + + +class TestTextInputWidget: + """Test suite for TextInput widget.""" + + def test_textinput_initialization(self): + """Test TextInput widget initialization.""" + text_input = TextInput(id="test_input", label="Enter Name") + + assert text_input.id == "test_input" + assert text_input.label == "Enter Name" + assert text_input.type == "textinput" + assert text_input.initial is None + assert text_input.placeholder is None + assert text_input.multiline is False + + def test_textinput_with_initial_and_placeholder(self): + """Test TextInput widget with initial value and placeholder.""" + text_input = TextInput( + id="test_input", + label="Enter Name", + initial="John Doe", + placeholder="Enter your name", + ) + + assert text_input.initial == "John Doe" + assert text_input.placeholder == "Enter your name" + + def test_textinput_multiline(self): + """Test TextInput widget in multiline mode.""" + text_input = TextInput( + id="test_input", label="Enter Description", multiline=True + ) + + assert text_input.multiline is True + + def test_textinput_to_dict(self): + """Test TextInput widget serialization.""" + text_input = TextInput( + id="test_input", + label="Enter Name", + initial="Default", + placeholder="Type here", + multiline=True, + tooltip="Enter your name", + ) + + result = text_input.to_dict() + + assert result["type"] == "textinput" + assert result["id"] == "test_input" + assert result["label"] == "Enter Name" + assert result["initial"] == "Default" + assert result["placeholder"] == "Type here" + assert result["multiline"] is True + assert result["tooltip"] == "Enter your name" + + +class TestNumberInputWidget: + """Test suite for NumberInput widget.""" + + def test_numberinput_initialization(self): + """Test NumberInput widget initialization.""" + number_input = NumberInput(id="test_number", label="Enter Age") + + assert number_input.id == "test_number" + assert number_input.label == "Enter Age" + assert number_input.type == "numberinput" + assert number_input.initial is None + assert number_input.placeholder is None + + def test_numberinput_with_initial(self): + """Test NumberInput widget with initial value.""" + number_input = NumberInput( + id="test_number", label="Enter Age", initial=25.5, placeholder="Age" + ) + + assert number_input.initial == 25.5 + assert number_input.placeholder == "Age" + + def test_numberinput_to_dict(self): + """Test NumberInput widget serialization.""" + number_input = NumberInput( + id="test_number", + label="Enter Age", + initial=30.0, + placeholder="Enter a number", + tooltip="Your age", + ) + + result = number_input.to_dict() + + assert result["type"] == "numberinput" + assert result["id"] == "test_number" + assert result["label"] == "Enter Age" + assert result["initial"] == 30.0 + assert result["placeholder"] == "Enter a number" + assert result["tooltip"] == "Your age" + + +class TestTagsWidget: + """Test suite for Tags widget.""" + + def test_tags_initialization(self): + """Test Tags widget initialization.""" + tags = Tags(id="test_tags", label="Add Tags") + + assert tags.id == "test_tags" + assert tags.label == "Add Tags" + assert tags.type == "tags" + assert tags.initial == [] + assert tags.values == [] + + def test_tags_with_initial_values(self): + """Test Tags widget with initial values.""" + tags = Tags( + id="test_tags", + label="Add Tags", + initial=["python", "javascript"], + values=["python", "javascript", "go", "rust"], + ) + + assert tags.initial == ["python", "javascript"] + assert tags.values == ["python", "javascript", "go", "rust"] + + def test_tags_to_dict(self): + """Test Tags widget serialization.""" + tags = Tags( + id="test_tags", + label="Add Tags", + initial=["tag1"], + tooltip="Add your tags", + ) + + result = tags.to_dict() + + assert result["type"] == "tags" + assert result["id"] == "test_tags" + assert result["label"] == "Add Tags" + assert result["initial"] == ["tag1"] + assert result["tooltip"] == "Add your tags" + + +class TestMultiSelectWidget: + """Test suite for MultiSelect widget.""" + + def test_multiselect_with_values(self): + """Test MultiSelect widget with values list.""" + multi_select = MultiSelect( + id="test_multiselect", + label="Choose Languages", + values=["Python", "JavaScript", "Go"], + ) + + assert multi_select.id == "test_multiselect" + assert multi_select.label == "Choose Languages" + assert multi_select.type == "multiselect" + assert multi_select.items == { + "Python": "Python", + "JavaScript": "JavaScript", + "Go": "Go", + } + + def test_multiselect_with_items(self): + """Test MultiSelect widget with items dict.""" + items = {"py": "Python", "js": "JavaScript", "go": "Go"} + multi_select = MultiSelect( + id="test_multiselect", label="Choose Languages", items=items + ) + + assert multi_select.items == items + + def test_multiselect_with_initial(self): + """Test MultiSelect widget with initial selection.""" + multi_select = MultiSelect( + id="test_multiselect", + label="Choose Languages", + values=["Python", "JavaScript", "Go"], + initial=["Python", "Go"], + ) + + assert multi_select.initial == ["Python", "Go"] + + def test_multiselect_requires_values_or_items(self): + """Test that MultiSelect requires either values or items.""" + with pytest.raises(ValueError, match="Must provide values or items"): + MultiSelect(id="test_multiselect", label="Choose Languages") + + def test_multiselect_cannot_have_both_values_and_items(self): + """Test that MultiSelect cannot have both values and items.""" + with pytest.raises(ValueError, match="only provide either values or items"): + MultiSelect( + id="test_multiselect", + label="Choose Languages", + values=["a", "b"], + items={"a": "A"}, + ) + + def test_multiselect_to_dict(self): + """Test MultiSelect widget serialization.""" + multi_select = MultiSelect( + id="test_multiselect", + label="Choose Languages", + values=["Python", "JavaScript"], + initial=["Python"], + tooltip="Select languages", + ) + + result = multi_select.to_dict() + + assert result["type"] == "multiselect" + assert result["id"] == "test_multiselect" + assert result["label"] == "Choose Languages" + assert result["initial"] == ["Python"] + assert len(result["items"]) == 2 + assert result["tooltip"] == "Select languages" + + +class TestCheckboxWidget: + """Test suite for Checkbox widget.""" + + def test_checkbox_initialization(self): + """Test Checkbox widget initialization.""" + checkbox = Checkbox(id="test_checkbox", label="Accept Terms") + + assert checkbox.id == "test_checkbox" + assert checkbox.label == "Accept Terms" + assert checkbox.type == "checkbox" + assert checkbox.initial is False + + def test_checkbox_with_initial_value(self): + """Test Checkbox widget with initial value.""" + checkbox = Checkbox(id="test_checkbox", label="Accept Terms", initial=True) + + assert checkbox.initial is True + + def test_checkbox_to_dict(self): + """Test Checkbox widget serialization.""" + checkbox = Checkbox( + id="test_checkbox", + label="Accept Terms", + initial=True, + tooltip="Check to accept", + description="Terms and conditions", + ) + + result = checkbox.to_dict() + + assert result["type"] == "checkbox" + assert result["id"] == "test_checkbox" + assert result["label"] == "Accept Terms" + assert result["initial"] is True + assert result["tooltip"] == "Check to accept" + assert result["description"] == "Terms and conditions" + + +class TestRadioGroupWidget: + """Test suite for RadioGroup widget.""" + + def test_radiogroup_with_values(self): + """Test RadioGroup widget with values list.""" + radio = RadioGroup( + id="test_radio", label="Choose Size", values=["Small", "Medium", "Large"] + ) + + assert radio.id == "test_radio" + assert radio.label == "Choose Size" + assert radio.type == "radio" + assert radio.items == {"Small": "Small", "Medium": "Medium", "Large": "Large"} + + def test_radiogroup_with_items(self): + """Test RadioGroup widget with items dict.""" + items = {"s": "Small", "m": "Medium", "l": "Large"} + radio = RadioGroup(id="test_radio", label="Choose Size", items=items) + + assert radio.items == items + + def test_radiogroup_with_initial_index(self): + """Test RadioGroup widget with initial_index.""" + radio = RadioGroup( + id="test_radio", + label="Choose Size", + values=["Small", "Medium", "Large"], + initial_index=1, + ) + + assert radio.initial == "Medium" + + def test_radiogroup_with_initial_value(self): + """Test RadioGroup widget with initial_value.""" + radio = RadioGroup( + id="test_radio", + label="Choose Size", + values=["Small", "Medium", "Large"], + initial_value="Large", + ) + + assert radio.initial == "Large" + + def test_radiogroup_requires_values_or_items(self): + """Test that RadioGroup requires either values or items.""" + with pytest.raises(ValueError, match="Must provide values or items"): + RadioGroup(id="test_radio", label="Choose Size") + + def test_radiogroup_cannot_have_both_values_and_items(self): + """Test that RadioGroup cannot have both values and items.""" + with pytest.raises(ValueError, match="only provide either values or items"): + RadioGroup( + id="test_radio", + label="Choose Size", + values=["a", "b"], + items={"a": "A"}, + ) + + def test_radiogroup_initial_index_requires_values(self): + """Test that initial_index requires values.""" + with pytest.raises( + ValueError, + match="Initial_index can only be used in combination with values", + ): + RadioGroup( + id="test_radio", label="Choose Size", items={"a": "A"}, initial_index=0 + ) + + def test_radiogroup_to_dict(self): + """Test RadioGroup widget serialization.""" + radio = RadioGroup( + id="test_radio", + label="Choose Size", + values=["Small", "Medium"], + initial_index=0, + tooltip="Select size", + ) + + result = radio.to_dict() + + assert result["type"] == "radio" + assert result["id"] == "test_radio" + assert result["label"] == "Choose Size" + assert result["initial"] == "Small" + assert len(result["items"]) == 2 + assert result["items"][0] == {"label": "Small", "value": "Small"} + assert result["tooltip"] == "Select size" + + +class TestTabWidget: + """Test suite for Tab widget.""" + + def test_tab_initialization(self): + """Test Tab initialization.""" + tab = Tab(id="test_tab", label="Settings") + + assert tab.id == "test_tab" + assert tab.label == "Settings" + assert tab.inputs == [] + + def test_tab_with_inputs(self): + """Test Tab with input widgets.""" + switch = Switch(id="switch1", label="Enable") + slider = Slider(id="slider1", label="Value") + tab = Tab(id="test_tab", label="Settings", inputs=[switch, slider]) + + assert len(tab.inputs) == 2 + assert tab.inputs[0] == switch + assert tab.inputs[1] == slider + + def test_tab_to_dict(self): + """Test Tab serialization.""" + switch = Switch(id="switch1", label="Enable", initial=True) + slider = Slider(id="slider1", label="Value", initial=5) + tab = Tab(id="test_tab", label="Settings", inputs=[switch, slider]) + + result = tab.to_dict() + + assert result["id"] == "test_tab" + assert result["label"] == "Settings" + assert len(result["inputs"]) == 2 + assert result["inputs"][0]["type"] == "switch" + assert result["inputs"][0]["id"] == "switch1" + assert result["inputs"][1]["type"] == "slider" + assert result["inputs"][1]["id"] == "slider1" + + def test_tab_to_dict_empty_inputs(self): + """Test Tab serialization with no inputs.""" + tab = Tab(id="test_tab", label="Empty Tab") + + result = tab.to_dict() + + assert result["id"] == "test_tab" + assert result["label"] == "Empty Tab" + assert result["inputs"] == [] + + +class TestInputWidgetEdgeCases: + """Test suite for InputWidget edge cases.""" + + def test_all_widgets_have_consistent_common_fields(self): + """Test that all widgets support common fields.""" + widgets = [ + Switch( + id="test", + label="Test", + tooltip="Tooltip", + description="Description", + disabled=True, + ), + Slider( + id="test", + label="Test", + tooltip="Tooltip", + description="Description", + disabled=True, + ), + Checkbox( + id="test", + label="Test", + tooltip="Tooltip", + description="Description", + disabled=True, + ), + TextInput( + id="test", + label="Test", + tooltip="Tooltip", + description="Description", + disabled=True, + ), + NumberInput( + id="test", + label="Test", + tooltip="Tooltip", + description="Description", + disabled=True, + ), + Tags( + id="test", + label="Test", + tooltip="Tooltip", + description="Description", + disabled=True, + ), + ] + + for widget in widgets: + assert widget.tooltip == "Tooltip" + assert widget.description == "Description" + assert widget.disabled is True + result = widget.to_dict() + assert result["tooltip"] == "Tooltip" + assert result["description"] == "Description" + assert result["disabled"] is True + + def test_select_with_complex_items(self): + """Test Select with complex item labels and values.""" + items = { + "option_1": "Option One with Spaces", + "option_2": "Option Two (with parentheses)", + "option_3": "Option Three - with dashes", + } + select = Select(id="test_select", label="Choose", items=items) + + result = select.to_dict() + assert len(result["items"]) == 3 + assert {"label": "option_1", "value": "Option One with Spaces"} in result[ + "items" + ] + + def test_multiselect_initial_with_multiple_values(self): + """Test MultiSelect with multiple initial values.""" + multi_select = MultiSelect( + id="test", + label="Choose", + values=["A", "B", "C", "D"], + initial=["A", "C", "D"], + ) + + assert len(multi_select.initial) == 3 + assert "A" in multi_select.initial + assert "C" in multi_select.initial + assert "D" in multi_select.initial + + def test_slider_with_negative_range(self): + """Test Slider with negative range.""" + slider = Slider(id="test", label="Test", min=-10, max=10, initial=-5, step=1) + + assert slider.min == -10 + assert slider.max == 10 + assert slider.initial == -5 + + def test_textinput_empty_initial_value(self): + """Test TextInput with empty string as initial value.""" + text_input = TextInput(id="test", label="Test", initial="") + + assert text_input.initial == "" + result = text_input.to_dict() + assert result["initial"] == "" diff --git a/backend/tests/test_markdown.py b/backend/tests/test_markdown.py new file mode 100644 index 0000000..e1e89b5 --- /dev/null +++ b/backend/tests/test_markdown.py @@ -0,0 +1,322 @@ +import os +import tempfile +from unittest.mock import patch + +import pytest + +from chainlit.markdown import DEFAULT_MARKDOWN_STR, get_markdown_str, init_markdown + + +class TestInitMarkdown: + """Test suite for init_markdown function.""" + + def test_init_markdown_creates_file(self): + """Test that init_markdown creates chainlit.md if it doesn't exist.""" + with tempfile.TemporaryDirectory() as tmpdir: + init_markdown(tmpdir) + + chainlit_md_path = os.path.join(tmpdir, "chainlit.md") + assert os.path.exists(chainlit_md_path) + + # Verify content is the default markdown + with open(chainlit_md_path, encoding="utf-8") as f: + content = f.read() + assert content == DEFAULT_MARKDOWN_STR + + def test_init_markdown_does_not_overwrite_existing(self): + """Test that init_markdown doesn't overwrite existing chainlit.md.""" + with tempfile.TemporaryDirectory() as tmpdir: + chainlit_md_path = os.path.join(tmpdir, "chainlit.md") + custom_content = "# My Custom Markdown" + + # Create existing file + with open(chainlit_md_path, "w", encoding="utf-8") as f: + f.write(custom_content) + + # Call init_markdown + init_markdown(tmpdir) + + # Verify content is unchanged + with open(chainlit_md_path, encoding="utf-8") as f: + content = f.read() + assert content == custom_content + + def test_init_markdown_with_nonexistent_directory(self): + """Test init_markdown with a directory that doesn't exist.""" + with tempfile.TemporaryDirectory() as tmpdir: + nonexistent_dir = os.path.join(tmpdir, "nonexistent") + + # Should raise an error when trying to create file in nonexistent dir + with pytest.raises(FileNotFoundError): + init_markdown(nonexistent_dir) + + def test_init_markdown_creates_utf8_file(self): + """Test that init_markdown creates file with UTF-8 encoding.""" + with tempfile.TemporaryDirectory() as tmpdir: + init_markdown(tmpdir) + + chainlit_md_path = os.path.join(tmpdir, "chainlit.md") + + # Verify UTF-8 encoding by reading with explicit encoding + with open(chainlit_md_path, encoding="utf-8") as f: + content = f.read() + + # Should contain emoji characters from DEFAULT_MARKDOWN_STR + assert "🚀" in content + assert "🤖" in content + + +class TestGetMarkdownStr: + """Test suite for get_markdown_str function.""" + + def test_get_markdown_str_returns_default(self): + """Test get_markdown_str returns default chainlit.md content.""" + with tempfile.TemporaryDirectory() as tmpdir: + chainlit_md_path = os.path.join(tmpdir, "chainlit.md") + content = "# Default Chainlit Markdown" + + with open(chainlit_md_path, "w", encoding="utf-8") as f: + f.write(content) + + result = get_markdown_str(tmpdir, "en") + assert result == content + + def test_get_markdown_str_returns_translated(self): + """Test get_markdown_str returns translated markdown when available.""" + with tempfile.TemporaryDirectory() as tmpdir: + # Create default markdown + default_content = "# Default English" + with open(os.path.join(tmpdir, "chainlit.md"), "w", encoding="utf-8") as f: + f.write(default_content) + + # Create translated markdown + translated_content = "# Français" + with open( + os.path.join(tmpdir, "chainlit_fr.md"), "w", encoding="utf-8" + ) as f: + f.write(translated_content) + + result = get_markdown_str(tmpdir, "fr") + assert result == translated_content + + def test_get_markdown_str_falls_back_to_default(self): + """Test get_markdown_str falls back to default when translation missing.""" + with tempfile.TemporaryDirectory() as tmpdir: + default_content = "# Default English" + with open(os.path.join(tmpdir, "chainlit.md"), "w", encoding="utf-8") as f: + f.write(default_content) + + # Request non-existent translation + with patch("chainlit.markdown.logger") as mock_logger: + result = get_markdown_str(tmpdir, "es") + + assert result == default_content + mock_logger.warning.assert_called_once() + assert "es" in str(mock_logger.warning.call_args) + + def test_get_markdown_str_returns_none_when_no_file(self): + """Test get_markdown_str returns None when no markdown file exists.""" + with tempfile.TemporaryDirectory() as tmpdir: + result = get_markdown_str(tmpdir, "en") + assert result is None + + def test_get_markdown_str_with_utf8_content(self): + """Test get_markdown_str handles UTF-8 content correctly.""" + with tempfile.TemporaryDirectory() as tmpdir: + content = "# Welcome 欢迎 🎉\n\nこんにちは" + with open(os.path.join(tmpdir, "chainlit.md"), "w", encoding="utf-8") as f: + f.write(content) + + result = get_markdown_str(tmpdir, "en") + assert result == content + assert "欢迎" in result + assert "こんにちは" in result + + def test_get_markdown_str_prevents_path_traversal(self): + """Test get_markdown_str prevents path traversal attacks.""" + with tempfile.TemporaryDirectory() as tmpdir: + # Create default markdown + default_content = "# Default" + with open(os.path.join(tmpdir, "chainlit.md"), "w", encoding="utf-8") as f: + f.write(default_content) + + # Try to access file outside root using path traversal + # The is_path_inside check should prevent this + result = get_markdown_str(tmpdir, "../../../etc/passwd") + + # Should fall back to default since traversal is blocked + assert result == default_content + + def test_get_markdown_str_with_multiple_languages(self): + """Test get_markdown_str with multiple language files.""" + with tempfile.TemporaryDirectory() as tmpdir: + # Create multiple language files + languages = { + "en": "# English", + "fr": "# Français", + "es": "# Español", + "ja": "# 日本語", + } + + # Create default + with open(os.path.join(tmpdir, "chainlit.md"), "w", encoding="utf-8") as f: + f.write(languages["en"]) + + # Create translations + for lang, content in languages.items(): + if lang != "en": + path = os.path.join(tmpdir, f"chainlit_{lang}.md") + with open(path, "w", encoding="utf-8") as f: + f.write(content) + + # Test each language + for lang, expected_content in languages.items(): + result = get_markdown_str(tmpdir, lang) + assert result == expected_content + + def test_get_markdown_str_with_empty_file(self): + """Test get_markdown_str with empty markdown file.""" + with tempfile.TemporaryDirectory() as tmpdir: + # Create empty file + with open(os.path.join(tmpdir, "chainlit.md"), "w", encoding="utf-8") as f: + f.write("") + + result = get_markdown_str(tmpdir, "en") + assert result == "" + + def test_get_markdown_str_with_large_file(self): + """Test get_markdown_str with large markdown file.""" + with tempfile.TemporaryDirectory() as tmpdir: + # Create large content + large_content = "# Header\n" + ("Lorem ipsum dolor sit amet.\n" * 1000) + with open(os.path.join(tmpdir, "chainlit.md"), "w", encoding="utf-8") as f: + f.write(large_content) + + result = get_markdown_str(tmpdir, "en") + assert result == large_content + assert len(result) > 10000 + + +class TestDefaultMarkdownStr: + """Test suite for DEFAULT_MARKDOWN_STR constant.""" + + def test_default_markdown_str_is_string(self): + """Test that DEFAULT_MARKDOWN_STR is a string.""" + assert isinstance(DEFAULT_MARKDOWN_STR, str) + + def test_default_markdown_str_not_empty(self): + """Test that DEFAULT_MARKDOWN_STR is not empty.""" + assert len(DEFAULT_MARKDOWN_STR) > 0 + + def test_default_markdown_str_contains_welcome(self): + """Test that DEFAULT_MARKDOWN_STR contains welcome message.""" + assert "Welcome to Chainlit" in DEFAULT_MARKDOWN_STR + + def test_default_markdown_str_contains_links(self): + """Test that DEFAULT_MARKDOWN_STR contains useful links.""" + assert "Documentation" in DEFAULT_MARKDOWN_STR + assert "Discord" in DEFAULT_MARKDOWN_STR + assert "https://docs.chainlit.io" in DEFAULT_MARKDOWN_STR + + def test_default_markdown_str_is_valid_markdown(self): + """Test that DEFAULT_MARKDOWN_STR contains valid markdown syntax.""" + assert "#" in DEFAULT_MARKDOWN_STR # Headers + assert "**" in DEFAULT_MARKDOWN_STR # Bold + assert "[" in DEFAULT_MARKDOWN_STR # Links + assert "](" in DEFAULT_MARKDOWN_STR # Link syntax + + +class TestMarkdownEdgeCases: + """Test suite for markdown edge cases.""" + + def test_init_markdown_with_special_characters_in_path(self): + """Test init_markdown with special characters in directory path.""" + with tempfile.TemporaryDirectory() as tmpdir: + special_dir = os.path.join(tmpdir, "test dir with spaces") + os.makedirs(special_dir) + + init_markdown(special_dir) + + chainlit_md_path = os.path.join(special_dir, "chainlit.md") + assert os.path.exists(chainlit_md_path) + + def test_get_markdown_str_with_symlink(self): + """Test get_markdown_str with symlinked markdown file.""" + with tempfile.TemporaryDirectory() as tmpdir: + # Create original file + original_dir = os.path.join(tmpdir, "original") + os.makedirs(original_dir) + original_file = os.path.join(original_dir, "chainlit.md") + content = "# Original Content" + with open(original_file, "w", encoding="utf-8") as f: + f.write(content) + + # Create symlink directory + link_dir = os.path.join(tmpdir, "link") + os.makedirs(link_dir) + link_file = os.path.join(link_dir, "chainlit.md") + + # Create symlink (skip on Windows if no permissions) + try: + os.symlink(original_file, link_file) + result = get_markdown_str(link_dir, "en") + assert result == content + except OSError: + pytest.skip("Symlink creation not supported") + + def test_get_markdown_str_with_relative_path(self): + """Test get_markdown_str with relative path.""" + with tempfile.TemporaryDirectory() as tmpdir: + content = "# Test Content" + with open(os.path.join(tmpdir, "chainlit.md"), "w", encoding="utf-8") as f: + f.write(content) + + # Use relative path + original_cwd = os.getcwd() + try: + os.chdir(tmpdir) + result = get_markdown_str(".", "en") + assert result == content + finally: + os.chdir(original_cwd) + + def test_get_markdown_str_language_case_sensitivity(self): + """Test get_markdown_str language code is used as-is in filename.""" + with tempfile.TemporaryDirectory() as tmpdir: + default_content = "# Default" + with open(os.path.join(tmpdir, "chainlit.md"), "w", encoding="utf-8") as f: + f.write(default_content) + + # Create a language file + fr_content = "# Français" + with open( + os.path.join(tmpdir, "chainlit_fr.md"), "w", encoding="utf-8" + ) as f: + f.write(fr_content) + + # Test exact match - should get the file + result = get_markdown_str(tmpdir, "fr") + assert result == fr_content + + # Test different case that doesn't exist - should fall back to default + # Note: On case-insensitive file systems (Windows), this might still find the file + # On case-sensitive file systems (Linux), it will fall back to default + with patch("chainlit.markdown.logger"): + result_different = get_markdown_str(tmpdir, "es") + assert result_different == default_content + + def test_init_markdown_concurrent_calls(self): + """Test init_markdown with concurrent calls (race condition).""" + with tempfile.TemporaryDirectory() as tmpdir: + # Call init_markdown multiple times + init_markdown(tmpdir) + init_markdown(tmpdir) + init_markdown(tmpdir) + + # Should only have one file with default content + chainlit_md_path = os.path.join(tmpdir, "chainlit.md") + assert os.path.exists(chainlit_md_path) + + with open(chainlit_md_path, encoding="utf-8") as f: + content = f.read() + assert content == DEFAULT_MARKDOWN_STR diff --git a/backend/tests/test_mcp.py b/backend/tests/test_mcp.py new file mode 100644 index 0000000..2963493 --- /dev/null +++ b/backend/tests/test_mcp.py @@ -0,0 +1,446 @@ +import sys +from unittest.mock import patch + +import pytest +from pydantic import ValidationError + +from chainlit.mcp import ( + HttpMcpConnection, + SseMcpConnection, + StdioMcpConnection, + validate_mcp_command, +) + + +class TestStdioMcpConnection: + """Test suite for StdioMcpConnection model.""" + + def test_stdio_connection_initialization(self): + """Test StdioMcpConnection initialization.""" + connection = StdioMcpConnection( + name="test_server", command="python", args=["-m", "mcp_server"] + ) + + assert connection.name == "test_server" + assert connection.command == "python" + assert connection.args == ["-m", "mcp_server"] + assert connection.clientType == "stdio" + + def test_stdio_connection_with_empty_args(self): + """Test StdioMcpConnection with empty args list.""" + connection = StdioMcpConnection(name="test_server", command="node", args=[]) + + assert connection.args == [] + assert connection.clientType == "stdio" + + def test_stdio_connection_requires_name(self): + """Test that StdioMcpConnection requires name.""" + with pytest.raises(ValidationError): + StdioMcpConnection(command="python", args=[]) + + def test_stdio_connection_requires_command(self): + """Test that StdioMcpConnection requires command.""" + with pytest.raises(ValidationError): + StdioMcpConnection(name="test_server", args=[]) + + def test_stdio_connection_requires_args(self): + """Test that StdioMcpConnection requires args.""" + with pytest.raises(ValidationError): + StdioMcpConnection(name="test_server", command="python") + + def test_stdio_connection_client_type_is_literal(self): + """Test that clientType is always 'stdio'.""" + connection = StdioMcpConnection(name="test_server", command="python", args=[]) + + assert connection.clientType == "stdio" + + def test_stdio_connection_serialization(self): + """Test StdioMcpConnection serialization.""" + connection = StdioMcpConnection( + name="test_server", command="python", args=["-m", "server"] + ) + + data = connection.model_dump() + + assert data["name"] == "test_server" + assert data["command"] == "python" + assert data["args"] == ["-m", "server"] + assert data["clientType"] == "stdio" + + +class TestSseMcpConnection: + """Test suite for SseMcpConnection model.""" + + def test_sse_connection_initialization(self): + """Test SseMcpConnection initialization.""" + connection = SseMcpConnection(name="test_server", url="https://example.com/mcp") + + assert connection.name == "test_server" + assert connection.url == "https://example.com/mcp" + assert connection.headers is None + assert connection.clientType == "sse" + + def test_sse_connection_with_headers(self): + """Test SseMcpConnection with headers.""" + headers = {"Authorization": "Bearer token123", "X-Custom": "value"} + connection = SseMcpConnection( + name="test_server", url="https://example.com/mcp", headers=headers + ) + + assert connection.headers == headers + + def test_sse_connection_requires_name(self): + """Test that SseMcpConnection requires name.""" + with pytest.raises(ValidationError): + SseMcpConnection(url="https://example.com/mcp") + + def test_sse_connection_requires_url(self): + """Test that SseMcpConnection requires url.""" + with pytest.raises(ValidationError): + SseMcpConnection(name="test_server") + + def test_sse_connection_client_type_is_literal(self): + """Test that clientType is always 'sse'.""" + connection = SseMcpConnection(name="test_server", url="https://example.com/mcp") + + assert connection.clientType == "sse" + + def test_sse_connection_serialization(self): + """Test SseMcpConnection serialization.""" + headers = {"Authorization": "Bearer token"} + connection = SseMcpConnection( + name="test_server", url="https://example.com/mcp", headers=headers + ) + + data = connection.model_dump() + + assert data["name"] == "test_server" + assert data["url"] == "https://example.com/mcp" + assert data["headers"] == headers + assert data["clientType"] == "sse" + + +class TestHttpMcpConnection: + """Test suite for HttpMcpConnection model.""" + + def test_http_connection_initialization(self): + """Test HttpMcpConnection initialization.""" + connection = HttpMcpConnection( + name="test_server", url="https://example.com/mcp" + ) + + assert connection.name == "test_server" + assert connection.url == "https://example.com/mcp" + assert connection.headers is None + assert connection.clientType == "streamable-http" + + def test_http_connection_with_headers(self): + """Test HttpMcpConnection with headers.""" + headers = { + "Authorization": "Bearer token123", + "Content-Type": "application/json", + } + connection = HttpMcpConnection( + name="test_server", url="https://example.com/mcp", headers=headers + ) + + assert connection.headers == headers + + def test_http_connection_requires_name(self): + """Test that HttpMcpConnection requires name.""" + with pytest.raises(ValidationError): + HttpMcpConnection(url="https://example.com/mcp") + + def test_http_connection_requires_url(self): + """Test that HttpMcpConnection requires url.""" + with pytest.raises(ValidationError): + HttpMcpConnection(name="test_server") + + def test_http_connection_client_type_is_literal(self): + """Test that clientType is always 'streamable-http'.""" + connection = HttpMcpConnection( + name="test_server", url="https://example.com/mcp" + ) + + assert connection.clientType == "streamable-http" + + def test_http_connection_serialization(self): + """Test HttpMcpConnection serialization.""" + headers = {"Authorization": "Bearer token"} + connection = HttpMcpConnection( + name="test_server", url="https://example.com/mcp", headers=headers + ) + + data = connection.model_dump() + + assert data["name"] == "test_server" + assert data["url"] == "https://example.com/mcp" + assert data["headers"] == headers + assert data["clientType"] == "streamable-http" + + +class TestValidateMcpCommand: + """Test suite for validate_mcp_command function.""" + + def test_validate_simple_command(self): + """Test validation of a simple command.""" + mcp_module = sys.modules["chainlit.mcp"] + with patch.object(mcp_module, "config") as mock_config: + mock_config.features.mcp.stdio.allowed_executables = ["python", "node"] + + env, executable, args = validate_mcp_command("python -m mcp_server") + + assert env == {} + assert executable == "python" + assert args == ["-m", "mcp_server"] + + def test_validate_command_with_path(self): + """Test validation of command with full path.""" + mcp_module = sys.modules["chainlit.mcp"] + with patch.object(mcp_module, "config") as mock_config: + mock_config.features.mcp.stdio.allowed_executables = ["python"] + + env, executable, args = validate_mcp_command( + "/usr/bin/python -m mcp_server" + ) + + assert env == {} + assert executable == "/usr/bin/python" + assert args == ["-m", "mcp_server"] + + def test_validate_command_with_windows_path(self): + """Test validation of command with Windows path (using forward slashes or quoted).""" + mcp_module = sys.modules["chainlit.mcp"] + with patch.object(mcp_module, "config") as mock_config: + mock_config.features.mcp.stdio.allowed_executables = ["python.exe"] + + # Use forward slashes which work on Windows and don't get escaped by shlex + env, executable, args = validate_mcp_command( + "C:/Python/python.exe -m mcp_server" + ) + + assert env == {} + assert executable == "C:/Python/python.exe" + assert args == ["-m", "mcp_server"] + + def test_validate_command_with_env_vars(self): + """Test validation of command with environment variables.""" + mcp_module = sys.modules["chainlit.mcp"] + with patch.object(mcp_module, "config") as mock_config: + mock_config.features.mcp.stdio.allowed_executables = ["node"] + + env, executable, args = validate_mcp_command( + "MY_VAR=value NODE_ENV=production node server.js" + ) + + assert env == {"MY_VAR": "value", "NODE_ENV": "production"} + assert executable == "node" + assert args == ["server.js"] + + def test_validate_command_with_env_var_with_spaces(self): + """Test validation of command with env var containing spaces.""" + mcp_module = sys.modules["chainlit.mcp"] + with patch.object(mcp_module, "config") as mock_config: + mock_config.features.mcp.stdio.allowed_executables = ["python"] + + env, executable, args = validate_mcp_command( + 'MY_VAR="value with spaces" python script.py' + ) + + assert env == {"MY_VAR": "value with spaces"} + assert executable == "python" + assert args == ["script.py"] + + def test_validate_command_with_quoted_args(self): + """Test validation of command with quoted arguments.""" + mcp_module = sys.modules["chainlit.mcp"] + with patch.object(mcp_module, "config") as mock_config: + mock_config.features.mcp.stdio.allowed_executables = ["python"] + + env, executable, args = validate_mcp_command( + 'python script.py --arg "value with spaces"' + ) + + assert env == {} + assert executable == "python" + assert args == ["script.py", "--arg", "value with spaces"] + + def test_validate_command_with_multiple_args(self): + """Test validation of command with multiple arguments.""" + mcp_module = sys.modules["chainlit.mcp"] + with patch.object(mcp_module, "config") as mock_config: + mock_config.features.mcp.stdio.allowed_executables = ["node"] + + env, executable, args = validate_mcp_command( + "node server.js --port 3000 --host localhost" + ) + + assert env == {} + assert executable == "node" + assert args == ["server.js", "--port", "3000", "--host", "localhost"] + + def test_validate_command_not_in_allowed_list(self): + """Test that validation fails for disallowed executable.""" + mcp_module = sys.modules["chainlit.mcp"] + with patch.object(mcp_module, "config") as mock_config: + mock_config.features.mcp.stdio.allowed_executables = ["python", "node"] + + with pytest.raises(ValueError, match="Only commands in"): + validate_mcp_command("bash script.sh") + + def test_validate_empty_command(self): + """Test that validation fails for empty command.""" + mcp_module = sys.modules["chainlit.mcp"] + with patch.object(mcp_module, "config") as mock_config: + mock_config.features.mcp.stdio.allowed_executables = ["python"] + + with pytest.raises(ValueError, match="Empty command string"): + validate_mcp_command("") + + def test_validate_command_with_invalid_syntax(self): + """Test that validation fails for invalid command syntax.""" + mcp_module = sys.modules["chainlit.mcp"] + with patch.object(mcp_module, "config") as mock_config: + mock_config.features.mcp.stdio.allowed_executables = ["python"] + + with pytest.raises(ValueError, match="Invalid command string"): + validate_mcp_command('python "unclosed quote') + + def test_validate_command_with_none_allowed_executables(self): + """Test validation when allowed_executables is None (all allowed).""" + mcp_module = sys.modules["chainlit.mcp"] + with patch.object(mcp_module, "config") as mock_config: + mock_config.features.mcp.stdio.allowed_executables = None + + env, executable, args = validate_mcp_command("any_command --arg value") + + assert env == {} + assert executable == "any_command" + assert args == ["--arg", "value"] + + def test_validate_command_with_invalid_env_var_format(self): + """Test that validation fails for invalid env var format.""" + mcp_module = sys.modules["chainlit.mcp"] + with patch.object(mcp_module, "config") as mock_config: + mock_config.features.mcp.stdio.allowed_executables = ["python"] + + with pytest.raises(ValueError, match="Invalid environment variable format"): + validate_mcp_command("INVALID_ENV python script.py") + + def test_validate_command_with_complex_env_vars(self): + """Test validation with complex environment variables.""" + mcp_module = sys.modules["chainlit.mcp"] + with patch.object(mcp_module, "config") as mock_config: + mock_config.features.mcp.stdio.allowed_executables = ["python"] + + env, executable, args = validate_mcp_command( + 'API_KEY=sk-123456 BASE_URL="https://api.example.com" python app.py' + ) + + assert env == { + "API_KEY": "sk-123456", + "BASE_URL": "https://api.example.com", + } + assert executable == "python" + assert args == ["app.py"] + + def test_validate_command_with_equals_in_arg(self): + """Test validation with equals sign in argument.""" + mcp_module = sys.modules["chainlit.mcp"] + with patch.object(mcp_module, "config") as mock_config: + mock_config.features.mcp.stdio.allowed_executables = ["python"] + + env, executable, args = validate_mcp_command( + "python script.py --config=value" + ) + + assert env == {} + assert executable == "python" + assert args == ["script.py", "--config=value"] + + def test_validate_command_preserves_arg_order(self): + """Test that argument order is preserved.""" + mcp_module = sys.modules["chainlit.mcp"] + with patch.object(mcp_module, "config") as mock_config: + mock_config.features.mcp.stdio.allowed_executables = ["node"] + + _, _, args = validate_mcp_command( + "node app.js arg1 arg2 arg3 --flag1 --flag2" + ) + + assert args == ["app.js", "arg1", "arg2", "arg3", "--flag1", "--flag2"] + + +class TestMcpConnectionEdgeCases: + """Test suite for MCP connection edge cases.""" + + def test_stdio_connection_with_complex_args(self): + """Test StdioMcpConnection with complex arguments.""" + connection = StdioMcpConnection( + name="complex_server", + command="python", + args=[ + "-m", + "mcp_server", + "--config", + "/path/to/config.json", + "--verbose", + ], + ) + + assert len(connection.args) == 5 + assert connection.args[0] == "-m" + assert connection.args[3] == "/path/to/config.json" + + def test_sse_connection_with_multiple_headers(self): + """Test SseMcpConnection with multiple headers.""" + headers = { + "Authorization": "Bearer token", + "X-API-Key": "key123", + "Content-Type": "application/json", + "Accept": "application/json", + } + connection = SseMcpConnection( + name="multi_header_server", url="https://api.example.com", headers=headers + ) + + assert len(connection.headers) == 4 + assert connection.headers["Authorization"] == "Bearer token" + assert connection.headers["X-API-Key"] == "key123" + + def test_http_connection_with_localhost_url(self): + """Test HttpMcpConnection with localhost URL.""" + connection = HttpMcpConnection( + name="local_server", url="http://localhost:8000/mcp" + ) + + assert connection.url == "http://localhost:8000/mcp" + + def test_connection_names_can_be_descriptive(self): + """Test that connection names can be descriptive strings.""" + stdio_conn = StdioMcpConnection( + name="My Custom MCP Server (Python)", command="python", args=[] + ) + sse_conn = SseMcpConnection( + name="Production API Server", url="https://api.example.com" + ) + http_conn = HttpMcpConnection( + name="Development Server - Local", url="http://localhost:3000" + ) + + assert "Python" in stdio_conn.name + assert "Production" in sse_conn.name + assert "Development" in http_conn.name + + def test_validate_command_with_special_characters_in_path(self): + """Test validation with special characters in path.""" + mcp_module = sys.modules["chainlit.mcp"] + with patch.object(mcp_module, "config") as mock_config: + mock_config.features.mcp.stdio.allowed_executables = ["python"] + + _, executable, args = validate_mcp_command( + "/opt/my-app/bin/python script.py" + ) + + assert executable == "/opt/my-app/bin/python" + assert args == ["script.py"] diff --git a/backend/tests/test_message.py b/backend/tests/test_message.py new file mode 100644 index 0000000..952f557 --- /dev/null +++ b/backend/tests/test_message.py @@ -0,0 +1,757 @@ +import asyncio +import json +from contextlib import contextmanager +from unittest.mock import AsyncMock, Mock, patch + +import pytest + +from chainlit.action import Action +from chainlit.context import ChainlitContext, context_var +from chainlit.message import ( + AskActionMessage, + AskElementMessage, + AskFileMessage, + AskUserMessage, + ErrorMessage, + Message, + MessageBase, +) + + +@contextmanager +def mock_chainlit_context(session=None): + """Context manager to set up and tear down Chainlit context.""" + mock_loop = Mock(spec=asyncio.AbstractEventLoop) + mock_session = session or Mock() + mock_session.thread_id = "thread_123" + + with patch("asyncio.get_running_loop", return_value=mock_loop): + mock_emitter = AsyncMock() + mock_context = ChainlitContext(session=mock_session, emitter=mock_emitter) + token = context_var.set(mock_context) + try: + yield mock_context + finally: + context_var.reset(token) + + +class TestMessageBase: + """Test suite for MessageBase class.""" + + def test_post_init_sets_thread_id(self): + """Test that __post_init__ sets thread_id from session.""" + with mock_chainlit_context(): + msg = Message(content="test") + assert msg.thread_id == "thread_123" + + def test_post_init_generates_id_if_not_provided(self): + """Test that __post_init__ generates UUID if id not provided.""" + with mock_chainlit_context(): + msg = Message(content="test") + assert msg.id is not None + assert len(msg.id) == 36 + + def test_post_init_uses_provided_id(self): + """Test that __post_init__ uses provided id.""" + with mock_chainlit_context(): + msg = Message(content="test", id="custom_id") + assert msg.id == "custom_id" + + def test_from_dict_creates_message(self): + """Test creating message from dictionary.""" + step_dict = { + "id": "msg_123", + "parentId": "parent_123", + "createdAt": "2024-01-01T00:00:00Z", + "output": "Hello world", + "name": "Assistant", + "command": "/test", + "type": "user_message", + "language": "python", + "metadata": {"key": "value"}, + } + + with mock_chainlit_context(): + msg = MessageBase.from_dict(step_dict) + + assert msg.id == "msg_123" + assert msg.parent_id == "parent_123" + assert msg.created_at == "2024-01-01T00:00:00Z" + assert msg.content == "Hello world" + assert msg.author == "Assistant" + assert msg.command == "/test" + assert msg.type == "user_message" + assert msg.language == "python" + assert msg.metadata == {"key": "value"} + + def test_from_dict_with_minimal_data(self): + """Test from_dict with minimal required fields.""" + step_dict = { + "id": "msg_123", + "createdAt": "2024-01-01T00:00:00Z", + "output": "Hello", + } + + with mock_chainlit_context(): + with patch("chainlit.message.config") as mock_config: + mock_config.ui.name = "DefaultBot" + msg = MessageBase.from_dict(step_dict) + + assert msg.id == "msg_123" + assert msg.content == "Hello" + assert msg.author == "DefaultBot" + assert msg.type == "assistant_message" + + def test_to_dict_returns_step_dict(self): + """Test converting message to dictionary.""" + with mock_chainlit_context(): + msg = Message( + content="Test content", + author="TestBot", + language="python", + type="user_message", + metadata={"key": "value"}, + tags=["tag1", "tag2"], + id="msg_123", + parent_id="parent_123", + command="/test", + ) + msg.created_at = "2024-01-01T00:00:00Z" + + result = msg.to_dict() + + assert result["id"] == "msg_123" + assert result["threadId"] == "thread_123" + assert result["parentId"] == "parent_123" + assert result["createdAt"] == "2024-01-01T00:00:00Z" + assert result["command"] == "/test" + assert result["output"] == "Test content" + assert result["name"] == "TestBot" + assert result["type"] == "user_message" + assert result["language"] == "python" + assert result["streaming"] is False + assert result["isError"] is False + assert result["waitForAnswer"] is False + assert result["metadata"] == {"key": "value"} + assert result["tags"] == ["tag1", "tag2"] + + @pytest.mark.asyncio + async def test_update_stops_streaming(self): + """Test that update stops streaming.""" + with mock_chainlit_context() as ctx: + msg = Message(content="test") + msg.streaming = True + + with patch("chainlit.message.chat_context") as mock_chat_ctx: + with patch("chainlit.message.get_data_layer", return_value=None): + result = await msg.update() + + assert msg.streaming is False + assert result is True + mock_chat_ctx.add.assert_called_once_with(msg) + ctx.emitter.update_step.assert_called_once() + + @pytest.mark.asyncio + async def test_update_with_data_layer(self): + """Test update with data layer.""" + with mock_chainlit_context() as ctx: + msg = Message(content="test") + mock_data_layer = AsyncMock() + + with patch("chainlit.message.chat_context"): + with patch( + "chainlit.message.get_data_layer", return_value=mock_data_layer + ): + with patch("asyncio.create_task") as mock_create_task: + await msg.update() + + mock_create_task.assert_called_once() + ctx.emitter.update_step.assert_called_once() + + @pytest.mark.asyncio + async def test_remove_from_chat_context(self): + """Test removing message from chat context.""" + with mock_chainlit_context() as ctx: + msg = Message(content="test", id="msg_123") + + with patch("chainlit.message.chat_context") as mock_chat_ctx: + with patch("chainlit.message.get_data_layer", return_value=None): + result = await msg.remove() + + assert result is True + mock_chat_ctx.remove.assert_called_once_with(msg) + ctx.emitter.delete_step.assert_called_once() + + +class TestMessage: + """Test suite for Message class.""" + + def test_message_with_string_content(self): + """Test creating message with string content.""" + with mock_chainlit_context(): + msg = Message(content="Hello world") + + assert msg.content == "Hello world" + assert msg.language is None + + def test_message_with_dict_content(self): + """Test creating message with dict content.""" + with mock_chainlit_context(): + content_dict = {"key": "value", "number": 42} + msg = Message(content=content_dict) + + expected = json.dumps(content_dict, indent=4, ensure_ascii=False) + assert msg.content == expected + assert msg.language == "json" + + def test_message_with_non_serializable_dict(self): + """Test message with non-JSON-serializable dict.""" + with mock_chainlit_context(): + + class NonSerializable: + pass + + content_dict = {"obj": NonSerializable()} + msg = Message(content=content_dict) + + assert msg.language == "text" + assert "NonSerializable" in msg.content + + def test_message_with_non_string_content(self): + """Test message with non-string, non-dict content.""" + with mock_chainlit_context(): + msg = Message(content=12345) + + assert msg.content == "12345" + assert msg.language == "text" + + def test_message_with_custom_author(self): + """Test message with custom author.""" + with mock_chainlit_context(): + msg = Message(content="test", author="CustomBot") + + assert msg.author == "CustomBot" + + def test_message_with_default_author(self): + """Test message uses default author from config.""" + with mock_chainlit_context(): + with patch("chainlit.message.config") as mock_config: + mock_config.ui.name = "DefaultBot" + msg = Message(content="test") + + assert msg.author == "DefaultBot" + + def test_message_with_actions(self): + """Test message with actions.""" + with mock_chainlit_context(): + action1 = Mock(spec=Action) + action2 = Mock(spec=Action) + msg = Message(content="test", actions=[action1, action2]) + + assert len(msg.actions) == 2 + assert action1 in msg.actions + assert action2 in msg.actions + + def test_message_with_elements(self): + """Test message with elements.""" + with mock_chainlit_context(): + element1 = Mock() + element2 = Mock() + msg = Message(content="test", elements=[element1, element2]) + + assert len(msg.elements) == 2 + assert element1 in msg.elements + assert element2 in msg.elements + + @pytest.mark.asyncio + async def test_message_send(self): + """Test sending a message.""" + with mock_chainlit_context() as ctx: + msg = Message(content="test") + + with patch("chainlit.message.chat_context") as mock_chat_ctx: + with patch("chainlit.message.get_data_layer", return_value=None): + with patch("chainlit.message.config") as mock_config: + mock_config.code.author_rename = None + + result = await msg.send() + + assert result == msg + assert msg.created_at is not None + assert msg.streaming is False + mock_chat_ctx.add.assert_called_once_with(msg) + ctx.emitter.send_step.assert_called_once() + + @pytest.mark.asyncio + async def test_message_send_with_author_rename(self): + """Test sending message with author rename.""" + with mock_chainlit_context(): + msg = Message(content="test", author="OldName") + + async def rename_author(name): + return "NewName" + + with patch("chainlit.message.chat_context"): + with patch("chainlit.message.get_data_layer", return_value=None): + with patch("chainlit.message.config") as mock_config: + mock_config.code.author_rename = rename_author + + await msg.send() + + assert msg.author == "NewName" + + @pytest.mark.asyncio + async def test_message_send_with_actions_and_elements(self): + """Test sending message with actions and elements.""" + with mock_chainlit_context(): + action = AsyncMock(spec=Action) + element = AsyncMock() + msg = Message(content="test", actions=[action], elements=[element]) + + with patch("chainlit.message.chat_context"): + with patch("chainlit.message.get_data_layer", return_value=None): + with patch("chainlit.message.config") as mock_config: + mock_config.code.author_rename = None + + await msg.send() + + action.send.assert_called_once() + element.send.assert_called_once() + + @pytest.mark.asyncio + async def test_message_update_with_actions(self): + """Test updating message with new actions.""" + with mock_chainlit_context(): + action1 = AsyncMock(spec=Action) + action1.forId = None + action2 = AsyncMock(spec=Action) + action2.forId = "existing_id" + + msg = Message(content="test", actions=[action1, action2]) + + with patch("chainlit.message.chat_context"): + with patch("chainlit.message.get_data_layer", return_value=None): + result = await msg.update() + + assert result is True + action1.send.assert_called_once() + action2.send.assert_not_called() + + @pytest.mark.asyncio + async def test_message_remove_actions(self): + """Test removing all actions from message.""" + with mock_chainlit_context(): + action1 = AsyncMock(spec=Action) + action2 = AsyncMock(spec=Action) + msg = Message(content="test", actions=[action1, action2]) + + await msg.remove_actions() + + action1.remove.assert_called_once() + action2.remove.assert_called_once() + + @pytest.mark.asyncio + async def test_stream_token_starts_streaming(self): + """Test that stream_token starts streaming.""" + with mock_chainlit_context() as ctx: + msg = Message(content="") + + await msg.stream_token("Hello") + + assert msg.streaming is True + assert msg.content == "Hello" + ctx.emitter.stream_start.assert_called_once() + + @pytest.mark.asyncio + async def test_stream_token_appends_content(self): + """Test that stream_token appends to content.""" + with mock_chainlit_context() as ctx: + msg = Message(content="Hello") + msg.streaming = True + + await msg.stream_token(" world") + + assert msg.content == "Hello world" + ctx.emitter.send_token.assert_called_once_with( + id=msg.id, token=" world", is_sequence=False + ) + + @pytest.mark.asyncio + async def test_stream_token_with_sequence(self): + """Test stream_token with is_sequence=True.""" + with mock_chainlit_context() as ctx: + msg = Message(content="Old content") + msg.streaming = True + + await msg.stream_token("New content", is_sequence=True) + + assert msg.content == "New content" + ctx.emitter.send_token.assert_called_once_with( + id=msg.id, token="New content", is_sequence=True + ) + + @pytest.mark.asyncio + async def test_stream_token_ignores_empty_token(self): + """Test that empty tokens are ignored.""" + with mock_chainlit_context() as ctx: + msg = Message(content="test") + + await msg.stream_token("") + + assert msg.content == "test" + ctx.emitter.stream_start.assert_not_called() + + +class TestErrorMessage: + """Test suite for ErrorMessage class.""" + + def test_error_message_initialization(self): + """Test ErrorMessage initialization.""" + with mock_chainlit_context(): + msg = ErrorMessage(content="An error occurred") + + assert msg.content == "An error occurred" + assert msg.author is not None + assert msg.type == "assistant_message" + assert msg.is_error is True + assert msg.fail_on_persist_error is False + + def test_error_message_with_custom_author(self): + """Test ErrorMessage with custom author.""" + with mock_chainlit_context(): + msg = ErrorMessage(content="Error", author="ErrorBot") + + assert msg.author == "ErrorBot" + + def test_error_message_with_fail_on_persist(self): + """Test ErrorMessage with fail_on_persist_error=True.""" + with mock_chainlit_context(): + msg = ErrorMessage(content="Error", fail_on_persist_error=True) + + assert msg.fail_on_persist_error is True + + @pytest.mark.asyncio + async def test_error_message_send(self): + """Test sending error message.""" + with mock_chainlit_context() as ctx: + msg = ErrorMessage(content="Error occurred") + + with patch("chainlit.message.chat_context"): + with patch("chainlit.message.get_data_layer", return_value=None): + with patch("chainlit.message.config") as mock_config: + mock_config.code.author_rename = None + + result = await msg.send() + + assert result == msg + ctx.emitter.send_step.assert_called_once() + + +class TestAskUserMessage: + """Test suite for AskUserMessage class.""" + + def test_ask_user_message_initialization(self): + """Test AskUserMessage initialization.""" + with mock_chainlit_context(): + msg = AskUserMessage(content="What is your name?") + + assert msg.content == "What is your name?" + assert msg.author is not None + assert msg.timeout == 60 + assert msg.raise_on_timeout is False + + def test_ask_user_message_with_custom_timeout(self): + """Test AskUserMessage with custom timeout.""" + with mock_chainlit_context(): + msg = AskUserMessage(content="Question?", timeout=120) + + assert msg.timeout == 120 + + @pytest.mark.asyncio + async def test_ask_user_message_send(self): + """Test sending AskUserMessage.""" + with mock_chainlit_context() as ctx: + msg = AskUserMessage(content="Question?") + ctx.emitter.send_ask_user = AsyncMock(return_value={"output": "Answer"}) + + with patch("chainlit.message.get_data_layer", return_value=None): + with patch("chainlit.message.config") as mock_config: + mock_config.code.author_rename = None + + result = await msg.send() + + assert result == {"output": "Answer"} + assert msg.wait_for_answer is False + ctx.emitter.send_ask_user.assert_called_once() + + +class TestAskFileMessage: + """Test suite for AskFileMessage class.""" + + def test_ask_file_message_initialization(self): + """Test AskFileMessage initialization.""" + with mock_chainlit_context(): + with patch("chainlit.message.config") as mock_config: + mock_config.ui.name = "Bot" + msg = AskFileMessage( + content="Upload a file", accept=["text/plain", "application/pdf"] + ) + + assert msg.content == "Upload a file" + assert msg.accept == ["text/plain", "application/pdf"] + assert msg.max_size_mb == 2 + assert msg.max_files == 1 + + def test_ask_file_message_with_custom_limits(self): + """Test AskFileMessage with custom limits.""" + with mock_chainlit_context(): + msg = AskFileMessage( + content="Upload", accept=["image/*"], max_size_mb=10, max_files=5 + ) + + assert msg.max_size_mb == 10 + assert msg.max_files == 5 + + @pytest.mark.asyncio + async def test_ask_file_message_send_with_response(self): + """Test AskFileMessage send with file response.""" + with mock_chainlit_context() as ctx: + msg = AskFileMessage(content="Upload", accept=["text/plain"]) + file_response = [ + { + "id": "file_123", + "name": "test.txt", + "path": "/path/to/test.txt", + "size": 1024, + "type": "text/plain", + } + ] + ctx.emitter.send_ask_user = AsyncMock(return_value=file_response) + + with patch("chainlit.message.get_data_layer", return_value=None): + with patch("chainlit.message.config") as mock_config: + mock_config.code.author_rename = None + + result = await msg.send() + + assert result is not None + assert len(result) == 1 + assert result[0].id == "file_123" + assert result[0].name == "test.txt" + assert result[0].path == "/path/to/test.txt" + + @pytest.mark.asyncio + async def test_ask_file_message_send_with_no_response(self): + """Test AskFileMessage send with no response.""" + with mock_chainlit_context() as ctx: + msg = AskFileMessage(content="Upload", accept=["text/plain"]) + ctx.emitter.send_ask_user = AsyncMock(return_value=None) + + with patch("chainlit.message.get_data_layer", return_value=None): + with patch("chainlit.message.config") as mock_config: + mock_config.code.author_rename = None + + result = await msg.send() + + assert result is None + + +class TestAskActionMessage: + """Test suite for AskActionMessage class.""" + + def test_ask_action_message_initialization(self): + """Test AskActionMessage initialization.""" + with mock_chainlit_context(): + with patch("chainlit.message.config") as mock_config: + mock_config.ui.name = "Bot" + action1 = Mock(spec=Action) + action2 = Mock(spec=Action) + msg = AskActionMessage( + content="Choose an action", actions=[action1, action2] + ) + + assert msg.content == "Choose an action" + assert len(msg.actions) == 2 + + @pytest.mark.asyncio + async def test_ask_action_message_send_with_response(self): + """Test AskActionMessage send with action response.""" + with mock_chainlit_context() as ctx: + action = AsyncMock(spec=Action) + action.id = "action_123" + msg = AskActionMessage(content="Choose", actions=[action]) + ctx.emitter.send_ask_user = AsyncMock( + return_value={"id": "action_123", "label": "Confirm"} + ) + + with patch("chainlit.message.get_data_layer", return_value=None): + with patch("chainlit.message.config") as mock_config: + mock_config.code.author_rename = None + + with patch("chainlit.message.chat_context"): + result = await msg.send() + + assert result == {"id": "action_123", "label": "Confirm"} + assert msg.content == "**Selected:** Confirm" + action.send.assert_called_once() + action.remove.assert_called_once() + + @pytest.mark.asyncio + async def test_ask_action_message_send_timeout(self): + """Test AskActionMessage send with timeout.""" + with mock_chainlit_context() as ctx: + action = AsyncMock(spec=Action) + action.id = "action_123" + msg = AskActionMessage(content="Choose", actions=[action]) + ctx.emitter.send_ask_user = AsyncMock(return_value=None) + + with patch("chainlit.message.get_data_layer", return_value=None): + with patch("chainlit.message.config") as mock_config: + mock_config.code.author_rename = None + + with patch("chainlit.message.chat_context"): + result = await msg.send() + + assert result is None + assert msg.content == "Timed out: no action was taken" + + +class TestAskElementMessage: + """Test suite for AskElementMessage class.""" + + def test_ask_element_message_initialization(self): + """Test AskElementMessage initialization.""" + with mock_chainlit_context(): + with patch("chainlit.message.config") as mock_config: + mock_config.ui.name = "Bot" + element = Mock() + msg = AskElementMessage(content="Submit form", element=element) + + assert msg.content == "Submit form" + assert msg.element == element + + @pytest.mark.asyncio + async def test_ask_element_message_send_submitted(self): + """Test AskElementMessage send with submitted response.""" + with mock_chainlit_context() as ctx: + element = AsyncMock() + element.id = "element_123" + msg = AskElementMessage(content="Submit", element=element) + ctx.emitter.send_ask_user = AsyncMock( + return_value={"submitted": True, "data": {"field": "value"}} + ) + + with patch("chainlit.message.get_data_layer", return_value=None): + with patch("chainlit.message.config") as mock_config: + mock_config.code.author_rename = None + + with patch("chainlit.message.chat_context"): + result = await msg.send() + + assert result == {"submitted": True, "data": {"field": "value"}} + assert msg.content == "Thanks for submitting" + element.send.assert_called_once() + element.remove.assert_called_once() + + @pytest.mark.asyncio + async def test_ask_element_message_send_cancelled(self): + """Test AskElementMessage send with cancelled response.""" + with mock_chainlit_context() as ctx: + element = AsyncMock() + element.id = "element_123" + msg = AskElementMessage(content="Submit", element=element) + ctx.emitter.send_ask_user = AsyncMock(return_value={"submitted": False}) + + with patch("chainlit.message.get_data_layer", return_value=None): + with patch("chainlit.message.config") as mock_config: + mock_config.code.author_rename = None + + with patch("chainlit.message.chat_context"): + result = await msg.send() + + assert result == {"submitted": False} + assert msg.content == "Cancelled" + + @pytest.mark.asyncio + async def test_ask_element_message_send_timeout(self): + """Test AskElementMessage send with timeout.""" + with mock_chainlit_context() as ctx: + element = AsyncMock() + element.id = "element_123" + msg = AskElementMessage(content="Submit", element=element) + ctx.emitter.send_ask_user = AsyncMock(return_value=None) + + with patch("chainlit.message.get_data_layer", return_value=None): + with patch("chainlit.message.config") as mock_config: + mock_config.code.author_rename = None + + with patch("chainlit.message.chat_context"): + result = await msg.send() + + assert result is None + assert msg.content == "Timed out" + + +class TestMessageEdgeCases: + """Test suite for message edge cases.""" + + def test_message_with_none_content(self): + """Test message handles None content.""" + with mock_chainlit_context(): + msg = Message(content=None) + assert msg.content == "None" + + def test_message_language_override(self): + """Test that dict content sets language to json.""" + with mock_chainlit_context(): + msg = Message(content={"key": "value"}, language="python") + # Dict content always sets language to json, overriding the parameter + assert msg.language == "json" + + @pytest.mark.asyncio + async def test_message_send_with_none_content(self): + """Test sending message with None content.""" + with mock_chainlit_context(): + msg = Message(content="test") + msg.content = None + + with patch("chainlit.message.chat_context"): + with patch("chainlit.message.get_data_layer", return_value=None): + with patch("chainlit.message.config") as mock_config: + mock_config.code.author_rename = None + + await msg.send() + + assert msg.content == "" + + @pytest.mark.asyncio + async def test_ask_message_remove_clears_ask(self): + """Test that AskMessage remove clears ask state.""" + with mock_chainlit_context() as ctx: + msg = AskUserMessage(content="Question?") + + with patch("chainlit.message.chat_context"): + with patch("chainlit.message.get_data_layer", return_value=None): + await msg.remove() + + ctx.emitter.clear.assert_called_once_with("clear_ask") + + def test_message_metadata_and_tags(self): + """Test message with metadata and tags.""" + with mock_chainlit_context(): + metadata = {"key1": "value1", "key2": 123} + tags = ["important", "user-query"] + msg = Message(content="test", metadata=metadata, tags=tags) + + assert msg.metadata == metadata + assert msg.tags == tags + + def test_message_to_dict_with_none_metadata(self): + """Test to_dict with None metadata.""" + with mock_chainlit_context(): + msg = Message(content="test") + msg.metadata = None + + result = msg.to_dict() + + assert result["metadata"] == {} diff --git a/backend/tests/test_modes.py b/backend/tests/test_modes.py new file mode 100644 index 0000000..292f6ee --- /dev/null +++ b/backend/tests/test_modes.py @@ -0,0 +1,279 @@ +"""Tests for Modes system functionality.""" + +from unittest.mock import MagicMock, patch + +import pytest + +import chainlit as cl +from chainlit.emitter import ChainlitEmitter +from chainlit.mode import Mode, ModeOption + + +@pytest.fixture +def mock_modes(): + """Fixture providing sample modes for testing.""" + return [ + Mode( + id="model", + name="Model", + options=[ + ModeOption( + id="gpt-4", + name="GPT-4", + description="Most capable model", + icon="sparkles", + default=True, + ), + ModeOption( + id="gpt-3.5-turbo", + name="GPT-3.5 Turbo", + description="Fast and efficient", + icon="bolt", + default=False, + ), + ], + ), + Mode( + id="reasoning", + name="Reasoning", + options=[ + ModeOption(id="high", name="High", description="Maximum depth"), + ModeOption( + id="medium", name="Medium", description="Balanced", default=True + ), + ModeOption(id="low", name="Low", description="Quick responses"), + ], + ), + ] + + +@pytest.mark.asyncio +class TestModeOption: + """Test suite for ModeOption dataclass.""" + + def test_mode_option_required_fields(self): + """Test ModeOption with required fields only.""" + option = ModeOption(id="test", name="Test Option") + + assert option.id == "test" + assert option.name == "Test Option" + assert option.description is None + assert option.icon is None + assert option.default is False + + def test_mode_option_all_fields(self): + """Test ModeOption with all fields.""" + option = ModeOption( + id="gpt-4", + name="GPT-4", + description="Most capable model", + icon="sparkles", + default=True, + ) + + assert option.id == "gpt-4" + assert option.name == "GPT-4" + assert option.description == "Most capable model" + assert option.icon == "sparkles" + assert option.default is True + + def test_mode_option_to_dict(self): + """Test ModeOption serialization.""" + option = ModeOption( + id="test", + name="Test", + description="Test desc", + icon="star", + default=True, + ) + + option_dict = option.to_dict() + + assert option_dict["id"] == "test" + assert option_dict["name"] == "Test" + assert option_dict["description"] == "Test desc" + assert option_dict["icon"] == "star" + assert option_dict["default"] is True + + +@pytest.mark.asyncio +class TestMode: + """Test suite for Mode dataclass.""" + + def test_mode_creation(self, mock_modes): + """Test Mode creation with options.""" + mode = mock_modes[0] + + assert mode.id == "model" + assert mode.name == "Model" + assert len(mode.options) == 2 + + def test_mode_to_dict(self, mock_modes): + """Test Mode serialization.""" + mode = mock_modes[0] + mode_dict = mode.to_dict() + + assert mode_dict["id"] == "model" + assert mode_dict["name"] == "Model" + assert len(mode_dict["options"]) == 2 + assert mode_dict["options"][0]["id"] == "gpt-4" + + def test_mode_default_option(self, mock_modes): + """Test finding default option in mode.""" + mode = mock_modes[0] + + default_option = next( + (opt for opt in mode.options if opt.default), mode.options[0] + ) + + assert default_option.id == "gpt-4" + + def test_mode_fallback_to_first(self, mock_modes): + """Test fallback to first option when no default set.""" + mode = Mode( + id="test", + name="Test", + options=[ + ModeOption(id="opt1", name="Option 1"), + ModeOption(id="opt2", name="Option 2"), + ], + ) + + default_option = next( + (opt for opt in mode.options if opt.default), + mode.options[0] if mode.options else None, + ) + + assert default_option is not None + assert default_option.id == "opt1" + + +@pytest.mark.asyncio +class TestMessageWithModes: + """Test suite for Message with modes field.""" + + async def test_message_with_modes(self, mock_chainlit_context): + """Test that Message can be created with modes field.""" + async with mock_chainlit_context: + modes = {"model": "gpt-4", "reasoning": "high"} + message = cl.Message(content="Test message", modes=modes) + + assert message.modes == modes + assert message.content == "Test message" + + async def test_message_to_dict_includes_modes(self, mock_chainlit_context): + """Test that Message.to_dict() includes the modes field.""" + async with mock_chainlit_context: + modes = {"model": "gpt-4", "reasoning": "medium"} + message = cl.Message(content="Test", modes=modes) + message_dict = message.to_dict() + + assert "modes" in message_dict + assert message_dict["modes"] == modes + + async def test_message_from_dict_with_modes(self, mock_chainlit_context): + """Test that Message.from_dict() correctly handles modes field.""" + async with mock_chainlit_context: + message_dict = { + "id": "test-id", + "content": "Test message", + "modes": {"model": "gpt-3.5-turbo", "reasoning": "low"}, + "type": "user_message", + "createdAt": "2024-01-01T00:00:00", + "output": "Test message", + } + message = cl.Message.from_dict(message_dict) + + assert message.modes == {"model": "gpt-3.5-turbo", "reasoning": "low"} + assert message.content == "Test message" + + async def test_message_without_modes(self, mock_chainlit_context): + """Test that Message works without modes field (backward compatibility).""" + async with mock_chainlit_context: + message = cl.Message(content="Test message") + + assert message.modes is None + message_dict = message.to_dict() + assert message_dict.get("modes") is None + + async def test_message_send_with_modes(self, mock_chainlit_context): + """Test that sending a message with modes works.""" + async with mock_chainlit_context as ctx: + modes = {"model": "gpt-4", "reasoning": "high"} + message = cl.Message(content="Test", modes=modes) + + with patch("chainlit.message.chat_context") as mock_chat_ctx: + with patch("chainlit.message.get_data_layer", return_value=None): + with patch("chainlit.message.config") as mock_config: + mock_config.code.author_rename = None + + result = await message.send() + + assert result == message + assert message.modes == modes + mock_chat_ctx.add.assert_called_once_with(message) + ctx.emitter.send_step.assert_called_once() + + # Verify the dict sent to emitter includes modes + call_args = ctx.emitter.send_step.call_args[0][0] + assert call_args["modes"] == modes + + +@pytest.mark.asyncio +class TestEmitterSetModes: + """Test suite for emitter set_modes functionality.""" + + async def test_set_modes( + self, mock_modes, mock_websocket_session: MagicMock + ) -> None: + """Test set_modes emits correct event.""" + emitter = ChainlitEmitter(mock_websocket_session) + modes_dicts = [mode.to_dict() for mode in mock_modes] + + await emitter.set_modes(mock_modes) + + mock_websocket_session.emit.assert_called_once_with("set_modes", modes_dicts) + + async def test_set_modes_empty_list( + self, mock_websocket_session: MagicMock + ) -> None: + """Test set_modes with empty list.""" + emitter = ChainlitEmitter(mock_websocket_session) + + await emitter.set_modes([]) + + mock_websocket_session.emit.assert_called_once_with("set_modes", []) + + async def test_set_modes_single_mode( + self, mock_websocket_session: MagicMock + ) -> None: + """Test set_modes with single mode.""" + emitter = ChainlitEmitter(mock_websocket_session) + mode = Mode( + id="model", + name="Model", + options=[ModeOption(id="gpt-4", name="GPT-4", default=True)], + ) + + await emitter.set_modes([mode]) + + mock_websocket_session.emit.assert_called_once() + call_args = mock_websocket_session.emit.call_args + assert call_args[0][0] == "set_modes" + assert len(call_args[0][1]) == 1 + assert call_args[0][1][0]["id"] == "model" + + +@pytest.mark.asyncio +class TestModeExports: + """Test that Mode and ModeOption are properly exported.""" + + def test_mode_exported_from_chainlit(self): + """Test Mode is exported from chainlit package.""" + assert hasattr(cl, "Mode") + assert cl.Mode is Mode + + def test_mode_option_exported_from_chainlit(self): + """Test ModeOption is exported from chainlit package.""" + assert hasattr(cl, "ModeOption") + assert cl.ModeOption is ModeOption diff --git a/backend/tests/test_oauth_providers.py b/backend/tests/test_oauth_providers.py new file mode 100644 index 0000000..09522c0 --- /dev/null +++ b/backend/tests/test_oauth_providers.py @@ -0,0 +1,1242 @@ +import os +from unittest.mock import AsyncMock, Mock, patch + +import httpx +import pytest +from fastapi import HTTPException + +from chainlit.oauth_providers import ( + ACCESS_TOKEN_MISSING, + Auth0OAuthProvider, + AWSCognitoOAuthProvider, + AzureADHybridOAuthProvider, + AzureADOAuthProvider, + DescopeOAuthProvider, + GenericOAuthProvider, + GithubOAuthProvider, + GitlabOAuthProvider, + GoogleOAuthProvider, + KeycloakOAuthProvider, + OAuthProvider, + OktaOAuthProvider, + get_configured_oauth_providers, + get_oauth_provider, +) +from chainlit.user import User + + +class TestOAuthProviderBase: + """Test suite for OAuthProvider base class.""" + + def test_oauth_provider_has_required_methods(self): + """Test that OAuthProvider defines required methods.""" + provider = OAuthProvider() + + # These should be methods + assert hasattr(provider, "is_configured") + assert hasattr(provider, "get_raw_token_response") + assert hasattr(provider, "get_token") + assert hasattr(provider, "get_user_info") + assert hasattr(provider, "get_env_prefix") + assert hasattr(provider, "get_prompt") + + def test_oauth_provider_is_configured_returns_false_when_env_missing(self): + """Test is_configured returns False when environment variables are missing.""" + provider = OAuthProvider() + provider.env = ["MISSING_VAR_1", "MISSING_VAR_2"] + + assert provider.is_configured() is False + + def test_oauth_provider_is_configured_returns_true_when_env_present(self): + """Test is_configured returns True when all environment variables are present.""" + provider = OAuthProvider() + provider.env = ["TEST_VAR_1", "TEST_VAR_2"] + + with patch.dict(os.environ, {"TEST_VAR_1": "value1", "TEST_VAR_2": "value2"}): + assert provider.is_configured() is True + + def test_oauth_provider_get_env_prefix(self): + """Test get_env_prefix converts id to uppercase with underscores.""" + provider = OAuthProvider() + provider.id = "azure-ad" + + assert provider.get_env_prefix() == "AZURE_AD" + + def test_oauth_provider_get_prompt_returns_provider_specific(self): + """Test get_prompt returns provider-specific prompt.""" + provider = OAuthProvider() + provider.id = "github" + provider.default_prompt = None + + with patch.dict(os.environ, {"OAUTH_GITHUB_PROMPT": "consent"}): + assert provider.get_prompt() == "consent" + + def test_oauth_provider_get_prompt_returns_global(self): + """Test get_prompt returns global prompt when provider-specific not set.""" + provider = OAuthProvider() + provider.id = "github" + provider.default_prompt = None + + with patch.dict(os.environ, {"OAUTH_PROMPT": "select_account"}): + assert provider.get_prompt() == "select_account" + + def test_oauth_provider_get_prompt_returns_default(self): + """Test get_prompt returns default when no env vars set.""" + provider = OAuthProvider() + provider.id = "github" + provider.default_prompt = "login" + + assert provider.get_prompt() == "login" + + @pytest.mark.asyncio + async def test_oauth_provider_abstract_methods_raise_not_implemented(self): + """Test that abstract methods raise NotImplementedError.""" + provider = OAuthProvider() + + with pytest.raises(NotImplementedError): + await provider.get_raw_token_response("code", "url") + + with pytest.raises(NotImplementedError): + await provider.get_token("code", "url") + + with pytest.raises(NotImplementedError): + await provider.get_user_info("token") + + +class TestGithubOAuthProvider: + """Test suite for GithubOAuthProvider.""" + + def test_github_provider_initialization(self): + """Test GithubOAuthProvider initialization.""" + with patch.dict( + os.environ, + { + "OAUTH_GITHUB_CLIENT_ID": "test_client_id", + "OAUTH_GITHUB_CLIENT_SECRET": "test_secret", + }, + ): + provider = GithubOAuthProvider() + + assert provider.id == "github" + assert provider.client_id == "test_client_id" + assert provider.client_secret == "test_secret" + assert "scope" in provider.authorize_params + assert provider.authorize_params["scope"] == "user:email" + + def test_github_provider_with_custom_urls(self): + """Test GithubOAuthProvider with custom URLs.""" + # Need to set env vars before importing/instantiating since they're class-level + with patch.dict( + os.environ, + { + "OAUTH_GITHUB_CLIENT_ID": "test_id", + "OAUTH_GITHUB_CLIENT_SECRET": "test_secret", + "OAUTH_GITHUB_AUTH_URL": "https://custom.github.com/oauth/authorize", + "OAUTH_GITHUB_TOKEN_URL": "https://custom.github.com/oauth/token", + "OAUTH_GITHUB_USER_INFO_URL": "https://custom.github.com/api/user", + }, + clear=False, + ): + # Re-import to get the updated class-level attributes + from importlib import reload + + import chainlit.oauth_providers as oauth_module + + reload(oauth_module) + + provider = oauth_module.GithubOAuthProvider() + + assert provider.authorize_url == "https://custom.github.com/oauth/authorize" + assert provider.token_url == "https://custom.github.com/oauth/token" + assert provider.user_info_url == "https://custom.github.com/api/user" + + @pytest.mark.asyncio + async def test_github_get_raw_token_response_success(self): + """Test GitHub get_raw_token_response with successful response.""" + with patch.dict( + os.environ, + { + "OAUTH_GITHUB_CLIENT_ID": "test_id", + "OAUTH_GITHUB_CLIENT_SECRET": "test_secret", + }, + ): + provider = GithubOAuthProvider() + + mock_response = Mock() + mock_response.text = "access_token=test_token&token_type=bearer" + mock_response.raise_for_status = Mock() + + with patch("httpx.AsyncClient") as mock_client: + mock_client.return_value.__aenter__.return_value.post = AsyncMock( + return_value=mock_response + ) + + result = await provider.get_raw_token_response( + "test_code", "http://localhost" + ) + + assert "access_token" in result + assert result["access_token"][0] == "test_token" + + @pytest.mark.asyncio + async def test_github_get_token_success(self): + """Test GitHub get_token with successful response.""" + with patch.dict( + os.environ, + { + "OAUTH_GITHUB_CLIENT_ID": "test_id", + "OAUTH_GITHUB_CLIENT_SECRET": "test_secret", + }, + ): + provider = GithubOAuthProvider() + + mock_response = Mock() + mock_response.text = "access_token=github_token_123&token_type=bearer" + mock_response.raise_for_status = Mock() + + with patch("httpx.AsyncClient") as mock_client: + mock_client.return_value.__aenter__.return_value.post = AsyncMock( + return_value=mock_response + ) + + token = await provider.get_token("test_code", "http://localhost") + + assert token == "github_token_123" + + @pytest.mark.asyncio + async def test_github_get_token_missing_access_token(self): + """Test GitHub get_token raises error when access_token is missing.""" + with patch.dict( + os.environ, + { + "OAUTH_GITHUB_CLIENT_ID": "test_id", + "OAUTH_GITHUB_CLIENT_SECRET": "test_secret", + }, + ): + provider = GithubOAuthProvider() + + mock_response = Mock() + mock_response.text = "error=invalid_grant" + mock_response.raise_for_status = Mock() + + with patch("httpx.AsyncClient") as mock_client: + mock_client.return_value.__aenter__.return_value.post = AsyncMock( + return_value=mock_response + ) + + with pytest.raises(HTTPException) as exc_info: + await provider.get_token("test_code", "http://localhost") + + assert exc_info.value.status_code == 400 + assert ACCESS_TOKEN_MISSING in str(exc_info.value.detail) + + @pytest.mark.asyncio + async def test_github_get_user_info_success(self): + """Test GitHub get_user_info with successful response.""" + with patch.dict( + os.environ, + { + "OAUTH_GITHUB_CLIENT_ID": "test_id", + "OAUTH_GITHUB_CLIENT_SECRET": "test_secret", + }, + ): + provider = GithubOAuthProvider() + + mock_user_response = Mock() + mock_user_response.json.return_value = { + "login": "testuser", + "avatar_url": "https://github.com/avatar.png", + "email": "test@example.com", + } + mock_user_response.raise_for_status = Mock() + + mock_emails_response = Mock() + mock_emails_response.json.return_value = [ + {"email": "test@example.com", "primary": True, "verified": True} + ] + mock_emails_response.raise_for_status = Mock() + + with patch("httpx.AsyncClient") as mock_client: + mock_get = AsyncMock( + side_effect=[mock_user_response, mock_emails_response] + ) + mock_client.return_value.__aenter__.return_value.get = mock_get + + github_user, user = await provider.get_user_info("test_token") + + assert github_user["login"] == "testuser" + assert "emails" in github_user + assert isinstance(user, User) + assert user.identifier == "testuser" + assert user.metadata["provider"] == "github" + assert user.metadata["image"] == "https://github.com/avatar.png" + + +class TestGoogleOAuthProvider: + """Test suite for GoogleOAuthProvider.""" + + def test_google_provider_initialization(self): + """Test GoogleOAuthProvider initialization.""" + with patch.dict( + os.environ, + { + "OAUTH_GOOGLE_CLIENT_ID": "google_client_id", + "OAUTH_GOOGLE_CLIENT_SECRET": "google_secret", + }, + ): + provider = GoogleOAuthProvider() + + assert provider.id == "google" + assert provider.client_id == "google_client_id" + assert provider.client_secret == "google_secret" + assert ( + provider.authorize_url == "https://accounts.google.com/o/oauth2/v2/auth" + ) + assert "scope" in provider.authorize_params + assert "userinfo.profile" in provider.authorize_params["scope"] + + @pytest.mark.asyncio + async def test_google_get_token_success(self): + """Test Google get_token with successful response.""" + with patch.dict( + os.environ, + { + "OAUTH_GOOGLE_CLIENT_ID": "google_id", + "OAUTH_GOOGLE_CLIENT_SECRET": "google_secret", + }, + ): + provider = GoogleOAuthProvider() + + mock_response = Mock() + mock_response.json.return_value = { + "access_token": "google_access_token", + "token_type": "Bearer", + } + mock_response.raise_for_status = Mock() + + with patch("httpx.AsyncClient") as mock_client: + mock_client.return_value.__aenter__.return_value.post = AsyncMock( + return_value=mock_response + ) + + token = await provider.get_token( + "auth_code", "http://localhost/callback" + ) + + assert token == "google_access_token" + + @pytest.mark.asyncio + async def test_google_get_user_info_success(self): + """Test Google get_user_info with successful response.""" + with patch.dict( + os.environ, + { + "OAUTH_GOOGLE_CLIENT_ID": "google_id", + "OAUTH_GOOGLE_CLIENT_SECRET": "google_secret", + }, + ): + provider = GoogleOAuthProvider() + + mock_response = Mock() + mock_response.json.return_value = { + "email": "user@gmail.com", + "name": "Test User", + "picture": "https://google.com/photo.jpg", + } + mock_response.raise_for_status = Mock() + + with patch("httpx.AsyncClient") as mock_client: + mock_client.return_value.__aenter__.return_value.get = AsyncMock( + return_value=mock_response + ) + + google_user, user = await provider.get_user_info("test_token") + + assert google_user["email"] == "user@gmail.com" + assert isinstance(user, User) + assert user.identifier == "user@gmail.com" + assert user.metadata["provider"] == "google" + assert user.metadata["image"] == "https://google.com/photo.jpg" + + +class TestAzureADOAuthProvider: + """Test suite for AzureADOAuthProvider.""" + + def test_azure_ad_provider_initialization(self): + """Test AzureADOAuthProvider initialization.""" + with patch.dict( + os.environ, + { + "OAUTH_AZURE_AD_CLIENT_ID": "azure_client_id", + "OAUTH_AZURE_AD_CLIENT_SECRET": "azure_secret", + "OAUTH_AZURE_AD_TENANT_ID": "tenant_123", + }, + ): + provider = AzureADOAuthProvider() + + assert provider.id == "azure-ad" + assert provider.client_id == "azure_client_id" + assert provider.client_secret == "azure_secret" + assert "tenant" in provider.authorize_params + assert provider.authorize_params["tenant"] == "tenant_123" + + def test_azure_ad_single_tenant_urls(self): + """Test Azure AD uses tenant-specific URLs when single tenant enabled.""" + # Azure AD URLs are set at class definition time, need to reload module + with patch.dict( + os.environ, + { + "OAUTH_AZURE_AD_CLIENT_ID": "azure_id", + "OAUTH_AZURE_AD_CLIENT_SECRET": "azure_secret", + "OAUTH_AZURE_AD_TENANT_ID": "tenant_abc", + "OAUTH_AZURE_AD_ENABLE_SINGLE_TENANT": "true", + }, + clear=False, + ): + from importlib import reload + + import chainlit.oauth_providers as oauth_module + + reload(oauth_module) + + provider = oauth_module.AzureADOAuthProvider() + + assert "tenant_abc" in provider.authorize_url + assert "tenant_abc" in provider.token_url + + @pytest.mark.asyncio + async def test_azure_ad_get_token_with_refresh_token(self): + """Test Azure AD get_token stores refresh token.""" + with patch.dict( + os.environ, + { + "OAUTH_AZURE_AD_CLIENT_ID": "azure_id", + "OAUTH_AZURE_AD_CLIENT_SECRET": "azure_secret", + "OAUTH_AZURE_AD_TENANT_ID": "tenant_123", + }, + ): + provider = AzureADOAuthProvider() + + mock_response = Mock() + mock_response.json.return_value = { + "access_token": "azure_access_token", + "refresh_token": "azure_refresh_token", + } + mock_response.raise_for_status = Mock() + + with patch("httpx.AsyncClient") as mock_client: + mock_client.return_value.__aenter__.return_value.post = AsyncMock( + return_value=mock_response + ) + + token = await provider.get_token( + "auth_code", "http://localhost/callback" + ) + + assert token == "azure_access_token" + assert provider._refresh_token == "azure_refresh_token" + + @pytest.mark.asyncio + async def test_azure_ad_get_user_info_with_photo(self): + """Test Azure AD get_user_info includes photo when available.""" + with patch.dict( + os.environ, + { + "OAUTH_AZURE_AD_CLIENT_ID": "azure_id", + "OAUTH_AZURE_AD_CLIENT_SECRET": "azure_secret", + "OAUTH_AZURE_AD_TENANT_ID": "tenant_123", + }, + ): + provider = AzureADOAuthProvider() + provider._refresh_token = "refresh_token_123" + + mock_user_response = Mock() + mock_user_response.json.return_value = { + "userPrincipalName": "user@company.com", + "displayName": "Test User", + } + mock_user_response.raise_for_status = Mock() + + mock_photo_response = Mock() + mock_photo_response.aread = AsyncMock(return_value=b"photo_data") + mock_photo_response.headers = {"Content-Type": "image/jpeg"} + + with patch("httpx.AsyncClient") as mock_client: + mock_get = AsyncMock( + side_effect=[mock_user_response, mock_photo_response] + ) + mock_client.return_value.__aenter__.return_value.get = mock_get + + azure_user, user = await provider.get_user_info("test_token") + + assert azure_user["userPrincipalName"] == "user@company.com" + assert "image" in azure_user + assert isinstance(user, User) + assert user.identifier == "user@company.com" + assert user.metadata["provider"] == "azure-ad" + assert user.metadata["refresh_token"] == "refresh_token_123" + + +class TestOktaOAuthProvider: + """Test suite for OktaOAuthProvider.""" + + def test_okta_provider_initialization(self): + """Test OktaOAuthProvider initialization.""" + with patch.dict( + os.environ, + { + "OAUTH_OKTA_CLIENT_ID": "okta_client_id", + "OAUTH_OKTA_CLIENT_SECRET": "okta_secret", + "OAUTH_OKTA_DOMAIN": "dev-12345.okta.com", + }, + clear=False, + ): + from importlib import reload + + import chainlit.oauth_providers as oauth_module + + reload(oauth_module) + + provider = oauth_module.OktaOAuthProvider() + + assert provider.id == "okta" + assert provider.client_id == "okta_client_id" + assert "dev-12345.okta.com" in provider.authorize_url + + def test_okta_authorization_server_path_default(self): + """Test Okta uses default authorization server.""" + with patch.dict( + os.environ, + { + "OAUTH_OKTA_CLIENT_ID": "okta_id", + "OAUTH_OKTA_CLIENT_SECRET": "okta_secret", + "OAUTH_OKTA_DOMAIN": "dev-12345.okta.com", + }, + ): + provider = OktaOAuthProvider() + + assert provider.get_authorization_server_path() == "/default" + + def test_okta_authorization_server_path_custom(self): + """Test Okta uses custom authorization server.""" + with patch.dict( + os.environ, + { + "OAUTH_OKTA_CLIENT_ID": "okta_id", + "OAUTH_OKTA_CLIENT_SECRET": "okta_secret", + "OAUTH_OKTA_DOMAIN": "dev-12345.okta.com", + "OAUTH_OKTA_AUTHORIZATION_SERVER_ID": "custom_server", + }, + ): + provider = OktaOAuthProvider() + + assert provider.get_authorization_server_path() == "/custom_server" + + def test_okta_authorization_server_path_false(self): + """Test Okta with no authorization server.""" + with patch.dict( + os.environ, + { + "OAUTH_OKTA_CLIENT_ID": "okta_id", + "OAUTH_OKTA_CLIENT_SECRET": "okta_secret", + "OAUTH_OKTA_DOMAIN": "dev-12345.okta.com", + "OAUTH_OKTA_AUTHORIZATION_SERVER_ID": "false", + }, + ): + provider = OktaOAuthProvider() + + assert provider.get_authorization_server_path() == "" + + +class TestAuth0OAuthProvider: + """Test suite for Auth0OAuthProvider.""" + + def test_auth0_provider_initialization(self): + """Test Auth0OAuthProvider initialization.""" + with patch.dict( + os.environ, + { + "OAUTH_AUTH0_CLIENT_ID": "auth0_client_id", + "OAUTH_AUTH0_CLIENT_SECRET": "auth0_secret", + "OAUTH_AUTH0_DOMAIN": "dev-12345.auth0.com", + }, + ): + provider = Auth0OAuthProvider() + + assert provider.id == "auth0" + assert provider.client_id == "auth0_client_id" + assert "dev-12345.auth0.com" in provider.domain + + def test_auth0_with_original_domain(self): + """Test Auth0 with separate original domain.""" + with patch.dict( + os.environ, + { + "OAUTH_AUTH0_CLIENT_ID": "auth0_id", + "OAUTH_AUTH0_CLIENT_SECRET": "auth0_secret", + "OAUTH_AUTH0_DOMAIN": "custom.domain.com", + "OAUTH_AUTH0_ORIGINAL_DOMAIN": "dev-12345.auth0.com", + }, + ): + provider = Auth0OAuthProvider() + + assert "custom.domain.com" in provider.domain + assert "dev-12345.auth0.com" in provider.original_domain + + +class TestGenericOAuthProvider: + """Test suite for GenericOAuthProvider.""" + + def test_generic_provider_initialization(self): + """Test GenericOAuthProvider initialization.""" + with patch.dict( + os.environ, + { + "OAUTH_GENERIC_CLIENT_ID": "generic_id", + "OAUTH_GENERIC_CLIENT_SECRET": "generic_secret", + "OAUTH_GENERIC_AUTH_URL": "https://auth.example.com/oauth/authorize", + "OAUTH_GENERIC_TOKEN_URL": "https://auth.example.com/oauth/token", + "OAUTH_GENERIC_USER_INFO_URL": "https://auth.example.com/oauth/userinfo", + "OAUTH_GENERIC_SCOPES": "openid profile email", + }, + ): + provider = GenericOAuthProvider() + + assert provider.id == "generic" + assert provider.client_id == "generic_id" + assert provider.authorize_url == "https://auth.example.com/oauth/authorize" + assert provider.token_url == "https://auth.example.com/oauth/token" + assert provider.user_info_url == "https://auth.example.com/oauth/userinfo" + + def test_generic_provider_custom_name(self): + """Test GenericOAuthProvider with custom name.""" + # Generic provider id is set at class definition time + with patch.dict( + os.environ, + { + "OAUTH_GENERIC_NAME": "my-custom-provider", + "OAUTH_GENERIC_CLIENT_ID": "generic_id", + "OAUTH_GENERIC_CLIENT_SECRET": "generic_secret", + "OAUTH_GENERIC_AUTH_URL": "https://auth.example.com/oauth/authorize", + "OAUTH_GENERIC_TOKEN_URL": "https://auth.example.com/oauth/token", + "OAUTH_GENERIC_USER_INFO_URL": "https://auth.example.com/oauth/userinfo", + "OAUTH_GENERIC_SCOPES": "openid profile", + }, + clear=False, + ): + from importlib import reload + + import chainlit.oauth_providers as oauth_module + + reload(oauth_module) + + provider = oauth_module.GenericOAuthProvider() + + assert provider.id == "my-custom-provider" + + def test_generic_provider_custom_user_identifier(self): + """Test GenericOAuthProvider with custom user identifier field.""" + with patch.dict( + os.environ, + { + "OAUTH_GENERIC_CLIENT_ID": "generic_id", + "OAUTH_GENERIC_CLIENT_SECRET": "generic_secret", + "OAUTH_GENERIC_AUTH_URL": "https://auth.example.com/oauth/authorize", + "OAUTH_GENERIC_TOKEN_URL": "https://auth.example.com/oauth/token", + "OAUTH_GENERIC_USER_INFO_URL": "https://auth.example.com/oauth/userinfo", + "OAUTH_GENERIC_SCOPES": "openid", + "OAUTH_GENERIC_USER_IDENTIFIER": "username", + }, + ): + provider = GenericOAuthProvider() + + assert provider.user_identifier == "username" + + +class TestAzureADHybridOAuthProvider: + """Test suite for AzureADHybridOAuthProvider.""" + + def test_azure_ad_hybrid_provider_initialization(self): + """Test AzureADHybridOAuthProvider initialization.""" + with patch.dict( + os.environ, + { + "OAUTH_AZURE_AD_HYBRID_CLIENT_ID": "hybrid_client_id", + "OAUTH_AZURE_AD_HYBRID_CLIENT_SECRET": "hybrid_secret", + "OAUTH_AZURE_AD_HYBRID_TENANT_ID": "tenant_456", + }, + ): + provider = AzureADHybridOAuthProvider() + + assert provider.id == "azure-ad-hybrid" + assert provider.client_id == "hybrid_client_id" + assert provider.client_secret == "hybrid_secret" + assert "tenant" in provider.authorize_params + assert provider.authorize_params["tenant"] == "tenant_456" + assert provider.authorize_params["response_type"] == "code id_token" + assert provider.authorize_params["response_mode"] == "form_post" + assert "nonce" in provider.authorize_params + + @pytest.mark.asyncio + async def test_azure_ad_hybrid_get_token_success(self): + """Test AzureADHybrid get_token with successful response.""" + with patch.dict( + os.environ, + { + "OAUTH_AZURE_AD_HYBRID_CLIENT_ID": "hybrid_id", + "OAUTH_AZURE_AD_HYBRID_CLIENT_SECRET": "hybrid_secret", + "OAUTH_AZURE_AD_HYBRID_TENANT_ID": "tenant_789", + }, + ): + provider = AzureADHybridOAuthProvider() + + mock_response = Mock() + mock_response.json.return_value = { + "access_token": "hybrid_access_token", + "refresh_token": "hybrid_refresh_token", + } + mock_response.raise_for_status = Mock() + + with patch("httpx.AsyncClient") as mock_client: + mock_client.return_value.__aenter__.return_value.post = AsyncMock( + return_value=mock_response + ) + + token = await provider.get_token( + "auth_code", "http://localhost/callback" + ) + + assert token == "hybrid_access_token" + assert provider._refresh_token == "hybrid_refresh_token" + + @pytest.mark.asyncio + async def test_azure_ad_hybrid_get_user_info_success(self): + """Test AzureADHybrid get_user_info with successful response.""" + with patch.dict( + os.environ, + { + "OAUTH_AZURE_AD_HYBRID_CLIENT_ID": "hybrid_id", + "OAUTH_AZURE_AD_HYBRID_CLIENT_SECRET": "hybrid_secret", + "OAUTH_AZURE_AD_HYBRID_TENANT_ID": "tenant_789", + }, + ): + provider = AzureADHybridOAuthProvider() + provider._refresh_token = "refresh_token_hybrid" + + mock_user_response = Mock() + mock_user_response.json.return_value = { + "userPrincipalName": "hybrid@company.com", + "displayName": "Hybrid User", + } + mock_user_response.raise_for_status = Mock() + + mock_photo_response = Mock() + mock_photo_response.aread = AsyncMock(return_value=b"photo_bytes") + mock_photo_response.headers = {"Content-Type": "image/png"} + + with patch("httpx.AsyncClient") as mock_client: + mock_get = AsyncMock( + side_effect=[mock_user_response, mock_photo_response] + ) + mock_client.return_value.__aenter__.return_value.get = mock_get + + azure_user, user = await provider.get_user_info("test_token") + + assert azure_user["userPrincipalName"] == "hybrid@company.com" + assert isinstance(user, User) + assert user.identifier == "hybrid@company.com" + assert user.metadata["provider"] == "azure-ad" + assert user.metadata["refresh_token"] == "refresh_token_hybrid" + + +class TestDescopeOAuthProvider: + """Test suite for DescopeOAuthProvider.""" + + def test_descope_provider_initialization(self): + """Test DescopeOAuthProvider initialization.""" + with patch.dict( + os.environ, + { + "OAUTH_DESCOPE_CLIENT_ID": "descope_client_id", + "OAUTH_DESCOPE_CLIENT_SECRET": "descope_secret", + }, + ): + provider = DescopeOAuthProvider() + + assert provider.id == "descope" + assert provider.client_id == "descope_client_id" + assert provider.client_secret == "descope_secret" + assert "openid profile email" in provider.authorize_params["scope"] + + @pytest.mark.asyncio + async def test_descope_get_token_success(self): + """Test Descope get_token with successful response.""" + with patch.dict( + os.environ, + { + "OAUTH_DESCOPE_CLIENT_ID": "descope_id", + "OAUTH_DESCOPE_CLIENT_SECRET": "descope_secret", + }, + ): + provider = DescopeOAuthProvider() + + mock_response = Mock() + mock_response.json.return_value = { + "access_token": "descope_access_token", + "token_type": "Bearer", + } + mock_response.raise_for_status = Mock() + + with patch("httpx.AsyncClient") as mock_client: + mock_client.return_value.__aenter__.return_value.post = AsyncMock( + return_value=mock_response + ) + + token = await provider.get_token( + "auth_code", "http://localhost/callback" + ) + + assert token == "descope_access_token" + + @pytest.mark.asyncio + async def test_descope_get_user_info_success(self): + """Test Descope get_user_info with successful response.""" + with patch.dict( + os.environ, + { + "OAUTH_DESCOPE_CLIENT_ID": "descope_id", + "OAUTH_DESCOPE_CLIENT_SECRET": "descope_secret", + }, + ): + provider = DescopeOAuthProvider() + + mock_response = Mock() + mock_response.json.return_value = { + "email": "user@descope.com", + "name": "Descope User", + } + mock_response.raise_for_status = Mock() + + with patch("httpx.AsyncClient") as mock_client: + mock_client.return_value.__aenter__.return_value.get = AsyncMock( + return_value=mock_response + ) + + descope_user, user = await provider.get_user_info("test_token") + + assert descope_user["email"] == "user@descope.com" + assert isinstance(user, User) + assert user.identifier == "user@descope.com" + assert user.metadata["provider"] == "descope" + + +class TestAWSCognitoOAuthProvider: + """Test suite for AWSCognitoOAuthProvider.""" + + def test_cognito_provider_initialization(self): + """Test AWSCognitoOAuthProvider initialization.""" + with patch.dict( + os.environ, + { + "OAUTH_COGNITO_CLIENT_ID": "cognito_client_id", + "OAUTH_COGNITO_CLIENT_SECRET": "cognito_secret", + "OAUTH_COGNITO_DOMAIN": "my-app.auth.us-east-1.amazoncognito.com", + }, + ): + provider = AWSCognitoOAuthProvider() + + assert provider.id == "aws-cognito" + assert provider.client_id == "cognito_client_id" + assert provider.client_secret == "cognito_secret" + assert "openid profile email" in provider.scopes + + def test_cognito_provider_custom_scopes(self): + """Test AWSCognitoOAuthProvider with custom scopes.""" + with patch.dict( + os.environ, + { + "OAUTH_COGNITO_CLIENT_ID": "cognito_id", + "OAUTH_COGNITO_CLIENT_SECRET": "cognito_secret", + "OAUTH_COGNITO_DOMAIN": "my-app.auth.us-east-1.amazoncognito.com", + "OAUTH_COGNITO_SCOPE": "openid email phone", + }, + ): + provider = AWSCognitoOAuthProvider() + + assert provider.scopes == "openid email phone" + assert provider.authorize_params["scope"] == "openid email phone" + + @pytest.mark.asyncio + async def test_cognito_get_token_success(self): + """Test Cognito get_token with successful response.""" + with patch.dict( + os.environ, + { + "OAUTH_COGNITO_CLIENT_ID": "cognito_id", + "OAUTH_COGNITO_CLIENT_SECRET": "cognito_secret", + "OAUTH_COGNITO_DOMAIN": "my-app.auth.us-east-1.amazoncognito.com", + }, + ): + provider = AWSCognitoOAuthProvider() + + mock_response = Mock() + mock_response.json.return_value = { + "access_token": "cognito_access_token", + "token_type": "Bearer", + } + mock_response.raise_for_status = Mock() + + with patch("httpx.AsyncClient") as mock_client: + mock_client.return_value.__aenter__.return_value.post = AsyncMock( + return_value=mock_response + ) + + token = await provider.get_token( + "auth_code", "http://localhost/callback" + ) + + assert token == "cognito_access_token" + + @pytest.mark.asyncio + async def test_cognito_get_user_info_success(self): + """Test Cognito get_user_info with successful response.""" + with patch.dict( + os.environ, + { + "OAUTH_COGNITO_CLIENT_ID": "cognito_id", + "OAUTH_COGNITO_CLIENT_SECRET": "cognito_secret", + "OAUTH_COGNITO_DOMAIN": "my-app.auth.us-east-1.amazoncognito.com", + }, + ): + provider = AWSCognitoOAuthProvider() + + mock_response = Mock() + mock_response.json.return_value = { + "email": "user@cognito.com", + "picture": "https://cognito.com/photo.jpg", + } + mock_response.raise_for_status = Mock() + + with patch("httpx.AsyncClient") as mock_client: + mock_client.return_value.__aenter__.return_value.get = AsyncMock( + return_value=mock_response + ) + + cognito_user, user = await provider.get_user_info("test_token") + + assert cognito_user["email"] == "user@cognito.com" + assert isinstance(user, User) + assert user.identifier == "user@cognito.com" + assert user.metadata["provider"] == "aws-cognito" + assert user.metadata["image"] == "https://cognito.com/photo.jpg" + + +class TestGitlabOAuthProvider: + """Test suite for GitlabOAuthProvider.""" + + def test_gitlab_provider_initialization(self): + """Test GitlabOAuthProvider initialization.""" + with patch.dict( + os.environ, + { + "OAUTH_GITLAB_CLIENT_ID": "gitlab_client_id", + "OAUTH_GITLAB_CLIENT_SECRET": "gitlab_secret", + "OAUTH_GITLAB_DOMAIN": "gitlab.example.com", + }, + ): + provider = GitlabOAuthProvider() + + assert provider.id == "gitlab" + assert provider.client_id == "gitlab_client_id" + assert provider.client_secret == "gitlab_secret" + assert "gitlab.example.com" in provider.domain + assert "openid profile email" in provider.authorize_params["scope"] + + def test_gitlab_provider_strips_trailing_slash(self): + """Test GitlabOAuthProvider strips trailing slash from domain.""" + with patch.dict( + os.environ, + { + "OAUTH_GITLAB_CLIENT_ID": "gitlab_id", + "OAUTH_GITLAB_CLIENT_SECRET": "gitlab_secret", + "OAUTH_GITLAB_DOMAIN": "gitlab.example.com/", + }, + ): + provider = GitlabOAuthProvider() + + assert not provider.domain.endswith("/") + + @pytest.mark.asyncio + async def test_gitlab_get_token_success(self): + """Test Gitlab get_token with successful response.""" + with patch.dict( + os.environ, + { + "OAUTH_GITLAB_CLIENT_ID": "gitlab_id", + "OAUTH_GITLAB_CLIENT_SECRET": "gitlab_secret", + "OAUTH_GITLAB_DOMAIN": "gitlab.example.com", + }, + ): + provider = GitlabOAuthProvider() + + mock_response = Mock() + mock_response.json.return_value = { + "access_token": "gitlab_access_token", + "token_type": "Bearer", + } + mock_response.raise_for_status = Mock() + + with patch("httpx.AsyncClient") as mock_client: + mock_client.return_value.__aenter__.return_value.post = AsyncMock( + return_value=mock_response + ) + + token = await provider.get_token( + "auth_code", "http://localhost/callback" + ) + + assert token == "gitlab_access_token" + + @pytest.mark.asyncio + async def test_gitlab_get_user_info_success(self): + """Test Gitlab get_user_info with successful response.""" + with patch.dict( + os.environ, + { + "OAUTH_GITLAB_CLIENT_ID": "gitlab_id", + "OAUTH_GITLAB_CLIENT_SECRET": "gitlab_secret", + "OAUTH_GITLAB_DOMAIN": "gitlab.example.com", + }, + ): + provider = GitlabOAuthProvider() + + mock_response = Mock() + mock_response.json.return_value = { + "email": "user@gitlab.com", + "picture": "https://gitlab.com/avatar.png", + } + mock_response.raise_for_status = Mock() + + with patch("httpx.AsyncClient") as mock_client: + mock_client.return_value.__aenter__.return_value.get = AsyncMock( + return_value=mock_response + ) + + gitlab_user, user = await provider.get_user_info("test_token") + + assert gitlab_user["email"] == "user@gitlab.com" + assert isinstance(user, User) + assert user.identifier == "user@gitlab.com" + assert user.metadata["provider"] == "gitlab" + assert user.metadata["image"] == "https://gitlab.com/avatar.png" + + +class TestKeycloakOAuthProvider: + """Test suite for KeycloakOAuthProvider.""" + + def test_keycloak_provider_initialization(self): + """Test KeycloakOAuthProvider initialization.""" + with patch.dict( + os.environ, + { + "OAUTH_KEYCLOAK_CLIENT_ID": "keycloak_client_id", + "OAUTH_KEYCLOAK_CLIENT_SECRET": "keycloak_secret", + "OAUTH_KEYCLOAK_REALM": "my-realm", + "OAUTH_KEYCLOAK_BASE_URL": "https://keycloak.example.com", + }, + ): + provider = KeycloakOAuthProvider() + + assert provider.client_id == "keycloak_client_id" + assert provider.client_secret == "keycloak_secret" + assert provider.realm == "my-realm" + assert provider.base_url == "https://keycloak.example.com" + assert "profile email openid" in provider.authorize_params["scope"] + + def test_keycloak_provider_custom_name(self): + """Test KeycloakOAuthProvider with custom name.""" + with patch.dict( + os.environ, + { + "OAUTH_KEYCLOAK_NAME": "my-keycloak", + "OAUTH_KEYCLOAK_CLIENT_ID": "keycloak_id", + "OAUTH_KEYCLOAK_CLIENT_SECRET": "keycloak_secret", + "OAUTH_KEYCLOAK_REALM": "my-realm", + "OAUTH_KEYCLOAK_BASE_URL": "https://keycloak.example.com", + }, + clear=False, + ): + from importlib import reload + + import chainlit.oauth_providers as oauth_module + + reload(oauth_module) + + provider = oauth_module.KeycloakOAuthProvider() + + assert provider.id == "my-keycloak" + + @pytest.mark.asyncio + async def test_keycloak_get_token_success(self): + """Test Keycloak get_token with successful response.""" + with patch.dict( + os.environ, + { + "OAUTH_KEYCLOAK_CLIENT_ID": "keycloak_id", + "OAUTH_KEYCLOAK_CLIENT_SECRET": "keycloak_secret", + "OAUTH_KEYCLOAK_REALM": "my-realm", + "OAUTH_KEYCLOAK_BASE_URL": "https://keycloak.example.com", + }, + ): + provider = KeycloakOAuthProvider() + + mock_response = Mock() + mock_response.json.return_value = { + "access_token": "keycloak_access_token", + "refresh_token": "keycloak_refresh_token", + } + mock_response.raise_for_status = Mock() + + with patch("httpx.AsyncClient") as mock_client: + mock_client.return_value.__aenter__.return_value.post = AsyncMock( + return_value=mock_response + ) + + token = await provider.get_token( + "auth_code", "http://localhost/callback" + ) + + assert token == "keycloak_access_token" + assert provider.refresh_token == "keycloak_refresh_token" + + @pytest.mark.asyncio + async def test_keycloak_get_user_info_success(self): + """Test Keycloak get_user_info with successful response.""" + with patch.dict( + os.environ, + { + "OAUTH_KEYCLOAK_CLIENT_ID": "keycloak_id", + "OAUTH_KEYCLOAK_CLIENT_SECRET": "keycloak_secret", + "OAUTH_KEYCLOAK_REALM": "my-realm", + "OAUTH_KEYCLOAK_BASE_URL": "https://keycloak.example.com", + }, + ): + provider = KeycloakOAuthProvider() + + mock_response = Mock() + mock_response.json.return_value = { + "email": "user@keycloak.com", + "name": "Keycloak User", + } + mock_response.raise_for_status = Mock() + + with patch("httpx.AsyncClient") as mock_client: + mock_client.return_value.__aenter__.return_value.get = AsyncMock( + return_value=mock_response + ) + + keycloak_user, user = await provider.get_user_info("test_token") + + assert keycloak_user["email"] == "user@keycloak.com" + assert isinstance(user, User) + assert user.identifier == "user@keycloak.com" + assert user.metadata["provider"] == "keycloak" + + +class TestHelperFunctions: + """Test suite for helper functions.""" + + def test_get_oauth_provider_returns_correct_provider(self): + """Test get_oauth_provider returns the correct provider.""" + provider = get_oauth_provider("github") + + assert provider is not None + assert provider.id == "github" + + def test_get_oauth_provider_returns_none_for_unknown(self): + """Test get_oauth_provider returns None for unknown provider.""" + provider = get_oauth_provider("unknown_provider") + + assert provider is None + + def test_get_configured_oauth_providers_empty_when_none_configured(self): + """Test get_configured_oauth_providers returns empty list when none configured.""" + # Clear all OAuth environment variables + with patch.dict(os.environ, {}, clear=True): + configured = get_configured_oauth_providers() + + assert configured == [] + + def test_get_configured_oauth_providers_returns_configured(self): + """Test get_configured_oauth_providers returns configured providers.""" + with patch.dict( + os.environ, + { + "OAUTH_GITHUB_CLIENT_ID": "github_id", + "OAUTH_GITHUB_CLIENT_SECRET": "github_secret", + "OAUTH_GOOGLE_CLIENT_ID": "google_id", + "OAUTH_GOOGLE_CLIENT_SECRET": "google_secret", + }, + ): + configured = get_configured_oauth_providers() + + assert "github" in configured + assert "google" in configured + + +class TestOAuthProviderEdgeCases: + """Test suite for OAuth provider edge cases.""" + + @pytest.mark.asyncio + async def test_provider_handles_http_error(self): + """Test provider handles HTTP errors gracefully.""" + with patch.dict( + os.environ, + { + "OAUTH_GITHUB_CLIENT_ID": "test_id", + "OAUTH_GITHUB_CLIENT_SECRET": "test_secret", + }, + ): + provider = GithubOAuthProvider() + + mock_response = Mock() + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + "Error", request=Mock(), response=Mock() + ) + + with patch("httpx.AsyncClient") as mock_client: + mock_client.return_value.__aenter__.return_value.post = AsyncMock( + return_value=mock_response + ) + + with pytest.raises(httpx.HTTPStatusError): + await provider.get_raw_token_response("code", "url") + + def test_provider_strips_trailing_slash_from_domain(self): + """Test providers strip trailing slashes from domains.""" + with patch.dict( + os.environ, + { + "OAUTH_AUTH0_CLIENT_ID": "auth0_id", + "OAUTH_AUTH0_CLIENT_SECRET": "auth0_secret", + "OAUTH_AUTH0_DOMAIN": "dev-12345.auth0.com/", + }, + ): + provider = Auth0OAuthProvider() + + assert not provider.domain.endswith("/") + + def test_provider_with_prompt_parameter(self): + """Test provider includes prompt parameter when configured.""" + with patch.dict( + os.environ, + { + "OAUTH_GOOGLE_CLIENT_ID": "google_id", + "OAUTH_GOOGLE_CLIENT_SECRET": "google_secret", + "OAUTH_GOOGLE_PROMPT": "consent", + }, + ): + provider = GoogleOAuthProvider() + + assert "prompt" in provider.authorize_params + assert provider.authorize_params["prompt"] == "consent" diff --git a/backend/tests/test_server.py b/backend/tests/test_server.py new file mode 100644 index 0000000..1441a3d --- /dev/null +++ b/backend/tests/test_server.py @@ -0,0 +1,1154 @@ +import datetime +import os +import pathlib +from pathlib import Path +from typing import Callable +from unittest.mock import AsyncMock, Mock, create_autospec, mock_open + +import pytest +from fastapi.testclient import TestClient + +from chainlit.auth import get_current_user +from chainlit.config import ( + APP_ROOT, + ChainlitConfig, + SpontaneousFileUploadFeature, +) +from chainlit.server import app +from chainlit.types import AskFileSpec +from chainlit.user import PersistedUser + + +@pytest.fixture +def test_client(): + return TestClient(app) + + +@pytest.fixture +def mock_load_translation(test_config: ChainlitConfig, monkeypatch: pytest.MonkeyPatch): + mock_method = Mock(return_value={"key": "value"}) + monkeypatch.setattr("chainlit.config.ChainlitConfig.load_translation", mock_method) + + return mock_method + + +def test_project_translations_default_language( + test_client: TestClient, mock_load_translation: Mock +): + """Test with default language.""" + response = test_client.get("/project/translations") + assert response.status_code == 200 + assert "translation" in response.json() + mock_load_translation.assert_called_once_with("en-US") + mock_load_translation.reset_mock() + + +def test_project_translations_specific_language( + test_client: TestClient, mock_load_translation: Mock +): + """Test with a specific language.""" + + response = test_client.get("/project/translations?language=fr-FR") + assert response.status_code == 200 + assert "translation" in response.json() + mock_load_translation.assert_called_once_with("fr-FR") + mock_load_translation.reset_mock() + + +def test_project_translations_invalid_language( + test_client: TestClient, mock_load_translation: Mock +): + """Test with an invalid language.""" + + response = test_client.get("/project/translations?language=invalid") + assert response.status_code == 422 + + assert ( + "translation" not in response.json() + ) # It should fall back to default translation + assert not mock_load_translation.called + + +def test_project_translations_bcp47_language( + test_client: TestClient, mock_load_translation: Mock +): + """Regression test for https://github.com/Chainlit/chainlit/issues/1352.""" + + response = test_client.get("/project/translations?language=es-419") + assert response.status_code == 200 + assert "translation" in response.json() + mock_load_translation.assert_called_once_with("es-419") + mock_load_translation.reset_mock() + + +@pytest.fixture +def mock_get_current_user(): + """Override get_current_user() dependency.""" + + # Programming sucks! + # Ref: https://github.com/fastapi/fastapi/issues/3331#issuecomment-1182452859 + app.dependency_overrides[get_current_user] = create_autospec(lambda: None) + + yield app.dependency_overrides[get_current_user] + + del app.dependency_overrides[get_current_user] + + +async def test_project_settings(test_client: TestClient, mock_get_current_user: Mock): + """Burn test for project settings.""" + response = test_client.get( + "/project/settings", + ) + + mock_get_current_user.assert_called_once() + + assert response.status_code == 200, response.json() + data = response.json() + + assert "ui" in data + assert "features" in data + assert "userEnv" in data + assert "dataPersistence" in data + assert "threadResumable" in data + assert "markdown" in data + assert "debugUrl" in data + assert data["chatProfiles"] == [] + assert data["starters"] == [] + + +def test_project_settings_path_traversal( + test_client: TestClient, + mock_get_current_user: Mock, + tmp_path: Path, + test_config: ChainlitConfig, +): + """Test to prevent path traversal in project settings.""" + + # Create a mock chainlit directory structure + app_dir = tmp_path / "app" + app_dir.mkdir() + (tmp_path / "README.md").write_text("This is a secret README") + + # This is required for the exploit to occur. + chainlit_dir = app_dir / "chainlit_stuff" + chainlit_dir.mkdir() + + # Mock the config root + test_config.root = str(app_dir) + + # Attempt to access the file using path traversal + response = test_client.get( + "/project/settings", params={"language": "stuff/../../README"} + ) + + # Should not be able to read the file + assert "This is a secret README" not in response.text + + assert response.status_code == 422 + + # The response should not contain the normally expected keys + data = response.json() + assert "ui" not in data + assert "features" not in data + assert "userEnv" not in data + assert "dataPersistence" not in data + assert "threadResumable" not in data + assert "markdown" not in data + assert "chatProfiles" not in data + assert "starters" not in data + assert "debugUrl" not in data + + +def test_get_avatar_default(test_client: TestClient, monkeypatch: pytest.MonkeyPatch): + """Test with default avatar.""" + response = test_client.get("/avatars/default") + assert response.status_code == 200 + assert response.headers["content-type"].startswith("image/") + + +def test_get_avatar_custom(test_client: TestClient, monkeypatch: pytest.MonkeyPatch): + """Test with custom avatar.""" + custom_avatar_path = os.path.join( + APP_ROOT, "public", "avatars", "custom_avatar.png" + ) + os.makedirs(os.path.dirname(custom_avatar_path), exist_ok=True) + with open(custom_avatar_path, "wb") as f: + f.write(b"fake image data") + + response = test_client.get("/avatars/custom_avatar") + assert response.status_code == 200 + assert response.headers["content-type"].startswith("image/") + assert response.content == b"fake image data" + + # Clean up + os.remove(custom_avatar_path) + + +def test_get_avatar_with_spaces( + test_client: TestClient, monkeypatch: pytest.MonkeyPatch +): + """Test with custom avatar.""" + custom_avatar_path = os.path.join(APP_ROOT, "public", "avatars", "my_assistant.png") + os.makedirs(os.path.dirname(custom_avatar_path), exist_ok=True) + with open(custom_avatar_path, "wb") as f: + f.write(b"fake image data") + + response = test_client.get("/avatars/My Assistant") + assert response.status_code == 200 + assert response.headers["content-type"].startswith("image/") + assert response.content == b"fake image data" + + # Clean up + os.remove(custom_avatar_path) + + +def test_get_avatar_non_existent_favicon( + test_client: TestClient, monkeypatch: pytest.MonkeyPatch +): + """Test with non-existent avatar (should return favicon).""" + favicon_response = test_client.get("/favicon") + assert favicon_response.status_code == 200 + + response = test_client.get("/avatars/non_existent") + + assert response.status_code == 200 + assert response.headers["content-type"].startswith("image/") + assert response.content == favicon_response.content + + +def test_avatar_path_traversal( + test_client: TestClient, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path +): + """Test to prevent potential path traversal in avatar route on Windows.""" + + # Create a Mock object for the glob function + mock_glob = Mock(return_value=[]) + monkeypatch.setattr("chainlit.server.glob.glob", mock_glob) + + mock_open_inst = mock_open(read_data=b'{"should_not": "Be readable."}') + monkeypatch.setattr("builtins.open", mock_open_inst) + + # Attempt to access a file using path traversal + response = test_client.get("/avatars/..%5C..%5Capp") + + # No glob should ever be called + assert not mock_glob.called + + # Should return an error status + assert response.status_code == 400 + + +@pytest.fixture +def mock_session_get_by_id_patched(mock_session: Mock, monkeypatch: pytest.MonkeyPatch): + test_session_id = "test_session_id" + + # Mock the WebsocketSession.get_by_id method to return the mock session + monkeypatch.setattr( + "chainlit.session.WebsocketSession.get_by_id", + lambda session_id: mock_session if session_id == test_session_id else None, + ) + + return mock_session + + +def test_get_file_success( + test_client: TestClient, + mock_session_get_by_id_patched: Mock, + tmp_path: pathlib.Path, + mock_get_current_user: Mock, +): + """ + Test successful retrieval of a file from a session. + """ + # Set current_user to match session.user + mock_get_current_user.return_value = mock_session_get_by_id_patched.user + + # Create test data + test_content = b"Test file content" + test_file_id = "test_file_id" + + # Create a temporary file with the test content + test_file = tmp_path / "test_file" + test_file.write_bytes(test_content) + + mock_session_get_by_id_patched.files = { + test_file_id: { + "id": test_file_id, + "path": test_file, + "name": "test.txt", + "type": "text/plain", + "size": len(test_content), + } + } + + # Make the GET request to retrieve the file + response = test_client.get( + f"/project/file/{test_file_id}?session_id={mock_session_get_by_id_patched.id}" + ) + + # Verify the response + assert response.status_code == 200 + assert response.content == test_content + assert response.headers["content-type"].startswith("text/plain") + + +def test_get_file_not_existent_file( + test_client: TestClient, + mock_session_get_by_id_patched: Mock, + mock_get_current_user: Mock, +): + """ + Test retrieval of a non-existing file from a session. + """ + # Set current_user to match session.user + mock_get_current_user.return_value = mock_session_get_by_id_patched.user + + # Make the GET request to retrieve the file + response = test_client.get("/project/file/test_file_id?session_id=test_session_id") + + # Verify the response + assert response.status_code == 404 + + +def test_get_file_non_existing_session( + test_client: TestClient, + tmp_path: pathlib.Path, + mock_session_get_by_id_patched: Mock, + mock_session: Mock, + monkeypatch: pytest.MonkeyPatch, +): + """ + Test that an unauthenticated user cannot retrieve a file uploaded by an authenticated user. + """ + + # Attempt to access the file without authentication by providing an invalid session_id + response = test_client.get( + "/project/file/nonexistent?session_id=unauthenticated_session_id" + ) + + # Verify the response + assert response.status_code == 401 # Unauthorized + + +def test_upload_file_success( + test_client: TestClient, + test_config: ChainlitConfig, + mock_session_get_by_id_patched: Mock, +): + """Test successful file upload.""" + + # Prepare the files to upload + file_content = b"Sample file content" + files = { + "file": ("test_upload.txt", file_content, "text/plain"), + } + + # Mock the persist_file method to return a known value + expected_file_id = "mocked_file_id" + mock_session_get_by_id_patched.persist_file = AsyncMock( + return_value={ + "id": expected_file_id, + "name": "test_upload.txt", + "type": "text/plain", + "size": len(file_content), + } + ) + + # Make the POST request to upload the file + response = test_client.post( + "/project/file", + files=files, + params={"session_id": mock_session_get_by_id_patched.id}, + ) + + # Verify the response + assert response.status_code == 200 + response_data = response.json() + assert "id" in response_data + assert response_data["id"] == expected_file_id + assert response_data["name"] == "test_upload.txt" + assert response_data["type"] == "text/plain" + assert response_data["size"] == len(file_content) + + # Verify that persist_file was called with the correct arguments + mock_session_get_by_id_patched.persist_file.assert_called_once_with( + name="test_upload.txt", content=file_content, mime="text/plain" + ) + + +def test_file_access_by_different_user( + test_client: TestClient, + mock_session_get_by_id_patched: Mock, + persisted_test_user: PersistedUser, + tmp_path: pathlib.Path, + mock_session_factory: Callable[..., Mock], +): + """Test that a file uploaded by one user cannot be accessed by another user.""" + + # Prepare the files to upload + file_content = b"Sample file content" + files = { + "file": ("test_upload.txt", file_content, "text/plain"), + } + + # Mock the persist_file method to return a known value + expected_file_id = "mocked_file_id" + mock_session_get_by_id_patched.persist_file = AsyncMock( + return_value={ + "id": expected_file_id, + "name": "test_upload.txt", + "type": "text/plain", + "size": len(file_content), + } + ) + + # Make the POST request to upload the file + response = test_client.post( + "/project/file", + files=files, + params={"session_id": mock_session_get_by_id_patched.id}, + ) + + # Verify the response + assert response.status_code == 200 + + response_data = response.json() + assert "id" in response_data + file_id = response_data["id"] + + # Create a second session with a different user + second_session = mock_session_factory( + id="another_session_id", + user=PersistedUser( + id="another_user_id", + createdAt=datetime.datetime.now().isoformat(), + identifier="another_user_identifier", + ), + ) + + # Attempt to access the uploaded file using the second user's session + response = test_client.get( + f"/project/file/{file_id}?session_id={second_session.id}" + ) + + # Verify that the access attempt fails + assert response.status_code == 401 # Unauthorized + + +def test_upload_file_missing_file( + test_client: TestClient, + mock_session: Mock, +): + """Test file upload with missing file in the request.""" + + # Make the POST request without a file + response = test_client.post( + "/project/file", + data={"session_id": mock_session.id}, + ) + + # Verify the response + assert response.status_code == 422 # Unprocessable Entity + assert "detail" in response.json() + + +def test_upload_file_invalid_session( + test_client: TestClient, +): + """Test file upload with an invalid session.""" + + # Prepare the files to upload + file_content = b"Sample file content" + files = { + "file": ("test_upload.txt", file_content, "text/plain"), + } + + # Make the POST request with an invalid session_id + response = test_client.post( + "/project/file", + files=files, + data={"session_id": "invalid_session_id"}, + ) + + # Verify the response + assert response.status_code == 422 + + +def test_upload_file_unauthorized( + test_client: TestClient, + test_config: ChainlitConfig, + mock_session_get_by_id_patched: Mock, +): + """Test file upload without proper authorization.""" + + # Mock the upload_file_session to have no user + mock_session_get_by_id_patched.user = None + + # Prepare the files to upload + file_content = b"Sample file content" + files = { + "file": ("test_upload.txt", file_content, "text/plain"), + } + + # Make the POST request to upload the file + response = test_client.post( + "/project/file", + files=files, + data={"session_id": mock_session_get_by_id_patched.id}, + ) + + assert response.status_code == 422 + + +def test_upload_file_disabled( + test_client: TestClient, + test_config: ChainlitConfig, + mock_session_get_by_id_patched: Mock, + monkeypatch: pytest.MonkeyPatch, +): + """Test file upload being disabled by config.""" + + # Set accept in config + monkeypatch.setattr( + test_config.features, + "spontaneous_file_upload", + SpontaneousFileUploadFeature(enabled=False), + ) + + # Prepare the files to upload + file_content = b"Sample file content" + files = { + "file": ("test_upload.txt", file_content, "text/plain"), + } + + # Make the POST request to upload the file + response = test_client.post( + "/project/file", + files=files, + params={"session_id": mock_session_get_by_id_patched.id}, + ) + + # Verify the response + assert response.status_code == 400 + + +@pytest.mark.parametrize( + ("accept_pattern", "mime_type", "expected_status"), + [ + ({"image/*": [".png", ".gif", ".jpeg", ".jpg"]}, "image/jpeg", 400), + (["image/*"], "text/plain", 400), + (["image/*", "application/*"], "text/plain", 400), + (["image/png", "application/pdf"], "image/jpeg", 400), + (["text/*"], "text/plain", 200), + (["application/*"], "application/pdf", 200), + (["image/*"], "image/jpeg", 200), + (["image/*", "text/*"], "text/plain", 200), + (["*/*"], "text/plain", 200), + (["*/*"], "image/jpeg", 200), + (["*/*"], "application/pdf", 200), + (["image/*", "application/*"], "application/pdf", 200), + (["image/*", "application/*"], "image/jpeg", 200), + (["image/png", "application/pdf"], "image/png", 200), + (["image/png", "application/pdf"], "application/pdf", 200), + ({"image/*": []}, "image/jpeg", 200), + ( + {"image/*": [".png", ".gif", ".jpeg", ".jpg"]}, + "text/plain", + 400, + ), # mime type not allowed + ( + {"*/*": [".txt", ".gif", ".jpeg", ".jpg"]}, + "text/plain", + 200, + ), # extension allowed + ( + {"*/*": [".gif", ".jpeg", ".jpg"]}, + "text/plain", + 400, + ), # extension not allowed + ], +) +def test_upload_file_mime_type_check( + test_client: TestClient, + test_config: ChainlitConfig, + mock_session_get_by_id_patched: Mock, + monkeypatch: pytest.MonkeyPatch, + accept_pattern: list[str], + mime_type: str, + expected_status: int, +): + """Test check of mime_type.""" + + # Set accept in config + monkeypatch.setattr( + test_config.features, + "spontaneous_file_upload", + SpontaneousFileUploadFeature(enabled=True, accept=accept_pattern), + ) + + # Prepare the files to upload + file_content = b"Sample file content" + files = { + "file": ("test_upload.txt", file_content, mime_type), + } + + # Mock the persist_file method to return a known value + expected_file_id = "mocked_file_id" + mock_session_get_by_id_patched.persist_file = AsyncMock( + return_value={ + "id": expected_file_id, + "name": "test_upload.txt", + "type": "text/plain", + "size": len(file_content), + } + ) + + # Make the POST request to upload the file + response = test_client.post( + "/project/file", + files=files, + params={"session_id": mock_session_get_by_id_patched.id}, + ) + + # Verify the response + assert response.status_code == expected_status + + +@pytest.mark.parametrize( + ("file_content", "content_multiplier", "max_size_mb", "expected_status"), + [ + (b"1", 1, 1, 200), + (b"11", 1024 * 1024, 1, 400), + ], +) +def test_upload_file_size_check( + test_client: TestClient, + test_config: ChainlitConfig, + mock_session_get_by_id_patched: Mock, + monkeypatch: pytest.MonkeyPatch, + file_content: bytes, + content_multiplier: int, + max_size_mb: int, + expected_status: int, +): + """Test check of max_size_mb.""" + + file_content = file_content * content_multiplier + + # Set accept in config + monkeypatch.setattr( + test_config.features, + "spontaneous_file_upload", + SpontaneousFileUploadFeature(max_size_mb=max_size_mb, enabled=True), + ) + + # Prepare the files to upload + files = { + "file": ("test_upload.txt", file_content, "text/plain"), + } + + # Mock the persist_file method to return a known value + expected_file_id = "mocked_file_id" + mock_session_get_by_id_patched.persist_file = AsyncMock( + return_value={ + "id": expected_file_id, + "name": "test_upload.txt", + "type": "text/plain", + "size": len(file_content), + } + ) + + # Make the POST request to upload the file + response = test_client.post( + "/project/file", + files=files, + params={"session_id": mock_session_get_by_id_patched.id}, + ) + + # Verify the response + assert response.status_code == expected_status + + +@pytest.mark.parametrize( + ( + "file_content", + "content_multiplier", + "max_size_mb", + "parent_id", + "expected_status", + "accept", + ), + [ + (b"1", 1, 1, "mocked_parent_id", 200, ["text/plain"]), + (b"11", 1024 * 1024, 1, "mocked_parent_id", 400, ["text/plain"]), + (b"11", 1, 1, "invalid_parent_id", 404, ["text/plain"]), + (b"11", 1, 1, "mocked_parent_id", 400, ["image/gif"]), + ], +) +def test_ask_file_with_spontaneous_upload_disabled( + test_client: TestClient, + test_config: ChainlitConfig, + mock_session_get_by_id_patched: Mock, + monkeypatch: pytest.MonkeyPatch, + file_content: bytes, + content_multiplier: int, + max_size_mb: int, + parent_id: str, + expected_status: int, + accept: list[str], +): + """Test file upload being disabled by config.""" + + # Set accept in config + monkeypatch.setattr( + test_config.features, + "spontaneous_file_upload", + SpontaneousFileUploadFeature(enabled=False), + ) + + # Prepare the files to upload + file_content = file_content * content_multiplier + files = { + "file": ("test_upload.txt", file_content, "text/plain"), + } + + expected_file_id = "mocked_file_id" + mock_session_get_by_id_patched.persist_file = AsyncMock( + return_value={ + "id": expected_file_id, + "name": "test_upload.txt", + "type": "text/plain", + "size": len(file_content), + } + ) + + mock_session_get_by_id_patched.files_spec = { + "mocked_parent_id": AskFileSpec( + step_id="mocked_file_spec", + timeout=1, + type="file", + accept=accept, + max_files=1, + max_size_mb=max_size_mb, + ) + } + + # Make the POST request to upload the file + response = test_client.post( + "/project/file", + files=files, + params={ + "session_id": mock_session_get_by_id_patched.id, + "ask_parent_id": parent_id, + }, + ) + + # Verify the response + assert response.status_code == expected_status + + +def test_project_translations_file_path_traversal( + test_client: TestClient, monkeypatch: pytest.MonkeyPatch +): + """Test to prevent file path traversal in project translations.""" + + mock_open_inst = mock_open(read_data='{"should_not": "Be readable."}') + monkeypatch.setattr("builtins.open", mock_open_inst) + + # Attempt to access the file using path traversal + response = test_client.get( + "/project/translations", params={"language": "/app/unreadable"} + ) + + # File should never be opened + assert not mock_open_inst.called + + # Should give error status + assert response.status_code == 422 + + +def test_project_settings_with_chat_profile_config_overrides( + test_client: TestClient, + test_config: ChainlitConfig, + monkeypatch: pytest.MonkeyPatch, +): + """Test that /project/settings endpoint returns merged configuration when chat_profile is specified.""" + from chainlit.config import ( + ChainlitConfigOverrides, + FeaturesSettings, + McpFeature, + UISettings, + ) + from chainlit.types import ChatProfile + + # Mock chat profiles with different config overrides + mock_profiles = [ + ChatProfile( + name="basic", + markdown_description="Basic profile without overrides", + default=True, + ), + ChatProfile( + name="mcp-enabled", + markdown_description="Profile with MCP enabled", + config_overrides=ChainlitConfigOverrides( + features=FeaturesSettings(mcp=McpFeature(enabled=True)), + ui=UISettings(name="MCP Assistant", default_theme="dark"), + ), + ), + ChatProfile( + name="light-theme", + markdown_description="Profile with light theme", + config_overrides=ChainlitConfigOverrides( + ui=UISettings( + name="Light Theme App", + default_theme="light", + description="Light theme app", + ) + ), + ), + ] + + # Mock the chat profiles callback + async def mock_get_chat_profiles(user, language): + # Use asyncio.sleep to make this truly async + import asyncio + + await asyncio.sleep(0) + return mock_profiles + + test_config.code.set_chat_profiles = mock_get_chat_profiles + + # Test 1: Default profile (no overrides) + response = test_client.get("/project/settings", params={"chat_profile": "basic"}) + assert response.status_code == 200 + config_data = response.json() + + # Should return base configuration without overrides + assert config_data["ui"]["name"] == test_config.ui.name # Original name + assert ( + config_data["features"]["mcp"]["enabled"] == test_config.features.mcp.enabled + ) # Original MCP setting + + # Test 2: MCP-enabled profile + response = test_client.get( + "/project/settings", params={"chat_profile": "mcp-enabled"} + ) + assert response.status_code == 200 + config_data = response.json() + + # Should return merged configuration with MCP enabled and custom UI + assert config_data["features"]["mcp"]["enabled"] is True # Overridden + assert config_data["ui"]["name"] == "MCP Assistant" # Overridden + assert config_data["ui"]["default_theme"] == "dark" # Overridden + + # Test 3: Light theme profile + response = test_client.get( + "/project/settings", params={"chat_profile": "light-theme"} + ) + assert response.status_code == 200 + config_data = response.json() + + # Should return merged configuration with light theme + assert config_data["ui"]["default_theme"] == "light" # Overridden + assert config_data["ui"]["description"] == "Light theme app" # Overridden + assert ( + config_data["features"]["mcp"]["enabled"] == test_config.features.mcp.enabled + ) # Not overridden + + # Test 4: Non-existent profile (should return base config) + response = test_client.get( + "/project/settings", params={"chat_profile": "non-existent"} + ) + assert response.status_code == 200 + config_data = response.json() + + # Should return base configuration + assert config_data["ui"]["name"] == test_config.ui.name + assert config_data["features"]["mcp"]["enabled"] == test_config.features.mcp.enabled + + # Test 5: No profile specified (should return base config) + response = test_client.get("/project/settings") + assert response.status_code == 200 + config_data = response.json() + + # Should return base configuration + assert config_data["ui"]["name"] == test_config.ui.name + assert config_data["features"]["mcp"]["enabled"] == test_config.features.mcp.enabled + + +def test_project_settings_config_overrides_serialization( + test_client: TestClient, + test_config: ChainlitConfig, + monkeypatch: pytest.MonkeyPatch, +): + """Test that config_overrides field is not included in serialized chat profiles.""" + from chainlit.config import ChainlitConfigOverrides, FeaturesSettings, McpFeature + from chainlit.types import ChatProfile + + # Mock chat profile with config overrides + mock_profile = ChatProfile( + name="test-profile", + markdown_description="Test profile", + config_overrides=ChainlitConfigOverrides( + features=FeaturesSettings(mcp=McpFeature(enabled=True)) + ), + ) + + async def mock_get_chat_profiles(user, language): + # Use asyncio.sleep to make this truly async + import asyncio + + await asyncio.sleep(0) + return [mock_profile] + + test_config.code.set_chat_profiles = mock_get_chat_profiles + + # Get the project settings + response = test_client.get( + "/project/settings", params={"chat_profile": "test-profile"} + ) + assert response.status_code == 200 + config_data = response.json() + + # Check that chatProfiles are included in the response + assert "chatProfiles" in config_data + assert len(config_data["chatProfiles"]) == 1 + + # Check that config_overrides is NOT included in the serialized profile + profile_data = config_data["chatProfiles"][0] + assert "config_overrides" not in profile_data + assert profile_data["name"] == "test-profile" + assert profile_data["markdown_description"] == "Test profile" + + +def test_project_settings_config_overrides_language( + test_client: TestClient, + test_config: ChainlitConfig, + monkeypatch: pytest.MonkeyPatch, +): + """Test that localized chat profiles use the right config overrides.""" + from chainlit.config import ChainlitConfigOverrides, FeaturesSettings, McpFeature + from chainlit.types import ChatProfile + + # Mock chat profile with config overrides + mock_profile_fr = ChatProfile( + name="test-profile-fr", + markdown_description="Test profil", + config_overrides=ChainlitConfigOverrides( + features=FeaturesSettings(mcp=McpFeature(enabled=True)) + ), + ) + + mock_profile_en = ChatProfile( + name="test-profile", + markdown_description="Test profile", + config_overrides=ChainlitConfigOverrides( + features=FeaturesSettings(mcp=McpFeature(enabled=False)) + ), + ) + + async def mock_get_chat_profiles(user, language): + # Use asyncio.sleep to make this truly async + import asyncio + + await asyncio.sleep(0) + if language == "fr-CA": + return [mock_profile_fr] + + return [mock_profile_en] + + test_config.code.set_chat_profiles = mock_get_chat_profiles + + # Get the project settings in French + response = test_client.get( + "/project/settings", + params={"language": "fr-CA", "chat_profile": "test-profile-fr"}, + ) + assert response.status_code == 200 + config_data = response.json() + + # Check that chatProfiles are included in the response + assert "chatProfiles" in config_data + assert len(config_data["chatProfiles"]) == 1 + + # Check that config_overrides is NOT included in the serialized profile + assert config_data["features"]["mcp"]["enabled"] is True # Overridden + + # Check that the profile_data matches the selected profile. + profile_data = config_data["chatProfiles"][0] + assert "config_overrides" not in profile_data + assert profile_data["name"] == "test-profile-fr" + assert profile_data["markdown_description"] == "Test profil" + + # Get the project settings in English + response = test_client.get( + "/project/settings", + params={"language": "en-US", "chat_profile": "test-profile"}, + ) + assert response.status_code == 200 + config_data = response.json() + + # Check that chatProfiles are included in the response + assert "chatProfiles" in config_data + assert len(config_data["chatProfiles"]) == 1 + + # Check that config_overrides is NOT included in the serialized profile + assert config_data["features"]["mcp"]["enabled"] is False # Overridden + + # Check that the profile_data matches the selected profile. + profile_data = config_data["chatProfiles"][0] + assert "config_overrides" not in profile_data + assert profile_data["name"] == "test-profile" + assert profile_data["markdown_description"] == "Test profile" + + +def test_project_settings_thread_sharing_flag( + test_client: TestClient, + test_config: ChainlitConfig, + monkeypatch: pytest.MonkeyPatch, +): + # Start with both disabled + test_config.features.allow_thread_sharing = False + test_config.code.on_shared_thread_view = None + resp = test_client.get("/project/settings") + assert resp.status_code == 200 + assert resp.json().get("threadSharing") is False + + # Enable flag only -> still False (callback missing) + test_config.features.allow_thread_sharing = True + test_config.code.on_shared_thread_view = None + resp = test_client.get("/project/settings") + assert resp.status_code == 200 + assert resp.json().get("threadSharing") is False + + # Enable callback only -> still False (flag disabled) + test_config.features.allow_thread_sharing = False + + def dummy_cb(*args, **kwargs): + return True + + test_config.code.on_shared_thread_view = dummy_cb + resp = test_client.get("/project/settings") + assert resp.status_code == 200 + assert resp.json().get("threadSharing") is False + + # Enable both -> True + test_config.features.allow_thread_sharing = True + test_config.code.on_shared_thread_view = dummy_cb + resp = test_client.get("/project/settings") + assert resp.status_code == 200 + assert resp.json().get("threadSharing") is True + + +def test_project_settings_forwards_chat_profile_to_starter_categories( + test_client: TestClient, + test_config: ChainlitConfig, + mock_get_current_user: Mock, +): + """Test that /project/settings forwards chat_profile to set_starter_categories.""" + from chainlit.types import Starter, StarterCategory + from chainlit.utils import wrap_user_function + + async def _get_starter_categories(user, language, chat_profile): + if chat_profile == "creative": + return [ + StarterCategory( + label="Writing", + starters=[Starter(label="Write a poem", message="poem")], + ), + ] + return [ + StarterCategory( + label="General", + starters=[Starter(label="Hello", message="hello")], + ), + ] + + test_config.code.set_starter_categories = wrap_user_function( + _get_starter_categories + ) + + response = test_client.get("/project/settings", params={"chat_profile": "creative"}) + assert response.status_code == 200 + categories = response.json()["starterCategories"] + assert len(categories) == 1 + assert categories[0]["label"] == "Writing" + assert categories[0]["starters"][0]["label"] == "Write a poem" + + response = test_client.get("/project/settings") + assert response.status_code == 200 + categories = response.json()["starterCategories"] + assert len(categories) == 1 + assert categories[0]["label"] == "General" + assert categories[0]["starters"][0]["label"] == "Hello" + + +def test_share_thread_endpoint_sets_flags( + test_client: TestClient, + monkeypatch: pytest.MonkeyPatch, +): + # Override current user to match thread author + from chainlit.server import app as _app, get_current_user as _get_current_user + + author = PersistedUser( + id="u1", + createdAt=datetime.datetime.now().isoformat(), + identifier="author", + ) + + _app.dependency_overrides[_get_current_user] = lambda: author + + # Mock data layer + from unittest.mock import AsyncMock + + dl = AsyncMock() + dl.get_thread.return_value = { + "id": "t1", + "name": "Thread 1", + "userIdentifier": "author", + "metadata": {"other": True}, + } + dl.get_thread_author.return_value = "author" + dl.build_debug_url.return_value = "" + + # Ensure data layer is initialized for both server routes and ACL checks + import chainlit.data as data_mod + + data_mod._data_layer = dl + data_mod._data_layer_initialized = True + + # Share + r = test_client.put( + "/project/thread/share", json={"threadId": "t1", "isShared": True} + ) + assert r.status_code == 200 + + # Validate metadata passed to update_thread includes is_shared and shared_at + assert dl.update_thread.await_count >= 1 + _, kwargs = dl.update_thread.await_args + assert kwargs.get("thread_id") == "t1" + meta = kwargs.get("metadata") or {} + assert meta.get("is_shared") is True + assert isinstance(meta.get("shared_at"), str) + + # Unshare + r = test_client.put( + "/project/thread/share", json={"threadId": "t1", "isShared": False} + ) + assert r.status_code == 200 + _, kwargs = dl.update_thread.await_args + meta = kwargs.get("metadata") or {} + assert meta.get("is_shared") is False + assert "shared_at" not in meta + + # Cleanup override and data layer + del _app.dependency_overrides[_get_current_user] + data_mod._data_layer = None + data_mod._data_layer_initialized = False + + +def test_health_check(test_client: TestClient): + response = test_client.get("/health") + assert response.status_code == 200 + assert response.json() == {"status": "ok"} diff --git a/backend/tests/test_session.py b/backend/tests/test_session.py new file mode 100644 index 0000000..f1a2665 --- /dev/null +++ b/backend/tests/test_session.py @@ -0,0 +1,765 @@ +import builtins +import json +import tempfile +import uuid +from pathlib import Path +from unittest.mock import AsyncMock, Mock, patch + +import pytest + +from chainlit.session import ( + BaseSession, + HTTPSession, + JSONEncoderIgnoreNonSerializable, + McpSession, + WebsocketSession, + clean_metadata, +) + + +def make_exception_group(message: str, exceptions: list[BaseException]): + base_exception_group = getattr(builtins, "BaseExceptionGroup", None) + if base_exception_group is None: + pytest.skip("BaseExceptionGroup is unavailable on this Python version") + return base_exception_group(message, exceptions) # type: ignore[misc] + + +class TestJSONEncoderIgnoreNonSerializable: + """Test suite for JSONEncoderIgnoreNonSerializable.""" + + def test_encoder_handles_serializable_objects(self): + """Test that encoder handles normal serializable objects.""" + data = { + "string": "value", + "number": 42, + "list": [1, 2, 3], + "dict": {"key": "value"}, + } + result = json.dumps(data, cls=JSONEncoderIgnoreNonSerializable) + assert json.loads(result) == data + + def test_encoder_ignores_non_serializable_objects(self): + """Test that encoder returns None for non-serializable objects.""" + + class NonSerializable: + pass + + data = {"normal": "value", "non_serializable": NonSerializable()} + result = json.dumps(data, cls=JSONEncoderIgnoreNonSerializable) + parsed = json.loads(result) + + assert parsed["normal"] == "value" + assert parsed["non_serializable"] is None + + def test_encoder_with_nested_non_serializable(self): + """Test encoder with nested non-serializable objects.""" + + class NonSerializable: + pass + + data = { + "level1": { + "level2": { + "serializable": "value", + "non_serializable": NonSerializable(), + } + } + } + result = json.dumps(data, cls=JSONEncoderIgnoreNonSerializable) + parsed = json.loads(result) + + assert parsed["level1"]["level2"]["serializable"] == "value" + assert parsed["level1"]["level2"]["non_serializable"] is None + + +class TestCleanMetadata: + """Test suite for clean_metadata function.""" + + def test_clean_metadata_with_normal_data(self): + """Test clean_metadata with normal serializable data.""" + metadata = {"key": "value", "number": 42, "list": [1, 2, 3]} + result = clean_metadata(metadata) + assert result == metadata + + def test_clean_metadata_removes_non_serializable(self): + """Test that clean_metadata removes non-serializable objects.""" + + class NonSerializable: + pass + + metadata = {"normal": "value", "non_serializable": NonSerializable()} + result = clean_metadata(metadata) + + assert result["normal"] == "value" + assert result["non_serializable"] is None + + def test_clean_metadata_redacts_large_data(self): + """Test that clean_metadata redacts data exceeding max size.""" + # Create large metadata + large_data = {"data": "x" * 2000000} # > 1MB + result = clean_metadata(large_data, max_size=1048576) + + assert "message" in result + assert "exceeds the limit" in result["message"] + + def test_clean_metadata_with_custom_max_size(self): + """Test clean_metadata with custom max size.""" + small_data = {"data": "x" * 100} + result = clean_metadata(small_data, max_size=50) + + # Should be redacted because it exceeds 50 bytes + assert "message" in result + assert "exceeds the limit" in result["message"] + + def test_clean_metadata_preserves_unicode(self): + """Test that clean_metadata preserves Unicode characters.""" + metadata = {"chinese": "你好", "emoji": "🎉", "japanese": "こんにちは"} + result = clean_metadata(metadata) + + assert result["chinese"] == "你好" + assert result["emoji"] == "🎉" + assert result["japanese"] == "こんにちは" + + +class TestBaseSession: + """Test suite for BaseSession class.""" + + def test_base_session_initialization(self): + """Test BaseSession initialization with required parameters.""" + session = BaseSession( + id="test_id", + client_type="webapp", + thread_id=None, + user=None, + token=None, + user_env=None, + ) + + assert session.id == "test_id" + assert session.client_type == "webapp" + assert session.thread_id is not None # Auto-generated UUID + assert session.user is None + assert session.token is None + assert session.user_env == {} + assert session.chat_settings == {} + + def test_base_session_with_thread_id(self): + """Test BaseSession with provided thread_id.""" + thread_id = str(uuid.uuid4()) + session = BaseSession( + id="test_id", + client_type="webapp", + thread_id=thread_id, + user=None, + token=None, + user_env=None, + ) + + assert session.thread_id == thread_id + assert session.thread_id_to_resume == thread_id + + def test_base_session_with_user_env(self): + """Test BaseSession with user environment variables.""" + user_env = {"API_KEY": "secret", "ENV_VAR": "value"} + session = BaseSession( + id="test_id", + client_type="webapp", + thread_id=None, + user=None, + token=None, + user_env=user_env, + ) + + assert session.user_env == user_env + + def test_base_session_with_chat_profile(self): + """Test BaseSession with chat profile.""" + session = BaseSession( + id="test_id", + client_type="webapp", + thread_id=None, + user=None, + token=None, + user_env=None, + chat_profile="gpt-4", + ) + + assert session.chat_profile == "gpt-4" + + def test_base_session_files_dir(self): + """Test BaseSession files_dir property.""" + with patch("chainlit.config.FILES_DIRECTORY", Path("/tmp/files")): + session = BaseSession( + id="test_id", + client_type="webapp", + thread_id=None, + user=None, + token=None, + user_env=None, + ) + + assert session.files_dir == Path("/tmp/files/test_id") + + @pytest.mark.asyncio + async def test_base_session_persist_file_with_content(self): + """Test persisting a file with content.""" + with tempfile.TemporaryDirectory() as tmpdir: + with patch("chainlit.config.FILES_DIRECTORY", Path(tmpdir)): + session = BaseSession( + id="test_id", + client_type="webapp", + thread_id=None, + user=None, + token=None, + user_env=None, + ) + + content = b"test file content" + result = await session.persist_file( + name="test.txt", + mime="text/plain", + content=content, + ) + + assert "id" in result + assert result["id"] in session.files + assert session.files[result["id"]]["name"] == "test.txt" + assert session.files[result["id"]]["type"] == "text/plain" + + @pytest.mark.asyncio + async def test_base_session_persist_file_with_string_content(self): + """Test persisting a file with string content.""" + with tempfile.TemporaryDirectory() as tmpdir: + with patch("chainlit.config.FILES_DIRECTORY", Path(tmpdir)): + session = BaseSession( + id="test_id", + client_type="webapp", + thread_id=None, + user=None, + token=None, + user_env=None, + ) + + content = "test string content" + result = await session.persist_file( + name="test.txt", + mime="text/plain", + content=content, + ) + + assert "id" in result + file_id = result["id"] + assert session.files[file_id]["size"] > 0 + + @pytest.mark.asyncio + async def test_base_session_persist_file_without_path_or_content(self): + """Test that persist_file raises error without path or content.""" + session = BaseSession( + id="test_id", + client_type="webapp", + thread_id=None, + user=None, + token=None, + user_env=None, + ) + + with pytest.raises(ValueError, match="Either path or content must be provided"): + await session.persist_file(name="test.txt", mime="text/plain") + + def test_base_session_to_persistable(self): + """Test BaseSession to_persistable method.""" + from chainlit.user_session import user_sessions + + original_sessions = user_sessions.copy() + user_sessions.update({"test_id": {"key": "value"}}) + + try: + with patch("chainlit.config.config") as mock_config: + mock_config.project.persist_user_env = True + + session = BaseSession( + id="test_id", + client_type="webapp", + thread_id=None, + user=None, + token=None, + user_env={"API_KEY": "secret"}, + chat_profile="gpt-4", + ) + session.chat_settings = {"temperature": 0.7} + + result = session.to_persistable() + + assert result["chat_settings"] == {"temperature": 0.7} + assert result["chat_profile"] == "gpt-4" + assert result["client_type"] == "webapp" + finally: + user_sessions.clear() + user_sessions.update(original_sessions) + + def test_base_session_to_persistable_without_persist_user_env(self): + """Test to_persistable removes user_env when persist_user_env is False.""" + from chainlit.user_session import user_sessions + + original_sessions = user_sessions.copy() + user_sessions.update({"test_id": {"env": {"KEY": "value"}}}) + + try: + with patch("chainlit.config.config") as mock_config: + mock_config.project.persist_user_env = False + + session = BaseSession( + id="test_id", + client_type="webapp", + thread_id=None, + user=None, + token=None, + user_env={"API_KEY": "secret"}, + ) + + result = session.to_persistable() + + assert result["env"] == {} + finally: + user_sessions.clear() + user_sessions.update(original_sessions) + + +class TestHTTPSession: + """Test suite for HTTPSession class.""" + + def test_http_session_initialization(self): + """Test HTTPSession initialization.""" + session = HTTPSession( + id="http_id", + client_type="copilot", + thread_id=None, + user=None, + token=None, + user_env=None, + ) + + assert session.id == "http_id" + assert session.client_type == "copilot" + assert isinstance(session, BaseSession) + + @pytest.mark.asyncio + async def test_http_session_delete(self): + """Test HTTPSession delete method.""" + with tempfile.TemporaryDirectory() as tmpdir: + with patch("chainlit.config.FILES_DIRECTORY", Path(tmpdir)): + session = HTTPSession( + id="http_id", + client_type="copilot", + ) + + # Create files directory + session.files_dir.mkdir(exist_ok=True) + test_file = session.files_dir / "test.txt" + test_file.write_text("test") + + assert session.files_dir.exists() + + await session.delete() + + assert not session.files_dir.exists() + + +class TestWebsocketSession: + """Test suite for WebsocketSession class.""" + + def test_websocket_session_initialization(self): + """Test WebsocketSession initialization.""" + emit_mock = Mock() + emit_call_mock = Mock() + + session = WebsocketSession( + id="ws_id", + socket_id="socket_123", + emit=emit_mock, + emit_call=emit_call_mock, + user_env={}, + client_type="webapp", + ) + + assert session.id == "ws_id" + assert session.socket_id == "socket_123" + assert session.emit == emit_mock + assert session.emit_call == emit_call_mock + assert session.restored is False + assert session.mcp_sessions == {} + + def test_websocket_session_language_detection(self): + """Test WebsocketSession language detection from HTTP headers.""" + session = WebsocketSession( + id="ws_id", + socket_id="socket_123", + emit=Mock(), + emit_call=Mock(), + user_env={}, + client_type="webapp", + environ={"HTTP_ACCEPT_LANGUAGE": "fr-FR,fr;q=0.9,en;q=0.8"}, + ) + + assert session.language == "fr-FR" + + def test_websocket_session_default_language(self): + """Test WebsocketSession defaults to en-US without language header.""" + session = WebsocketSession( + id="ws_id", + socket_id="socket_123", + emit=Mock(), + emit_call=Mock(), + user_env={}, + client_type="webapp", + environ={}, + ) + + assert session.language == "en-US" + + def test_websocket_session_restore(self): + """Test WebsocketSession restore method.""" + from chainlit.session import ws_sessions_sid + + session = WebsocketSession( + id="ws_id", + socket_id="old_socket", + emit=Mock(), + emit_call=Mock(), + user_env={}, + client_type="webapp", + ) + + assert ws_sessions_sid.get("old_socket") == session + + session.restore("new_socket") + + assert session.socket_id == "new_socket" + assert session.restored is True + assert ws_sessions_sid.get("old_socket") is None + assert ws_sessions_sid.get("new_socket") == session + + @pytest.mark.asyncio + async def test_websocket_session_delete(self): + """Test WebsocketSession delete method.""" + from chainlit.session import ws_sessions_id, ws_sessions_sid + + with tempfile.TemporaryDirectory() as tmpdir: + with patch("chainlit.config.FILES_DIRECTORY", Path(tmpdir)): + session = WebsocketSession( + id="ws_id", + socket_id="socket_123", + emit=Mock(), + emit_call=Mock(), + user_env={}, + client_type="webapp", + ) + + # Create files directory + session.files_dir.mkdir(exist_ok=True) + + assert ws_sessions_sid.get("socket_123") == session + assert ws_sessions_id.get("ws_id") == session + + await session.delete() + + assert not session.files_dir.exists() + assert ws_sessions_sid.get("socket_123") is None + assert ws_sessions_id.get("ws_id") is None + + def test_websocket_session_get(self): + """Test WebsocketSession.get class method.""" + session = WebsocketSession( + id="ws_id", + socket_id="socket_123", + emit=Mock(), + emit_call=Mock(), + user_env={}, + client_type="webapp", + ) + + retrieved = WebsocketSession.get("socket_123") + assert retrieved == session + + def test_websocket_session_get_by_id(self): + """Test WebsocketSession.get_by_id class method.""" + session = WebsocketSession( + id="ws_id", + socket_id="socket_123", + emit=Mock(), + emit_call=Mock(), + user_env={}, + client_type="webapp", + ) + + retrieved = WebsocketSession.get_by_id("ws_id") + assert retrieved == session + + def test_websocket_session_require_success(self): + """Test WebsocketSession.require with existing session.""" + session = WebsocketSession( + id="ws_id", + socket_id="socket_123", + emit=Mock(), + emit_call=Mock(), + user_env={}, + client_type="webapp", + ) + + retrieved = WebsocketSession.require("socket_123") + assert retrieved == session + + def test_websocket_session_require_failure(self): + """Test WebsocketSession.require raises error for missing session.""" + with pytest.raises(ValueError, match="Session not found"): + WebsocketSession.require("nonexistent_socket") + + @pytest.mark.asyncio + async def test_websocket_session_flush_method_queue(self): + """Test WebsocketSession flush_method_queue.""" + from collections import deque + + session = WebsocketSession( + id="ws_id", + socket_id="socket_123", + emit=Mock(), + emit_call=Mock(), + user_env={}, + client_type="webapp", + ) + + # Create a mock async method + mock_method = AsyncMock() + + # Add items to queue + session.thread_queues["test_method"] = deque( + [ + (mock_method, session, ("arg1",), {"kwarg1": "value1"}), + (mock_method, session, ("arg2",), {"kwarg2": "value2"}), + ] + ) + + await session.flush_method_queue() + + assert mock_method.call_count == 2 + assert len(session.thread_queues["test_method"]) == 0 + + +class TestSessionEdgeCases: + """Test suite for session edge cases.""" + + def test_base_session_with_all_client_types(self): + """Test BaseSession with different client types.""" + client_types = ["webapp", "copilot", "teams", "slack", "discord"] + + for client_type in client_types: + session = BaseSession( + id=f"test_{client_type}", + client_type=client_type, + thread_id=None, + user=None, + token=None, + user_env=None, + ) + assert session.client_type == client_type + + @pytest.mark.asyncio + async def test_persist_file_with_mime_extension(self): + """Test that persist_file adds correct file extension based on MIME type.""" + with tempfile.TemporaryDirectory() as tmpdir: + with patch("chainlit.config.FILES_DIRECTORY", Path(tmpdir)): + session = BaseSession( + id="test_id", + client_type="webapp", + thread_id=None, + user=None, + token=None, + user_env=None, + ) + + # Test with image MIME type + result = await session.persist_file( + name="image.png", + mime="image/png", + content=b"fake image data", + ) + + file_id = result["id"] + file_path = session.files[file_id]["path"] + assert file_path.suffix == ".png" + + def test_clean_metadata_with_empty_dict(self): + """Test clean_metadata with empty dictionary.""" + result = clean_metadata({}) + assert result == {} + + def test_websocket_session_with_chat_profile(self): + """Test WebsocketSession with chat profile.""" + session = WebsocketSession( + id="ws_id", + socket_id="socket_123", + emit=Mock(), + emit_call=Mock(), + user_env={}, + client_type="webapp", + chat_profile="gpt-4", + ) + + assert session.chat_profile == "gpt-4" + + @pytest.mark.asyncio + async def test_websocket_session_delete_with_mcp_sessions(self): + """Test WebsocketSession delete with MCP sessions.""" + + with tempfile.TemporaryDirectory() as tmpdir: + with patch("chainlit.config.FILES_DIRECTORY", Path(tmpdir)): + session = WebsocketSession( + id="ws_id", + socket_id="socket_123", + emit=Mock(), + emit_call=Mock(), + user_env={}, + client_type="webapp", + ) + + # Create a real McpSession with a completed task + import asyncio + + stop = asyncio.Event() + stop.set() # already stopped + + async def _noop(): + pass + + task = asyncio.create_task(_noop()) + await task # let it finish + + mcp = McpSession( + name="mcp1", + client=Mock(), + task=task, + stop_event=stop, + ) + session.mcp_sessions["mcp1"] = mcp + + await session.delete() + + assert "mcp1" not in session.mcp_sessions + + @pytest.mark.asyncio + async def test_websocket_session_delete_with_hanging_mcp(self): + """Test that session delete handles a slow MCP session gracefully.""" + import asyncio + + with tempfile.TemporaryDirectory() as tmpdir: + with patch("chainlit.config.FILES_DIRECTORY", Path(tmpdir)): + session = WebsocketSession( + id="ws_id", + socket_id="socket_123", + emit=Mock(), + emit_call=Mock(), + user_env={}, + client_type="webapp", + ) + + stop = asyncio.Event() + + async def _hang(): + await stop.wait() + + task = asyncio.create_task(_hang()) + + mcp = McpSession( + name="mcp1", + client=Mock(), + task=task, + stop_event=stop, + ) + session.mcp_sessions["mcp1"] = mcp + + # delete() should close the session cleanly + await session.delete() + + assert task.done() + assert "mcp1" not in session.mcp_sessions + + +class TestMcpSession: + """Test suite for the McpSession dataclass.""" + + @pytest.mark.asyncio + async def test_close_signals_stop_and_awaits_task(self): + """close() sets the stop event and waits for the task.""" + import asyncio + + stop = asyncio.Event() + + async def _runner(): + await stop.wait() + + task = asyncio.create_task(_runner()) + mcp = McpSession( + name="test", + client=Mock(), + task=task, + stop_event=stop, + ) + + await mcp.close() + + assert stop.is_set() + assert task.done() + + @pytest.mark.asyncio + async def test_close_cancels_on_timeout(self): + """close() cancels a task that doesn't respond to stop_event.""" + import asyncio + + stop = asyncio.Event() + + async def _stuck(): + # Ignore stop_event entirely + await asyncio.sleep(3600) + + task = asyncio.create_task(_stuck()) + mcp = McpSession( + name="stuck", + client=Mock(), + task=task, + stop_event=stop, + ) + + # Temporarily reduce timeout for this test + import chainlit.session as session_mod + + original_timeout = session_mod._CLOSE_TIMEOUT + session_mod._CLOSE_TIMEOUT = 0.1 + try: + await mcp.close() + finally: + session_mod._CLOSE_TIMEOUT = original_timeout + + assert task.done() + + @pytest.mark.asyncio + async def test_close_idempotent(self): + """Calling close() twice does not raise.""" + import asyncio + + stop = asyncio.Event() + + async def _runner(): + await stop.wait() + + task = asyncio.create_task(_runner()) + mcp = McpSession( + name="test", + client=Mock(), + task=task, + stop_event=stop, + ) + + await mcp.close() + await mcp.close() # second call should be safe + + assert task.done() diff --git a/backend/tests/test_sidebar.py b/backend/tests/test_sidebar.py new file mode 100644 index 0000000..1cc6c98 --- /dev/null +++ b/backend/tests/test_sidebar.py @@ -0,0 +1,303 @@ +import pytest + +from chainlit.element import File, Image, Text +from chainlit.sidebar import ElementSidebar + + +@pytest.mark.asyncio +class TestElementSidebar: + """Test suite for ElementSidebar class.""" + + async def test_set_title(self, mock_chainlit_context): + """Test ElementSidebar.set_title() method.""" + async with mock_chainlit_context as ctx: + await ElementSidebar.set_title("My Sidebar Title") + + ctx.emitter.emit.assert_called_once_with( + "set_sidebar_title", "My Sidebar Title" + ) + + async def test_set_title_with_empty_string(self, mock_chainlit_context): + """Test ElementSidebar.set_title() with empty string.""" + async with mock_chainlit_context as ctx: + await ElementSidebar.set_title("") + + ctx.emitter.emit.assert_called_once_with("set_sidebar_title", "") + + async def test_set_title_with_special_characters(self, mock_chainlit_context): + """Test ElementSidebar.set_title() with special characters.""" + async with mock_chainlit_context as ctx: + title = "Title with 特殊字符 & symbols! 🎉" + await ElementSidebar.set_title(title) + + ctx.emitter.emit.assert_called_once_with("set_sidebar_title", title) + + async def test_set_elements_with_single_element(self, mock_chainlit_context): + """Test ElementSidebar.set_elements() with a single element.""" + async with mock_chainlit_context as ctx: + element = File(name="test.txt", url="https://example.com/test.txt") + + await ElementSidebar.set_elements([element]) + + # Verify element.send() was called + ctx.emitter.send_element.assert_called_once() + + # Verify emit was called with correct structure + ctx.emitter.emit.assert_called_once() + call_args = ctx.emitter.emit.call_args + assert call_args[0][0] == "set_sidebar_elements" + assert "elements" in call_args[0][1] + assert "key" in call_args[0][1] + assert len(call_args[0][1]["elements"]) == 1 + assert call_args[0][1]["key"] is None + + async def test_set_elements_with_multiple_elements(self, mock_chainlit_context): + """Test ElementSidebar.set_elements() with multiple elements.""" + async with mock_chainlit_context as ctx: + elements = [ + File(name="file1.txt", url="https://example.com/file1.txt"), + Image(name="image1.png", url="https://example.com/image1.png"), + Text(name="text1", content="Some text content"), + ] + + await ElementSidebar.set_elements(elements) + + # Verify all elements were sent (3 send_element calls) + assert ctx.emitter.send_element.call_count == 3 + + # Verify emit was called with all elements + ctx.emitter.emit.assert_called_once() + call_args = ctx.emitter.emit.call_args + assert call_args[0][0] == "set_sidebar_elements" + assert len(call_args[0][1]["elements"]) == 3 + + async def test_set_elements_with_empty_list(self, mock_chainlit_context): + """Test ElementSidebar.set_elements() with empty list (closes sidebar).""" + async with mock_chainlit_context as ctx: + await ElementSidebar.set_elements([]) + + # No elements to send + ctx.emitter.send_element.assert_not_called() + + # Emit should still be called with empty elements + ctx.emitter.emit.assert_called_once() + call_args = ctx.emitter.emit.call_args + assert call_args[0][0] == "set_sidebar_elements" + assert call_args[0][1]["elements"] == [] + assert call_args[0][1]["key"] is None + + async def test_set_elements_with_key(self, mock_chainlit_context): + """Test ElementSidebar.set_elements() with a key.""" + async with mock_chainlit_context as ctx: + element = File(name="test.txt", url="https://example.com/test.txt") + key = "my_sidebar_key" + + await ElementSidebar.set_elements([element], key=key) + + # Verify emit was called with the key + ctx.emitter.emit.assert_called_once() + call_args = ctx.emitter.emit.call_args + assert call_args[0][1]["key"] == key + + async def test_set_elements_with_for_id(self, mock_chainlit_context): + """Test ElementSidebar.set_elements() with elements that have for_id.""" + async with mock_chainlit_context as ctx: + element = File( + name="test.txt", + url="https://example.com/test.txt", + for_id="message_123", + ) + + await ElementSidebar.set_elements([element]) + + # Element should be sent with its for_id + ctx.emitter.send_element.assert_called_once() + + # Verify emit was called + ctx.emitter.emit.assert_called_once() + + async def test_set_elements_without_for_id(self, mock_chainlit_context): + """Test ElementSidebar.set_elements() with elements without for_id.""" + async with mock_chainlit_context as ctx: + element = File(name="test.txt", url="https://example.com/test.txt") + + await ElementSidebar.set_elements([element]) + + # Element should be sent with empty string for_id + ctx.emitter.send_element.assert_called_once() + + # Verify emit was called + ctx.emitter.emit.assert_called_once() + + async def test_set_elements_persist_false(self, mock_chainlit_context): + """Test that set_elements() sends elements with persist=False.""" + async with mock_chainlit_context as ctx: + # Mock persist_file to provide chainlit_key + ctx.session.persist_file.return_value = {"id": "test_key"} + + element = File(name="test.txt", content=b"test content") + + await ElementSidebar.set_elements([element]) + + # persist_file is still called to get chainlit_key, even with persist=False + # The persist=False affects data layer persistence, not file upload + ctx.session.persist_file.assert_called_once() + + # Verify element was sent + ctx.emitter.send_element.assert_called_once() + + async def test_set_elements_serialization(self, mock_chainlit_context): + """Test that elements are properly serialized in set_elements().""" + async with mock_chainlit_context as ctx: + file_elem = File(name="file.txt", url="https://example.com/file.txt") + image_elem = Image( + name="image.png", url="https://example.com/image.png", size="large" + ) + + await ElementSidebar.set_elements([file_elem, image_elem]) + + # Verify emit was called with serialized elements + call_args = ctx.emitter.emit.call_args + elements_data = call_args[0][1]["elements"] + + assert len(elements_data) == 2 + assert elements_data[0]["name"] == "file.txt" + assert elements_data[0]["type"] == "file" + assert elements_data[1]["name"] == "image.png" + assert elements_data[1]["type"] == "image" + assert elements_data[1]["size"] == "large" + + +@pytest.mark.asyncio +class TestElementSidebarEdgeCases: + """Test suite for ElementSidebar edge cases.""" + + async def test_set_title_multiple_times(self, mock_chainlit_context): + """Test calling set_title() multiple times.""" + async with mock_chainlit_context as ctx: + await ElementSidebar.set_title("First Title") + await ElementSidebar.set_title("Second Title") + await ElementSidebar.set_title("Third Title") + + assert ctx.emitter.emit.call_count == 3 + + # Verify last call had the third title + last_call = ctx.emitter.emit.call_args + assert last_call[0][1] == "Third Title" + + async def test_set_elements_multiple_times(self, mock_chainlit_context): + """Test calling set_elements() multiple times.""" + async with mock_chainlit_context as ctx: + element1 = File(name="file1.txt", url="https://example.com/file1.txt") + element2 = File(name="file2.txt", url="https://example.com/file2.txt") + + await ElementSidebar.set_elements([element1]) + await ElementSidebar.set_elements([element2]) + + # Should have sent both elements + assert ctx.emitter.send_element.call_count == 2 + + # Should have emitted twice + assert ctx.emitter.emit.call_count == 2 + + async def test_set_elements_with_same_key_twice(self, mock_chainlit_context): + """Test calling set_elements() with the same key twice.""" + async with mock_chainlit_context as ctx: + element1 = File(name="file1.txt", url="https://example.com/file1.txt") + element2 = File(name="file2.txt", url="https://example.com/file2.txt") + + await ElementSidebar.set_elements([element1], key="same_key") + await ElementSidebar.set_elements([element2], key="same_key") + + # Both should be sent (server doesn't prevent this) + assert ctx.emitter.send_element.call_count == 2 + assert ctx.emitter.emit.call_count == 2 + + async def test_set_elements_with_different_keys(self, mock_chainlit_context): + """Test calling set_elements() with different keys.""" + async with mock_chainlit_context as ctx: + element1 = File(name="file1.txt", url="https://example.com/file1.txt") + element2 = File(name="file2.txt", url="https://example.com/file2.txt") + + await ElementSidebar.set_elements([element1], key="key1") + await ElementSidebar.set_elements([element2], key="key2") + + assert ctx.emitter.emit.call_count == 2 + + # Verify different keys were used + calls = ctx.emitter.emit.call_args_list + assert calls[0][0][1]["key"] == "key1" + assert calls[1][0][1]["key"] == "key2" + + async def test_set_elements_with_large_number_of_elements( + self, mock_chainlit_context + ): + """Test set_elements() with many elements.""" + async with mock_chainlit_context as ctx: + # Create 50 elements + elements = [ + File(name=f"file{i}.txt", url=f"https://example.com/file{i}.txt") + for i in range(50) + ] + + await ElementSidebar.set_elements(elements) + + # All 50 elements should be sent + assert ctx.emitter.send_element.call_count == 50 + + # Verify emit was called with all 50 elements + call_args = ctx.emitter.emit.call_args + assert len(call_args[0][1]["elements"]) == 50 + + async def test_set_title_and_set_elements_together(self, mock_chainlit_context): + """Test using set_title() and set_elements() together.""" + async with mock_chainlit_context as ctx: + await ElementSidebar.set_title("My Documents") + + elements = [ + File(name="doc1.pdf", url="https://example.com/doc1.pdf"), + File(name="doc2.pdf", url="https://example.com/doc2.pdf"), + ] + await ElementSidebar.set_elements(elements) + + # Verify both methods were called + assert ctx.emitter.emit.call_count == 2 + + # Verify the calls were correct + calls = ctx.emitter.emit.call_args_list + assert calls[0][0][0] == "set_sidebar_title" + assert calls[0][0][1] == "My Documents" + assert calls[1][0][0] == "set_sidebar_elements" + + async def test_set_elements_with_mixed_element_types(self, mock_chainlit_context): + """Test set_elements() with various element types.""" + async with mock_chainlit_context as ctx: + elements = [ + File(name="document.pdf", url="https://example.com/doc.pdf"), + Image( + name="photo.jpg", url="https://example.com/photo.jpg", size="medium" + ), + Text(name="notes", content="Some important notes"), + ] + + await ElementSidebar.set_elements(elements) + + # Verify all different types were sent + assert ctx.emitter.send_element.call_count == 3 + + # Verify serialization includes type information + call_args = ctx.emitter.emit.call_args + elements_data = call_args[0][1]["elements"] + + assert elements_data[0]["type"] == "file" + assert elements_data[1]["type"] == "image" + assert elements_data[2]["type"] == "text" + + async def test_set_title_with_long_string(self, mock_chainlit_context): + """Test set_title() with a very long title.""" + async with mock_chainlit_context as ctx: + long_title = "A" * 1000 # 1000 character title + + await ElementSidebar.set_title(long_title) + + ctx.emitter.emit.assert_called_once_with("set_sidebar_title", long_title) diff --git a/backend/tests/test_slack_socket_mode.py b/backend/tests/test_slack_socket_mode.py new file mode 100644 index 0000000..8e0e436 --- /dev/null +++ b/backend/tests/test_slack_socket_mode.py @@ -0,0 +1,54 @@ +# tests/test_slack_socket_mode.py +import importlib +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.mark.asyncio +async def test_start_socket_mode_starts_handler(monkeypatch): + """ + The function should: + • build an AsyncSocketModeHandler with the global slack_app + • use the token found in SLACK_WEBSOCKET_TOKEN + • await the handler.start_async() coroutine exactly once + """ + token = "xapp-fake-token" + # minimal env required for the Slack module to initialise + monkeypatch.setenv("SLACK_BOT_TOKEN", "xoxb-fake-bot") + monkeypatch.setenv("SLACK_WEBSOCKET_TOKEN", token) + + # Import the module first to avoid lazy import registry issues + slack_app_mod = importlib.import_module("chainlit.slack.app") + + # Patch the object directly instead of using string path + with patch.object( + slack_app_mod, "AsyncSocketModeHandler", autospec=True + ) as handler_cls: + handler_instance = AsyncMock() + handler_cls.return_value = handler_instance + + # Run: should build handler + await start_async + await slack_app_mod.start_socket_mode() + + handler_cls.assert_called_once_with(slack_app_mod.slack_app, token) + handler_instance.start_async.assert_awaited_once() + + +def test_slack_http_route_registered(monkeypatch): + """ + When only the classic HTTP tokens are set (no websocket token), + the FastAPI app should expose POST /slack/events. + """ + # HTTP-only environment + monkeypatch.setenv("SLACK_BOT_TOKEN", "xoxb-fake-bot") + monkeypatch.setenv("SLACK_SIGNING_SECRET", "shhh-fake-secret") + monkeypatch.delenv("SLACK_WEBSOCKET_TOKEN", raising=False) + + # Re-import server with the fresh env so the route table is built correctly + server = importlib.reload(importlib.import_module("chainlit.server")) + + assert any( + route.path == "/slack/events" and "POST" in route.methods + for route in server.router.routes + ), "Slack HTTP handler route was not registered" diff --git a/backend/tests/test_socket.py b/backend/tests/test_socket.py new file mode 100644 index 0000000..e20959c --- /dev/null +++ b/backend/tests/test_socket.py @@ -0,0 +1,630 @@ +import json +from unittest.mock import AsyncMock, Mock, patch + +import pytest + +from chainlit.session import WebsocketSession +from chainlit.socket import ( + _authenticate_connection, + _get_token, + _get_token_from_cookie, + clean_session, + connection_successful, + load_user_env, + persist_user_session, + restore_existing_session, + resume_thread, +) + + +class TestGetTokenFromCookie: + """Test suite for _get_token_from_cookie function.""" + + def test_get_token_from_cookie_with_valid_cookie(self): + """Test extracting token from valid cookie header.""" + with patch("chainlit.socket.get_token_from_cookies") as mock_get_token: + mock_get_token.return_value = "test_token" + environ = {"HTTP_COOKIE": "session=abc123; token=test_token"} + + result = _get_token_from_cookie(environ) + + assert result == "test_token" + mock_get_token.assert_called_once() + + def test_get_token_from_cookie_without_cookie(self): + """Test when no cookie header is present.""" + environ = {} + result = _get_token_from_cookie(environ) + assert result is None + + def test_get_token_from_cookie_with_empty_cookie(self): + """Test with empty cookie header.""" + with patch("chainlit.socket.get_token_from_cookies") as mock_get_token: + mock_get_token.return_value = None + environ = {"HTTP_COOKIE": ""} + + result = _get_token_from_cookie(environ) + + assert result is None + + +class TestGetToken: + """Test suite for _get_token function.""" + + def test_get_token_calls_get_token_from_cookie(self): + """Test that _get_token delegates to _get_token_from_cookie.""" + with patch("chainlit.socket._get_token_from_cookie") as mock_get_cookie: + mock_get_cookie.return_value = "token_value" + environ = {"HTTP_COOKIE": "token=token_value"} + + result = _get_token(environ) + + assert result == "token_value" + mock_get_cookie.assert_called_once_with(environ) + + +class TestAuthenticateConnection: + """Test suite for _authenticate_connection function.""" + + @pytest.mark.asyncio + async def test_authenticate_connection_with_valid_token(self): + """Test authentication with valid token.""" + mock_user = Mock() + mock_user.identifier = "user123" + + with patch("chainlit.socket._get_token") as mock_get_token: + with patch("chainlit.socket.get_current_user") as mock_get_user: + mock_get_token.return_value = "valid_token" + mock_get_user.return_value = mock_user + + environ = {"HTTP_COOKIE": "token=valid_token"} + user, token = await _authenticate_connection(environ) + + assert user == mock_user + assert token == "valid_token" + mock_get_user.assert_called_once_with(token="valid_token") + + @pytest.mark.asyncio + async def test_authenticate_connection_without_token(self): + """Test authentication without token.""" + with patch("chainlit.socket._get_token") as mock_get_token: + mock_get_token.return_value = None + + environ = {} + user, token = await _authenticate_connection(environ) + + assert user is None + assert token is None + + @pytest.mark.asyncio + async def test_authenticate_connection_with_invalid_token(self): + """Test authentication with invalid token.""" + with patch("chainlit.socket._get_token") as mock_get_token: + with patch("chainlit.socket.get_current_user") as mock_get_user: + mock_get_token.return_value = "invalid_token" + mock_get_user.return_value = None + + environ = {"HTTP_COOKIE": "token=invalid_token"} + user, token = await _authenticate_connection(environ) + + assert user is None + assert token is None + + +class TestRestoreExistingSession: + """Test suite for restore_existing_session function.""" + + def test_restore_existing_session_success(self): + """Test restoring an existing session.""" + mock_session = Mock(spec=WebsocketSession) + mock_session.user = None + emit_fn = Mock() + emit_call_fn = Mock() + environ = {"HTTP_COOKIE": "token=token"} + + with patch.object(WebsocketSession, "get_by_id") as mock_get: + mock_get.return_value = mock_session + + result = restore_existing_session( + "new_sid", "session_123", emit_fn, emit_call_fn, environ + ) + + assert result is True + mock_session.restore.assert_called_once_with(new_socket_id="new_sid") + assert mock_session.emit == emit_fn + assert mock_session.emit_call == emit_call_fn + assert mock_session.environ == environ + + def test_restore_existing_session_with_matching_user(self): + """Test restoring a session for its authenticated owner.""" + mock_session = Mock(spec=WebsocketSession) + mock_session.user = Mock(identifier="user123") + authenticated_user = Mock(identifier="user123") + + with patch.object(WebsocketSession, "get_by_id") as mock_get: + mock_get.return_value = mock_session + + result = restore_existing_session( + "new_sid", + "session_123", + Mock(), + Mock(), + {"HTTP_COOKIE": "token=token"}, + user=authenticated_user, + ) + + assert result is True + mock_session.restore.assert_called_once_with(new_socket_id="new_sid") + + def test_restore_existing_session_rejects_different_user(self): + """Test that a session cannot be restored by another user.""" + mock_session = Mock(spec=WebsocketSession) + mock_session.user = Mock(identifier="victim") + authenticated_user = Mock(identifier="attacker") + + with patch.object(WebsocketSession, "get_by_id") as mock_get: + mock_get.return_value = mock_session + + with pytest.raises(ConnectionRefusedError, match="authorization failed"): + restore_existing_session( + "new_sid", + "session_123", + Mock(), + Mock(), + {"HTTP_COOKIE": "token=token"}, + user=authenticated_user, + ) + + mock_session.restore.assert_not_called() + + def test_restore_existing_session_not_found(self): + """Test when session is not found.""" + with patch.object(WebsocketSession, "get_by_id") as mock_get: + mock_get.return_value = None + + result = restore_existing_session( + "new_sid", "session_123", Mock(), Mock(), {"HTTP_COOKIE": "token=token"} + ) + + assert result is False + + +class TestPersistUserSession: + """Test suite for persist_user_session function.""" + + @pytest.mark.asyncio + async def test_persist_user_session_with_data_layer(self): + """Test persisting user session with data layer.""" + mock_data_layer = AsyncMock() + + with patch("chainlit.socket.get_data_layer") as mock_get_dl: + mock_get_dl.return_value = mock_data_layer + + metadata = {"key": "value"} + await persist_user_session("thread_123", metadata) + + mock_data_layer.update_thread.assert_called_once_with( + thread_id="thread_123", metadata=metadata + ) + + @pytest.mark.asyncio + async def test_persist_user_session_without_data_layer(self): + """Test persisting when no data layer is available.""" + with patch("chainlit.socket.get_data_layer") as mock_get_dl: + mock_get_dl.return_value = None + + # Should not raise an error + await persist_user_session("thread_123", {"key": "value"}) + + +class TestResumeThread: + """Test suite for resume_thread function.""" + + @pytest.mark.asyncio + async def test_resume_thread_without_data_layer(self): + """Test resume thread when no data layer exists.""" + mock_session = Mock(spec=WebsocketSession) + mock_session.user = Mock() + mock_session.thread_id_to_resume = "thread_123" + + with patch("chainlit.socket.get_data_layer") as mock_get_dl: + mock_get_dl.return_value = None + + result = await resume_thread(mock_session) + + assert result is None + + @pytest.mark.asyncio + async def test_resume_thread_without_user(self): + """Test resume thread when session has no user.""" + mock_session = Mock(spec=WebsocketSession) + mock_session.user = None + mock_session.thread_id_to_resume = "thread_123" + + result = await resume_thread(mock_session) + + assert result is None + + @pytest.mark.asyncio + async def test_resume_thread_without_thread_id(self): + """Test resume thread when no thread_id_to_resume.""" + mock_session = Mock(spec=WebsocketSession) + mock_session.user = Mock() + mock_session.thread_id_to_resume = None + + result = await resume_thread(mock_session) + + assert result is None + + @pytest.mark.asyncio + async def test_resume_thread_thread_not_found(self): + """Test resume thread when thread doesn't exist.""" + mock_session = Mock(spec=WebsocketSession) + mock_session.user = Mock(identifier="user123") + mock_session.thread_id_to_resume = "thread_123" + mock_session.id = "session_123" + + mock_data_layer = AsyncMock() + mock_data_layer.get_thread.return_value = None + + with patch("chainlit.socket.get_data_layer") as mock_get_dl: + mock_get_dl.return_value = mock_data_layer + + result = await resume_thread(mock_session) + + assert result is None + mock_data_layer.get_thread.assert_called_once_with(thread_id="thread_123") + + @pytest.mark.asyncio + async def test_resume_thread_user_not_author(self): + """Test resume thread when user is not the thread author.""" + mock_session = Mock(spec=WebsocketSession) + mock_session.user = Mock(identifier="user123") + mock_session.thread_id_to_resume = "thread_123" + mock_session.id = "session_123" + + thread = {"userIdentifier": "different_user", "metadata": {}} + mock_data_layer = AsyncMock() + mock_data_layer.get_thread.return_value = thread + + with patch("chainlit.socket.get_data_layer") as mock_get_dl: + mock_get_dl.return_value = mock_data_layer + + result = await resume_thread(mock_session) + + assert result is None + + @pytest.mark.asyncio + async def test_resume_thread_success(self): + """Test successful thread resumption.""" + from chainlit.user_session import user_sessions + + mock_session = Mock(spec=WebsocketSession) + mock_session.user = Mock(identifier="user123") + mock_session.thread_id_to_resume = "thread_123" + mock_session.id = "session_123" + + metadata = { + "chat_profile": "gpt-4", + "chat_settings": {"temperature": 0.7}, + } + thread = {"userIdentifier": "user123", "metadata": metadata} + + mock_data_layer = AsyncMock() + mock_data_layer.get_thread.return_value = thread + + original_sessions = user_sessions.copy() + try: + with patch("chainlit.socket.get_data_layer") as mock_get_dl: + mock_get_dl.return_value = mock_data_layer + + result = await resume_thread(mock_session) + + assert result == thread + assert mock_session.chat_profile == "gpt-4" + assert mock_session.chat_settings == {"temperature": 0.7} + assert user_sessions.get("session_123") == metadata + finally: + user_sessions.clear() + user_sessions.update(original_sessions) + + @pytest.mark.asyncio + async def test_resume_thread_with_string_metadata(self): + """Test thread resumption with JSON string metadata.""" + from chainlit.user_session import user_sessions + + mock_session = Mock(spec=WebsocketSession) + mock_session.user = Mock(identifier="user123") + mock_session.thread_id_to_resume = "thread_123" + mock_session.id = "session_123" + + metadata_dict = {"chat_profile": "gpt-4"} + thread = { + "userIdentifier": "user123", + "metadata": json.dumps(metadata_dict), + } + + mock_data_layer = AsyncMock() + mock_data_layer.get_thread.return_value = thread + + original_sessions = user_sessions.copy() + try: + with patch("chainlit.socket.get_data_layer") as mock_get_dl: + mock_get_dl.return_value = mock_data_layer + + result = await resume_thread(mock_session) + + assert result == thread + assert mock_session.chat_profile == "gpt-4" + finally: + user_sessions.clear() + user_sessions.update(original_sessions) + + +class TestLoadUserEnv: + """Test suite for load_user_env function.""" + + def test_load_user_env_with_valid_json(self): + """Test loading valid user environment JSON.""" + user_env = '{"API_KEY": "secret", "ENV_VAR": "value"}' + + with patch("chainlit.socket.config") as mock_config: + mock_config.project.user_env = [] + + result = load_user_env(user_env) + + assert result == {"API_KEY": "secret", "ENV_VAR": "value"} + + def test_load_user_env_with_required_keys(self): + """Test loading user env with required keys.""" + user_env = '{"API_KEY": "secret", "OTHER_KEY": "value"}' + + with patch("chainlit.socket.config") as mock_config: + mock_config.project.user_env = ["API_KEY", "OTHER_KEY"] + + result = load_user_env(user_env) + + assert result == {"API_KEY": "secret", "OTHER_KEY": "value"} + + def test_load_user_env_missing_required_key(self): + """Test error when required key is missing.""" + user_env = '{"API_KEY": "secret"}' + + with patch("chainlit.socket.config") as mock_config: + mock_config.project.user_env = ["API_KEY", "MISSING_KEY"] + + with pytest.raises( + ConnectionRefusedError, match="Missing user environment variable" + ): + load_user_env(user_env) + + def test_load_user_env_none_with_required_keys(self): + """Test error when user_env is None but keys are required.""" + with patch("chainlit.socket.config") as mock_config: + mock_config.project.user_env = ["API_KEY"] + + with pytest.raises( + ConnectionRefusedError, match="Missing user environment variables" + ): + load_user_env(None) + + def test_load_user_env_none_without_required_keys(self): + """Test when user_env is None and no keys are required.""" + with patch("chainlit.socket.config") as mock_config: + mock_config.project.user_env = [] + + result = load_user_env(None) + + assert result == {} + + +class TestCleanSession: + """Test suite for clean_session function.""" + + @pytest.mark.asyncio + async def test_clean_session_with_existing_session(self): + """Test marking session for cleanup.""" + mock_session = Mock(spec=WebsocketSession) + mock_session.to_clear = False + + with patch.object(WebsocketSession, "get") as mock_get: + mock_get.return_value = mock_session + + await clean_session("socket_123") + + assert mock_session.to_clear is True + mock_get.assert_called_once_with("socket_123") + + @pytest.mark.asyncio + async def test_clean_session_without_session(self): + """Test clean_session when session doesn't exist.""" + with patch.object(WebsocketSession, "get") as mock_get: + mock_get.return_value = None + + # Should not raise an error + await clean_session("socket_123") + + +class TestSocketEdgeCases: + """Test suite for socket edge cases.""" + + def test_restore_existing_session_with_none_session_id(self): + """Test restore with None session_id.""" + with patch.object(WebsocketSession, "get_by_id") as mock_get: + mock_get.return_value = None + + result = restore_existing_session(None, None, Mock(), Mock(), None) + + assert result is False + + @pytest.mark.asyncio + async def test_persist_user_session_with_empty_metadata(self): + """Test persisting empty metadata.""" + mock_data_layer = AsyncMock() + + with patch("chainlit.socket.get_data_layer") as mock_get_dl: + mock_get_dl.return_value = mock_data_layer + + await persist_user_session("thread_123", {}) + + mock_data_layer.update_thread.assert_called_once_with( + thread_id="thread_123", metadata={} + ) + + def test_load_user_env_with_empty_json(self): + """Test loading empty user environment.""" + user_env = "{}" + + with patch("chainlit.socket.config") as mock_config: + mock_config.project.user_env = [] + + result = load_user_env(user_env) + + assert result == {} + + @pytest.mark.asyncio + async def test_resume_thread_with_empty_metadata(self): + """Test resuming thread with empty metadata.""" + from chainlit.user_session import user_sessions + + mock_session = Mock(spec=WebsocketSession) + mock_session.user = Mock(identifier="user123") + mock_session.thread_id_to_resume = "thread_123" + mock_session.id = "session_123" + + thread = {"userIdentifier": "user123", "metadata": {}} + + mock_data_layer = AsyncMock() + mock_data_layer.get_thread.return_value = thread + + original_sessions = user_sessions.copy() + try: + with patch("chainlit.socket.get_data_layer") as mock_get_dl: + mock_get_dl.return_value = mock_data_layer + + result = await resume_thread(mock_session) + + assert result == thread + assert user_sessions.get("session_123") == {} + finally: + user_sessions.clear() + user_sessions.update(original_sessions) + + @pytest.mark.asyncio + async def test_authenticate_connection_with_exception(self): + """Test authentication when get_current_user raises exception.""" + with patch("chainlit.socket._get_token") as mock_get_token: + with patch("chainlit.socket.get_current_user") as mock_get_user: + mock_get_token.return_value = "token" + mock_get_user.side_effect = Exception("Auth error") + + environ = {"HTTP_COOKIE": "token=token"} + + # Should propagate the exception + with pytest.raises(Exception, match="Auth error"): + await _authenticate_connection(environ) + + +class TestConnectionSuccessfulIdempotency: + """Regression tests: on_chat_start must fire exactly once per + WebsocketSession, even when connection_successful is dispatched multiple + times (Socket.IO reconnect, React StrictMode double-mount, reverse-proxy + WS 101 retry). + + Refs chainlit#2535, chainlit#2549, chainlit#2228. + """ + + @pytest.mark.asyncio + async def test_on_chat_start_not_duplicated_on_reconnect( + self, mock_session_factory + ): + """Reconnect path (session.restored=True): on_chat_start scheduled once.""" + on_chat_start = AsyncMock() + + session = mock_session_factory(has_first_interaction=False) + session.restored = True + session.chat_started = False + session.current_task = None + session.thread_id_to_resume = None + + mock_context = Mock() + mock_context.session = session + mock_context.emitter = AsyncMock() + + mock_config = Mock() + mock_config.code.on_chat_start = on_chat_start + mock_config.code.on_chat_resume = None + + with ( + patch("chainlit.socket.init_ws_context", return_value=mock_context), + patch("chainlit.socket.config", mock_config), + ): + await connection_successful("sid-1") + # Simulate reconnect: same session object, chat_started now True. + await connection_successful("sid-1") + + assert on_chat_start.call_count == 1, ( + "on_chat_start must be scheduled exactly once per WebsocketSession" + ) + assert session.chat_started is True + + @pytest.mark.asyncio + async def test_on_chat_start_fires_once_on_fresh_session( + self, mock_session_factory + ): + """Normal one-connect path still greets exactly once.""" + on_chat_start = AsyncMock() + + session = mock_session_factory(has_first_interaction=False) + session.restored = False + session.chat_started = False + session.current_task = None + session.thread_id_to_resume = None + + mock_context = Mock() + mock_context.session = session + mock_context.emitter = AsyncMock() + + mock_config = Mock() + mock_config.code.on_chat_start = on_chat_start + mock_config.code.on_chat_resume = None + + with ( + patch("chainlit.socket.init_ws_context", return_value=mock_context), + patch("chainlit.socket.config", mock_config), + ): + await connection_successful("sid-1") + + assert on_chat_start.call_count == 1 + assert session.chat_started is True + + @pytest.mark.asyncio + async def test_on_chat_start_not_duplicated_on_fresh_then_reconnect( + self, mock_session_factory + ): + """Fresh connect followed by a reconnect still fires exactly once.""" + on_chat_start = AsyncMock() + + session = mock_session_factory(has_first_interaction=False) + session.restored = False + session.chat_started = False + session.current_task = None + session.thread_id_to_resume = None + + mock_context = Mock() + mock_context.session = session + mock_context.emitter = AsyncMock() + + mock_config = Mock() + mock_config.code.on_chat_start = on_chat_start + mock_config.code.on_chat_resume = None + + with ( + patch("chainlit.socket.init_ws_context", return_value=mock_context), + patch("chainlit.socket.config", mock_config), + ): + await connection_successful("sid-1") + session.restored = True + await connection_successful("sid-1") + + assert on_chat_start.call_count == 1 diff --git a/backend/tests/test_step.py b/backend/tests/test_step.py new file mode 100644 index 0000000..6516f3b --- /dev/null +++ b/backend/tests/test_step.py @@ -0,0 +1,616 @@ +import sys +import uuid +from unittest.mock import AsyncMock, Mock, patch + +import pytest + +from chainlit.context import local_steps +from chainlit.element import Element +from chainlit.step import ( + Step, + check_add_step_in_cot, + flatten_args_kwargs, + step, + stub_step, +) + + +@pytest.mark.asyncio +class TestStepClass: + """Test suite for the Step class.""" + + async def test_step_initialization_with_defaults(self, mock_chainlit_context): + """Test Step initialization with default values.""" + async with mock_chainlit_context: + test_step = Step(name="test_step") + + assert test_step.name == "test_step" + assert test_step.type == "undefined" + assert isinstance(test_step.id, str) + uuid.UUID(test_step.id) # Verify valid UUID + assert test_step.parent_id is None + assert test_step.metadata == {} + assert test_step.tags is None + assert test_step.is_error is False + assert test_step.show_input == "json" + assert test_step.language is None + assert test_step.default_open is False + assert test_step.elements == [] + assert test_step.streaming is False + assert test_step.persisted is False + assert test_step.fail_on_persist_error is False + assert test_step.input == "" + assert test_step.output == "" + assert test_step.created_at is not None + assert test_step.start is None + assert test_step.end is None + + async def test_step_initialization_with_all_fields(self, mock_chainlit_context): + """Test Step initialization with all fields provided.""" + async with mock_chainlit_context: + test_id = str(uuid.uuid4()) + parent_id = str(uuid.uuid4()) + metadata = {"key": "value"} + tags = ["tag1", "tag2"] + + test_step = Step( + name="custom_step", + type="tool", + id=test_id, + parent_id=parent_id, + metadata=metadata, + tags=tags, + language="python", + default_open=True, + show_input=False, + ) + + assert test_step.name == "custom_step" + assert test_step.type == "tool" + assert test_step.id == test_id + assert test_step.parent_id == parent_id + assert test_step.metadata == metadata + assert test_step.tags == tags + assert test_step.language == "python" + assert test_step.default_open is True + assert test_step.show_input is False + + async def test_step_input_setter_with_string(self, mock_chainlit_context): + """Test Step input setter with string content.""" + async with mock_chainlit_context: + test_step = Step(name="test") + test_step.input = "This is input text" + + assert test_step.input == "This is input text" + + async def test_step_input_setter_with_dict(self, mock_chainlit_context): + """Test Step input setter with dictionary content.""" + async with mock_chainlit_context: + test_step = Step(name="test") + input_dict = {"param1": "value1", "param2": 42} + test_step.input = input_dict + + # Should be JSON formatted + assert "param1" in test_step.input + assert "value1" in test_step.input + assert isinstance(test_step.input, str) + + async def test_step_output_setter_with_string(self, mock_chainlit_context): + """Test Step output setter with string content.""" + async with mock_chainlit_context: + test_step = Step(name="test") + test_step.output = "This is output text" + + assert test_step.output == "This is output text" + + async def test_step_output_setter_with_dict(self, mock_chainlit_context): + """Test Step output setter with dictionary content and language detection.""" + async with mock_chainlit_context: + test_step = Step(name="test") + output_dict = {"result": "success", "data": [1, 2, 3]} + test_step.output = output_dict + + # Should be JSON formatted and language set to json + assert "result" in test_step.output + assert "success" in test_step.output + assert test_step.language == "json" + + async def test_step_clean_content_with_bytes(self, mock_chainlit_context): + """Test that bytes in content are stripped.""" + async with mock_chainlit_context: + test_step = Step(name="test") + content_with_bytes = { + "text": "hello", + "binary": b"binary_data", + "nested": {"data": b"more_binary"}, + } + test_step.output = content_with_bytes + + assert "STRIPPED_BINARY_DATA" in test_step.output + assert b"binary_data" not in test_step.output.encode() + + async def test_step_to_dict(self, mock_chainlit_context): + """Test Step serialization to dictionary.""" + async with mock_chainlit_context as ctx: + test_step = Step( + name="test_step", + type="tool", + metadata={"key": "value"}, + tags=["tag1"], + ) + test_step.input = "test input" + test_step.output = "test output" + + step_dict = test_step.to_dict() + + assert step_dict["name"] == "test_step" + assert step_dict["type"] == "tool" + assert step_dict["id"] == test_step.id + assert step_dict["threadId"] == ctx.session.thread_id + assert step_dict["parentId"] is None + assert step_dict["streaming"] is False + assert step_dict["metadata"] == {"key": "value"} + assert step_dict["tags"] == ["tag1"] + assert step_dict["input"] == "test input" + assert step_dict["output"] == "test output" + assert step_dict["isError"] is False + assert step_dict["createdAt"] is not None + assert step_dict["start"] is None + assert step_dict["end"] is None + + async def test_step_send(self, mock_chainlit_context): + """Test Step.send() method.""" + async with mock_chainlit_context as ctx: + test_step = Step(name="test_step") + + result = await test_step.send() + + assert result == test_step + assert test_step.persisted is False # No data layer configured + ctx.emitter.send_step.assert_called_once() + + async def test_step_send_with_elements(self, mock_chainlit_context): + """Test Step.send() with elements.""" + async with mock_chainlit_context: + mock_element = Mock(spec=Element) + mock_element.send = AsyncMock() + + test_step = Step(name="test_step", elements=[mock_element]) + + await test_step.send() + + mock_element.send.assert_called_once_with(for_id=test_step.id) + + async def test_step_send_already_persisted(self, mock_chainlit_context): + """Test that send() returns early if already persisted.""" + async with mock_chainlit_context as ctx: + test_step = Step(name="test_step") + test_step.persisted = True + + result = await test_step.send() + + assert result == test_step + ctx.emitter.send_step.assert_not_called() + + async def test_step_update(self, mock_chainlit_context): + """Test Step.update() method.""" + async with mock_chainlit_context as ctx: + test_step = Step(name="test_step") + test_step.streaming = True + + result = await test_step.update() + + assert result is True + assert test_step.streaming is False + ctx.emitter.update_step.assert_called_once() + + async def test_step_remove(self, mock_chainlit_context): + """Test Step.remove() method.""" + async with mock_chainlit_context as ctx: + test_step = Step(name="test_step") + + result = await test_step.remove() + + assert result is True + ctx.emitter.delete_step.assert_called_once() + + async def test_step_stream_token_output(self, mock_chainlit_context): + """Test streaming tokens to output.""" + async with mock_chainlit_context: + test_step = Step(name="test_step") + + await test_step.stream_token("Hello") + await test_step.stream_token(" ") + await test_step.stream_token("World") + + assert test_step.output == "Hello World" + assert test_step.streaming is True + + async def test_step_stream_token_input(self, mock_chainlit_context): + """Test streaming tokens to input.""" + async with mock_chainlit_context: + test_step = Step(name="test_step") + + await test_step.stream_token("Input", is_input=True) + await test_step.stream_token(" text", is_input=True) + + assert test_step.input == "Input text" + + async def test_step_stream_token_sequence(self, mock_chainlit_context): + """Test streaming tokens with is_sequence flag.""" + async with mock_chainlit_context: + test_step = Step(name="test_step") + + await test_step.stream_token("First", is_sequence=True) + await test_step.stream_token("Second", is_sequence=True) + + # With is_sequence, it replaces instead of appending + assert test_step.output == "Second" + + async def test_step_stream_token_empty(self, mock_chainlit_context): + """Test that empty tokens are ignored.""" + async with mock_chainlit_context as ctx: + test_step = Step(name="test_step") + + await test_step.stream_token("") + + assert test_step.output == "" + ctx.emitter.stream_start.assert_not_called() + + async def test_step_context_manager_async(self, mock_chainlit_context): + """Test Step as async context manager.""" + async with mock_chainlit_context as ctx: + async with Step(name="context_step") as test_step: + assert test_step.start is not None + assert test_step.end is None + + # After exiting context + assert test_step.end is not None + assert ctx.emitter.send_step.call_count == 1 + assert ctx.emitter.update_step.call_count == 1 + + async def test_step_context_manager_with_exception(self, mock_chainlit_context): + """Test Step context manager handles exceptions.""" + async with mock_chainlit_context: + try: + async with Step(name="error_step") as test_step: + raise ValueError("Test error") + except ValueError: + pass + + assert test_step.is_error is True + assert "Test error" in test_step.output + + async def test_step_parent_id_from_context(self, mock_chainlit_context): + """Test that parent_id is set from context when nesting steps.""" + async with mock_chainlit_context: + async with Step(name="parent_step") as parent: + async with Step(name="child_step") as child: + assert child.parent_id == parent.id + + async def test_step_local_steps_tracking(self, mock_chainlit_context): + """Test that local_steps tracks step hierarchy.""" + async with mock_chainlit_context: + async with Step(name="step1") as step1: + steps = local_steps.get() + assert step1 in steps + + async with Step(name="step2") as step2: + steps = local_steps.get() + assert step1 in steps + assert step2 in steps + + # After step2 exits + steps = local_steps.get() + assert step1 in steps + assert step2 not in steps + + async def test_step_with_none_input(self, mock_chainlit_context): + """Test Step handles None input correctly.""" + async with mock_chainlit_context: + test_step = Step(name="test") + test_step.input = None + + assert test_step.input == "" + + async def test_step_with_none_output(self, mock_chainlit_context): + """Test Step handles None output correctly.""" + async with mock_chainlit_context: + test_step = Step(name="test") + test_step.output = None + + assert test_step.output == "" + + async def test_step_with_list_content(self, mock_chainlit_context): + """Test Step handles list content.""" + async with mock_chainlit_context: + test_step = Step(name="test") + test_step.output = [1, 2, 3, "four"] + + assert "[" in test_step.output + assert "1" in test_step.output + assert "four" in test_step.output + assert test_step.language == "json" + + async def test_step_with_tuple_content(self, mock_chainlit_context): + """Test Step handles tuple content.""" + async with mock_chainlit_context: + test_step = Step(name="test") + test_step.output = ("a", "b", "c") + + assert test_step.output != "" + assert test_step.language == "json" + + +@pytest.mark.asyncio +class TestStepDecorator: + """Test suite for the @step decorator.""" + + async def test_step_decorator_async_function(self, mock_chainlit_context): + """Test @step decorator on async function.""" + async with mock_chainlit_context as ctx: + + @step(name="async_step", type="tool") + async def async_function(x: int, y: int): + return x + y + + result = await async_function(2, 3) + + assert result == 5 + ctx.emitter.send_step.assert_called() + + async def test_step_decorator_sync_function(self, mock_chainlit_context): + """Test @step decorator on sync function.""" + async with mock_chainlit_context: + + @step(name="sync_step", type="tool") + def sync_function(x: int, y: int): + return x + y + + result = sync_function(2, 3) + + assert result == 5 + + async def test_step_decorator_uses_function_name(self, mock_chainlit_context): + """Test that decorator uses function name when name not provided.""" + async with mock_chainlit_context as ctx: + + @step(type="tool") + async def my_custom_function(): + return "result" + + await my_custom_function() + + # Check that step was created with function name + call_args = ctx.emitter.send_step.call_args + step_dict = call_args[0][0] + assert step_dict["name"] == "my_custom_function" + + async def test_step_decorator_captures_input(self, mock_chainlit_context): + """Test that decorator captures function arguments as input.""" + async with mock_chainlit_context as ctx: + + @step(name="test_step") + async def function_with_args(a: str, b: int, c: bool = True): + return "done" + + await function_with_args("hello", 42, c=False) + + # Verify send_step was called (input is set during step execution) + ctx.emitter.send_step.assert_called() + + async def test_step_decorator_captures_output(self, mock_chainlit_context): + """Test that decorator captures function return value as output.""" + async with mock_chainlit_context as ctx: + + @step(name="test_step") + async def function_with_return(): + return {"status": "success", "value": 123} + + await function_with_return() + + call_args = ctx.emitter.update_step.call_args + step_dict = call_args[0][0] + assert "status" in step_dict["output"] + assert "success" in step_dict["output"] + + async def test_step_decorator_handles_exception(self, mock_chainlit_context): + """Test that decorator handles exceptions in wrapped function.""" + async with mock_chainlit_context as ctx: + + @step(name="error_step") + async def function_with_error(): + raise ValueError("Something went wrong") + + try: + await function_with_error() + except ValueError: + pass + + call_args = ctx.emitter.update_step.call_args + step_dict = call_args[0][0] + assert step_dict["isError"] is True + assert "Something went wrong" in step_dict["output"] + + async def test_step_decorator_with_metadata(self, mock_chainlit_context): + """Test decorator with metadata parameter.""" + async with mock_chainlit_context as ctx: + metadata = {"version": "1.0", "author": "test"} + + @step(name="test_step", metadata=metadata) + async def function_with_metadata(): + return "result" + + await function_with_metadata() + + call_args = ctx.emitter.send_step.call_args + step_dict = call_args[0][0] + assert step_dict["metadata"] == metadata + + async def test_step_decorator_with_tags(self, mock_chainlit_context): + """Test decorator with tags parameter.""" + async with mock_chainlit_context as ctx: + tags = ["important", "production"] + + @step(name="test_step", tags=tags) + async def function_with_tags(): + return "result" + + await function_with_tags() + + call_args = ctx.emitter.send_step.call_args + step_dict = call_args[0][0] + assert step_dict["tags"] == tags + + async def test_step_decorator_without_parentheses(self, mock_chainlit_context): + """Test @step decorator without parentheses.""" + async with mock_chainlit_context as ctx: + + @step + async def simple_function(): + return "result" + + result = await simple_function() + + assert result == "result" + ctx.emitter.send_step.assert_called() + + +@pytest.mark.asyncio +class TestStepHelperFunctions: + """Test suite for Step helper functions.""" + + def test_flatten_args_kwargs(self): + """Test flatten_args_kwargs function.""" + + def sample_func(a, b, c=10, d=20): + pass + + result = flatten_args_kwargs(sample_func, (1, 2), {"d": 30}) + + assert result["a"] == 1 + assert result["b"] == 2 + assert result["c"] == 10 # default value + assert result["d"] == 30 + + def test_flatten_args_kwargs_with_all_kwargs(self): + """Test flatten_args_kwargs with all keyword arguments.""" + + def sample_func(x, y, z): + pass + + result = flatten_args_kwargs(sample_func, (), {"x": 1, "y": 2, "z": 3}) + + assert result == {"x": 1, "y": 2, "z": 3} + + async def test_stub_step(self, mock_chainlit_context): + """Test stub_step function creates minimal step dict.""" + async with mock_chainlit_context: + test_step = Step(name="test_step", type="tool") + test_step.parent_id = "parent_123" + test_step.input = "full input" + test_step.output = "full output" + + stub = stub_step(test_step) + + assert stub["name"] == "test_step" + assert stub["type"] == "tool" + assert stub["id"] == test_step.id + assert stub["parentId"] == "parent_123" + assert stub["threadId"] == test_step.thread_id + assert stub["input"] == "" # Stubbed + assert stub["output"] == "" # Stubbed + + async def test_check_add_step_in_cot_hidden(self, mock_chainlit_context): + """Test check_add_step_in_cot with hidden COT.""" + async with mock_chainlit_context: + step_module = sys.modules["chainlit.step"] + with patch.object(step_module, "config") as mock_config: + mock_config.ui.cot = "hidden" + + # Message types should be added + message_step = Step(name="test", type="assistant_message") + assert check_add_step_in_cot(message_step) is True + + # Non-message types should not be added + tool_step = Step(name="test", type="tool") + assert check_add_step_in_cot(tool_step) is False + + async def test_check_add_step_in_cot_visible(self, mock_chainlit_context): + """Test check_add_step_in_cot with visible COT.""" + async with mock_chainlit_context: + step_module = sys.modules["chainlit.step"] + with patch.object(step_module, "config") as mock_config: + mock_config.ui.cot = "visible" + + # All steps should be added + tool_step = Step(name="test", type="tool") + assert check_add_step_in_cot(tool_step) is True + + +@pytest.mark.asyncio +class TestStepEdgeCases: + """Test suite for Step edge cases and error handling.""" + + async def test_step_with_non_serializable_content(self, mock_chainlit_context): + """Test Step handles non-JSON-serializable content.""" + async with mock_chainlit_context: + test_step = Step(name="test") + + class NonSerializable: + pass + + test_step.output = NonSerializable() + + # Should convert to string + assert isinstance(test_step.output, str) + assert test_step.language == "text" + + async def test_step_with_very_long_content(self, mock_chainlit_context): + """Test Step handles very long content.""" + async with mock_chainlit_context: + test_step = Step(name="test") + long_text = "x" * 10000 + + test_step.output = long_text + + assert len(test_step.output) == 10000 + + async def test_step_multiple_updates(self, mock_chainlit_context): + """Test calling update() multiple times.""" + async with mock_chainlit_context as ctx: + test_step = Step(name="test") + + await test_step.update() + await test_step.update() + await test_step.update() + + assert ctx.emitter.update_step.call_count == 3 + + async def test_step_id_uniqueness(self, mock_chainlit_context): + """Test that each Step gets a unique ID.""" + async with mock_chainlit_context: + step1 = Step(name="step1") + step2 = Step(name="step2") + step3 = Step(name="step3") + + ids = {step1.id, step2.id, step3.id} + assert len(ids) == 3 # All unique + + async def test_step_with_custom_thread_id(self, mock_chainlit_context): + """Test Step with custom thread_id.""" + async with mock_chainlit_context: + custom_thread_id = "custom_thread_123" + test_step = Step(name="test", thread_id=custom_thread_id) + + assert test_step.thread_id == custom_thread_id + + async def test_step_fail_on_persist_error_flag(self, mock_chainlit_context): + """Test fail_on_persist_error flag behavior.""" + async with mock_chainlit_context: + test_step = Step(name="test") + + assert test_step.fail_on_persist_error is False + + test_step.fail_on_persist_error = True + assert test_step.fail_on_persist_error is True diff --git a/backend/tests/test_teams_adapter.py b/backend/tests/test_teams_adapter.py new file mode 100644 index 0000000..64e4ac9 --- /dev/null +++ b/backend/tests/test_teams_adapter.py @@ -0,0 +1,58 @@ +import importlib +from unittest.mock import MagicMock, call, patch + + +def _reload_teams_app(monkeypatch, env_vars: dict): + """Reload chainlit.teams.app with the given environment variables set.""" + for key, value in env_vars.items(): + monkeypatch.setenv(key, value) + # Remove keys not in env_vars so tests are independent + for key in ("TEAMS_APP_ID", "TEAMS_APP_PASSWORD", "TEAMS_APP_TENANT_ID"): + if key not in env_vars: + monkeypatch.delenv(key, raising=False) + + teams_mod = importlib.import_module("chainlit.teams.app") + return importlib.reload(teams_mod) + + +def test_teams_adapter_without_tenant(monkeypatch): + """Omitting TEAMS_APP_TENANT_ID leaves channel_auth_tenant as None (multi-tenant).""" + with ( + patch("botbuilder.core.BotFrameworkAdapterSettings") as mock_settings, + patch("botbuilder.core.BotFrameworkAdapter"), + ): + mock_settings.return_value = MagicMock() + _reload_teams_app( + monkeypatch, + {"TEAMS_APP_ID": "app-id", "TEAMS_APP_PASSWORD": "app-secret"}, + ) + assert mock_settings.call_count >= 1 + assert mock_settings.call_args_list[-1] == call( + app_id="app-id", + app_password="app-secret", + channel_auth_tenant=None, + ) + + +def test_teams_adapter_with_tenant(monkeypatch): + """Setting TEAMS_APP_TENANT_ID forwards the tenant to BotFrameworkAdapterSettings.""" + tenant_id = "00000000-0000-0000-0000-000000000001" + with ( + patch("botbuilder.core.BotFrameworkAdapterSettings") as mock_settings, + patch("botbuilder.core.BotFrameworkAdapter"), + ): + mock_settings.return_value = MagicMock() + _reload_teams_app( + monkeypatch, + { + "TEAMS_APP_ID": "app-id", + "TEAMS_APP_PASSWORD": "app-secret", + "TEAMS_APP_TENANT_ID": tenant_id, + }, + ) + assert mock_settings.call_count >= 1 + assert mock_settings.call_args_list[-1] == call( + app_id="app-id", + app_password="app-secret", + channel_auth_tenant=tenant_id, + ) diff --git a/backend/tests/test_translations.py b/backend/tests/test_translations.py new file mode 100644 index 0000000..6e517c4 --- /dev/null +++ b/backend/tests/test_translations.py @@ -0,0 +1,386 @@ +from io import StringIO +from unittest.mock import patch + +import pytest + +from chainlit.translations import compare_json_structures, lint_translation_json + + +class TestCompareJsonStructures: + """Test suite for compare_json_structures function.""" + + def test_compare_identical_structures(self): + """Test comparing identical JSON structures.""" + truth = {"key1": "value1", "key2": "value2"} + to_compare = {"key1": "value1", "key2": "value2"} + + errors = compare_json_structures(truth, to_compare) + + assert errors == [] + + def test_compare_with_missing_keys(self): + """Test when to_compare is missing keys.""" + truth = {"key1": "value1", "key2": "value2", "key3": "value3"} + to_compare = {"key1": "value1"} + + errors = compare_json_structures(truth, to_compare) + + assert len(errors) == 2 + assert "❌ Missing key: 'key2'" in errors + assert "❌ Missing key: 'key3'" in errors + + def test_compare_with_extra_keys(self): + """Test when to_compare has extra keys.""" + truth = {"key1": "value1"} + to_compare = {"key1": "value1", "key2": "value2", "key3": "value3"} + + errors = compare_json_structures(truth, to_compare) + + assert len(errors) == 2 + assert "⚠️ Extra key: 'key2'" in errors + assert "⚠️ Extra key: 'key3'" in errors + + def test_compare_with_both_missing_and_extra_keys(self): + """Test when there are both missing and extra keys.""" + truth = {"key1": "value1", "key2": "value2"} + to_compare = {"key1": "value1", "key3": "value3"} + + errors = compare_json_structures(truth, to_compare) + + assert len(errors) == 2 + assert any("Extra key: 'key3'" in e for e in errors) + assert any("Missing key: 'key2'" in e for e in errors) + + def test_compare_nested_structures(self): + """Test comparing nested JSON structures.""" + truth = {"level1": {"level2": {"key": "value"}}} + to_compare = {"level1": {"level2": {"key": "value"}}} + + errors = compare_json_structures(truth, to_compare) + + assert errors == [] + + def test_compare_nested_with_missing_keys(self): + """Test nested structures with missing keys.""" + truth = {"level1": {"key1": "value1", "key2": "value2"}} + to_compare = {"level1": {"key1": "value1"}} + + errors = compare_json_structures(truth, to_compare) + + assert len(errors) == 1 + assert "❌ Missing key: 'level1.key2'" in errors + + def test_compare_nested_with_extra_keys(self): + """Test nested structures with extra keys.""" + truth = {"level1": {"key1": "value1"}} + to_compare = {"level1": {"key1": "value1", "key2": "value2"}} + + errors = compare_json_structures(truth, to_compare) + + assert len(errors) == 1 + assert "⚠️ Extra key: 'level1.key2'" in errors + + def test_compare_deeply_nested_structures(self): + """Test deeply nested structures.""" + truth = {"a": {"b": {"c": {"d": "value"}}}} + to_compare = {"a": {"b": {"c": {}}}} + + errors = compare_json_structures(truth, to_compare) + + assert len(errors) == 1 + assert "❌ Missing key: 'a.b.c.d'" in errors + + def test_compare_structure_mismatch_dict_vs_value(self): + """Test when one is dict and other is value.""" + truth = {"key": {"nested": "value"}} + to_compare = {"key": "not_a_dict"} + + errors = compare_json_structures(truth, to_compare) + + assert len(errors) == 1 + assert "❌ Structure mismatch at: 'key'" in errors + + def test_compare_structure_mismatch_value_vs_dict(self): + """Test when truth is value and to_compare is dict.""" + truth = {"key": "value"} + to_compare = {"key": {"nested": "value"}} + + errors = compare_json_structures(truth, to_compare) + + assert len(errors) == 1 + assert "❌ Structure mismatch at: 'key'" in errors + + def test_compare_with_non_dict_input_truth(self): + """Test error when truth is not a dict.""" + with pytest.raises(ValueError, match="Both inputs must be dictionaries"): + compare_json_structures("not_a_dict", {}) + + def test_compare_with_non_dict_input_to_compare(self): + """Test error when to_compare is not a dict.""" + with pytest.raises(ValueError, match="Both inputs must be dictionaries"): + compare_json_structures({}, "not_a_dict") + + def test_compare_with_both_non_dict_inputs(self): + """Test error when both inputs are not dicts.""" + with pytest.raises(ValueError, match="Both inputs must be dictionaries"): + compare_json_structures("not_a_dict", "also_not_a_dict") + + def test_compare_empty_dicts(self): + """Test comparing empty dictionaries.""" + truth = {} + to_compare = {} + + errors = compare_json_structures(truth, to_compare) + + assert errors == [] + + def test_compare_empty_truth_with_data(self): + """Test when truth is empty but to_compare has data.""" + truth = {} + to_compare = {"key1": "value1", "key2": "value2"} + + errors = compare_json_structures(truth, to_compare) + + assert len(errors) == 2 + assert all("Extra key" in e for e in errors) + + def test_compare_empty_to_compare_with_data(self): + """Test when to_compare is empty but truth has data.""" + truth = {"key1": "value1", "key2": "value2"} + to_compare = {} + + errors = compare_json_structures(truth, to_compare) + + assert len(errors) == 2 + assert all("Missing key" in e for e in errors) + + def test_compare_with_different_value_types(self): + """Test that different value types at leaf nodes don't cause errors.""" + truth = {"key1": "string", "key2": 123, "key3": True} + to_compare = {"key1": "different", "key2": 456, "key3": False} + + errors = compare_json_structures(truth, to_compare) + + # Structure matches, so no errors (values are not compared) + assert errors == [] + + def test_compare_complex_nested_structure(self): + """Test complex nested structure with multiple levels.""" + truth = { + "app": { + "title": "My App", + "settings": {"theme": "dark", "language": "en"}, + }, + "user": {"name": "John", "preferences": {"notifications": True}}, + } + to_compare = { + "app": { + "title": "My App", + "settings": {"theme": "light"}, # Missing 'language' + }, + "user": { + "name": "Jane", + "preferences": {"notifications": False, "extra": "value"}, # Extra key + }, + } + + errors = compare_json_structures(truth, to_compare) + + assert len(errors) == 2 + assert any("Missing key: 'app.settings.language'" in e for e in errors) + assert any("Extra key: 'user.preferences.extra'" in e for e in errors) + + def test_compare_with_null_values(self): + """Test structures with None/null values.""" + truth = {"key1": None, "key2": "value"} + to_compare = {"key1": None, "key2": "value"} + + errors = compare_json_structures(truth, to_compare) + + assert errors == [] + + def test_compare_with_list_values(self): + """Test structures with list values (treated as leaf nodes).""" + truth = {"key1": ["a", "b", "c"], "key2": "value"} + to_compare = {"key1": ["x", "y"], "key2": "value"} + + errors = compare_json_structures(truth, to_compare) + + # Lists are leaf nodes, structure matches + assert errors == [] + + def test_compare_path_formatting(self): + """Test that error paths are formatted correctly.""" + truth = {"a": {"b": {"c": "value"}}} + to_compare = {"a": {"b": {}}} + + errors = compare_json_structures(truth, to_compare) + + assert len(errors) == 1 + assert "a.b.c" in errors[0] + assert not errors[0].startswith(".") + + +class TestLintTranslationJson: + """Test suite for lint_translation_json function.""" + + def test_lint_with_no_errors(self): + """Test linting when there are no errors.""" + truth = {"key1": "value1", "key2": "value2"} + to_compare = {"key1": "value1", "key2": "value2"} + + with patch("sys.stdout", new=StringIO()) as fake_out: + lint_translation_json("test.json", truth, to_compare) + output = fake_out.getvalue() + + assert "Linting test.json..." in output + assert "✅ No errors found in test.json" in output + + def test_lint_with_errors(self): + """Test linting when there are errors.""" + truth = {"key1": "value1", "key2": "value2"} + to_compare = {"key1": "value1", "key3": "value3"} + + with patch("sys.stdout", new=StringIO()) as fake_out: + lint_translation_json("test.json", truth, to_compare) + output = fake_out.getvalue() + + assert "Linting test.json..." in output + assert "Missing key: 'key2'" in output + assert "Extra key: 'key3'" in output + assert "✅ No errors found" not in output + + def test_lint_with_nested_errors(self): + """Test linting with nested structure errors.""" + truth = {"level1": {"key1": "value1", "key2": "value2"}} + to_compare = {"level1": {"key1": "value1"}} + + with patch("sys.stdout", new=StringIO()) as fake_out: + lint_translation_json("nested.json", truth, to_compare) + output = fake_out.getvalue() + + assert "Linting nested.json..." in output + assert "Missing key: 'level1.key2'" in output + + def test_lint_with_structure_mismatch(self): + """Test linting with structure mismatch.""" + truth = {"key": {"nested": "value"}} + to_compare = {"key": "not_nested"} + + with patch("sys.stdout", new=StringIO()) as fake_out: + lint_translation_json("mismatch.json", truth, to_compare) + output = fake_out.getvalue() + + assert "Linting mismatch.json..." in output + assert "Structure mismatch at: 'key'" in output + + def test_lint_with_multiple_errors(self): + """Test linting with multiple types of errors.""" + truth = { + "key1": "value1", + "key2": {"nested": "value"}, + "key3": "value3", + } + to_compare = { + "key1": "value1", + "key2": "not_nested", + "key4": "extra", + } + + with patch("sys.stdout", new=StringIO()) as fake_out: + lint_translation_json("multi.json", truth, to_compare) + output = fake_out.getvalue() + + assert "Linting multi.json..." in output + assert "Structure mismatch" in output + assert "Missing key: 'key3'" in output + assert "Extra key: 'key4'" in output + + def test_lint_output_format(self): + """Test that lint output is properly formatted.""" + truth = {"key1": "value1"} + to_compare = {"key2": "value2"} + + with patch("sys.stdout", new=StringIO()) as fake_out: + lint_translation_json("format.json", truth, to_compare) + output = fake_out.getvalue() + + # Check that output starts with newline and linting message + lines = output.strip().split("\n") + assert "Linting format.json..." in lines[0] + assert len(lines) >= 2 # At least linting message + errors + + +class TestTranslationsEdgeCases: + """Test suite for edge cases in translations module.""" + + def test_compare_with_numeric_keys(self): + """Test structures with numeric keys (as strings).""" + truth = {"1": "value1", "2": "value2"} + to_compare = {"1": "value1", "2": "value2"} + + errors = compare_json_structures(truth, to_compare) + + assert errors == [] + + def test_compare_with_special_characters_in_keys(self): + """Test keys with special characters.""" + truth = {"key-1": "value", "key_2": "value", "key.3": "value"} + to_compare = {"key-1": "value", "key_2": "value", "key.3": "value"} + + errors = compare_json_structures(truth, to_compare) + + assert errors == [] + + def test_compare_with_unicode_keys(self): + """Test keys with unicode characters.""" + truth = {"键": "value", "clé": "value", "مفتاح": "value"} + to_compare = {"键": "value", "clé": "value", "مفتاح": "value"} + + errors = compare_json_structures(truth, to_compare) + + assert errors == [] + + def test_compare_very_deeply_nested(self): + """Test very deeply nested structures.""" + truth = {"a": {"b": {"c": {"d": {"e": {"f": "value"}}}}}} + to_compare = {"a": {"b": {"c": {"d": {"e": {}}}}}} + + errors = compare_json_structures(truth, to_compare) + + assert len(errors) == 1 + assert "a.b.c.d.e.f" in errors[0] + + def test_compare_with_empty_string_values(self): + """Test structures with empty string values.""" + truth = {"key1": "", "key2": "value"} + to_compare = {"key1": "", "key2": "value"} + + errors = compare_json_structures(truth, to_compare) + + assert errors == [] + + def test_lint_with_empty_filename(self): + """Test lint with empty filename.""" + truth = {"key": "value"} + to_compare = {"key": "value"} + + with patch("sys.stdout", new=StringIO()) as fake_out: + lint_translation_json("", truth, to_compare) + output = fake_out.getvalue() + + assert "Linting ..." in output + + def test_compare_preserves_error_order(self): + """Test that errors are reported in a consistent order.""" + truth = {"a": "1", "b": "2", "c": "3"} + to_compare = {"d": "4", "e": "5"} + + errors = compare_json_structures(truth, to_compare) + + # Should have 2 extra keys and 3 missing keys + assert len(errors) == 5 + extra_errors = [e for e in errors if "Extra" in e] + missing_errors = [e for e in errors if "Missing" in e] + assert len(extra_errors) == 2 + assert len(missing_errors) == 3 diff --git a/backend/tests/test_user_session.py b/backend/tests/test_user_session.py new file mode 100644 index 0000000..dd9b881 --- /dev/null +++ b/backend/tests/test_user_session.py @@ -0,0 +1,14 @@ +async def test_user_session_set_get(mock_chainlit_context, user_session): + async with mock_chainlit_context as context: + # Test setting a value + user_session.set("test_key", "test_value") + + # Test getting the value + assert user_session.get("test_key") == "test_value" + + # Test getting a default value for a non-existent key + assert user_session.get("non_existent_key", "default") == "default" + + # Test getting session-related values + assert user_session.get("id") == context.session.id + assert user_session.get("env") == context.session.user_env diff --git a/backend/tests/test_utils.py b/backend/tests/test_utils.py new file mode 100644 index 0000000..5687c78 --- /dev/null +++ b/backend/tests/test_utils.py @@ -0,0 +1,449 @@ +import os +import tempfile +from datetime import datetime, timezone +from unittest.mock import AsyncMock, patch + +import click +import pytest + +from chainlit.utils import ( + check_file, + check_module_version, + make_module_getattr, + timestamp_utc, + utc_now, + wrap_user_function, +) + + +class TestUtcNow: + """Test suite for utc_now function.""" + + def test_utc_now_returns_string(self): + """Test that utc_now returns a string.""" + result = utc_now() + assert isinstance(result, str) + + def test_utc_now_ends_with_z(self): + """Test that utc_now returns ISO format with Z suffix.""" + result = utc_now() + assert result.endswith("Z") + + def test_utc_now_is_iso_format(self): + """Test that utc_now returns valid ISO format.""" + result = utc_now() + # Remove the Z and parse + dt_str = result[:-1] + # Should be parseable as ISO format + datetime.fromisoformat(dt_str) + + def test_utc_now_is_current_time(self): + """Test that utc_now returns approximately current time.""" + before = datetime.now(timezone.utc).replace(tzinfo=None) + result = utc_now() + after = datetime.now(timezone.utc).replace(tzinfo=None) + + # Parse the result (naive datetime) + result_dt = datetime.fromisoformat(result[:-1]) + + # Should be between before and after (with some tolerance for microseconds) + assert ( + before.replace(microsecond=0) <= result_dt <= after.replace(microsecond=0) + or before <= result_dt <= after + ) + + def test_utc_now_multiple_calls(self): + """Test that multiple calls to utc_now return different values.""" + result1 = utc_now() + result2 = utc_now() + + # Results should be very close but might differ + assert isinstance(result1, str) + assert isinstance(result2, str) + + +class TestTimestampUtc: + """Test suite for timestamp_utc function.""" + + def test_timestamp_utc_returns_string(self): + """Test that timestamp_utc returns a string.""" + result = timestamp_utc(1234567890.0) + assert isinstance(result, str) + + def test_timestamp_utc_ends_with_z(self): + """Test that timestamp_utc returns ISO format with Z suffix.""" + result = timestamp_utc(1234567890.0) + assert result.endswith("Z") + + def test_timestamp_utc_converts_correctly(self): + """Test that timestamp_utc converts timestamp correctly.""" + # Known timestamp: 2009-02-13 23:31:30 UTC + timestamp = 1234567890.0 + result = timestamp_utc(timestamp) + + # Parse and verify + dt = datetime.fromisoformat(result[:-1]) + assert dt.year == 2009 + assert dt.month == 2 + assert dt.day == 13 + + def test_timestamp_utc_with_zero(self): + """Test timestamp_utc with epoch (0).""" + result = timestamp_utc(0.0) + dt = datetime.fromisoformat(result[:-1]) + assert dt.year == 1970 + assert dt.month == 1 + assert dt.day == 1 + + def test_timestamp_utc_with_fractional_seconds(self): + """Test timestamp_utc with fractional seconds.""" + timestamp = 1234567890.123456 + result = timestamp_utc(timestamp) + + # Should be valid ISO format + dt = datetime.fromisoformat(result[:-1]) + assert isinstance(dt, datetime) + + def test_timestamp_utc_with_negative_timestamp(self): + """Test timestamp_utc with negative timestamp (before epoch).""" + # 1969-12-31 23:00:00 UTC + timestamp = -3600.0 + result = timestamp_utc(timestamp) + + dt = datetime.fromisoformat(result[:-1]) + assert dt.year == 1969 + + +@pytest.mark.asyncio +class TestWrapUserFunction: + """Test suite for wrap_user_function.""" + + async def test_wrap_user_function_with_sync_function(self, mock_chainlit_context): + """Test wrapping a synchronous function.""" + async with mock_chainlit_context: + + def user_func(a, b): + return a + b + + wrapped = wrap_user_function(user_func) + result = await wrapped(5, 3) + + assert result == 8 + + async def test_wrap_user_function_with_async_function(self, mock_chainlit_context): + """Test wrapping an asynchronous function.""" + async with mock_chainlit_context: + + async def user_func(x, y): + return x * y + + wrapped = wrap_user_function(user_func) + result = await wrapped(4, 7) + + assert result == 28 + + async def test_wrap_user_function_with_no_args(self, mock_chainlit_context): + """Test wrapping a function with no arguments.""" + async with mock_chainlit_context: + + def user_func(): + return "hello" + + wrapped = wrap_user_function(user_func) + result = await wrapped() + + assert result == "hello" + + async def test_wrap_user_function_with_task(self, mock_chainlit_context): + """Test wrapping a function with task management.""" + async with mock_chainlit_context as ctx: + ctx.emitter.task_start = AsyncMock() + ctx.emitter.task_end = AsyncMock() + + def user_func(value): + return value * 2 + + wrapped = wrap_user_function(user_func, with_task=True) + result = await wrapped(10) + + assert result == 20 + ctx.emitter.task_start.assert_called_once() + ctx.emitter.task_end.assert_called_once() + + async def test_wrap_user_function_handles_exception(self, mock_chainlit_context): + """Test that wrapped function handles exceptions.""" + async with mock_chainlit_context: + + def user_func(): + raise ValueError("Test error") + + wrapped = wrap_user_function(user_func) + result = await wrapped() + + # Should return None when exception occurs + assert result is None + + async def test_wrap_user_function_with_task_handles_exception( + self, mock_chainlit_context + ): + """Test that wrapped function with task handles exceptions.""" + async with mock_chainlit_context as ctx: + ctx.emitter.task_start = AsyncMock() + ctx.emitter.task_end = AsyncMock() + + def user_func(): + raise ValueError("Test error") + + with patch("chainlit.utils.logger") as mock_logger: + wrapped = wrap_user_function(user_func, with_task=True) + result = await wrapped() + + assert result is None + ctx.emitter.task_start.assert_called_once() + ctx.emitter.task_end.assert_called_once() + mock_logger.exception.assert_called_once() + + async def test_wrap_user_function_preserves_function_metadata( + self, mock_chainlit_context + ): + """Test that wrapping preserves function metadata.""" + async with mock_chainlit_context: + + def user_func(a, b): + """Test function docstring.""" + return a + b + + wrapped = wrap_user_function(user_func) + + assert wrapped.__name__ == "user_func" + assert wrapped.__doc__ == "Test function docstring." + + async def test_wrap_user_function_with_kwargs(self, mock_chainlit_context): + """Test wrapping a function and calling with positional args.""" + async with mock_chainlit_context: + + def user_func(x, y, z): + return x + y + z + + wrapped = wrap_user_function(user_func) + result = await wrapped(1, 2, 3) + + assert result == 6 + + +class TestMakeModuleGetattr: + """Test suite for make_module_getattr.""" + + def test_make_module_getattr_creates_function(self): + """Test that make_module_getattr creates a function.""" + registry = {"SomeClass": "some.module"} + getattr_func = make_module_getattr(registry) + + assert callable(getattr_func) + + def test_make_module_getattr_imports_module(self): + """Test that the created function imports modules.""" + # Use a real module for testing + registry = {"datetime": "datetime"} + getattr_func = make_module_getattr(registry) + + result = getattr_func("datetime") + assert result is datetime + + def test_make_module_getattr_with_nested_module(self): + """Test with nested module path.""" + registry = {"timezone": "datetime"} + getattr_func = make_module_getattr(registry) + + result = getattr_func("timezone") + assert result is timezone + + +class TestCheckModuleVersion: + """Test suite for check_module_version.""" + + def test_check_module_version_with_installed_module(self): + """Test checking version of an installed module.""" + # pytest should be installed + result = check_module_version("pytest", "1.0.0") + assert result is True + + def test_check_module_version_with_higher_required_version(self): + """Test with a required version higher than installed.""" + # Require an impossibly high version + result = check_module_version("pytest", "999.0.0") + assert result is False + + def test_check_module_version_with_nonexistent_module(self): + """Test with a module that doesn't exist.""" + result = check_module_version("nonexistent_module_xyz", "1.0.0") + assert result is False + + def test_check_module_version_exact_match(self): + """Test with exact version match.""" + # Get actual pytest version + result = check_module_version("pytest", pytest.__version__) + assert result is True + + def test_check_module_version_with_builtin_module(self): + """Test with a builtin module that has no __version__.""" + # os module doesn't have __version__ + with pytest.raises(AttributeError): + check_module_version("os", "1.0.0") + + +class TestCheckFile: + """Test suite for check_file function.""" + + def test_check_file_with_valid_py_file(self): + """Test check_file with a valid .py file.""" + with tempfile.NamedTemporaryFile(suffix=".py", delete=False) as f: + temp_file = f.name + + try: + # Should not raise any exception + check_file(temp_file) + finally: + os.unlink(temp_file) + + def test_check_file_with_valid_py3_file(self): + """Test check_file with a valid .py3 file.""" + with tempfile.NamedTemporaryFile(suffix=".py3", delete=False) as f: + temp_file = f.name + + try: + # Should not raise any exception + check_file(temp_file) + finally: + os.unlink(temp_file) + + def test_check_file_with_invalid_extension(self): + """Test check_file with invalid file extension.""" + with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as f: + temp_file = f.name + + try: + with pytest.raises(click.BadArgumentUsage) as exc_info: + check_file(temp_file) + assert ".txt" in str(exc_info.value) + finally: + os.unlink(temp_file) + + def test_check_file_with_no_extension(self): + """Test check_file with file that has no extension.""" + with tempfile.NamedTemporaryFile(suffix="", delete=False) as f: + temp_file = f.name + + try: + with pytest.raises(click.BadArgumentUsage) as exc_info: + check_file(temp_file) + assert "no extension" in str(exc_info.value) + finally: + os.unlink(temp_file) + + def test_check_file_with_nonexistent_file(self): + """Test check_file with a file that doesn't exist.""" + nonexistent_file = "/path/to/nonexistent/file.py" + + with pytest.raises(click.BadParameter) as exc_info: + check_file(nonexistent_file) + assert "does not exist" in str(exc_info.value) + + def test_check_file_with_json_extension(self): + """Test check_file with .json extension.""" + with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f: + temp_file = f.name + + try: + with pytest.raises(click.BadArgumentUsage) as exc_info: + check_file(temp_file) + assert ".json" in str(exc_info.value) + finally: + os.unlink(temp_file) + + +class TestUtilsEdgeCases: + """Test suite for utils edge cases.""" + + def test_utc_now_format_consistency(self): + """Test that utc_now format is consistent across calls.""" + results = [utc_now() for _ in range(5)] + + for result in results: + # All should have same format + assert result.endswith("Z") + assert "T" in result + # Should be parseable + datetime.fromisoformat(result[:-1]) + + def test_timestamp_utc_with_large_timestamp(self): + """Test timestamp_utc with very large timestamp (far future).""" + # Year 2100 + timestamp = 4102444800.0 + result = timestamp_utc(timestamp) + + dt = datetime.fromisoformat(result[:-1]) + assert dt.year == 2100 + + @pytest.mark.asyncio + async def test_wrap_user_function_with_multiple_exceptions( + self, mock_chainlit_context + ): + """Test wrapped function handles different exception types.""" + async with mock_chainlit_context: + exceptions = [ValueError("error1"), TypeError("error2"), KeyError("error3")] + + for exc in exceptions: + + def user_func(): + raise exc + + with patch("chainlit.utils.logger"): + wrapped = wrap_user_function(user_func) + result = await wrapped() + assert result is None + + def test_check_file_with_relative_path(self): + """Test check_file with relative path.""" + # Create a temp file in current directory + with tempfile.NamedTemporaryFile(suffix=".py", delete=False, dir=".") as f: + temp_file = os.path.basename(f.name) + + try: + # Should work with relative path + check_file(temp_file) + finally: + os.unlink(temp_file) + + def test_check_file_with_absolute_path(self): + """Test check_file with absolute path.""" + with tempfile.NamedTemporaryFile(suffix=".py", delete=False) as f: + temp_file = os.path.abspath(f.name) + + try: + # Should work with absolute path + check_file(temp_file) + finally: + os.unlink(temp_file) + + def test_make_module_getattr_with_empty_registry(self): + """Test make_module_getattr with empty registry.""" + registry = {} + getattr_func = make_module_getattr(registry) + + with pytest.raises(KeyError): + getattr_func("nonexistent") + + @pytest.mark.asyncio + async def test_wrap_user_function_with_default_args(self, mock_chainlit_context): + """Test wrapping function with default arguments.""" + async with mock_chainlit_context: + + def user_func(a, b=10): + return a + b + + wrapped = wrap_user_function(user_func) + + # Call with only required arg + result = await wrapped(5) + assert result == 15 diff --git a/cypress.config.ts b/cypress.config.ts new file mode 100644 index 0000000..7f036f3 --- /dev/null +++ b/cypress.config.ts @@ -0,0 +1,78 @@ +import { defineConfig } from 'cypress'; +import cypressSplit from 'cypress-split'; +import fkill from 'fkill'; + +import { runChainlit } from './cypress/support/run'; + +export const CHAINLIT_APP_PORT = 8000; + +async function killChainlit() { + await fkill(`:${CHAINLIT_APP_PORT}`, { + force: true, + silent: true + }); +} + +['SIGTERM', 'SIGINT', 'SIGHUP', 'SIGBREAK'].forEach((signal) => { + process.on(signal, () => { + (async () => { + await killChainlit(); // Ensure Chainlit is killed on exit + + const signalMap = { SIGTERM: 15, SIGINT: 2, SIGHUP: 1, SIGBREAK: 21 }; + process.exit(128 + (signalMap[signal] || 0)); + })(); + }); +}); + +export default defineConfig({ + projectId: 'ij1tyk', + + retries: 3, + + viewportWidth: 1200, + + e2e: { + defaultCommandTimeout: 30000, + baseUrl: `http://127.0.0.1:${CHAINLIT_APP_PORT}`, + experimentalInteractiveRunEvents: true, + async setupNodeEvents(on, config) { + cypressSplit(on, config); + + await killChainlit(); // Fallback to ensure no previous instance is running + await runChainlit(); // Start Chainlit before running tests as Cypress require + + on('before:spec', async (spec) => { + await killChainlit(); + await runChainlit(spec); + }); + + on('after:spec', async () => { + await killChainlit(); + }); + + on('after:run', async () => { + await killChainlit(); + }); + + on('task', { + log(message) { + console.log(message); + return null; + }, + restartChainlit(spec: Cypress.Spec) { + return new Promise((resolve) => { + killChainlit().then(() => { + runChainlit(spec).then(() => { + setTimeout(() => { + resolve(null); + }, 1000); + }); + }); + }); + } + }); + + return config; + } + } +}); diff --git a/cypress/e2e/action/main.py b/cypress/e2e/action/main.py new file mode 100644 index 0000000..95de7de --- /dev/null +++ b/cypress/e2e/action/main.py @@ -0,0 +1,82 @@ +import chainlit as cl + + +@cl.action_callback("test action") +async def on_test_action(): + await cl.Message(content="Executed test action!").send() + + +@cl.action_callback("removable action") +async def on_removable_action(action: cl.Action): + await cl.Message(content="Executed removable action!").send() + await action.remove() + + +@cl.action_callback("multiple actions") +async def on_multiple_actions(action: cl.Action): + await cl.Message(content=f"Action(id={action.id}) has been removed!").send() + await action.remove() + + +@cl.action_callback("all actions removed") +async def on_all_actions_removed(_: cl.Action): + await cl.Message(content="All actions have been removed!").send() + to_remove = cl.user_session.get("to_remove") # type: cl.Message + await to_remove.remove_actions() + + +@cl.on_chat_start +async def main(): + actions = [ + cl.Action(id="test-action", name="test action", payload={"value": "test"}), + cl.Action( + id="removable-action", name="removable action", payload={"value": "test"} + ), + cl.Action( + id="label-action", + name="label action", + payload={"value": "test"}, + label="Test Label", + ), + cl.Action( + id="multiple-action-one", + name="multiple actions", + payload={"value": "multiple action one"}, + label="multiple action one", + ), + cl.Action( + id="multiple-action-two", + name="multiple actions", + payload={"value": "multiple action two"}, + label="multiple action two", + ), + cl.Action( + id="all-actions-removed", + name="all actions removed", + payload={"value": "test"}, + ), + ] + message = cl.Message("Hello, this is a test message!", actions=actions) + cl.user_session.set("to_remove", message) + await message.send() + + result = await cl.AskActionMessage( + content="Please, pick an action!", + actions=[ + cl.Action( + id="first-action", + name="first_action", + payload={"value": "first-action"}, + label="First action", + ), + cl.Action( + id="second-action", + name="second_action", + payload={"value": "second-action"}, + label="Second action", + ), + ], + ).send() + + if result is not None: + await cl.Message(f"Thanks for pressing: {result['payload']['value']}").send() diff --git a/cypress/e2e/action/spec.cy.ts b/cypress/e2e/action/spec.cy.ts new file mode 100644 index 0000000..1d45352 --- /dev/null +++ b/cypress/e2e/action/spec.cy.ts @@ -0,0 +1,49 @@ +describe('Action', () => { + it('should correctly execute and display actions', () => { + // Click on "first action" + cy.get('#first-action').should('exist'); + cy.get('#first-action').click(); + cy.get('.step').should('have.length', 3); + cy.get('.step') + .eq(2) + .should('contain', 'Thanks for pressing: first-action'); + + // Click on "test action" + cy.get("[id='test-action']").should('exist'); + cy.get("[id='test-action']").click(); + cy.get('.step').should('have.length', 4); + cy.get('.step').eq(3).should('contain', 'Executed test action!'); + cy.get("[id='test-action']").should('exist'); + + // Click on "removable action" + cy.get("[id='removable-action']").should('exist'); + cy.get("[id='removable-action']").click(); + cy.get('.step').should('have.length', 5); + cy.get('.step').eq(4).should('contain', 'Executed removable action!'); + cy.get("[id='removable-action']").should('not.exist'); + + cy.get('.step').should('have.length', 5); + + cy.get("[id='multiple-action-one']").should('exist'); + cy.get("[id='multiple-action-one']").click(); + cy.get('.step') + .eq(5) + .should('contain', 'Action(id=multiple-action-one) has been removed!'); + cy.get("[id='multiple-action-one']").should('not.exist'); + + // Click on "multiple action two", should remove the correct action button + cy.get('.step').should('have.length', 6); + cy.get("[id='multiple-action-two']").click(); + cy.get('.step') + .eq(6) + .should('contain', 'Action(id=multiple-action-two) has been removed!'); + cy.get("[id='multiple-action-two']").should('not.exist'); + + // Click on "all actions removed", should remove all buttons + cy.get("[id='all-actions-removed']").should('exist'); + cy.get("[id='all-actions-removed']").click(); + cy.get('.step').eq(7).should('contain', 'All actions have been removed!'); + cy.get("[id='all-actions-removed']").should('not.exist'); + cy.get("[id='test-action']").should('not.exist'); + }); +}); diff --git a/cypress/e2e/ask_custom_element/main.py b/cypress/e2e/ask_custom_element/main.py new file mode 100644 index 0000000..2f887dc --- /dev/null +++ b/cypress/e2e/ask_custom_element/main.py @@ -0,0 +1,35 @@ +import chainlit as cl + + +@cl.on_chat_start +async def on_start(): + element = cl.CustomElement( + name="JiraTicket", + props={ + "timeout": 20, + "fields": [ + {"id": "summary", "label": "Summary", "type": "text", "required": True}, + {"id": "description", "label": "Description", "type": "textarea"}, + { + "id": "due", + "label": "Due Date", + "type": "date", + }, + { + "id": "priority", + "label": "Priority", + "type": "select", + "options": ["Low", "Medium", "High"], + "value": "Medium", + "required": True, + }, + ], + }, + ) + res = await cl.AskElementMessage( + content="Create a new Jira ticket:", element=element, timeout=10 + ).send() + if res and res.get("submitted"): + await cl.Message( + content=f"Ticket '{res['summary']}' with priority {res['priority']} submitted" + ).send() diff --git a/cypress/e2e/ask_custom_element/public/elements/JiraTicket.jsx b/cypress/e2e/ask_custom_element/public/elements/JiraTicket.jsx new file mode 100644 index 0000000..53e0c8b --- /dev/null +++ b/cypress/e2e/ask_custom_element/public/elements/JiraTicket.jsx @@ -0,0 +1,151 @@ +import React, { useEffect, useMemo, useState } from 'react'; + +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle +} from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '@/components/ui/select'; +import { Textarea } from '@/components/ui/textarea'; + +export default function JiraTicket() { + const [timeLeft, setTimeLeft] = useState(props.timeout || 30); + const [values, setValues] = useState(() => { + const init = {}; + (props.fields || []).forEach((f) => { + init[f.id] = f.value || ''; + }); + return init; + }); + + const allValid = useMemo(() => { + if (!props.fields) return true; + return props.fields.every((f) => { + if (!f.required) return true; + const val = values[f.id]; + return val !== undefined && val !== ''; + }); + }, [props.fields, values]); + + useEffect(() => { + const interval = setInterval(() => { + setTimeLeft((t) => (t > 0 ? t - 1 : 0)); + }, 1000); + return () => clearInterval(interval); + }, []); + + const handleChange = (id, val) => { + setValues((v) => ({ ...v, [id]: val })); + }; + + const renderField = (field) => { + const value = values[field.id]; + switch (field.type) { + case 'textarea': + return ( +